一、前言
上一篇文章给大家介绍了Vue3 中的虚拟DOM、 h() 函数,渲染函数,渲染器等知识点,这次给大家介绍一下 Vue3
的响应式原理。
Vue2
使用 Object.defineProperty
函数实现响应式,而 Vue3
改用了 Proxy
来处理响应式对象。接下来,笔者将会带着各位,一步一步了解 Vue3
的响应式原理,大纲如下:
二、副作用函数与响应式数据
在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数,向主调方的终端、管道输出字符或改变外部存储信息等。 ------------维基百科
从定义可以得知,函数副作用会对函数外的变量进行改变
js
// 全局变量
let val = 1;
function effect() {
val = 2; // 修改全局变量,产生副作用
}
effect
函数在执行过程中修改了全局变量,产生了副作用,我们可以称 effect
为副作用函数。
在函数式编程中,函数副作用是需要消除的,而 Vue3
则利用了副作用函数的特点,在我们修改对象时,使得对应的副作用函数能够重新执行。例如:
js
const obj = { text: "hello Vue3" };
function effect() {
document.body.innerTexxt = obj.text;
}
在我们修改了 obj.text
对象后,希望 effect
函数能够重新执行,读取最新的数据,从而更新页面信息。此时的 obj
对象,就是响应式对象,而 effect
函数就是对应的依赖。
为了使得 obj
成为响应式数据,有两个关键:
effect
函数执行时,会触发obj
对象的读操作;obj.text
内容修改时,会触发obj
对象的写操作。
为此,我们通过 Proxy
来拦截该对象的读写操作,示例如下:
js
/** 存储副作用的桶 */
const bucket = new Set();
/** 当前正在执行的副作用函数 */
let activeEffect = null;
/** effect 用来注册副作用函数 */
function effect(fn) {
// 当调用 effect 注册副作用函数时,将 fn 赋值给 activeEffect
activeEffect = fn;
fn();
activeEffect = null
}
/** 原始数据 */
const data = { text: "Hello Vue3!" };
/** 代理后的数据 */
const obj = new Proxy(data, {
// 拦截读操作
get(target, key) {
// 将副作用 effect 添加到桶中
if (activeEffect) {
bucket.add(activeEffect);
}
// 返回属性内容
return target[key];
},
// 拦截写操作
set(target, key, newVal) {
target[key] = newVal;
bucket.forEach((callback) => callback());
return true;
},
});
我们先用一个桶 bucket
来存放所有副作用函数,接着,用一个全局对象 activeEffect
表示当前正在执行的副作用函数,而为了能让匿名函数也能够响应,我们需要一个 effect
方法来注册副作用函数并执行。
然后我们通过 Proxy
代理 data
对象的 get
和 set
操作:
- 当触发读 操作时,将当前执行的副作用函数添加到桶中,即
bucket.add(activeEffect)
; - 当触发写作用时,遍历执行桶中的副作用函数。
我们用以下代码来测试一下:
js
let testResult = "";
effect(() => {
testResult = obj.text;
console.log("副作用函数执行, testResult值为:" + testResult);
});
setTimeout(() => {
obj.text = "Hello World";
});
至此,一个微型响应系统就实现了。
三、依赖收集过程
上一节我们使用了 Set
结构来存储副作用函数,但是存在一个问题,当我们设置一个不存在的属性时:
js
let testResult = "";
effect(() => {
testResult = obj.text;
console.log("副作用函数执行, testResult值为:" + testResult);
});
setTimeout(() => {
obj.name = "Hello World";
});
可以看到,副作用函数内部读取了 obj.text
的值,因此该函数与 obj.text
建立了相应联系。
此时,我们在定时器中给对象添加了 name
属性,而副作用函数并没有读取该属性值。理论上,我们只希望在修改 obj.text
时,匿名副作用函数才执行。实际上,副作用函数在我们添加 name
属性时还是重新执行了,这是不正确的。为此,我们需要重新设计存储桶的数据结构。
在设计数据结构前,我们分析下面这段代码:
js
effect(function effectFn() {
testResult = obj.text;
console.log("副作用函数执行, testResult值为:" + testResult);
});
我们发现,需要建立联系的主要有三个角色:
- 代理对象
obj
; - 字段属性
text
; - 副作用函数
effectFn
。
我们知道,一个对象可以拥有多个字段,一个字段可以在多个副作用函数中使用,因此需要一个树形结构要存储副作用函数。
分析完数据结构,我们来实现一下这个存储桶。首先,我们使用 WeakMap
代替 Set
作为桶的数据结构:
js
const bucket = new WeakMap();
接着,我们修改 Proxy
的代码:
js
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return target[key];
// 根据 target 从"桶"中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将当前激活的副作用函数添加到"桶"里
deps.add(activeEffect);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key);
// 执行副作用函数
effects && effects.forEach((fn) => fn());
},
});
我们分别使用了 WeakMap
、Map
和 Set
:
WeakMap
由target
-->Map
构成;Map
由key
-->Set
构成。
其中 WeakMap
的键是原始对象 target
,WeakMap
的值是一个 Map
实例,而 Map
的键是原始对象 target
的 key
,Map
的值是一个由副作用函数组成的 Set
。
使用 WeakMap
原因是 WeakMap
对 key
是弱引用,不会造成内存泄漏。即一旦 target
对象没有任何引用时,垃圾回收器会回收对应内存。
另外,考虑到后续维护代码的便捷性,我们将 get
拦截函数中涉及副作用函数的部分提取到 track
函数中,把 set
拦截函数中涉及副作用函数的部分提取到 trigger
函数中:
js
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key);
// 返回属性值
return target[key];
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal;
// 把副作用函数从桶里取出并执行
trigger(target, key);
},
});
// 在 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()));
}
deps.add(activeEffect);
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
四、分支切换
接下来我们来看一个场景:
js
const data = { ok: true, text: "text" }
const obj = new Proxy(data, { ... })
effect(function effectFn() {
const text = obj.ok ? obj.text : "hello vue3"
console.log(text)
})
在执行上面的副作用函数后,我们建立了以下依赖关系:
当 data.ok
为 true 时,我们修改 data.text
的值,会触发 effectFn
函数重新执行。当我们将其修改为 false 时:
js
obj.ok = false
此时会触发副作用函数重新执行,此时 text
永远是 hello vue3
,而 data.text
无论怎么修改都不会影响 text
的值。所以理论上,无论我们怎么修改 data.text
的值,都不应该触发 effectFn
函数重新执行。
实际上,由于 effectFn
和 data.text
建立了依赖关系,所以当我们修改 data.text
的值,还是触发了 effectFn
副作用函数执行。这个问题是遗留的副作用函数导致的。
为了解决上述问题,我们需要在副作用函数执行前,将它从所有与之关联的依赖集合中删除,等副作用函数执行完毕后,再建立新的依赖集合。
要想将一个副作用函数从所有与之关联的依赖集合删除,我们需要知道该副作用函数存在于哪些依赖集合中,因此我们对 effect
注册函数做以下改造:
js
// 当前正在执行的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
我们在 effect
内部定义了一个新的 effectFn
函数,并挂载一个数组,用来存储所有包含该副作用函数的依赖集合。依赖集合收集过程如下:
js
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()));
}
// 把当前激活的副作用函数添加到依赖集合 deps 中
deps.add(activeEffect);
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps); // 新增
}
在 track
函数中,我们将当前正在执行的副作用函数 acticeEffect
添加到依赖集合 deps
中,因此 deps
是与当前副作用函数存在关联的依赖集合,于是我们将该集合添加到 activeEffect.deps
中,这样便完成了对依赖集合的反向收集。
接下来,我们需要在每次副作用函数执行之前,根据 effectFn.deps
获取所有相关联的依赖,并将当前副作用函数从中移除:
js
// 当前正在执行的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数进行清除
cleanup(effectFn) // 新增
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
cleanup
函数实现如下, 该函数接收需要清除的副作用函数作为参数,接着遍历该函数的依赖集合数组 deps
,然后将该副作用函数从每一个依赖集合中移除,最后再重置 deps
数组:
js
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn);
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0;
}
由于我们的 cleanup
是在副作用函数执行前执行,接着执行了副作用函数,这时候又会把该函数重新收集到集合中,而此时对 effects
集合的遍历依然在进行,因此会导致无限循环。(调用 forEach
遍历 Set
集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合中,若此时遍历没有结束,则该值会重新被访问。)
为了避免这种情况,我们需要对 trigger
函数中的遍历过程做改造:
js
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const newEffectsToRun = new Set()
effects && effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
newEffectsToRun.add(effectFn)
}
});
newEffectsToRun.forEach(effectFn => effectFn())
}
如上代码所示,我们新构造了 newEffectsToRun
集合代替 effects
进行遍历,并且在遍历 effects
的同时增加判断条件:如果 trigger
触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。这是为了避免响应式对象在自增操作 obj.text++
时引发的无限递归调用问题,从而避免栈溢出。
五、嵌套依赖
我们在日常开发中,经常会引用组件,如:
js
// Child 组件
const Child = {
render() {
/** ... */
},
};
// Parent 组件渲染了 Child 组件
const Parent = {
render() {
return <Child />; // jsx 语法
},
};
Vue.js
的渲染函数是在一个 effect
中执行的,此时就会发生 effect
嵌套,而由于我们的全局变量 activeEffect
目前只能存储一个副作用函数,当遇到这种情况时,就会导致响应式对象的依赖集合只会存储最里层的副作用函数。
为了解决这个问题,我们引入一个副作用函数栈 effectStack
。当副作用函数执行时,将当前副作用函数进行压栈,等该函数执行完毕后将其从栈中弹出,而 activeEffect
则一直指向栈顶的副作用函数:
js
// 当前正在执行的副作用函数
let activeEffect;
// effect 栈
const effectStack = []; // 新增
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn); // 新增
fn();
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
};
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
我们定义了 effectStack
数组来模拟栈。当前执行的副作用函数会压入栈顶,当遇到嵌套的副作用函数时,栈底存储的是外层的副作用函数,而栈顶存储的是内层的副作用函数,如图所示:
当内层副作用函数 effectFn 2
执行完毕后,它会出栈,此时 activeEffect
会指向 effectFn 1
。
经过改造后,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。至此,我们已经实现了基本的响应式对象代理了。
六、总结
我们首先介绍了副作用函数和响应式数据的概念,以及它们之间的关系。一个响应式数据最基本的实现依赖于对读写操作的拦截,从而在副作用函数与响应式数据之间建立依赖关系。
接着,我们使用 WeakMap
、Map
和 Set
来构造存储桶的数据结构,WeakMap
对 key 是弱引用,不会造成内存泄漏。
然后我们又处理了分支切换和循环依赖嵌套问题,避免了函数无限循环调用。至此,一个基本的响应式对象便完成了。