前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Vue3响应系统设计-上

Vue3响应系统设计-上

作者头像
韦东锏
发布2023-08-26 14:55:48
1430
发布2023-08-26 14:55:48
举报
文章被收录于专栏:Android码农Android码农

一步步由浅入深了解vue3的响应式设计;每一步的设计都充满了智慧,让人不得不佩服

响应式数据

假设我们在一个函数中,读取了某个对象的属性

代码语言:javascript
复制
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叫做副作用函数,接下来就讨论如何实现这个响应系统的设计

响应式数据的基本实现逻辑

通过观察,有两点线索

  • 当函数effect执行的时候,会触发obj.text字段的读取操作
  • 当修改obj.text的值时,会触发obj.text字段的设置操作

如果可以拦截obj对象的读取和设置,当读取obj.text时,我们把对应的函数存储在一个“桶”里,接着当设置obj.text时,再把对应函数从“桶”里取出并执行即可

如何拦截对象的读取和设置,在vue3的源码中,是用Proxy来实现,根据上面的思路,附上实现代码

代码语言:javascript
复制
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这个代理对象来执行,而非原始对象,测试代码如下

代码语言:javascript
复制
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的值,对应的副作用函数也会重新执行,符合我们期望的结果,这个就是响应式数据的基本实现原理

完善响应系统-注册函数

从上面的例子中,可以明白一个响应系统的工作流程如下

  • 当读取操作发生时,将副作用函数收集到“桶”中
  • 当设置操作发生时,从“桶”中取出副作用函数并执行 上一节的实现,有个致命的缺陷,我们把副作用函数写死成effect,万一这个函数名字不叫effect,或者干脆是个匿名函数,也需要能够实现响应式,这里要把函数注册的机制做下调整
代码语言:javascript
复制
01 // 用一个全局变量存储被注册的副作用函数
02 let activeEffect
03 // effect 函数用于注册副作用函数
04 function effect(fn) {
05   // 将副作用函数 fn 赋值给 activeEffect
06   activeEffect = fn
07   // 执行副作用函数
08   fn()
09 }

重新定义了effect函数,变成了一个专门用来注册副作用函数的函数(顺便感慨下,JavaScript的写法真的是太灵活了),接受的参数fn就是要注册的副作用函数;这样也支持匿名函数的注册了

代码语言:javascript
复制
01 effect(
02   // 一个匿名的副作用函数
03   () => {
04     document.body.innerText = obj.text
05   }
06 )

对应的,对象的Proxy的实现也要做下调整

代码语言:javascript
复制
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设置一个不存在的属性,也会触发副作用函数的执行

代码语言:javascript
复制
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 并没有与副作用建立响应联系,因此不应该触发匿名副作用函数重新执行;该如何解决这个问题呢?

在回答这个问题之前,我们需要先仔细观察下面的代码:

代码语言:javascript
复制
01 effect(function effectFn() {
02   document.body.innerText = obj.text
03 })

这段代码中,存在三个角色

  • 被读取的对象obj
  • 被读取的字段名text
  • 使用effect函数注册的副作用函数

存储副作用函数的桶要重新实现

用WeakMap代替 Set,WeakMap的key就是读取的对象obj,value是一个Map,value这个Map的key是各个字段名,比如这里是text,字段名对应value是一个Set,这个Set存储的就是相关联的副作用函数

这样设计就可以把副作用函数跟字段名,对象做严格的对应关系,解决上面的问题

代码语言:javascript
复制
01 // 存储副作用函数的桶
02 const bucket = new WeakMap()

然后修改 get/set 拦截器代码

代码语言:javascript
复制
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函数用于触发副作用函数重新执行

代码语言:javascript
复制
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 }

cleanup

上面的设计还是有缺陷的,当碰到下面这种三元表达式的代码

代码语言:javascript
复制
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的值,所以跟着两个字段都建立了联系

代码语言:javascript
复制
01 data
02     └── ok
03         └── effectFn
04     └── text
05         └── effectFn

当ok变成false的时候,跟值text已经没有关系了,不过这个联系还在,就产生了遗留的副作用函数,会导致不必要的更新,如果尝试修改obj.text的值 obj.text = 'hello vue3' 仍然会导致函数重新执行,这个问题要如何解决?

解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,代码如下(再次感慨JavaScript的写法太灵活了)

代码语言: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的数组集合

代码语言:javascript
复制
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 }

这样就完成了副作用函数对依赖集合的收集了,有了这个联系,就可以将副作用函数从依赖集合中移除了

代码语言:javascript
复制
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函数的实现

代码语言:javascript
复制
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函数中

代码语言:javascript
复制
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的操作,会导致无限死循环,正如如下的代码,会无限执行下去

代码语言:javascript
复制
01 const set = new Set([1])
02
03 set.forEach(item => {
04   set.delete(1)
05   set.add(1)
06   console.log('遍历中')
07 })

这种问题要如何解决呢?

方法很简单,构造另外一个set集合专门去遍历即可

代码语言:javascript
复制
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函数

effect碰到嵌套的场景,失灵了,看下代码

代码语言:javascript
复制
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 的读取操作;理想情况下,希望的关联关系如下

代码语言:javascript
复制
01 data
02   └── foo
03     └── effectFn1
04   └── bar
05     └── effectFn2

实际情况是,不管修改字段foo还是bar,都是触发effectFn2的重新执行,显然不符合预期,这个问题出在哪里呢?

其实问题出在effect函数的activeEffect上

代码语言:javascript
复制
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 指向栈顶的副作用函数

代码语言:javascript
复制
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;如此一来,就可以避免错乱了

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-06-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Android码农 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 响应式数据
  • 响应式数据的基本实现逻辑
  • 完善响应系统-注册函数
  • 完善响应系统-响应字段
  • cleanup
  • 嵌套effect函数
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档