Vue.js设计与实现:响应式系统的作用与实现 4-1

本章目标

本章目标:

  1. 讨论什么是响应式数据和副作用函数,实现一个相对完善的响应式系统
  2. 如何避免无限递归?
  3. 为什么需要嵌套副作用函数?
  4. 两个副作用函数之间会产生哪些影响?
  5. ……

4.1 响应式数据和副作用函数

  • 副作用函数:是指执行会直接或间接影响其他函数执行的函数。例如,一个函数内修改全局变量的值,函数就是一个副作用。
  • 响应式数据:在一个副作用 effect 函数中读取对象 obj 的 text 属性值,在对象 obj.text 值发生变化后,副作用 effect 函数自动执行。那个这个对象 obj 就是响应式数据。

4.2 响应式数据的基本实现

如果要实现 obj 成为响应式数据,需要拦截对象的读取和设置操作。

  • 在读取字段 obj.text 时,把副作用函数 effect 存储“桶”中。
  • 在设置字段 obj.text 时,把副作用 effect 从 “桶”中取出并执行即可。

在 Vue2 中使用 Object.defineProperty 函数实现。在 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
25
26
27
28
29
30
31
32
33
34
// 原始数据
const data = {
text: 'Hello text!',
}
// 存储副作用的桶
const bucket = new Set()
// 对原始数据的代理
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())
return true
},
})
// 副作用
function effect() {
console.log('---', obj.text)
}
// 执行副作用,触发读取
effect()
// 1s 后修改响应数据
setTimeout(() => {
obj.text = 'Hello vue3'
}, 1000)

运行上面代码,会得到期望结果。
但是,这个实现存在缺陷,硬绑定副作用的名字 effect。期望可以任意取名字,或使用匿名函数。

4.3 设计一个完整的响应系统

为了解决副作用函数名字硬绑定问题,需要提供一个用来注册副作用函数的机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 全局变量存储被注册的副作用函数
let activeEffect
// effect 用于注册副作用函数
function effect(fn) {
// 调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}

// 修改 get 中
const obj = new Proxy(data, {
get(target, key) {
// ....
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
// ...
})

全局变量 activeEffect 用于存放被注册的副作用函数。重新定义 effect 函数用于注册副作用函数的函数,参数 fn 是要注册的副作用函数。

1
2
3
4
5
6
7
8
// 注册一个匿名副作用函数
effect(() => {
console.log(obj.text)
})
// 1s 后修改响应数据
setTimeout(() => {
obj.text = 'Hello vue3'
}, 1000)

上面存在一个问题,我们在修改 obj 其他属性时,匿名函数也被执行了。我们使用 Set 桶存放副作用函数,这导致匿名副作用函数与被操作的目标字段之间没有建立明确的联系。无论读取哪个属性,都会将副作用加入桶中,无论设置哪个属性都会将桶中的副作用函数取出并执行。
为了解决上述问题,需要重新设计桶的结构。涉及三个角色:

  1. 代理对象 obj
  2. 被操作的字段 text
  3. 注册的副作用函数 effectFn

他们之间的关系

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

根据新的桶接口实现 set/get 拦截器

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 bucket = new WeakMap()
// 全局变量存储被注册的副作用函数
let activeEffect
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect 直接返回
if (!activeEffect) return target[key]
// 根据 target 从桶中趋势 depsMap, 他是一个 map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在新建并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取出 deps,他是 set 类型,里面存储当前 key 对应的副作用函数 effects
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 将当前激活的 effect 添加到桶中
deps.add(activeEffect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用从桶中取出病执行
const depsMap = bucket.get(target)
if (!depsMap) return
// 取出所有的 effects 副作用函数执行
const effects = depsMap.get(key)
effects && effects.forEach((fn) => fn())
return true
},
})

他们之间的关系:
image.png

存储副作用的 Set 集合称为依赖集合

对上述代码进行封装处理,读取属性时,单独封装到 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
40
41
42
43
44
//....
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
trigger(target, key)
return true
},
})

function track(target, key) {
// 没有 activeEffect 直接返回
if (!activeEffect) return
// 根据 target 从桶中趋势 depsMap, 他是一个 map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在新建并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取出 deps,他是 set 类型,里面存储当前 key 对应的副作用函数 effects
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 将当前激活的 effect 添加到桶中
deps.add(activeEffect)
}
function trigger(target, key) {
// 把副作用从桶中取出病执行
const depsMap = bucket.get(target)
if (!depsMap) return
// 取出所有的 effects 副作用函数执行
const effects = depsMap.get(key)
effects && effects.forEach((fn) => fn())
}
//....

4.4 分支切换和 cleanup

理解什么是分支切换,看下面的代码

1
2
3
4
5
6
const data = { ok: true, text: 'hello vue3' }
//...
effect(() => {
console.log('effect run')
document.body.innerHTML = obj.ok ? obj.text : 'not'
})

副作用函数内部是一个三元表达式,根据字段 obj.ok 的值执行不同的分支。当 obj.ok 的值发生变化时,代码的分支也随之切换,这就是所谓的分支切换。分支切换会导致遗留副作用函数。当 obj.ok 为 true 时,副作用和响应式数据之间的关系:

1
2
3
4
5
01 data
02 └── ok
03 └── effectFn
04 └── text
05 └── effectFn

当 obj.ok 为 false 时,副作用函数不应该被字段 obj.text 所对应的依赖集合收集。副作用遗留会导致不必要的更新。

1
2
3
obj.ok = false

obj.text = 'hello world'

即使 obj.ok 设置为 false ,修改 obj.text 的值也会导致副作用函数重新执行,理论上是不需要更新的。
决上述问题的方法很简单,每次副作用执行时,我们把它从所有与之关联的依赖集合中删除。当副作用执行完毕后,会重新建立联系,在新的联系中不存在遗留的副作用函数。
想从依赖集合中移除副作用函数,必须知道那些依赖集合中存在该副作用函数。重新设计副作用函数,添加 effectFn.deps 数组属性,用来存储所有包含当前副作用函数的依赖集合。

1
2
3
4
5
6
7
8
9
10
11
// 注册副作用函数的函数
export function effect(fn) {
const effectFn = () => {
// 当前 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [] // <----- 新增
effectFn()
}

现在有手机依赖集合的数组,何时收集呢?其实是在 track 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function track(target, key) {
// 没有 activeEffect 直接返回
if (!activeEffect) return
// 根据 target 从桶中趋势 depsMap, 他是一个 map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在新建并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取出 deps,他是 set 类型,里面存储当前 key 对应的副作用函数 effects
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 将当前激活的 effect 添加到桶中
deps.add(activeEffect)

// deps 是当前副作用存在的依赖集合添加到 activeEffect.deps 中
activeEffect.deps.push(deps) // <------ 新增
}

现在有与副作用相关的依赖集合,在每次副作用函数执行时,根据 effectFn.deps 获取依赖集合,将副作用从依赖集合中移除:

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
// 注册副作用函数的函数
export function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当前 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
effectFn()
}

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
}

至此响应式系统避免了副作用遗留问题,尝试运行代码会出现无限循环错误,问题出在 trigger 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
export function trigger(target, key) {
// 把副作用从桶中取出病执行
const depsMap = bucket.get(target)
if (!depsMap) return
// 取出所有的 effects 副作用函数执行
const effects = depsMap.get(key)

// 为了解决 cleanup 后,重新添加到 set 导致的无限循环
const effectsToRun = new Set(effects)
effectsToRun.forEach((fn) => fn())
// effects && effects.forEach(fn => fn()) // <----- 导致无限循环的原因
}

effects 是一个 set 类型存储副作用函数。当副作用函数执行时,会调用 cleanup 进行清除,实际上是从 effects 集合中将当前执行的副作用函数提出,但是副作用函数的执行导致重新被加入到集合中,因此导致 effects 集合的遍历一直执行。解决上述问题,只需构建新的集合遍历即可。

4.5 嵌套的 effect 与 effect 栈

effect 是需要支持嵌套的,因为在 Vue 中,父子组件就存在嵌套渲染的。我们设计的 effect 不支持嵌套,会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const data = { foo: true, bar: true }
// ...
// 全局变量
let temp1, temp2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
obj.foo = false
// 输出
// effectFn1 执行
// effectFn2 执行
// effectFn2 执行

测试发现修改 obj.foo 共打印了三次,前两次是副作用初始化的打印结果,这一步是正常的。问题出在第三步,我们修改的 obj.foo 的值,effectFn1 并没有重新执行,而是 effectFn2 执行了,显然有问题。
实际上是在 effect 函数与 activeEffect 上出问题,回顾前面的 effect 实现,使用 activeEffect 来存储当前注册副作用函数,同一时刻 activeEffect 只会存储一个副作用。当发生嵌套时,内部的副作用函数的执行会覆盖 activeEffect 的值,从而导致响应数据是在外层副作用函数中读取的,也只会触发内部的副作用函数。
为了解决这个问题,我们可以引入副作用函数栈 effectStack,在副作用执行时,将副作用压入栈中,副作用执行完成后从栈中弹出,并且 activeEffect 始终指向栈顶的副作用函数。
下面修改 effect 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const effectStack = [] // 新增
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 完成清除工作
cleanup(effectFn)
// 当前激活的副作用函数
activeEffect = effectFn
// 再副作用函数执行前将当前副作用压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行后,从栈中弹出,并把 activeEffect 还原到之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 定义 deps 数组,用户保存所有和当前副作用相关的依赖集合
effectFn.deps = []
effectFn()
}

通过引入 effectStack 栈,在发生嵌套时,可以记录外层的副作用函数,栈顶的是内层的副作用函数。

4.6 避免无限递归循环

上面实现的响应系统还存在,无限循环的问题。例如:

1
2
3
effect(() => {
obj.foo++
})

上面的自增操作会导致栈溢出。因为在副作用中,即读取了 obj.foo 值,又设置了 obj.foo 值。
执行流程:

  1. 读取 obj.foo 的值,触发 track 操作将当前副作用收集
  2. 设置 obj.foo 的值,触发 trigger 操作,把副作用取出并执行
  3. 问题是该副作用函数正在执行,还没执行完毕,就开始了下一次执行。导致无限递归地调用自己,产生栈溢出。

解决方法:
通过分析读取和设置操作在一个副作用函数中进行。无论是 track 收集还是 trigger 触发都是 activeEffect。所以我们在 trigger 触发是添加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// 赋值给新的 set 是为了避免,重新加入副作用导致无限循环问题
const effectToRun = new Set()
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectToRun.add(effectFn)
}
})
effectToRun.forEach((effectFn) => effectFn())
}

4.7 调度执行

可调度性是响应式系统的重要特性,所谓的可调度,是指 trigger 触发副作用函数重新执行时,有能力决定副作用函数执行的时机,次数以及方式。

控制执行时机

如何实现下面的输出:

1
2
3
4
5
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')

默认情况下,输出 1 、2 和 “结束了”,如果在不改变顺序的情况下,实现 1、”结束了” 和 2 输出呢?
为 effect 函数添加一个 options 选项参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function effect(fn, options = {}) {
const effectFn = () => {
// 调用 cleanup 完成清除工作
cleanup(effectFn)
// 当前激活的副作用函数
activeEffect = effectFn
// 再副作用函数执行前将当前副作用压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行后,从栈中弹出,并把 activeEffect 还原到之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂在到 effectFn 上
effectFn.options = options // <------- 新增
// 定义 deps 数组,用户保存所有和当前副作用相关的依赖集合
effectFn.deps = []
effectFn()
}

将 options 绑定到副作用函数上,在 trigger 触发时可以直接调用用户传入的调度器函数,把控制权交给用户。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
// 赋值给新的 set 是为了避免,重新加入副作用导致无限循环问题
const effectToRun = new Set()
effects &&
effects.forEach((effectFn) => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectToRun.add(effectFn)
}
})
effectToRun.forEach((effectFn) => {
// 如果一个副作用存在调度器,在调用调度器,将副作用作为参数传递过去
if (effectFn.options.scheduler) {
// <---------- 新增
effectFn.options.scheduler(effectFn) // <--------- 新增
} else {
effectFn()
}
})
}

测试执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
effect(
() => {
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
setTimeout(fn) // 将fn添加到宏任务中执行
},
}
)
obj.foo++
console.log('结束了')
// 输出:
// 1
// 结束了
// 2

控制执行次数

在 Vue 实现中连续多次修改响应数据,只会触发一次 effect 执行是如何实现的呢?例如下面的代码

1
2
3
4
5
6
7
// 初始 foo 为 1
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
// 输出:1,2,3

对用户来说,中间的过渡状态是不需要的,如何避免重复执行呢?通过调度器很容易实现。
实现原理:连续对 obj.foo 执行操作时,会同步且连续地执行两次 scheduler 调度函数,将副作用函数放在 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
// 定义一个宏任务队列
const jobQueue = new Set()
// 将一个任务添加到任务队列
const p = Promise.resolve()
// 一个标识代表是否正在刷新任务
let isFlushing = false
function flushJob() {
// 队列正在刷新,什么都不做
if (isFlushing) return
// 设置为 true, 代表正在刷新
isFlushing = true
// 在微任务队列中刷新 jobQueu 队列
p.then(() => {
jobQueue.forEach((job) => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}

// 定义 副作用函数,设置 scheduler 调度器
effect(
() => {
console.log(obj.foo)
},
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// setTimeout(fn) // 将fn添加到宏任务中执行
jobQueue.add(fn)
flushJob()
},
}
)
obj.foo++
obj.foo++

说明:

  1. 定义 Set 类型的 jobQueue 任务队列,利用 Set 的去重能力。
  2. 在调度器 scheduler 中,每次调度执行,先将当前的副作用函数添加到 jobQueue 队列中,在调用 flushJob 刷新队列。
  3. 在 flushJob 中,通过 isFlushing 标志判断是否需要执行,只有当 isFlushing 为 false 时才会执行。一个周期内只会执行一次。
  4. 在 flushJob 中通过 p.then 将一个函数添加到微任务队列,微任务队列完成对 jobQueue 的遍历执行。

参考