《Vuejs 设计与实现》第 4 章(响应式系统)( 上 )
目录
[TOC]
4.1 响应式数据和副作用函数
副作用函数是指那些产生副作用的函数
function effect() {
document.body.innerText = 'hello vue3'
}
执行 effect 函数时,它会设置 body 的文本内容,这种更改可以被其他任何函数读取或设置。
因此,effect 的执行会直接或间接影响其他函数的执行,这就是它产生副作用的地方。
副作用很容易产生,比如修改一个全局变量:
// 全局变量
let val = 1
function effect() {
val = 2 // 更改全局变量,产生副作用
}
理解了副作用函数后,我们再来看看响应式数据是什么。设想在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello world' }
function effect() {
// 在执行effect函数时会读取 obj.text
document.body.innerText = obj.text
}
上述代码,effect 函数会设置 body 元素的 innerText 属性,其值为 obj.text。当 obj.text 值发生变化时,我们希望 effect 函数会重新执行:
obj.text = 'hello vue3' // 修改 obj.text 的值,并希望副作用函数重新执行
当 obj.text 值改变时,我们希望副作用函数能自动重新执行。如果这可以实现,那么对象 obj 就可以被称为响应式数据。
但显然,现在我们无法实现这一点,因为 obj 仅仅是一个普通对象,当我们改变它的值时,除了值本身之外,不会有任何其他反应。
下一节我们将讨论如何让数据变为响应式数据。
4.2 基本响应式数据实现
为了使 obj 成为响应式数据,我们可以从以下两点出发:
- 执行副作用函数 effect 时,会触发 obj.text 的读取操作。
- 当修改 obj.text 的值时,会触发 obj.text 的设置操作。
如果我们能拦截对象的读取和设置操作,这个问题就简单了。
- 读取 obj.text 时,将副作用函数 effect 存储到一个“桶”中。
- 在设置 obj.text 时,从“桶”中取出副作用函数 effect 并执行。
在 ES2015 之前,我们可以使用 Object.defineProperty 函数,这是 Vue.js 2 的实现方式。
在 ES2015+ 中,我们可以使用代理对象 Proxy,这是 Vue3 的实现方式。


基于以上思路,我们可以用 Proxy 来实现响应式数据:
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// 代理原始数据
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 添加副作用函数 effect 到桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 取出并执行桶中的副作用函数
bucket.forEach(fn => fn())
// 返回 true 表示设置成功
return true
}
})
首先,我们创建一个用于存储副作用函数的 Set 类型的桶 bucket。
然后,定义原始数据 data,并创建其代理对象 obj,我们为代理对象设置了 get 和 set 拦截器,以拦截读取和设置操作。
读取属性时,我们把副作用函数 effect 添加到桶中。
设置属性时,我们先更新原始数据,然后重新执行桶中的副作用函数。这样就实现了响应式数据。
测试一下:
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 触发读取
effect()
// 1秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)
在浏览器中运行以上代码,我们将得到预期的结果。
但当前的实现仍有不足,比如我们是直接通过函数名 effect 获取副作用函数,这种硬编码方式缺乏灵活性。
副作用函数的名字是可以任意命名的,我们可以把副作用函数命名为 myEffect,或者甚至用一个匿名函数。
因此,我们需要找到去除这种硬编码的方法。
下一节将详细讨论这个问题,这里只需理解响应式数据的初步实现和工作原理即可。
4.3 设计完善响应系统
接下来我们将构建一个更完善的响应系统,实现步骤如下:
- 读取操作时,将副作用函数收集到“桶”中。
- 设置操作时,从“桶”中取出并执行副作用函数。
为了解决硬编码副作用函数名(effect)的问题,我们提供一个注册副作用函数的机制:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
首先,我们定义了一个全局变量 activeEffect,用于存储被注册的副作用函数。
然后定义了 effect 函数,这个函数用于注册副作用函数,接受一个参数 fn,也就是我们要注册的副作用函数。
使用 effect 函数的示例:
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
我们传递一个匿名的副作用函数作为 effect 函数的参数。
当 effect 函数执行时,会先将匿名副作用函数 fn 赋值给全局变量 activeEffect,然后执行注册的副作用函数 fn,触发响应式数据 obj.text 的读取操作,同时触发 Proxy 的 get 拦截函数:
const obj = new Proxy(data, {
get(target, key) {
// 如果存在 activeEffect,将其收集到“桶”中
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
上述代码,由于副作用函数已经存储在 activeEffect 中,因此在 get 拦截函数中,我们将 activeEffect 收集到“桶”中。
这样,响应系统就不再依赖副作用函数的名字了。
但是如果我们进行更深入测试,尝试设置响应式数据 obj 上的一个不存在的属性:
effect(
// 匿名副作用函数
() => {
console.log('effect run') // 会打印 2 次
document.body.innerText = obj.text
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)
这段代码中,匿名副作用函数读取了 obj.text,从而和这个字段建立了响应联系。
接着,我们启动一个定时器,1秒后为 obj 添加新的 notExist 属性。
理论上,由于副作用函数并未读取 obj.notExist,因此这个字段并未与副作用建立响应联系。
因此,当定时器内的语句执行时,不应触发副作用函数的重新执行。
然而,运行上述代码,我们发现在定时器触发后,副作用函数却重新执行了。
这是因为我们的"桶"数据结构的设计存在问题。只要触发了 obj 对象的 get 操作就会收集副作用进桶。
因此,我们需要重新设计“桶”的数据结构,使得副作用函数与被操作的字段之间建立联系。
首先,让我们更仔细地观察以下的代码:
effect(function effectFn() {
document.body.innerText = obj.text
})
这段代码中存在三个角色:
- 被操作(读取)的代理对象 obj;
- 被操作(读取)的字段名 text;
- 使用 effect 函数注册的副作用函数 effectFn。
如果我们用 target 表示代理对象所代理的原始对象,用 key 表示被操作的字段名,用 effectFn 表示被注册的副作用函数。
我们可以为这三个角色建立如下关系:
target
└── key
└── effectFn
这是一种树形结构。例如,如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})
那么关系如下:
target
└── text
├── effectFn1
└── effectFn2
如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})
那么关系如下:
target
└── text1
└── effectFn
└── text2
└── effectFn
如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})
那么关系如下:
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2
通过建立这个树型数据结构,我们就可以解决前面提到的问题。
例如,如果我们设置了 obj2.text2 的值,就只会触发 effectFn2 函数重新执行,并不会触发 effectFn1 函数。
接下来,我们将尝试用代码实现新的“桶”。首先,用 WeakMap 替换 Set 作为桶的数据结构:
// 创建用于存储副作用函数的桶
const bucket = new WeakMap()
随后,我们修改 get/set 拦截器的代码:
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,值是一个 Map 实例。
而 Map 的键是原始对象 target 的 key,值是一个由副作用函数组成的 Set。
这些关系如下图所示:

我们可以称 Set 数据结构中存储的副作用函数集合为 key 的依赖集合
使用 WeakMap 的原因在于其键为弱引用,不影响垃圾回收器的工作。一旦 key 被垃圾回收器回收,那么对应的键和值就无法访问。
因此,WeakMap 常用于存储只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。
例如上面场景,如果 target 对象没有任何引用,它会被垃圾回收器回收。如果使用 Map 可能会导致内存泄露。
下面这段代码展示了 WeakMap 和 Map 的区别:
const map = new Map();
const weakmap = new WeakMap();
(function(){
const foo = {foo: 1};
const bar = {bar: 2};
map.set(foo, 1);
weakmap.set(bar, 2);
})()
当该函数表达式执行完毕后,对于对象 foo 来说,它仍然作为 map 的 key 被引用着,因此垃圾回收器(garbage collector)不会把它从内存中移除
而对于对象 bar 来说,由于 WeakMap 的 key 是弱引用,它不影响垃圾回收器的工作,所以一旦表达式执行完毕,垃圾回收器就会把对象 bar 从内存中移除。
最后我们优化前面响应式代码,将收集副作用函数到“桶”以及触发副作用函数的逻辑分别封装到 track 和 trigger 函数中:
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())
}
4.4 分支切换与清理
首先,我们定义一个简单的响应式数据和副作用函数:
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
在 effectFn 内部,存在一个三元表达式,根据 obj.ok 的值的不同,代码会执行不同的分支。
当 obj.ok 的值发生变化时,代码执行的分支也会随之变化,这就是我们所说的“分支切换”。
分支切换可能会导致副作用函数的遗留。
以上面的代码为例,obj.ok 的初始值为 true,此时会读取 obj.text 的值,所以当 effectFn 函数执行时,会触发 obj.ok 和 obj.text 两个属性的读取操作,此时副作用函数 effectFn 与响应式数据的联系如下图所示:
data
├── ok
│ └── effectFn
└── text
└── effectFn

可以看到,副作用函数 effectFn 被 data.ok 和 data.text 所对应的依赖集合收集。
当 obj.ok 的值修改为 false,触发副作用函数重新执行后,此时不会读取 obj.text,只会触发 obj.ok 的读取操作。
理想情况下,副作用函数 effectFn 不应该被 obj.text 所对应的依赖集合收集。如下所示:

但是,根据前面的实现,我们还做不到这一点。
换言之,当我们将 obj.ok 的值修改为 false 并触发副作用函数重新执行后,整个依赖关系仍然保持不变,这就产生了副作用函数的遗留。
遗留的副作用函数可能会导致不必要的更新。例如,在上面的代码中,当我们将 obj.ok 从 true 修改为 false 后:
obj.ok = false
这将触发更新,即副作用函数重新执行。但由于此时 obj.ok 的值为 false,所以不再读取 obj.text 的值。
换句话说,无论 obj.text 的值如何变化,document.body.innerText 的值始终都是 'not'。
理想的情况是,无论 obj.text 的值怎么变,都不需要重新执行副作用函数。但如果我们尝试修改 obj.text 的值:
obj.text = 'hello vue3'
这仍然会导致副作用函数重新执行,即使 document.body.innerText 的值并不需要改变。
解决此问题思路在于:
- 每次执行副作用函数前,我们将其从相关联的依赖集合中移除,函数执行完后再重新建立联系,新的联系中则不包含遗留的副作用函数。

为了实现这一点,我们需要重新设计副作用函数,使其具有一个 deps 属性,用于存储与其相关联的依赖集合:
// 用一个全局变量存储正在执行的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 将 effectFn 设为当前活动的副作用函数
activeEffect = effectFn;
fn();
};
// 用 effectFn.deps 存储与此副作用函数相关的所有依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
接下来我们讨论如何收集 effectFn.deps 数组中的依赖集合。我们需要在 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 中
deps.add(activeEffect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps) // 新增
}
在 track 函数中,我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,然后把依赖集合 deps 添加到 activeEffect.deps 数组中:

接下来,我们在每次执行副作用函数时,根据 effectFn.deps 获取所有相关联的依赖集合,将副作用函数从依赖集合中移除:
let activeEffect;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn); // 执行清除操作
activeEffect = effectFn;
fn();
};
effectFn.deps = [];
effectFn();
}
// 实现 cleanup 函数
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn); // 将 effectFn 从依赖集合中移除
}
effectFn.deps.length = 0; // 重置 effectFn.deps 数组
}
cleanup 函数接受副作用函数作为参数,遍历其 effectFn.deps 数组,该数组中每个元素都是一个依赖集合,然后从这些集合中移除该副作用函数,并最后清空 effectFn.deps 数组。至此,我们已经可以避免副作用函数产生遗留。
但是,我们可能会遇到无限循环执行的问题。问题出在 trigger 函数中:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
// 问题出在下面这句代码 执行 effects 里面副作用函数会先清除再收集,相当于在遍历时候删除元素又添加元素,遍历永远在执行
effects && effects.forEach(fn => fn());
}
为了避免无限执行,我们可以构造一个新的 Set 集合并遍历它:
function trigger(target, key) {
const depsMap = bucket.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set(effects); // 新建一个集合并遍历
effectsToRun.forEach(effectFn => effectFn());
}
4.5 嵌套的 effect 与 effect 栈
effect 能够被嵌套使用,例如,以下代码中 effectFn1 中嵌套了 effectFn2,执行 effectFn1 会触发 effectFn2 的执行::
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ })
/* ... */
})
实际上,Vue.js 的渲染函数本身就在一个 effect 中执行。例如,对于如下定义的 Foo 组件:
// Foo 组件
const Foo = {
render() {
return /* ... */
}
}
我们需要在 effect 中执行 Foo 组件的渲染函数:
effect(() => {
Foo.render()
})
当组件被嵌套时,例如 Foo 组件渲染了 Bar 组件:
// Bar 组件
const Bar = {
render() { /* ... */ },
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}
如果 effect 不支持嵌套,会导致问题。例如,以下代码:
// 原始数据
const data = { foo: true, bar: true }
// 代理对象
const obj = new Proxy(data, { /* ... */ })
// 全局变量
let temp1, temp2
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
上述代码,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行应导致 effectFn2 的执行。
注意:我们在 effectFn2 中读取了字段 obj.bar,在 effectFn1 中读取了字段 obj.foo,并且 effectFn2 的执行先于对字段 obj.foo 的读取操作。
理想情况下,副作用函数与对象属性之间的联系如下:
data
└── foo
└── effectFn1
└── bar
└── effectFn2
三次打印的结果分别是 :
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'
前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的。
问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。
问题的根源在于我们使用全局变量 activeEffect 来存储当前激活的 effect 函数,当 effect 函数被嵌套调用时,内层 effect 的执行会覆盖 activeEffect 的值,且无法恢复至原先的状态:
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
解决方法是使用一个副作用函数栈 effectStack。
执行 effect 函数时,将当前函数压入栈中;执行完毕后,再将其从栈中弹出,保持 activeEffect 始终指向栈顶的 effect 函数。:
// 用一个全局变量存储当前激活的 effect 函数
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 数组作为栈,用于存储嵌套的 effect 函数, activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。
不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的则是内层副作用函数,

当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数 effectFn1 设置为 activeEffect

如此,我们可以保证响应式数据只收集直接读取其值的 effect 函数,避免了混乱。