# 响应式数据与副作用函数
一切非纯函数(同样的输入对应同样的输出)都是有副作用的函数,也就是说副作用函数内部会直接或间接地影响到外部变量或函数。
副作用函数指的是会产生副作用的函数,副作用函数的执行会直接或间接影响其他函数的执行。
副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用。
// 全局变量
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
2
3
4
5
6
**当一个对象的值发生改变会触发副作用函数执行,这个对象可以称为响应式数据。**响应式数据的核心在于数据变化能够被监测到,并自动触发相关的逻辑更新,无论这些更新是视图的更新,还是其他数据的更新。
# 响应式数据的基本实现
什么是响应式数据?如以下代码所示,副作用函数 effect 会设置 body 元素的 innerText 属性,其值为 obj.text,我们希望当 obj.text 的值发生变化时,副作用函数 effect 能够自动重新执行。如果我们能实现这个目标,那么 obj 就可以称为响应式数据。
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
2
3
4
5
通过观察发现,实现该需求的两点线索:
①当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作;
②当修改 obj.text 的值时,会触发字段 obj.text 的设置操作。
如果我们能在 obj.text 的读取和设置时做一个拦截,事情就好办了。
显然,上面的代码无法实现这一点,因为 obj 只是一个普通的对象。但是如果我们能拦截一个对象的读取和设置操作,事情就变得简单了。
- 当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里,
- 接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行即可。
现在问题的关键变成了我们如何才能拦截一个对象属性的读取和设置操作。
- 在 ES2015 之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。
- 在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。
# 用 Set 作为桶的数据结构
接下来我们就根据如上思路,采用 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
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
在浏览器中运行以下这段代码,会得到期望的结果。
// 副作用函数 function effect() { document.body.innerText = obj.text } // 执行副作用函数,触发读取 effect() // 1 秒后修改响应式数据 setTimeout(() => { obj.text = 'hello vue3' }, 1000)
1
2
3
4
5
6
7
8
9
10
但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为 myEffect,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。
# 设计一个完善的响应系统
此前,我们设计了一个微型的响应系统,它还不完善,所以我们需要进一步来完善它。
一个响应系统的工作流程
当读取操作发生时,将副作用函数收集到“桶”中;
当设置操作发生时,从“桶”中取出副作用函数并执行。
在上一节的实现中,我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了,我们的响应式系统也就失效了。
而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到“桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的机制:
// 用一个全局变量存储被注册的副作用函数(初始值是 undefined)
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect(fn是要注册的副作用函数)
activeEffect = fn
// 执行副作用函数
fn()
}
2
3
4
5
6
7
8
9
当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给全局变量activeEffect,接着执行被注册的匿名副作用函数 fn。
我们可以使用 effect 函数来注册一个副作用函数:
effect(
// 一个匿名的副作用函数
() => {
document.body.innerText = obj.text
}
)
2
3
4
5
6
这将会触发响应式数据 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 中取出并执行
bucket.forEach(fn => fn())
// 返回 true 表示设置操作成功
return true
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样,响应式系统就不依赖副作用函数的名字了。
但如果我们在响应式数据 obj 上设置一个不存在的属性时:
effect(
// 匿名副作用函数
() => {
console.log('effect run') // 会打印 2 次
document.body.innerText = obj.text
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
}, 1000)
2
3
4
5
6
7
8
9
10
11
12
在1秒后,我们给 obj 增加了一个新的属性,副作用函数也会执行,这是不正确的。
匿名副作用函数内部读取了字段 obj.text 的值,于是匿名副作用函数与字段 obj.text 之间会建立响应联系。
但是匿名副作用函数内并没有读取 obj.notExist 属性的值,所以理论上,字段obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。
但我们执行上述代码发现,定时器到时后,匿名副作用函数却重新执行了,这是不正确的。为了解决这个问题,我们需要重新设计“桶”的数据结构。
导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系,无论我们设置的是什么属性,副作用函数都会被收集到"桶"中。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。
# 用 WeakMap 作为桶的数据结构
解决方法很简单,我们只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个 Set 类型的数据作为“桶”了。
我们仔细观察以下代码:
effect(function effectFn () {
document.body.innerText = obj.text
})
2
3
可以发现,如果用 target 来表示一个代理对象所代理的原始对象,用 key 来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target // 一个代理对象所代理的原始对象 obj
└── key // 被操作的字段名 text
└── effectFn // 被注册的副作用函数 effectFn
2
3
这其实就是一个树型数据结构,这个联系建立起来之后,就可以解决前文提到的问题了。
一个 target 可以有多个 key;一个 key 可以有多个 effectFn;一个 effectFn 可以对应多个 key。
下面举几个例子:
effect(function effectFn1 () { obj.text }) effect(function effectFn2 () { obj.text })
1
2
3
4
5
6关系如下:
target └── key └── effectFn1 └── effectFn2
1
2
3
4
effect(function effectFn () { obj.foo obj.bar })
1
2
3
4关系如下:
target └── foo └── effectFn └── bar └── effectFn
1
2
3
4
5
effect(function effectFn1 () { obj1.foo }) effect(function effectFn2 () { obj2.bar })
1
2
3
4
5
6关系如下:
target1 └── foo └── effectFn1 target2 └── bar └── effectFn2
1
2
3
4
5
6
全局创建一个 Map 桶和 activeEffect 副作用函数,被代理的目标对象作为 key 放到桶里面,值是每个对象的 Map,对象的 Map 根据属性 key,访问装着副作用函数的 Set。
读取属性值的时候,副作用函数存在就添加到 Set;写入属性值的时候,Set 中存在副作用函数就运行。
这里的副作用函数就是同步到模版展示的函数。
Set、Map 和 WeakMap 是 JavaScript 中的三种不同的集合类型,它们各自有着不同的特点和用途。
集合类型 特点和用途 Set Set 是一种无序且唯一的值的集合。
它只存储唯一的值,重复的值会被忽略。
Set 对象允许你插入、删除和检查某个值是否存在于集合中。
它提供了一些方法和属性,如add()、delete()、has()等。Map Map 是一种键值对的集合。
在 Map 中,每个元素都由一个键(key)和一个值(value)组成,类似于字典。
与 Set 不同,Map 中的键可以是任何类型的值,包括基本数据类型和对象引用。
Map 对象提供了通过键进行操作的一系列方法,如set()、get()、delete()等。WeakMap WeakMap 与 Map 类似,也是一种键值对的集合。
但是,WeakMap 的键只能是对象。 WeakMap 中的键是弱引用,这意味着如果没有其他引用指向该键对象时,垃圾回收机制会自动回收该对象。
WeakMap 没有提供像 Map 那样的迭代方法,也没有 size 属性。
- Set 用于存储唯一值的集合。
- Map 用于存储键值对的集合,键可以是任何类型。
- WeakMap 与 Map 类似,但只接受对象作为键,并且键是弱引用,可自动回收。
针对以上的分析,我们需要重新设置响应式系统。
1、首先,需要使用 WeakMap 代替Set 作为桶的数据结构:
// 存储副作用函数的桶
const bucket = new WeakMap()
2
2、然后修改 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())
}
})
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
实际上是这样的三层数据结构:WeakMap<原始对象, Map<原始对象的某个属性, 副作用函数Set>>
WeakMap 的 key 是源对象,value 是一个 Map。而这个 Map 的 key 是源对象的键名,value 是获取这个键所在的副作用函数列表,也就是依赖(把 Set 数据结构所存储的副作用函数集合称为 key 的依赖集合)。
桶和对象是 WeakMap 关系,对象和属性是 Map 关系,属性和副作用函数是 Set 关系,大树下是小树。
WeakMap
由 target --> Map
构成;Map
由 key --> Set
构成。
其中, WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。
这里为什么要使用WeakMap?
- **WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。**据这个特性可知,**一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。**所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息。
- 如果 target 对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。
3、最后,我们对上文中的代码做一些封装处理。在目前的实现中,当读取属性值时,我们直接在 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个 track 函数中,track 是表达追踪的含义。同样,我们也可以把触发副作用函数重新执行的逻辑封装到 trigger 函数中,trigger 是表达触发的含义。:
// 存储副作用的“桶”
const bucket = new WeakMap()
// 原始数据
const data = { text: 'Hello world.' }
// 对原始数据进行代理
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)
// 返回 true 表示设置操作成功
return true
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect(不存在副作用函数),直接 return
if (!activeEffect) return
// 从 bucket 中取出 depsMap,它是一个 Map 类型
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取出 deps,它是一个 Set 类型
// 里面存储着所有与当前 key 相当的副作用函数
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将副作用函数存储进 deps 里面
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
// 根据 target 从 bucket 中取出所有的 depsMap
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 从 depsMap 中取出所有的副作用函数
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
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
40
41
42
43
44
45
46
47
48
49
50
51
如以上代码所示,分别把逻辑封装到 track 和 trigger 函数内,这能为我们带来极大的灵活性。
track 和 trigger 函数是 Vue 中依赖收集的核心。
- track 函数在 get 拦截器中被调用,用于追踪此对象的某个属性。当我们访问一个属性时,该函数将activeEffect(即对应的副作用函数)添加到一个名为 bucket 的 WeakMap 中,具体的存放位置是
bucket[target][key]
。这意味着,当属性 key 的值改变时,需要执行的函数(activeEffect)已经被正确地记住了。- trigger 函数在 set 拦截器中被调用,用于在修改属性的值时触发更新。当我们设置一个属性的新值时,该函数会从 bucket 中找到对应的副作用函数并执行它们。
- effects 语义:执行并收集一个函数的依赖,当这些依赖发生改变时,重新执行这个函数。依赖需要被清晰地描述,以避免发生冲突,详细记录函数依赖了哪个对象的哪个属性。
# 分支切换与 cleanup
# 分支切换
首先,我们需要明确分支切换的定义,如下面的代码所示:
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
2
3
4
5
6
在 effectFn 函数内部存在一个三元表达式,根据字段 obj.ok
值的不同会执行不同的代码分支。当字段 obj.ok
的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。
分支切换可能会产生遗留的副作用函数。拿上面这段代码来说,当 effectFn 函数执行时会触发字段 obj.ok
和字段 obj.text
这两个属性的读取操作,此时副作用函数 effectFn 与响应式数据之间建立的联系如下:
data
└── ok
└── effectFn
└── text
└── effectFn
2
3
4
5
可见,副作用函数 effectFn 分别被字段 data.ok
和字段 data.text
所对应的依赖集合收集。
当字段 obj.ok
的值修改为 false,并触发副作用函数重新执行后,由于此时字段 obj.text
不会被读取,只会触发字段 obj.ok
的读取操作,所以理想情况下副作用函数 effectFn 不应该被字段 obj.text
所对应的依赖集合收集:
但按照前文的实现,我们还做不到这一点。也就是说,当我们把字段obj.ok
的值修改为 false,并触发副作用函数重新执行之后,整个依赖关系仍然保持一开始所描述的那样,这时就产生了遗留的副作用函数。
obj.text
对应的 effectFn 还是被收集起来了,当我们改变obj.text
的值时,会触发更新,导致副作用函数重新运行,即使document.body.textContent
的值不需要变化。这就会导致无用的更新。
遗留的副作用函数会导致不必要的更新。
仍然拿前文的这段代码来说,
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
2
3
4
5
6
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 的值不需要变化,说明原来收集的依赖还在。
# cleanup
解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除(清除它关联的副作用函数)。当副作用函数执行完毕后,会重新再建立联系,但在新的联系中不再包含遗留的副作用函数。
注意:
- 此处的依赖集合是上面提到的,Set 数据结构所存储的副作用函数集合。
- 对应的关系是:target --> key --> effects;
- 使用的数据结构是:WeakMap(target --> depsMap) --> Map(key --> deps) --> Set(effects)
如果我们能做到每次副作用函数执行前,将其从相关联的依赖集合中移除,那么问题就迎刃而解了。**只对执行到的副作用函数进行收集/保留,而删掉那些多余的,**这就是一种更新机制。
相当于每次执行过程前先清除依赖 deps,执行完之后如果有三元,会将仅存的有读取的属性绑定 effetFn 依赖。
每次在副作用回调函数 fn 执行前,都将 effectFn 从相关联的依赖集合中删除,然后再执行回调函数 fn,重新建立联系。
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,如下面的代码所示。在 effect 内部我们定义了新的 effectFn 函数,并为其添加了effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
2
3
4
5
6
7
8
9
10
11
12
13
那么 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) // 新增
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在 track 函数中我们将当前执行的副作用函数 activeEffect 添加到依赖集合 deps 中,这说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到 activeEffect.deps 数组中,这样就完成了对依赖集合的收集。
有了这个联系后,我们就可以在每次副作用函数执行时,先清除之前的联系,即根据 effectFn.deps 获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn) // 新增
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
2
3
4
5
6
7
8
9
10
11
12
下面是 cleanup 函数的实现:
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
}
2
3
4
5
6
7
8
9
10
11
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.forEach(fn => fn()) // 问题出在这句代码
}
2
3
4
5
6
在 trigger 函数内部,我们遍历 effects 集合,它是一个 Set 集合,里面存储着副作用函数。当副作用函数执行时,会调用 cleanup 进行清除,实际上就是从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合中,而此时对于 effects 集合的遍历仍在进行。
- 其实就是在同一个 Set 中 forEach 遍历,某个值又删又增的情况(不断删除添加同一个元素),会进行无限循环,所以需要新增一个 Set,这样,一个负责遍历,另一个负责增删,本质上是避开规范带来的bug 而已。
- 执行副作用函数时移除它的依赖项,如果它的副作用函数执行了 set,又会重新收集依赖项。所以会形成死循环,解决办法是用一个新 set。
为何会出现这样的现象?
执行步骤:
1、副作用函数注册执行,在 track 函数中收集了对应的依赖关系。
2、此时,访问了代理对象的 set 方法,触发了 trigger 函数;
3、trigge r函数此时先调用 cleanup 函数,后调用原始的 fn 副作用函数
4、问题来了,在 trigger 函数中对副作用队列遍历执行中,先从队列中删除了副作用函数(执行了cleanup),后又因为执行了 fn 函数,导致依赖被重新收集到队列中,从而出现了死循环。
这个行为可以用如下简短的代码来表达:
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
2
3
4
5
6
7
语言规范中对此有明确的说明:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。因此,上面的代码会无限执行。解决办法很简单,我们可以构造另外一个 Set 集合并遍历它:
const set = new Set([1]) const newSet = new Set(set) newSet.forEach(item => { set.delete(1) set.add(1) console.log('遍历中') })
1
2
3
4
5
6
7
8这样就不会无限执行了。
回到 trigger 函数,我们需要同样的手段来防止代码进入死循环:
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()) // 新增
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
2
3
4
5
6
7
8
9
如以上代码所示,我们新构造了 effectsToRun 集合并遍历它,代替直接遍历 effects 集合,从而避免了无限执行。
new 一个新的 set 去遍历,但实际上修改的是原来的 set 结构,所以不会循环。分成了 2 个 set,一个负责遍历,一个负责增删。
最后我们的检验一下代码:
effect(
// 副作用函数
() => {
console.log('run')
document.body.innerText = obj.ok ? obj.text : 'plain text.'
}
)
setTimeout(() => {
obj.ok = false
setTimeout(() => {
obj.text = 'Hello again.'
}, 1000)
}, 1000)
2
3
4
5
6
7
8
9
10
11
12
13
14
控制台一共输出两次 run,一次是
obj.ok
初始值为 true时,另一次为obj.ok
的值设置为 false 时。当obj.text
的值改变时,副作用不再执行,说明我们上面的改动是符合需求的。
# 嵌套的 effect 与 effect 栈
**effect 是可以发生嵌套的。**例如下面这段代码,effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。
effect(function effectFn1() {
effect(function effectFn2() { /* ... */ })
/* ... */
})
2
3
4
# 为什么 effect 要设计成可嵌套的
那么什么场景会出现嵌套的 effect 呢?实际上, Vue.js 的渲染函数就是在一个 effect 中执行的:
// Foo 组件
const Foo = {
render() {
return /* ... */
}
}
2
3
4
5
6
在一个 effect 中执行 Foo 组件的渲染函数:
effect(() => {
Foo.render()
})
2
3
当组件发生嵌套时,例如 Foo 组件渲染了 Bar 组件:
// Bar 组件
const Bar = {
render() { /* ... */ },
}
// Foo 组件渲染了 Bar 组件
const Foo = {
render() {
return <Bar /> // jsx 语法
},
}
2
3
4
5
6
7
8
9
10
此时就发生了 effect 嵌套,它相当于:
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
2
3
4
5
6
7
# 如果 effect 不支持嵌套会发生什么
实际上,按照前文的实现,我们所实现的响应系统并不支持 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
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在理想情况下,我们希望副作用函数与对象属性之间的联系如下:
data
└── foo
└── effectFn1
└── bar
└── effectFn2
2
3
4
5
我们希望当修改 obj.foo 时会触发 effectFn1 执行。由于effectFn2 嵌套在 effectFn1 里,所以会间接触发 effectFn2 执行,而当修改 obj.bar 时,只会触发 effectFn2 执行。
但结果不是这样的,我们尝试修改 obj.foo 的值,会发现输出为:
'effectFn1 执行'
'effectFn2 执行'
'effectFn2 执行'
2
3
我们修改了字段 obj.foo 的值,发现 effectFn1 并没有重新执行,反而使得 effectFn2重新执行了,这显然不符合预期。
**问题出在哪里呢?**其实就出在我们实现的 effect 函数与 activeEffect 上。
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
我们用全局变量 activeEffect 来存储通过 effect 函数注册的副作用函数,这意味着同一时刻 activeEffect 所存储的副作用函数只能有一个。
当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。
这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也都会是内层副作用函数,这就是问题所在。
# 函数栈 effectStack
为了解决这个问题,我们需要一个副作用函数栈 effectStack 来缓存外层函数的副作用函数,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。
副作用函数执行时 push,执行完成就从栈中 pop,始终保持 activeEffect 指向栈顶的副作用函数。
如以下代码所示:
// 用一个全局变量存储当前激活的 effect 函数
let activeEffect
// effect 栈
const effectStack = [] // 新增
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn) // 新增
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈
effectStack.pop() // 新增
// 并把 activeEffect 还原为之前的值
activeEffect = effectStack[effectStack.length - 1] // 新增
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
定义了 effectStack 数组,用它来模拟栈,activeEffect 没有变化,它仍然指向当前正在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数。
effect(function effectFn1 () {
effect(function effectFn2 () {
// ...
})
})
// 第一次 effect() 执行
// effectFn === effectFn1
// 那么 effectStack.push(effectFn) 后,effectStack 就变成了 [effectFn1]
// 之后 fn() 执行,也就是整个 effectFn1() 执行,
// effectFn1() 的执行,导致 effect() 再次执行
// 此时 effectFn === effectFn2
// 那么 effectStack.push(effectFn) 后,effectStack 就变成了 [effectFn1, effectFn2]
// fn() 执行后,会回到 effectFn1 的副作用函数上面
// 此时 effectStack.pop() 后 effectStack 就变成了 [effectFn1]
// 我们从 effectStack 中取出最后一项给 activeEffect
2
3
4
5
6
7
8
9
10
11
12
13
14
15
当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数 effectFn1 设置为 activeEffect
如此一来,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。
# 避免无限递归循环
实现一个完善的响应系统要考虑诸多细节,而无限递归循环就是其中之一。
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => obj.foo++)
2
3
4
可以看到,在 effect 注册的副作用函数内有一个自增操作 obj.foo++,该操作会引起栈溢出:
Uncaught RangeError: Maximum call stack size exceeded
1
实际上,我们可以把 obj.foo++ 这个自增操作分开来看,它相当于:
effect(() => {
// 语句
obj.foo = obj.foo + 1
})
2
3
4
在这个语句中,既会读取 obj.foo 的值,又会设置 obj.foo 的值,而这就是导致问题的根本原因。
- 执行流程:首先读取 obj.foo 的值,这会触发 track 操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给 obj.foo,此时会触发 trigger 操作,即把“桶”中的副作用函数取出并执行。
- 但问题是该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。这样会导致无限递归地调用自己,于是就产生了栈溢出。
通过分析这个问题我们能够发现,读取和设置操作是在同一个副作用函数内进行的。此时无论是 track 时收集的副作用函数,还是 trigger 时要触发执行的副作用函数,都是 activeEffect。
基于此,我们可以在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。如以下代码所示:
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())
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这样我们就能够避免无限递归调用,从而避免栈溢出。
# 调度执行
可调度性是响应系统非常重要的特性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。简单的来说,可调度性就是让用户可以根据需求来决定什么时候执行副作用函数。
# 执行顺序
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
2
3
4
5
6
7
8
9
10
这段代码的输出结果如下:
1 2 '结束了'
1
2
3现在假设需求有变,输出顺序需要调整为:
1 '结束了' 2
1
2
3根据打印结果我们很容易想到对策,即把语句 obj.foo++ 和语句console.log('结束了') 位置互换即可。
那么有没有什么办法能够在不调整代码的情况下实现需求呢?这时就需要响应系统支持调度。
我们可以为 effect 函数设计一个选项参数 options,允许用户指定调度器:
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)
2
3
4
5
6
7
8
9
10
11
12
用户在调用 effect 函数注册副作用函数时,可以传递第二个参数 options。它是一个对象,其中允许指定 scheduler 调度函数,同时在 effect 函数内部我们需要把 options 选项挂载到对应的副作用函数上。
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
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 => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) { // 新增
effectFn.options.scheduler(effectFn) // 新增
} else {
// 否则直接执行副作用函数(之前的默认行为)
effectFn() // 新增
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果有,则将副作用函数作为调度器的参数,并执行调度器函数,由用户自己控制如何执行;否则保留之前的行为,即直接执行副作用函数。
有了这些基础设施之后,我们就可以实现前文的需求了,如以下代码所示:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn)
}
}
)
obj.foo++
console.log('结束了')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
我们使用 setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能实现期望的打印顺序了:
1 '结束了' 2
1
2
3
# 执行次数
除了控制副作用函数的执行顺序,通过调度器还可以做到控制它的执行次数,这一点也尤为重要。
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
2
3
4
5
6
7
8
9
在没有指定调度器的情况下,这段代码的输出如下:
1 2 3
1
2
3字段 obj.foo 的值一定会从 1 自增到 3,2 只是它的过渡状态。如果我们只关心最终结果而不关心过程,那么执行三次打印操作是多余的,我们期望的打印结果是不包含过渡状态的:
1 3
1
2
基于调度器我们可以很容易地实现此功能:
// 定义一个任务队列
const jobQueue = new Set() // 目的是利用 Set 数据结构的自动去重能力
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false // 判断是否需要执行,只有当其为 false 时才需要执行
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true,代表正在刷新
isFlushing = true // 无论调用多少次 flushJob 函数,在一个周期内都只会执行一次
// 在微任务队列中刷新 jobQueue 队列
p.then(() => { // 通过p.then将一个函数添加到微任务队列,在微任务队列内完成对jobQueue的遍历执行
jobQueue.forEach(job => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
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
连续对 obj.foo 执行两次自增操作,会同步且连续地执行两次 scheduler 调度函数,这意味着同一个副作用函数会被jobQueue.add(fn) 语句添加两次,但由于 Set 数据结构的去重能力,最终 jobQueue 中只会有一项,即当前副作用函数。
类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次,即在微任务队列内执行一次。
当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:
1 3
1
2
通过 isFlushing 标志和 Set 数据结构去重和微任务队列的结合,解决了频繁更新响应式变量而频繁触发 effect 回调的问题,使多次修改响应式变量,只会触发最终的 effect 回调,从而提升性能。
这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,思路与上文介绍的相同。
# 计算属性 computed 与 lazy
# lazy 的 effect
effect(
// 这个函数会立即执行
() => {
console.log(obj.foo)
}
)
2
3
4
5
6
但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的:
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)
2
3
4
5
6
7
8
9
10
有了它,我们就可以修改 effect 函数的实现逻辑了,当 options.lazy 为true 时,则不立即执行副作用函数:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options
effectFn.deps = []
// 只有非 lazy 的时候,才执行
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn // 新增
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
我们将副作用函数 effectFn 作为 effect 函数的返回值,这就意味着当调用effect 函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行该副作用函数了(实现 lazy 的 effect):
const effectFn = effect(() => {
console.log(obj.foo)
}, { lazy: true })
// 手动执行副作用函数
effectFn()
2
3
4
5
6
如果仅仅能够手动执行副作用函数,其意义并不大。但如果我们把传递给effect 的函数看作一个 getter,那么这个 getter 函数可以返回任何值,例如:
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
// 这样,当我们手动执行副作用函数时,就能拿到obj.foo+obj.bar的结果
// value 是 getter 的返回值
const value = effectFn()
2
3
4
5
6
7
8
为了实现这个目标,我们需要再对 effect 函数做一些修改:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn() // 新增
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 将 res 作为 effectFn 的返回值
return res // 新增
}
effectFn.options = options
effectFn.deps = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。
为了通过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其保存到 res 变量中,然后将其作为 effectFn 函数的返回值。
# 计算属性 - 懒计算
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了:
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回。
我们可以使用 computed 函数来创建一个计算属性:
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
2
3
4
5
6
可以看到它能够正确地工作,不过现在我们实现的计算属性只做到了懒计算,但是还做不到对值进行缓存,我们多次访问就会导致 effectFn 多次计算,即使访问的值本身并无变化。
# 计算属性 - 缓存
为了解决这个问题,就需要我们在实现 computed 函数时,添加对值进行缓存的功能:
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}
return obj
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
只有当 dirty 为 true 时才会调用 effectFn 重新计算值,否则直接使用上一次缓存在 value 中的值。
这样子虽然解决了缓存问题,但是如果此时我们修改 obj.foo 或 obj.bar 的值,再访问 sumRes.value,会发现访问到的值没有发生变化:
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
// 修改 obj.foo
obj.foo++
// 再次访问,得到的仍然是 3,但预期结果应该是 4
console.log(sumRes.value) // 3
2
3
4
5
6
7
8
9
10
11
12
13
这是因为,当第一次访问 sumRes.value 的值后,变量 dirty 会设置为false,代表不需要计算。即使我们修改了 obj.foo 的值,但只要 dirty 的值为 false,就不会重新计算,所以导致我们得到了错误的值。
解决办法很简单,当 obj.foo 或 obj.bar 的值发生变化时,只要 dirty 的值重置为 true 就可以了。(利用调度器,在依赖的响应式数据发生变化而触发 trigger()
函数执行时,把 dirty 的值改成 true 就行)
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
当响应式数据发生变化时,会触发 trigger(),执行副作用函数的调度器 scheduler 方法,将 dirty 重置为 true,当再次访问 value 时,重新调用副作用函数 effectFn 计算值,得到最新的结果,这样就能够得到预期的结果了。
当我们在另外一个 effect 中读取计算属性的值时,会发现一个缺陷:
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo++
2
3
4
5
6
7
8
9
修改 obj.foo 的值并不会触发副作用函数的渲染。
这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时,就会发生 effect 嵌套,外层的 effect 不会被内层 effect 中的响应式数据收集。
外层的是普通的 effect,内层的是 computed 的 effect,computed 的 effect 是没有收集依赖的,所以要主动添加响应性的依赖收集。
解决办法很简单。当读取计算属性的值时,我们可以手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,会执行调度器函数,在调度器函数内,我们可以手动调用 trigger 函数触发响应:
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}
return obj
}
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
执行顺序:computed 函数依赖的值产生变动,dirty 被设为 true,triigger 通知之前 track 收集的依赖副作用函数执行,副作用函数内部会调用一次 computed 产生的 obj 的 value,从而进行一次副作用计算更新值,同时把 dirty 设为false,之后执行的依赖副作用读取的是缓存的值。
**计算属性依赖的值变化时,计算属性会触发内层 effect;而内层 effect 的改变也会触发外层 effect 的改变,这样就间接使得外层 effect 可以被内层 effect 的依赖收集到。**而这一实现通过的是手动追踪依赖实现的,即手动调用 track 和 trigger 函数。
这时,对于如下代码来说:
effect(function effectFn() { console.log(sumRes.value) })
1
2
3它会建立这样的联系:
computed(obj) └── value └── effectFn
1
2
3
在 effect 中读取计算属性与前面章节中 effect 嵌套之间的区别?
1、前文中的 effect 嵌套在内外层的 effect 函数分别读取了与之关联的响应式数据,因此正确执行的关键是在读取操作中收集正确的activeEffect,因此我们需要通过引入effect 栈来实现 activeEffect 的及时更新。
2、而在 effect 中读取计算属性的情况中只有计算属性中的 getter 触发了响应式数据的读取操作,因此计算属性的变更无法引起外层 effec t函数的重新执行,因此我们需要在计算属性的读取与变更行为中手动触发 track 与 trigger 操作,注意,由于 track 函数是在计算属性 effectFn 执行完毕后调用,因此,此时 activeEffect 的指向也是外层 effect,因此,这里的 trigger 可以准确将计算属性和外层的 effect 关联起来。
# watch 的实现原理
在一个副作用函数中访问响应式数据 ,这会在副作用函数与响应式数据之间建立联系,当响应式数据变化时,会触发副作用函数重新执行。
如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 调度函数执行,而非直接触发副作用函数执行。
其实 scheduler调度函数就相当于一个回调函数,而 watch 的实现就是利用了这个特点。
所谓 watch,其本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++
2
3
4
5
watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项。
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
2
3
4
5
6
7
我们可以如下所示使用 watch 函数:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
watch(obj, () => {
console.log('数据变化了')
})
obj.foo++
2
3
4
5
6
7
8
可以看到它能够正确地工作,但是我们在 watch 函数的实现中,硬编码了对 obj.foo 的读取操作,换句话说,现在只能观测 obj.foo 的改变。
为了让 watch 函数具有通用性,我们需要一个封装一个通用的读取操作:
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen)
}
return value
}
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
在 watch 内部的 effect 中调用 traverse 函数进行递归的读取操作,代替硬编码的方式,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。
watch 函数除了可以观测响应式数据,还可以接收一个 getter 函数:
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
2
3
4
5
6
7
8
在 getter 函数内部,用户可以指定该 watch 依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。
我们需要对 watch 函数做一些修改来实现这一功能:
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb()
}
}
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
有个大问题:在回调函数中拿不到旧值与新值。
通常我们在使用 Vue.js 中的 watch 函数时,能够在回调函数中得到变化前后的值:
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue) // 2, 1
}
)
obj.foo++
2
3
4
5
6
7
8
lazy 属性为 true 代表使用 effect 的时候副作用回调不会立即执行,根据这个特性,实现了 watch 中的新I旧值获取问题和 computed 的特性。
那么如何获得新值与旧值呢?最核心的改动是使用 lazy 选项创建了一个懒执行的 effect:
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
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
我们手动调用 effectFn 函数得到的返回值就是旧值,即第一次执行得到的值。
当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 函数并得到新值,这样我们就拿到了旧值与新值,接着将它们作为参数传递给回调函数 cb 就可以了。
最后,使用新值更新旧值:oldValue = newValue,否则在下一次变更发生时会得到错误的旧值。
# 立即执行的 watch 与回调执行时机
watch 的本质其实是对 effect 的二次封装。 watch的两个特性:一个是立即执行的回调函数,另一个是回调函数的执行时机。
# 立即执行的回调函数
默认情况下,一个 watch 的回调只会在响应式数据发生变化时才执行:
// 回调函数只有在响应式数据 obj 后续发生变化时才执行
watch(obj, () => {
console.log('变化了')
})
2
3
4
在 Vue.js 中可以通过选项参数 immediate 来指定回调是否需要立即执行:
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})
2
3
4
5
6
回调函数的立即执行与后续执行本质上没有任何差别,所以我们可以把 scheduler 调度函数封装为一个通用函数,分别在初始化和变更时执行它:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
oldValue = effectFn()
}
}
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
由于回调函数是立即执行的,所以第一次回调执行时没有所谓的旧值,因此此时回调函数的 oldValue 值为 undefined,这也是符合预期的。
# 回调函数的执行时机
除了指定回调函数为立即执行之外,还可以通过其他选项参数来指定回调函数的执行时机,例如在 Vue.js 3 中使用 flush 选项来指定:
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
flush: 'pre' // 还可以指定为 'post' | 'sync'
})
2
3
4
5
6
flush 本质上是在指定调度函数的执行时机。前文讲解过如何在微任务队列中执行调度函数 scheduler,这与 flush 的功能相同。
flush 的值为'post' 时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,我们可以用如下代码进行模拟:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微任务队列中执行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
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
在调度器函数内检测 options.flush 的值是否为 post,如果是,则将 job 函数放到微任务队列中,从而实现异步延迟执行;否则直接执行 job 函数,这本质上相当于 'sync' 的实现机制,即同步执行。
对于 options.flush 的值为'pre' 的情况,我们暂时还没有办法模拟,因为这涉及组件的更新时机。
# 过期的副作用
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})
obj.foo++
setTimeout(() => {
obj.foo++
}, 200)
2
3
4
5
6
7
8
9
10
11
12
13
14
假设 fetch() 第一次请求(请求 A)需要 1000ms 才会返回请求结果,而第二次(请求 B)只需要 100ms 就能完成。那么会造成最终我们的 finalData 拿到的是第一次请求结果,和我们预期的不一样。
- 请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是副作用函数第二次执行所产生的副作用。
- 如果请求 B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果。
- 但是我们希望变量 finalData 存储的值应该是由请求 B 返回的结果,而非请求 A 返回的结果。由于请求 B 后发生,所以请求 B 的结果应该被视为“最新”的,而请求 A已经“过期”了,其产生的结果应被视为无效。通过这种方式就可以避免竞态问题导致的错误结果。
我们需要的是一个让副作用过期的手段。在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 函数注册一个回调,这个回调函数会在当前副作用函数过期时执行:
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- 在发送请求之前,定义了 expired 标志变量,用来标识当前副作用函数的执行是否过期;接着调用 onInvalidate 函数注册了一个过期回调,当该副作用函数的执行过期时将 expired 标志变量设置为 true;只有当没有过期时,才采用请求结果,这样就可以有效地避免上述问题了。
- await 要等异步操作完成才会执行后面的语句,所以可以利用同步操作 B 改变 A 的 expired 变量,等恢复 await 后面的执行,A 场景的 expired 变量已经变成 true 了。
那么 Vue.js 是怎么做到的呢?onInvalidate 的原理是什么呢?在 watch 内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过 onInvalidate 函数注册的过期回调,仅此而已。
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
const job = () => {
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数传递给 cb,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
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
40
41
42
43
44
45
46
47
48
49
50
51
很巧妙,通过栈+回调 +闭包,追踪当前函数调用栈是否结束。我们还是通过一个例子来进一步说明:
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/path/to/request') // 假设请求 A需要 1000ms 才能返回结果
if (!expired) {
finalData = res
}
})
// 第一次修改是立即执行的,这会导致 watch 的回调函数执行。
obj.foo++
setTimeout(() => {
// 200ms 后做第二次修改,这又会导致 watch 的回调函数执行。
obj.foo++
}, 200)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
**每次 foo 发生变化,都会触发 watch 的回调,但是每次触发新一次的回调函数,都会调用上一次中 onlnvalidate 的回调函数,从而影响了上一次的结果。**只有回调函数最后一次执行的时候,expired 才不会被设置为 true,因为设置 expired 的条件是当前回调后面还有回调,只有在第二次回调执行的时候,第一次注册的过期回调才会执行。
每次执行回调函数之前要先检查过期回调是否存在,如果存在,会优先执行过期回调。由于在 watch 的回调函数第一次执行时,我们已经注册了一个过期回调,所以在 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。于是等请求 A的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响。
# 总结
- 一个响应式数据最基本的实现依赖于对“读取”和“设置”操作的拦截,从而在副作用函数与响应式数据之间建立联系。当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应系统的根本实现原理。
- 使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。
- 分支切换会导致冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存在冗余副作用问题。
- 遍历 Set 数据结构导致无限循环的新问题,该问题产生的原因可以从 ECMA 规范中得知,即“在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”解决方案是建立一个新的 Set 数据结构用来遍历。
- 在实际场景中,嵌套的副作用函数发生在组件嵌套的场景中,即父子组件关系。这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱,我们需要使用副作用函数栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈中弹出,当读取响应式数据的时候,被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系
- 副作用函数无限递归地调用自身,导致栈溢出的问题。该问题的根本原因在于,对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法很简单,如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
- 所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,我们为 effect 函数增加了第二个选项参数,可以通过 scheduler 选项指定调用器,这样用户可以通过调度器自行完成任务的调度。可以通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。
- 计算属性实际上是一个懒执行的副作用函数,我们通过 lazy 选项使得副作用函数可以懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化时,会通过 scheduler 将dirty 标记设置为 true,代表“脏”。这样,下次读取计算属性的值时,我们会重新计算真正的值。
- watch 的实现原理:它本质上利用了副作用函数重新执行时的可调度性。一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即scheduler。这里的 scheduler 可以理解为“回调”,所以我们只需要在scheduler 中执行用户通过 watch 函数注册的回调函数即可。立即执行回调的 watch 通过添加新的 immediate 选项来实现,通过 flush 选项来指定回调函数具体的执行时机,本质上是利用了调用器和异步的微任务队列。
- 过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch 的回调函数设计了第三个参数,即 onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。