Vue3响应式对象是如何实现的(2)

作者:???????咕咕鸡_ 时间:2024-05-09 15:10:01 

前言

在Vue3响应式对象是如何实现的(1)中,我们已经从功能上实现了一个响应式对象。如果仅仅满足于功能实现,我们就可以止步于此了。但在上篇中,我们仅考虑了最简单的情况,想要完成一个完整可用的响应式,需要我们继续对细节深入思考。在特定场景下,是否存在BUG?是否还能继续优化?

分支切换的优化

在上篇中,收集副作用函数是利用get自动收集。那么被get自动收集的副作用函数,是否有可能会产生多余的触发呢?或者说,我们其实进行了多余的收集呢?同样,还是从一个例子入手。

let activeEffect
function effect(fn) {
 activeEffect = fn
 fn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true } // (1)
const obj = new Proxy(data, {
 get(target, key) {
   track(target, key)
   return target[key]
 },
 set(target, key, newValue) {
   target[key] = newValue
   trigger(target, key)
   return true
 }
})
function track(target, key) {
 if(!activeEffect) return
 let propsMap = objsMap.get(target)
 if(!propsMap) {
   objsMap.set(target, (propsMap = new Map()))
 }
 let fns = propsMap.get(key)
 if(!fns) {
   propsMap.set(key, (fns = new Set()))
 }
 fns.add(activeEffect)
}

function trigger(target, key) {
 const propsMap = objsMap.get(target)
 if(!propsMap) return
 const fns = propsMap.get(key)
 fns && fns.forEach(fn => fn())
}

function fn() {
 document.body.innerText = obj.ok ? obj.text : 'ops...' // (2)
 console.log('Done!')
}
effect(fn)

这段代码中,我们做了(1)(2)两处更改。我们在(1)处给响应式对象新增加了一个boolean类型的属性ok,在(2)处我们利用ok的真值,来选择将谁赋值给document.body.innerText。现在,我们将obj.ok的值置为false,这就意味着,document.body.innerText的值不再依赖于obj.text,而直接取字符串'ops...'

Vue3响应式对象是如何实现的(2)

此时,我们要能够注意到一件事,虽然document.body.innerText的值不再依赖于obj.text了,但由于ok的初值是true,也就意味着在ok的值没有改变时,document.body.innerText的值依赖于obj.text,更进一步说,这个函数已经被obj.text当作自己的副作用函数收集了。这会导致什么呢?

Vue3响应式对象是如何实现的(2)

我们更改了obj.text的值,这会触发副作用函数。但此时由于ok的值为false,界面上显示的内容没有发生任何改变。也就是说,此时修改obj.text触发的副作用函数的更新是不必要的。

这部分有些绕,让我们通过画图来尝试说明。当oktrue时,数据结构的状态如图所示:

Vue3响应式对象是如何实现的(2)

从图中可以看到,obj.textobj.ok都收集了同一个副作用函数fn。这也解释了为什么即使我们将obj.ok的值为false,更改obj.text仍然会触发副作用函数fn

我们希望的理想状况是,当okfalse时,副作用函数fn被从obj.text的副作用函数收集器中删除,数据结构的状态能改变为如下状态。

Vue3响应式对象是如何实现的(2)

这就要求我们能够在每次执行副作用函数前,将该副作用函数从相关的副作用函数收集器中删除,再重新建立联系。为了实现这一点,就要求我们记录哪些副作用函数收集器收集了该副作用函数。

let activeEffect
function cleanup(effectFn) { // (3)
 for(let i = 0; i < effectFn.deps.length; i++) {
   const fns = effectFn.deps[i]
   fns.delete(effectFn)
 }
 effectFn.deps.length = 0
}
function effect(fn) {
 const effectFn = () => {
   cleanup(effectFn)
   activeEffect = effectFn
   fn()
 }
 effectFn.deps = [] // (1)
 effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
 get(target, key) {
   track(target, key)
   return target[key]
 },
 set(target, key, newValue) {
   target[key] = newValue
   trigger(target, key)
   return true
 }
})

function track(target, key) {
 if(!activeEffect) return
 let propsMap = objsMap.get(target)
 if(!propsMap) {
   objsMap.set(target, (propsMap = new Map()))
 }
 let fns = propsMap.get(key)
 if(!fns) {
   propsMap.set(key, (fns = new Set()))
 }
 fns.add(activeEffect)
 activeEffect.deps.push(fns) // (2)
}

function trigger(target, key) {
 const propsMap = objsMap.get(target)
 if(!propsMap) return
 const fns = propsMap.get(key)
 fns && fns.forEach(fn => fn())
}

function fn() {
 document.body.innerText = obj.ok ? obj.text : 'ops...'
 console.log('Done!')
}
effect(fn)

在这段代码中,我们增加了3处改动。为了记录副作用函数被哪些副作用函数收集器收集,我们在(1)处给每个副作用函数挂载了一个deps,用于记录该副作用函数被谁收集。在(2)处,副作用函数被收集时,我们记录副作用函数收集器。在(3)处,我们新增了cleanup函数,从含有该副作用函数的副作用函数收集器中,删除该副作用函数。

看上去好像没啥问题了,但是运行代码会发现产生了死循环。问题出在哪呢?

以下面这段代码为例:

const set = new Set([1])
set.forEach(item => {
   set.delete(1)
   set.add(1)
   console.log('Done!')
})

是的,这段代码会产生死循环。原因是ECMAScript对Set.prototype.forEach的规范中明确,使用forEach遍历Set时,如果有值被直接添加到该Set上,则forEach会再次访问该值。

const effectFn = () => {
   cleanup(effectFn) // (1)
   activeEffect = effectFn
   fn() // (2)
 }

同理,我们的代码中,当effectFn被执行时,(1)处的cleanup清除副作用函数,就相当于set.delete;而(2)处执行副作用函数fn时,会触发依赖收集,将副作用函数又加入到了副作用函数收集器中,相当于set.add,从而造成死循环。

解决的方法也很简单,我们只需要避免在原Set上直接进行遍历即可。

const set = new Set([1])
const otherSet = new Set(set)
otherSet.forEach(item => {
   set.delete(1)
   set.add(1)
   console.log('Done!')
})

在上例中,我们复制了setotherset中,otherset仅会执行set.length次。按照这个思路,修改我们的代码。

let activeEffect

function cleanup(effectFn) {
 for(let i = 0; i < effectFn.deps.length; i++) {
   const fns = effectFn.deps[i]
   fns.delete(effectFn)
 }
 effectFn.deps.length = 0
}

function effect(fn) {
 const effectFn = () => {
   cleanup(effectFn)
   activeEffect = effectFn
   fn()
 }
 effectFn.deps = []
 effectFn()
}

const objsMap = new WeakMap()
const data = { text: 'hello vue', ok: true }
const obj = new Proxy(data, {
 get(target, key) {
   track(target, key)
   return target[key]
 },
 set(target, key, newValue) {
   target[key] = newValue
   trigger(target, key)
   return true
 }
})

function track(target, key) {
 if(!activeEffect) return
 let propsMap = objsMap.get(target)
 if(!propsMap) {
   objsMap.set(target, (propsMap = new Map()))
 }
 let fns = propsMap.get(key)
 if(!fns) {
   propsMap.set(key, (fns = new Set()))
 }
 fns.add(activeEffect)
 activeEffect.deps.push(fns)
}

function trigger(target, key) {
 const propsMap = objsMap.get(target)
 if(!propsMap) return
 const fns = propsMap.get(key)
 const otherFns = new Set(fns) // (1)
 otherFns.forEach(fn => fn())
}

function fn() {
 document.body.innerText = obj.ok ? obj.text : 'ops...'
 console.log('Done!')
}
effect(fn)

在(1)处我们新增了一个otherFns,复制了fns用来遍历。让我们再来看看结果。

Vue3响应式对象是如何实现的(2)

①处,更改obj.ok的值为false,改变了页面的显示,没有导致死循环。②处,当obj.okfalse时,副作用函数没有执行。至此,我们完成了针对分支切换场景下的优化。

副作用函数嵌套产生的BUG

我们继续从功能角度考虑,前面我们的副作用函数还是不够复杂,实际应用中(如组件嵌套渲染),副作用函数是可以发生嵌套的。

我们举个简单的嵌套示例:

let t1, t2
effect(function effectFn1() {
 console.log('effectFn1')
 effect(function effectFn2() {
   console.log('effectFn2')
   t2 = obj.bar
 })
 t1 = obj.foo
})

这段代码中,我们将effectFn2嵌入了effectFn1中,将obj.foo赋值给t1,obj.bar赋值给t2。从响应式的功能上看,如果我们修改obj.foo的值,应该会触发effectFn1的执行,且间接触发effectFn2执行。

Vue3响应式对象是如何实现的(2)

修改obj.foo的值仅触发了effectFn2的更新,这与我们的预期不符。既然是effect这里出了问题,让我们再来过一遍effect部分的代码,看看能不能发现点什么。

let activeEffect // (1)

function cleanup(effectFn) {
 for(let i = 0; i < effectFn.deps.length; i++) {
   const fns = effectFn.deps[i]
   fns.delete(effectFn)
 }
 effectFn.deps.length = 0
}

function effect(fn) {
 const effectFn = () => {
   cleanup(effectFn)
   activeEffect = effectFn
   fn() // (2)
 }
 effectFn.deps = []
 effectFn()
}

仔细思考后,不难发现问题所在。我们在(1)处定义了一个全局变量activeEffect用于副作用函数注册,这意味着同一时刻,我们仅能注册一个副作用函数。在(2)处执行了fn,此时注意,在我们给出的副作用函数嵌套示例中,effectFn1是先执行effectFn2,再执行t1 = obj.foo。也就是说,此时activeEffect注册的副作用函数已经由effectFn1变为了effectFn2。因此,当执行到t1 = obj.foo时,track收集的activeEffect已经是被effectFn2覆盖过的。所以,修改obj.footrigger触发的就是effectFn2了。

要解决这个问题也很简单,既然后出现的要先被收集,后进先出,用栈解决就好了。

let activeEffect
const effectStack = [] // (1)

function cleanup(effectFn) {
 for(let i = 0; i < effectFn.deps.length; i++) {
   const fns = effectFn.deps[i]
   fns.delete(effectFn)
 }
 effectFn.deps.length = 0
}

function effect(fn) {
 const effectFn = () => {
   cleanup(effectFn)
   activeEffect = effectFn
   effectStack.push(effectFn)
   fn() // (2)
   effectStack.pop()
   activeEffect = effectStack[effectStack.length - 1]
 }
 effectFn.deps = []
 effectFn()
}

这段代码中,我们在(1)处定义了一个栈effectStack。不管(2)处如何更改activeEffect的内容,都会被effectStack[effectStack.length - 1]回滚到原先正确的副作用函数上。

Vue3响应式对象是如何实现的(2)

运行的结果和我们的预期一致,到此为止,我们已经完成了对嵌套副作用函数的处理。

自增/自减操作产生的BUG

这里还存在一个隐蔽的BUG,还和之前一样,我们修改effect

effect(() => obj.foo++)

很简单的副作用函数,这会有什么问题呢?执行一下看看。

Vue3响应式对象是如何实现的(2)

很不幸,栈溢出了。这个副作用函数仅包含一个obj.foo++,所以可以确定,栈溢出就是由这个自增运算引起的。接下来的问题就是,这么简单的自增操作,怎么会引起栈溢出呢?为了更好的说明问题,让我们先来拆解问题。

effect(() => obj.foo = obj.foo + 1)

这段代码中obj.foo = obj.foo + 1就等价于obj.foo++。这样拆开之后问题一下就清楚了。这里同时进行了obj.foogetset操作。先读取obj.foo,收集了副作用函数,再设置obj.foo,触发了副作用函数,而这个副作用函数中obj.foo又要被读取,如此往复,产生了死循环。为了验证这一点,我们打印执行的副作用函数。

Vue3响应式对象是如何实现的(2)

上面的打印结果印证了我们的想法。造成这个BUG的主要原因是,当getset操作同时存在时,我们收集和触发的都是同一个副作用函数。这里我们只需要添加一个守卫条件:当触发的副作用函数正在被执行时,该副作用函数则不必再被执行。

function trigger(target, key) {
 const propsMap = objsMap.get(target)
 if(!propsMap) return
 const fns = propsMap.get(key)
 const otherFns = new Set()
 fns && fns.forEach(fn => {
   if(fn !== activeEffect) { // (1)
     otherFns.add(fn)
   }
 })
 otherFns.forEach(fn => fn())
}

如此一来,相同的副作用函数仅会被触发一次,避免了产生死循环。最后,我们验证一下即可。

Vue3响应式对象是如何实现的(2)

来源:https://juejin.cn/post/7127964680563195941

标签:Vue3,响应式,对象
0
投稿

猜你喜欢

  • 在Python程序和Flask框架中使用SQLAlchemy的教程

    2021-10-28 06:14:21
  • MySQL复制的概述、安装、故障、技巧、工具

    2011-04-11 08:36:00
  • ORACLE常见错误代码的分析与解决(三)

    2010-07-31 12:45:00
  • Python可视化神器pyecharts之绘制箱形图

    2021-08-04 03:40:53
  • Python3.6实现连接mysql或mariadb的方法分析

    2024-01-26 19:46:36
  • 使用pandas忽略行列索引,纵向拼接多个dataframe

    2022-05-23 08:52:42
  • python numpy和list查询其中某个数的个数及定位方法

    2021-04-29 01:36:50
  • python实现知乎高颜值图片爬取

    2023-03-11 10:35:54
  • Pytorch提取模型特征向量保存至csv的例子

    2022-09-28 00:41:17
  • 基于pdf2docx模块Python实现批量将PDF转Word文档的完整代码教程

    2022-06-24 15:55:02
  • SQL常用的四个排序函数梳理

    2024-01-13 04:41:49
  • Tornado Web服务器多进程启动的2个方法

    2022-01-21 04:41:05
  • SQL 统计一个数据库中所有表记录的数量

    2012-01-29 18:21:36
  • python爬虫入门教程--优雅的HTTP库requests(二)

    2022-04-01 05:10:43
  • python3 lambda表达式详解

    2021-03-01 20:28:20
  • CSS写法性能

    2009-05-28 19:09:00
  • webpack cjs运行时分析示例详解

    2024-04-19 09:51:56
  • 在django中图片上传的格式校验及大小方法

    2023-04-02 23:12:56
  • JavaScript插件化开发教程 (一)

    2024-04-26 17:13:07
  • 使用Python实现二分法查找的示例

    2022-02-08 13:52:53
  • asp之家 网络编程 m.aspxhome.com