一、响应式数据和副作用函数

副作用函数是指那些产生副作用的函数:

1
2
3
function effect() {
document.body.innerText = 'hello vue3'
}

执行 effect 函数时,它会设置 body 的文本内容,这种更改可以被其他任何函数读取或设置。
因此,effect 的执行会直接或间接影响其他函数的执行,这就是它产生副作用的地方。
副作用很容易产生,比如修改一个全局变量:

1
2
3
4
5
6
// 全局变量
let val = 1

function effect() {
val = 2 // 更改全局变量,产生副作用
}

理解了副作用函数后,我们再来看看响应式数据是什么。设想在一个副作用函数中读取了某个对象的属性:

1
2
3
4
5
6
const obj = { text: 'hello world' }

function effect() {
// 在执行effect函数时会读取 obj.text
document.body.innerText = obj.text
}

上述代码,effect 函数会设置 body 元素的 innerText 属性,其值为 obj.text。当 obj.text 值发生变化时,我们希望 effect 函数会重新执行:

1
obj.text = 'hello vue3' // 修改 obj.text 的值,并希望副作用函数重新执行

当 obj.text 值改变时,我们希望副作用函数能自动重新执行。如果这可以实现,那么对象 obj 就可以被称为响应式数据。
但显然,现在我们无法实现这一点,因为 obj 仅仅是一个普通对象,当我们改变它的值时,除了值本身之外,不会有任何其他反应。

二、基本响应式数据实现

为了使 obj 成为响应式数据,我们可以从以下两点出发:

1、执行副作用函数 effect 时,会触发 obj.text 的读取操作。
2、当修改 obj.text 的值时,会触发 obj.text 的设置操作。

如果我们能拦截对象的读取和设置操作,这个问题就简单了。

1、读取 obj.text 时,将副作用函数 effect 存储到一个“桶”中。
2、在设置 obj.text 时,从“桶”中取出副作用函数 effect 并执行。

在 ES2015 之前,我们可以使用 Object.defineProperty 函数,这是 Vue.js 2 的实现方式。
在 ES2015+ 中,我们可以使用代理对象 Proxy,这是 Vue3 的实现方式。

用 Proxy 来实现响应式数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 存储副作用函数的桶
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 添加到桶中。
设置属性时,我们先更新原始数据,然后重新执行桶中的副作用函数。这样就实现了响应式数据。
测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
// 副作用函数
function effect() {
document.body.innerText = obj.text
}

// 触发读取
effect()

// 1秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
}, 1000)

在浏览器中运行以上代码,我们将得到预期的结果。
但当前的实现仍有不足,比如我们是直接通过函数名 effect 获取副作用函数,这种硬编码方式缺乏灵活性。
副作用函数的名字是可以任意命名的,我们可以把副作用函数命名为 myEffect,或者甚至用一个匿名函数。
因此,我们需要找到去除这种硬编码的方法。

三、设计完善响应式系统

实现步骤如下:

1、读取操作时,将副作用函数收集到“桶”中。
2、设置操作时,从“桶”中取出并执行副作用函数。

为了解决硬编码副作用函数名(effect)的问题,我们提供一个注册副作用函数的机制:

1
2
3
4
5
6
7
8
9
10
// 用一个全局变量存储被注册的副作用函数
let activeEffect

// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}

首先,我们定义了一个全局变量 activeEffect,用于存储被注册的副作用函数。
然后定义了 effect 函数,这个函数用于注册副作用函数,接受一个参数 fn,也就是我们要注册的副作用函数。
使用 effect 函数的示例:

1
2
3
4
5
6
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)

我们传递一个匿名的副作用函数作为 effect 函数的参数。
当 effect 函数执行时,会先将匿名副作用函数 fn 赋值给全局变量 activeEffect,然后执行注册的副作用函数 fn,触发响应式数据 obj.text 的读取操作,同时触发 Proxy 的 get 拦截函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 上的一个不存在的属性:

1
2
3
4
5
6
7
8
9
10
11
12
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 操作就会收集副作用进桶。
因此,我们需要重新设计“桶”的数据结构,使得副作用函数与被操作的字段之间建立联系。
首先,让我们更仔细地观察以下的代码:

1
2
3
effect(function effectFn() {
document.body.innerText = obj.text
})

1、被操作(读取)的代理对象 obj;
2、被操作(读取)的字段名 text;
3、使用 effect 函数注册的副作用函数 effectFn。

如果我们用 target 表示代理对象所代理的原始对象,用 key 表示被操作的字段名,用 effectFn 表示被注册的副作用函数。
我们可以为这三个角色建立如下关系:

1
2
3
target
└── key
└── effectFn

这是一种树形结构。例如,如果有两个副作用函数同时读取同一个对象的属性值:

1
2
3
4
5
6
7
effect(function effectFn1() {
obj.text
})

effect(function effectFn2() {
obj.text
})

那么关系如下:

1
2
3
4
target
└── text
├── effectFn1
└── effectFn2

如果一个副作用函数中读取了同一个对象的两个不同属性:

1
2
3
4
effect(function effectFn() {
obj.text1
obj.text2
})

那么关系如下:

1
2
3
4
5
target
└── text1
└── effectFn
└── text2
└── effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

1
2
3
4
5
6
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})

那么关系如下:

1
2
3
4
5
6
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2

通过建立这个树型数据结构,我们就可以解决前面提到的问题。
例如,如果我们设置了 obj2.text2 的值,就只会触发 effectFn2 函数重新执行,并不会触发 effectFn1 函数。

接下来,我们将尝试用代码实现新的“桶”。首先,用 WeakMap 替换 Set 作为桶的数据结构:

1
2
// 创建用于存储副作用函数的桶
const bucket = new WeakMap()

修改 get/set 拦截器的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 的区别:

1
2
3
4
5
6
7
8
9
10
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 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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())
}

通过将这些逻辑封装到 track 和 trigger 函数中,我们可以使代码更加灵活

四、分支切换与清理

定义一个简单的响应式数据和副作用函数:

1
2
3
4
5
6
7
8
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 与响应式数据的联系如下图所示:

1
2
3
4
5
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 后:

1
obj.ok = false

这将触发更新,即副作用函数重新执行。但由于此时 obj.ok 的值为 false,所以不再读取 obj.text 的值。
换句话说,无论 obj.text 的值如何变化,document.body.innerText 的值始终都是 ‘not’。
理想的情况是,无论 obj.text 的值怎么变,都不需要重新执行副作用函数。但如果我们尝试修改 obj.text 的值:

1
obj.text = 'hello vue3'

这仍然会导致副作用函数重新执行,即使 document.body.innerText 的值并不需要改变。
解决此问题思路在于:

每次执行副作用函数前,我们将其从相关联的依赖集合中移除,函数执行完后再重新建立联系,新的联系中则不包含遗留的副作用函数。
重新设计副作用函数,使其具有一个 deps 属性,用于存储与其相关联的依赖集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 用一个全局变量存储正在执行的副作用函数
let activeEffect

function effect(fn) {
const effectFn = () => {
// 将 effectFn 设为当前活动的副作用函数
activeEffect = effectFn
fn()
}
// 用 effectFn.deps 存储与此副作用函数相关的所有依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

如何收集 effectFn.deps 数组中的依赖集合。我们需要在 track 函数中完成收集过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 获取所有相关联的依赖集合,将副作用函数从依赖集合中移除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 函数中:

1
2
3
4
5
6
7
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// 问题出在下面这句代码 执行 effects 里面副作用函数会先清除再收集,相当于在遍历时候删除元素又添加元素,遍历永远在执行
effects && effects.forEach((fn) => fn())
}

为了避免无限执行,我们可以构造一个新的 Set 集合并遍历它:

1
2
3
4
5
6
7
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())
}

我们创建了一个新的集合 effectsToRun,遍历它而不是直接遍历 effects 集合,从而避免无限执行。

五、嵌套的 effect 与 effect 栈

effect 能够被嵌套使用,例如,以下代码中 effectFn1 中嵌套了 effectFn2,执行 effectFn1 会触发 effectFn2 的执行:

1
2
3
4
5
6
effect(function effectFn1() {
effect(function effectFn2() {
/* ... */
})
/* ... */
})

实际上,Vue.js 的渲染函数本身就在一个 effect 中执行。例如,对于如下定义的 Foo 组件:

1
2
3
4
5
6
// Foo 组件
const Foo = {
render() {
return /* ... */
}
}

我们需要在 effect 中执行 Foo 组件的渲染函数:

1
2
3
effect(() => {
Foo.render()
})

当组件被嵌套时,例如 Foo 组件渲染了 Bar 组件:

1
2
3
4
5
6
7
8
9
10
// Bar 组件
const Bar = {
render() { /* ... */ },

// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}

这时就会出现嵌套的 effect,类似于以下的代码结构:

1
2
3
4
5
6
7
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})

如果 effect 不支持嵌套,会导致问题。例如,以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 原始数据
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 的读取操作。
理想情况下,副作用函数与对象属性之间的联系如下:

1
2
3
4
5
data
└── foo
└── effectFn1
└── bar
└── effectFn2

三次打印的结果分别是 :

1
2
3
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'

前两次分别是副作用函数 effectFn1 与 effectFn2 初始执行的打印结果,到这一步是正常的。
问题出在第三行打印。我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2 重新执行了,这显然不符合预期。
问题的根源在于我们使用全局变量 activeEffect 来存储当前激活的 effect 函数,当 effect 函数被嵌套调用时,内层 effect 的执行会覆盖 activeEffect 的值,且无法恢复至原先的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}

解决方法是使用一个副作用函数栈 effectStack。
执行 effect 函数时,将当前函数压入栈中;执行完毕后,再将其从栈中弹出,保持 activeEffect 始终指向栈顶的 effect 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 用一个全局变量存储当前激活的 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 函数,避免了混乱。