本章目标
本章目标:
- 讨论什么是响应式数据和副作用函数,实现一个相对完善的响应式系统
- 如何避免无限递归?
- 为什么需要嵌套副作用函数?
- 两个副作用函数之间会产生哪些影响?
- ……
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) { 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()
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
function effect(fn) { activeEffect = fn fn() }
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) })
setTimeout(() => { obj.text = 'Hello vue3' }, 1000)
|
上面存在一个问题,我们在修改 obj 其他属性时,匿名函数也被执行了。我们使用 Set 桶存放副作用函数,这导致匿名副作用函数与被操作的目标字段之间没有建立明确的联系。无论读取哪个属性,都会将副作用加入桶中,无论设置哪个属性都会将桶中的副作用函数取出并执行。
为了解决上述问题,需要重新设计桶的结构。涉及三个角色:
- 代理对象 obj
- 被操作的字段 text
- 注册的副作用函数 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) { if (!activeEffect) return target[key] 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) return target[key] }, set(target, key, newVal) { target[key] = newVal const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach((fn) => fn()) return true }, })
|
他们之间的关系:
存储副作用的 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) { 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) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return 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 = () => { activeEffect = effectFn fn() } 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) { 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)
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(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] effectFn() }
function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } 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 const effects = depsMap.get(key)
const effectsToRun = new Set(effects) effectsToRun.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
|
测试发现修改 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(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() }
|
通过引入 effectStack 栈,在发生嵌套时,可以记录外层的副作用函数,栈顶的是内层的副作用函数。
4.6 避免无限递归循环
上面实现的响应系统还存在,无限循环的问题。例如:
1 2 3
| effect(() => { obj.foo++ })
|
上面的自增操作会导致栈溢出。因为在副作用中,即读取了 obj.foo 值,又设置了 obj.foo 值。
执行流程:
- 读取 obj.foo 的值,触发 track 操作将当前副作用收集
- 设置 obj.foo 的值,触发 trigger 操作,把副作用取出并执行
- 问题是该副作用函数正在执行,还没执行完毕,就开始了下一次执行。导致无限递归地调用自己,产生栈溢出。
解决方法:
通过分析读取和设置操作在一个副作用函数中进行。无论是 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) const effectToRun = new Set() effects && effects.forEach((effectFn) => { 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(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.options = options 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) const effectToRun = new Set() effects && effects.forEach((effectFn) => { 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(fn) { setTimeout(fn) }, } ) obj.foo++ console.log('结束了')
|
控制执行次数
在 Vue 实现中连续多次修改响应数据,只会触发一次 effect 执行是如何实现的呢?例如下面的代码
1 2 3 4 5 6 7
| effect(() => { console.log(obj.foo) }) obj.foo++ obj.foo++
|
对用户来说,中间的过渡状态是不需要的,如何避免重复执行呢?通过调度器很容易实现。
实现原理:连续对 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 isFlushing = true p.then(() => { jobQueue.forEach((job) => job()) }).finally(() => { isFlushing = false }) }
effect( () => { console.log(obj.foo) }, { scheduler(fn) { jobQueue.add(fn) flushJob() }, } ) obj.foo++ obj.foo++
|
说明:
- 定义 Set 类型的 jobQueue 任务队列,利用 Set 的去重能力。
- 在调度器 scheduler 中,每次调度执行,先将当前的副作用函数添加到 jobQueue 队列中,在调用 flushJob 刷新队列。
- 在 flushJob 中,通过 isFlushing 标志判断是否需要执行,只有当 isFlushing 为 false 时才会执行。一个周期内只会执行一次。
- 在 flushJob 中通过 p.then 将一个函数添加到微任务队列,微任务队列完成对 jobQueue 的遍历执行。
参考