Vue3.js响应式系统设计的核心原理

发表时间: 2024-06-13 14:47

书籍:《Vue.js设计与实现》 作者:霍春阳

本篇博文将在书第4.5节至4.7节的基础上进一步解析,附加了测试的代码运行示例,以及对书籍中提到的ES6中的数据结构及其特点进行阐述,方便正在学习Vue3分析Vue3源码的朋友快速阅读

如有帮助,不胜荣幸

在上一节深入理解Vue3.js响应式系统基础逻辑中,我们阐述了Vue3.js通过ES6的Proxy、Set、WeakMap、Map等语法和数据结构实现了基础的响应式系统,那么在这节笔记中,笔者将会对书中第4.5节至4.6节的内容进行阐述

需要注意的是,第4.5节至4.6节的内容主要讲解了effect的嵌套及问题和解决方案、避免effect无限递归两个方面,内容相对于4.1节至4.5节少许多,但却是实现computed和watch计算属性的不可缺少的步骤

所以这节笔记会相对较少字数

嵌套的effect和effect栈

4.5节的开篇中,作者提到了Vue.js的渲染函数就是在effect中执行的,我们知道,effect就是副作用函数,同时,Vue.js的组件也是通过渲染函数去return的,代码如下:

const Foo = {  render() {    return /* ... */;  }};

而组件是可以嵌套的,进而引出了嵌套effect,并进一步引出在嵌套情况下effect函数将会面临的问题,其实是指我们在上一节设计的响应式系统不完善的问题

我们知道,副作用函数会和属性进行关联,关系如下:

target     └── key        └── effectFn

具体的实现过程分为两步,首先是执行并注册副作用函数(复习一下上节内容),代码如下:

// 用一个全局变量存储被注册的副作用函数let activeEffect;function effect(fn) {  const effectFn = () => {    // 调用 cleanup 函数完成清除工作    cleanup(effectFn); // 新增    activeEffect = effectFn;    fn();  };  effectFn.deps = [];  effectFn();}function cleanup(effectFn) {  //遍历effectFn.deps...  //重置effectFn.deps...}

第二步是在Proxy.get()中将副作用函数会和属性进行关联

// 在 get 拦截函数内调用 track 函数追踪变化function track(target, key) {  // 没有 activeEffect,直接 return  if (!activeEffect) return;  let depsMap = bucket.get(target);  if (!depsMap) {    bucket.set(target, (depsMap = new Map()));  }  let deps = depsMap.get(key);  if (!deps) {    depsMap.set(key, (deps = new Set()));  }  // 全局变量activeEffect  deps.add(activeEffect);}

嵌套带来的问题

那么,假设我们现在有一个effect嵌套effect的函数,代码如下:

effect(() => {  Foo.render();  // 嵌套  effect(() => {    Bar.render();  });});

我们来分析一下这个嵌套effect()执行的过程:

  1. 匿名函数()=>{...}被传入effect()中并执行effectFn(),我们给这个effectFn()起个别名effectFn1()
  2. 由于不是修改操作,我们可以忽略clean(),effectFn1()与activeEffect拥有共同地址,执行传入的Foo.render(),后续流程忽略
  3. 执行effect(() => {Bar.render();});
  4. 在第3步中,重复了第2步的操作,但此时的effectFn2()不等于第二步中的effectFn1(),为啥?两个effectFn()内的fn()不一样!
  5. 而此时activeEffect的地址被替换(覆盖)成effectFn2(),然后执行Bar.render(),至此结束

所以我们可以得到一个结论,activeEffect总是与内层嵌套的副作用函数地址相同

现在我们再来看书中给出的代码例子:

// 原始数据const data = { foo: true, bar: true };// 代理对象const obj = new Proxy(data, { /* ... */ });// 全局变量let temp1, temp2;// effectFn1 嵌套了 effectFn2effect(function effectFn1() {  console.log('effectFn1 执行');  effect(function effectFn2() {    console.log('effectFn2 执行');    // 在 effectFn2 中读取 obj.bar 属性    temp2 = obj.bar;  });  // 在 effectFn1 中读取 obj.foo 属性  temp1 = obj.foo;});

通过去上面嵌套effect的分析,我们知道activeEffect总是与内层嵌套的副作用函数地址相同,在书中代码的例子中,activeEffect等于effectFn2()内的effectFn(),而这个effectFn()内部执行的是什么?是function effectFn2()

代码执行到temp1 = obj.foo;时,就会触发obj.foo的读取,而此时的全局变量activeEffect绑定的逻辑是执行effectFn2()

所以,当我们修改obj.foo时,与我们想要的触发执行effectFn1()结果不一致,因为执行的是effectFn2()

这就是目前代码下的嵌套effect导致的问题——activeEffect总是指向内层嵌套的副作用函数地址,也就是同一时刻activeEffect所存储的副作用函数只能有一个(原文)

解决方案

Vue.js团队是如何处理的呢?答案是结构,而在JavaScript中,通常使用数组实现

其实就是一种先进后出的思维,比方说在上面关于Foo组件嵌套Bar组件执行的过程,我们知道在第3步的时候activeEffect被覆盖了,那我们是不是可以设计一个数组effectStack,在第2步的时候把此时的activeEffect即effectFn1()存进effectStack,然后当执行第3步的时候,把effectFn2()赋值给activeEffect,然后再把这个effectFn2()存进进effectStack,那么此时的effectStack结构如下:

effectStack = [effectFn1(),effectFn2()]

副作用函数赋值给activeEffect的下一步是执行Fn(),在执行Fn()的过程中会将此时的activeEffect与对应的target.key相关联

别忘了我们的目的是什么,我们只是想target.key关联的是与之对应的effectFn

ok!关联之后,我们再把effectStack的最后一个(此时为effectFn2())删除,然后再把activeEffect变为effectStack里的effectStack.length - 1项,其实也就是当前的最后一项,在上述例子中就是effectFn1()

那么再回看关于最后一句代码是读取obj.foo的例子,显而易见,使用了之后,obj.foo关联的就是effectFn1()了

最后,我们来看下具体的实现代码,代码如下:

let activeEffect;const effectStack = [];function effect(fn) {  const effectFn = () => {    cleanup(effectFn);    activeEffect = effectFn;    effectStack.push(effectFn);    fn();    effectStack.pop();    activeEffect = effectStack[effectStack.length - 1] || null;  };  effectFn.deps = [];  effectFn();}

关键的几行代码如下:

  1. activeEffect = effectFn,把当前的effectFn赋值给activeEffect
  2. effectStack.push(effectFn),把effectFn推入
  3. 执行fn(),在这一步会在Proxy.get()中形成与key的关联
  4. effectStack.pop(),把栈顶的effect弹出
  5. activeEffect = effectStack[effectStack.length - 1] || null等于当前栈顶函数

小记:思路非常的清晰,我在看这本书的时候,感觉读这本书不仅可以学习Vue.js的底层实现逻辑,提高自己的代码理解水平,还能学习在面对复杂情况下的解决方案,只能说真是一本好书

下面,我们接着跟着书的章节,进入4.6节,去解决无限循环的问题

无限循环

这一节的问题来自下面这段代码:

const data = { foo: 1 };const obj = new Proxy(data, { /*...*/ });effect(() => obj.foo++);

在前面的设计过程中。我们知道总体的流程是读取的时候存储副作用函数修改的时候再把副作用函数拿出来

但现在我们来分析一下obj.foo++这段副作用函数的执行流程

  1. obj.foo触发了读取操作,将执行() => obj.foo++的effectFn存入activeEffect
  2. obj.foo++触发了修改操作,将() => obj.foo++(外层是effectFn)从里拿出来执行
  3. obj.foo触发了读取操作,将执行() => obj.foo++的effectFn存入activeEffect
  4. ...开始循环

其实按正常的逻辑来说,应该等读取修改完成后,再进行其他的逻辑,但现在修改都还没完成,又进入读取操作了

这就是问题所在,既会读取 obj.foo 的值,又会设置 obj.foo 的值,而这就是导致问题的根本原因(原文)

这里可以看出要设计一个基本的响应式系统,要考虑的情况是十分多的,要解决的问题也是十分多的

解决无限循环

从上面的执行流程可以看出,出现问题的根本原因在于读取和设置操作是在同一个副作用函数内进行的(原文),当前的activeEffect和从里拿出来的effectFn的一样的,所以就可以在trigger里进行判断,如果发现保存在全局变量的activeEffect和从里拿出来的是一样的,那么就不执行,代码如下:

function trigger(target, key) {  const depsMap = bucket.get(target)  if (!depsMap) return  const effects = depsMap.get(key)  const effectsToRun = new Set()  effects && effects.forEach(effectFn => {    // 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行    if (effectFn !== activeEffect) { // 新增      effectsToRun.add(effectFn)    }  })  effectsToRun.forEach(effectFn => effectFn())  // effects && effects.forEach(effectFn => effectFn()) // 原来的代码}

那么到这里,我们就跟着书籍解决了嵌套自增逻辑带来的问题,下一节笔记我们来探讨如何实现调度执行,这是实现响应式核心computed()的关键内容

谢谢大家的阅读,如有错误的地方请私信笔者


文章转自:
https://juejin.cn/post/7379522106726285322