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...'
。
此时,我们要能够注意到一件事,虽然document.body.innerText
的值不再依赖于obj.text
了,但由于ok
的初值是true
,也就意味着在ok
的值没有改变时,document.body.innerText
的值依赖于obj.text
,更进一步说,这个函数已经被obj.text
当作自己的副作用函数收集了。这会导致什么呢?
我们更改了obj.text
的值,这会触发副作用函数。但此时由于ok
的值为false
,界面上显示的内容没有发生任何改变。也就是说,此时修改obj.text
触发的副作用函数的更新是不必要的。
这部分有些绕,让我们通过画图来尝试说明。当ok
为true
时,数据结构的状态如图所示:
从图中可以看到,obj.text
和obj.ok
都收集了同一个副作用函数fn
。这也解释了为什么即使我们将obj.ok
的值为false
,更改obj.text
仍然会触发副作用函数fn
。
我们希望的理想状况是,当ok
为false
时,副作用函数fn
被从obj.text
的副作用函数收集器中删除,数据结构的状态能改变为如下状态。
这就要求我们能够在每次执行副作用函数前,将该副作用函数从相关的副作用函数收集器中删除,再重新建立联系。为了实现这一点,就要求我们记录哪些副作用函数收集器收集了该副作用函数。
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!')
})
在上例中,我们复制了set
到otherset
中,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
用来遍历。让我们再来看看结果。
①处,更改obj.ok
的值为false
,改变了页面的显示,没有导致死循环。②处,当obj.ok
为false
时,副作用函数没有执行。至此,我们完成了针对分支切换场景下的优化。
副作用函数嵌套产生的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
执行。
修改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.foo
,trigger
触发的就是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]
回滚到原先正确的副作用函数上。
运行的结果和我们的预期一致,到此为止,我们已经完成了对嵌套副作用函数的处理。
自增/自减操作产生的BUG
这里还存在一个隐蔽的BUG,还和之前一样,我们修改effect
。
effect(() => obj.foo++)
很简单的副作用函数,这会有什么问题呢?执行一下看看。
很不幸,栈溢出了。这个副作用函数仅包含一个obj.foo++
,所以可以确定,栈溢出就是由这个自增运算引起的。接下来的问题就是,这么简单的自增操作,怎么会引起栈溢出呢?为了更好的说明问题,让我们先来拆解问题。
effect(() => obj.foo = obj.foo + 1)
这段代码中obj.foo = obj.foo + 1
就等价于obj.foo++
。这样拆开之后问题一下就清楚了。这里同时进行了obj.foo
的get
和set
操作。先读取obj.foo
,收集了副作用函数,再设置obj.foo
,触发了副作用函数,而这个副作用函数中obj.foo
又要被读取,如此往复,产生了死循环。为了验证这一点,我们打印执行的副作用函数。
上面的打印结果印证了我们的想法。造成这个BUG的主要原因是,当get
和set
操作同时存在时,我们收集和触发的都是同一个副作用函数。这里我们只需要添加一个守卫条件:当触发的副作用函数正在被执行时,该副作用函数则不必再被执行。
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())
}
如此一来,相同的副作用函数仅会被触发一次,避免了产生死循环。最后,我们验证一下即可。
来源:https://juejin.cn/post/7127964680563195941


猜你喜欢
- 前言我们把可能发生错误的语句放在try模块里,用except来处理异常。except可以处理一个专门的异常,也可以处理一组圆括号中的异常,如
- CAST、CONVERT都可以执行数据类型转换。在大部分情况下,两者执行同样的功能,不同的是CONVERT还提供一些特别的日期格式转换,而C
- 封装是一个将Python数据对象转化为字节流的过程,拆封是封装的逆操作,将字节文件或字节对象中的字节流转化为Python数据对象,不要从不收
- 本文实例讲述了JavaScript队列的应用。分享给大家供大家参考,具体如下:和前面介绍的栈相反,队列是一种先进先出的线性表,它只允许在表的
- //获取字符数组String.prototype.ToCharArray=function() { &n
- 上一篇列出了Perl中定义数组,对象的方式与JS的异同。这里继续补充数组,哈希的相关操作。一、数组可以对数组进行增删,插入。与JS不同的是这
- Python文件操作和异常处理Python作为一门高级编程语言,为我们提供了丰富的文件操作和异常处理机制。在本文中,我们将从以下几个方面讨论
- 一、数据库的建立和销毁建立数据库:create database [if not exists] 数据库名 [default charset
- 简介每一门数据库语言语法都基本相似,但是对于他们各自的一些特性(函数、存储过程等)的用法就不大相同了,就好比Oracle与Mysql存储过程
- 前言argsparse是python的命令行解析的标准模块,内置于python,不需要安装。这个库可以让我们直接在命令行中就可以向程序中传入
- 1. NumPy安装使用pip包管理工具进行安装$ sudo pip install numpy使用pip包管理工具安装ipython(交互
- 本文实例讲述了JS与jQuery判断文本框还剩多少字符可以输入的方法。分享给大家供大家参考,具体如下:javascript部分:functi
- 有这种要求,更新自己本身的字段的某个值进行加或者减常规方法:UPDATE tbl_kintai_print_hisSET &nb
- 版本:python2.7 2.7 2.7!!!症状:比如,我编写了一个字符串number,输出到网页上,变成了u'number
- 前言最近不小心把硬盘给格式化了,由于当时的文件没有备份,所以一下所有的文件都没有了,于是只能采取补救措施,用文件恢复软件恢复了一部分的数据出
- import timenow_time = time.time()print(now_time)结果是1594
- 一.字典的基本方法1.新建字典1)、建立一个空的字典>>> dict1={} >>> dict2=dic
- 本文实例为大家分享了Python版名片管理系统的具体代码,供大家参考,具体内容如下先建立cards_main的文件import cards_
- BETWEEN 运算符用于 WHERE 表达式中,选取介于两个值之间的数据范围。BETWEEN 同 AND 一起搭配使用,语法如下:WHER
- 导入模块import numpy as npimport pandas as pd1.读取测试数据data=pd.read_csv(r