一步步由浅入深了解vue3的响应式设计;每一步的设计都充满了智慧,让人不得不佩服
假设我们在一个函数中,读取了某个对象的属性
01 const obj = { text: 'hello world' }
02 function effect() {
03 // effect 函数的执行会读取 obj.text
04 document.body.innerText = obj.text
05 }
当obj.text的值发生变化时,effect函数会重新执行
obj.text = 'hello vue3' // 修改 obj.text 的值,希望相关函数会重新执行
如果可以实现这个目标,那么对象obj就是响应式数据,函数effect叫做副作用函数,接下来就讨论如何实现这个响应系统的设计
通过观察,有两点线索
如果可以拦截obj对象的读取和设置,当读取obj.text时,我们把对应的函数存储在一个“桶”里,接着当设置obj.text时,再把对应函数从“桶”里取出并执行即可
如何拦截对象的读取和设置,在vue3的源码中,是用Proxy来实现,根据上面的思路,附上实现代码
01 // 存储函数的桶
02 const bucket = new Set()
03
04 // 原始数据
05 const data = { text: 'hello world' }
06 // 对原始数据的代理
07 const obj = new Proxy(data, {
08 // 拦截读取操作
09 get(target, key) {
10 // 将副作用函数 effect 添加到桶中
11 bucket.add(effect)
12 // 返回属性值
13 return target[key]
14 },
15 // 拦截设置操作
16 set(target, key, newVal) {
17 // 设置属性值
18 target[key] = newVal
19 // 把副作用函数从桶里取出并执行
20 bucket.forEach(fn => fn())
21 // 返回 true 代表设置操作成功
22 return true
23 }
24 })
存储函数的桶用Set类型,避免同个重复存储,然后把obj改成原始数据的一个代理对象,对象读取都改成针对obj这个代理对象来执行,而非原始对象,测试代码如下
01 // 副作用函数读取的是obj这个代理对象
02 function effect() {
03 document.body.innerText = obj.text
04 }
05 // 执行副作用函数,触发读取
06 effect()
07 // 1 秒后修改响应式数据
08 setTimeout(() => {
09 obj.text = 'hello vue3'
10 }, 1000)
一秒后修改obj的值,对应的副作用函数也会重新执行,符合我们期望的结果,这个就是响应式数据的基本实现原理
从上面的例子中,可以明白一个响应系统的工作流程如下
01 // 用一个全局变量存储被注册的副作用函数
02 let activeEffect
03 // effect 函数用于注册副作用函数
04 function effect(fn) {
05 // 将副作用函数 fn 赋值给 activeEffect
06 activeEffect = fn
07 // 执行副作用函数
08 fn()
09 }
重新定义了effect函数,变成了一个专门用来注册副作用函数的函数(顺便感慨下,JavaScript的写法真的是太灵活了),接受的参数fn就是要注册的副作用函数;这样也支持匿名函数的注册了
01 effect(
02 // 一个匿名的副作用函数
03 () => {
04 document.body.innerText = obj.text
05 }
06 )
对应的,对象的Proxy的实现也要做下调整
01 const obj = new Proxy(data, {
02 get(target, key) {
03 // 将 activeEffect 中存储的副作用函数收集到“桶”中
04 if (activeEffect) { // 新增
05 bucket.add(activeEffect) // 新增
06 } // 新增
07 return target[key]
08 },
09 set(target, key, newVal) {
10 target[key] = newVal
11 bucket.forEach(fn => fn())
12 return true
13 }
14 })
这样可以实现任意函数的注册了
上面的副作用函数响应的是obj.text字段的值,如果给obj设置一个不存在的属性,也会触发副作用函数的执行
01 effect(
02 // 匿名副作用函数
03 () => {
04 console.log('effect run') // 会打印 2 次
05 document.body.innerText = obj.text
06 }
07 )
08
09 setTimeout(() => {
10 // obj更新一个无关的属性值,也触发副作用函数的执行
11 obj.notExist = 'hello vue3'
12 }, 1000)
字段 obj.notExist 并没有与副作用建立响应联系,因此不应该触发匿名副作用函数重新执行;该如何解决这个问题呢?
在回答这个问题之前,我们需要先仔细观察下面的代码:
01 effect(function effectFn() {
02 document.body.innerText = obj.text
03 })
这段代码中,存在三个角色
存储副作用函数的桶要重新实现
用WeakMap代替 Set,WeakMap的key就是读取的对象obj,value是一个Map,value这个Map的key是各个字段名,比如这里是text,字段名对应value是一个Set,这个Set存储的就是相关联的副作用函数
这样设计就可以把副作用函数跟字段名,对象做严格的对应关系,解决上面的问题
01 // 存储副作用函数的桶
02 const bucket = new WeakMap()
然后修改 get/set 拦截器代码
01 const obj = new Proxy(data, {
02 // 拦截读取操作
03 get(target, key) {
04 // 没有 activeEffect,直接 return
05 if (!activeEffect) return target[key]
06 // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
07 let depsMap = bucket.get(target)
08 // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
09 if (!depsMap) {
10 bucket.set(target, (depsMap = new Map()))
11 }
12 // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
13 // 里面存储着所有与当前 key 相关联的副作用函数:effects
14 let deps = depsMap.get(key)
15 // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
16 if (!deps) {
17 depsMap.set(key, (deps = new Set()))
18 }
19 // 最后将当前激活的副作用函数添加到“桶”里
20 deps.add(activeEffect)
21
22 // 返回属性值
23 return target[key]
24 },
25 // 拦截设置操作
26 set(target, key, newVal) {
27 // 设置属性值
28 target[key] = newVal
29 // 根据 target 从桶中取得 depsMap,它是 key --> effects
30 const depsMap = bucket.get(target)
31 if (!depsMap) return
32 // 根据 key 取得所有副作用函数 effects
33 const effects = depsMap.get(key)
34 // 执行副作用函数
35 effects && effects.forEach(fn => fn())
36 }
37 })
分别使用了 WeakMap、Map 和Set,WeakMap 由 target --> Map 构成,Map 由 key --> Set 构成
为什么要使用WeakMap而不是Map
WeakMap的Key就是响应式对应target,由于是弱引用,当target没有任何引用,用户侧已经不需要它的时候,不影响垃圾回收器完成回收任务,如果是用Map,target将被Map一直引用住不会被回收,导致内存溢出
最后,对上述代码做一些封装处理,定义track函数用于收集副作用函数,定义trigger函数用于触发副作用函数重新执行
01 const obj = new Proxy(data, {
02 // 拦截读取操作
03 get(target, key) {
04 // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
05 track(target, key)
06 // 返回属性值
07 return target[key]
08 },
09 // 拦截设置操作
10 set(target, key, newVal) {
11 // 设置属性值
12 target[key] = newVal
13 // 把副作用函数从桶里取出并执行
14 trigger(target, key)
15 }
16 })
17
18 // 在 get 拦截函数内调用 track 函数追踪变化
19 function track(target, key) {
20 // 没有 activeEffect,直接 return
21 if (!activeEffect) return
22 let depsMap = bucket.get(target)
23 if (!depsMap) {
24 bucket.set(target, (depsMap = new Map()))
25 }
26 let deps = depsMap.get(key)
27 if (!deps) {
28 depsMap.set(key, (deps = new Set()))
29 }
30 deps.add(activeEffect)
31 }
32 // 在 set 拦截函数内调用 trigger 函数触发变化
33 function trigger(target, key) {
34 const depsMap = bucket.get(target)
35 if (!depsMap) return
36 const effects = depsMap.get(key)
37 effects && effects.forEach(fn => fn())
38 }
上面的设计还是有缺陷的,当碰到下面这种三元表达式的代码
01 const data = { ok: true, text: 'hello world' }
02 const obj = new Proxy(data, { /* ... */ })
03
04 effect(function effectFn() {
05 document.body.innerText = obj.ok ? obj.text : 'not'
06 })
一开始ok的值是true,方法会同时读取ok跟text的值,所以跟着两个字段都建立了联系
01 data
02 └── ok
03 └── effectFn
04 └── text
05 └── effectFn
当ok变成false的时候,跟值text已经没有关系了,不过这个联系还在,就产生了遗留的副作用函数,会导致不必要的更新,如果尝试修改obj.text的值
obj.text = 'hello vue3'
仍然会导致函数重新执行,这个问题要如何解决?
解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,代码如下(再次感慨JavaScript的写法太灵活了)
01 // 用一个全局变量存储被注册的副作用函数
02 let activeEffect
03 function effect(fn) {
04 const effectFn = () => {
05 // 当 effectFn 执行时,将其设置为当前激活的副作用函数,每次数据变化是走到这里
06 activeEffect = effectFn
07 fn()
08 }
09 // deps 用来存储所有与该副作用函数相关联的依赖集合,第一次执行会走到这里
10 effectFn.deps = []
11 // 执行副作用函数
12 effectFn()
13 }
在track函数中收集effectFn.deps的数组集合
01 function track(target, key) {
02 // 没有 activeEffect,直接 return
03 if (!activeEffect) return
04 let depsMap = bucket.get(target)
05 if (!depsMap) {
06 bucket.set(target, (depsMap = new Map()))
07 }
08 let deps = depsMap.get(key)
09 if (!deps) {
10 depsMap.set(key, (deps = new Set()))
11 }
12 // 把当前激活的副作用函数添加到依赖集合 deps 中
13 deps.add(activeEffect)
14 // deps 就是一个与当前副作用函数存在联系的依赖集合
15 // 将其添加到 activeEffect.deps 数组中
16 activeEffect.deps.push(deps) // 新增
17 }
这样就完成了副作用函数对依赖集合的收集了,有了这个联系,就可以将副作用函数从依赖集合中移除了
01 // 用一个全局变量存储被注册的副作用函数
02 let activeEffect
03 function effect(fn) {
04 const effectFn = () => {
05 // 调用 cleanup 函数完成清除工作
06 cleanup(effectFn) // 新增
07 activeEffect = effectFn
08 fn()
09 }
10 effectFn.deps = []
11 effectFn()
12 }
cleanup函数的实现
01 function cleanup(effectFn) {
02 // 遍历 effectFn.deps 数组
03 for (let i = 0; i < effectFn.deps.length; i++) {
04 // deps 是依赖集合
05 const deps = effectFn.deps[i]
06 // 将 effectFn 从依赖集合中移除
07 deps.delete(effectFn)
08 }
09 // 最后需要重置 effectFn.deps 数组
10 effectFn.deps.length = 0
11 }
每次执行,都会把函数从原有的依赖集合中移除,当fn()执行后,在track方法中,就会重新建立依赖关系,相当于每次执行都做了一轮清洗,避免产生副作用函数,不过目前的实现,会导致无限死循环,问题出现在trigger函数中
01 function trigger(target, key) {
02 const depsMap = bucket.get(target)
03 if (!depsMap) return
04 const effects = depsMap.get(key)
05 effects && effects.forEach(fn => fn()) // 问题出在这句代码
06 }
trigger函数正在遍历Set集合的时候,会先执行cleanup进行Set的清除,但是fn()方法执行后,又会重新加到集合中;Set正在遍历中,同时进行delete跟add的操作,会导致无限死循环,正如如下的代码,会无限执行下去
01 const set = new Set([1])
02
03 set.forEach(item => {
04 set.delete(1)
05 set.add(1)
06 console.log('遍历中')
07 })
这种问题要如何解决呢?
方法很简单,构造另外一个set集合专门去遍历即可
01 function trigger(target, key) {
02 const depsMap = bucket.get(target)
03 if (!depsMap) return
04 const effects = depsMap.get(key)
05
06 const effectsToRun = new Set(effects) // 新增
07 effectsToRun.forEach(effectFn => effectFn()) // 新增
08 // effects && effects.forEach(effectFn => effectFn()) // 删除
09 }
effect碰到嵌套的场景,失灵了,看下代码
01 // 原始数据
02 const data = { foo: true, bar: true }
03 // 代理对象
04 const obj = new Proxy(data, { /* ... */ })
05
06 // 全局变量
07 let temp1, temp2
08
09 // effectFn1 嵌套了 effectFn2
10 effect(function effectFn1() {
11 console.log('effectFn1 执行')
12
13 effect(function effectFn2() {
14 console.log('effectFn2 执行')
15 // 在 effectFn2 中读取 obj.bar 属性
16 temp2 = obj.bar
17 })
18 // 在 effectFn1 中读取 obj.foo 属性
19 temp1 = obj.foo
20 })
effectFn1 内部嵌套了 effectFn2,effectFn1 的执行会导致 effectFn2 的执行。effectFn2 中读取了字段obj.bar,在 effectFn1 中读取了字段 obj.foo,并且 effectFn2 的执行先于对字段obj.foo 的读取操作;理想情况下,希望的关联关系如下
01 data
02 └── foo
03 └── effectFn1
04 └── bar
05 └── effectFn2
实际情况是,不管修改字段foo还是bar,都是触发effectFn2的重新执行,显然不符合预期,这个问题出在哪里呢?
其实问题出在effect函数的activeEffect上
01 // 用一个全局变量存储当前激活的 effect 函数
02 let activeEffect
03 function effect(fn) {
04 const effectFn = () => {
05 cleanup(effectFn)
06 // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
07 activeEffect = effectFn
08 fn()
09 }
10 // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
11 effectFn.deps = []
12 // 执行副作用函数
13 effectFn()
14 }
同一时刻 activeEffect 所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值,这个就是问题所在,那这个要处理处理?
我们需要一个副作用函数栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数
01 // 用一个全局变量存储当前激活的 effect 函数
02 let activeEffect
03 // effect 栈
04 const effectStack = [] // 新增
05
06 function effect(fn) {
07 const effectFn = () => {
08 cleanup(effectFn)
09 // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
10 activeEffect = effectFn
11 // 在调用副作用函数之前将当前副作用函数压入栈中
12 effectStack.push(effectFn) // 新增
13 fn()
14 // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
15 effectStack.pop() // 新增
16 activeEffect = effectStack[effectStack.length - 1] // 新增
17 }
18 // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
19 effectFn.deps = []
20 // 执行副作用函数
21 effectFn()
22 }
当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数,当内层副作用函数 effectFn2 执行完毕后,它会被弹出栈,并将副作用函数effectFn1 设置为 activeEffect;如此一来,就可以避免错乱了