持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情
Vue源码学习4(中) | 响应系统的作用与实现
续上文,我们了解了响应式的基本实现,那么接下来,让我们继续来深入响应式这个大山!
4.4 分支切换 与 cleanup
看到这个问题,想必大家会有一个疑问:
- 什么叫做
分支切换?
明确分支切换的定义
话不多说,先看一段代码:
const data = { ok:true,text:'hello world' }
const obj = new Proxy(data,{/*...*/})
effect(function effectFn() {
document.body.innerText = obj.ok ? obj.text : 'not'
})
在 effectFn 函数内部存在一个三元表达式,根据字段obj.ok 值得不同会执行不同的代码分支。当字段obj.ok的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。
理想状态
分支切换可能会产生遗留的副作用函数。拿上面的这段代码来说,字段obj.ok的初始值为 true ,这时会读取字段obj.text的值,所以当 effect 函数执行时会触发字段obj.ok和字段obj.text这两个属性的读取操作,此时副作用函数effectFn与响应式数据之间建立的联系如下:
data
└─ ok
└─ effectFn
└─ text
└─ effectFn
下图给出了更详细的描述。
但是上面的这种联系并不是理想状态下的联系,上面的这种表示:当副作用函数effectFn分别被字段data.ok和字段data.text所对应依赖集合收集,也就是说无论是读取哪个字段,都会触发副作用函数并执行。但我们理想状态下是当只触发字段obj.ok的读取操作时,副作用函数effect只会被obj.ok对应的依赖集合收集,而不会被字段obj.text所对应的依赖集合收集,如下图所示。
但是按照上文的代码实现中,我们还做不到这一点,也就是说会产生遗留的副作用函数。这个时候,大家可能会产生一个疑惑:
遗留的副作用函数是什么?它对我们有什么样的影响?
嗷!这里咱就直接给出答案:遗留的副作用函数会导致不必要的更新,拿下面这段代码来说:
const data = { ok:true,text:'hello world' }
const obj = new Proxy(data,{ /*...*/ })
effect(function effectFn(){
document.body.innerText = obj.ok ? obj.text : 'not'
})
obj.ok的初始值为true,当将其修改为false后:
obj.ok = false
这会触发更新,即副作用函数会重新执行。但是由于此时obj.ok的值为false,所以不会读取字段obj.text的值。换句话说,无论字段obj.text的值如何变化,document.body.innerText的值始终都是字符串'not'。
所以此时最好的结果:无论obj的值怎么变,都不需要重新执行副作用函数。但是事与愿违,如果再次尝试修改obj.text的值:
obj.text = 'hello vue3'
可以发现,obj.text 视图上的值还是改变了,也就是说仍然会导致副作用函数重新执行,即使document.body.innerText的值不需要变化。
追求理想状态
其实解决这个问题的思路很简单,即每次副作用函数执行时,我们可以先把它从与之相关联的依赖集合中删除,如图所示。
当副作用函数执行完毕后,会重新建立联系,但是新的联系中不会包含
遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从相关的依赖集合中移除,那么问题就迎刃而解了。
实现理想状态
要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,代码如下:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effectFn () => {
// 当effect 执行时,将其设置为当时激活的副作用函数
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
在effect内部我们定义了新的effectFn函数,并为其添加了effect.deps属性,该属性是一个数组,用来存储所有的包含当前副作用函数的依赖集合。
那么这个时候掘友们应该回想:
effectFn.deps数组中的依赖集合是如何收集的呢?
其实这些是在track函数中:
function track(target,key) {
// 没有 activeEffect,直接return
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 中
deps.add(activeEfect)
// deps 就是一个与当前副作用函数存在联系的依赖集合
// 将其添加到 activeEffect.deps 数组中
activeEffect.deps.push(deps) // 新增
}
如上面的代码所示,在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合deps中,这就说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。如下图,就是这一步所建立的关系:
有了这个联系后,我们就可以在每次副作用函数执行时,根据
effect.deps获取所有相关联的依赖集合,进而将副作用韩式从依赖集合中移除:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effect = () => {
// 调用 cleanup 函数完成清楚工作
cleanup(effectFN) // 新增
activeEffct = effectFn
fn()
}
effrctFn.deps = []
effectFn()
}
cleanup 函数的实现
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for(let i = 0; i < effectFn.deps.length;i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}
cleanup函数接收副作用函数作为参数,遍历副作用函数effectFn.deps数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置effectFn.deps数组。
目前理想状态下的"弊端"
好!到目前为止,咱的响应系统已经可以避免副作用函数产生遗留了。但是如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题就出现在trigger函数中:
function trigger(target,key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn()) // 就这这句玩意出了问题!
}
在trigger函数内部,咱们遍历effect集合,它是一个Set集合,里面存储着副作用函数。当副作用函数执行时,会调用cleanup进行清除,实际上就是从effect集合中将当前执行的副作用函数剔除,但是副作用的执行会导致其重新被收集到集合中,而此时对于effect集合的遍历仍在进行,或者你可以看看下面的这行代码,你就知道我在说啥了
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
在上面这段代码中,咱创建了一个集合set,它里面有一个元素数字1,接着调用forEach遍历该集合。在遍历过程中,首先调用delete(1)删除数字1,紧接着调用add(1)将数字1加回,最后打印遍历中。如果我们在浏览器中执行这段代码,就会发现它会无限执行下去。
- 为什么会有个问题呢?
语言规范中对此有明确的说明:在调用forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。 因此,上面的代码会无限被执行。解决办法很简单,我们可以构造另外一个Set集合并遍历它:
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
consolo.log('遍历中')
})
这样就不会无限执行了。回到trigger函数,我们需要同样的手段来避免无限执行:
function trigger(target,key) {
const depsMap = bucket.get(target)
if(!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
effects && effects.forEach(fn => fn()) // 删除
如以上代码所示,我们新构造了effectsToRun集合并遍历它,代替直接遍历effect集合,从而避免了无限执行。
4.5 嵌套的effect与effect栈
提出嵌套,问题随之来临
effect是可以发生嵌套的,例如:
effect(function effectFn1() {
effect(function effectFn2() { /*...*/ })
})
似乎这种嵌套的在Vue.js中随处可见,比如“组件嵌套组件”。但实际上,按照前文的介绍与实现来看,我们所实现的响应系统并不支持effect嵌套(毕竟只声明了一个全局变量去接)。具体的看代码:
// 原始数据
const data = { foo:true,bar:true }
// 代理对象
const obj = new proxy(data,{ /*...*/ })
// 全局变量
let temp1,temp2
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
在effectFn2中读取了字段obj.bar,在effectFn1中读取了字段obj.foo,并且effectFn2的执行先于obj.foo的读取操作。在理想状态下,我们希望副作用函数与对象属性之间的联系如下:
data
└─ foo
└─ effectFn1
└─ bar
└─ effectFn2
在这种情况下,我们希望当修改obj.foo时会触发effectFn1执行。由于effectFn2嵌套在effect里,所以会间接触发effectFn2执行,而当修改obj.bar时,只会触发effectFn2执行。但是事与愿违,当尝试修改obj.foo时,会发现输出为:
effectFn1 执行
effectFn2 执行
effectFn2 执行
一共打印三次,前两次分别是副作用函数effectFn1与effectFn2执行的打印结果,到这一步是正常的,问题出现在第三行打印。我们修改了字段obj.foo的值,发现effectFn1并没有重新执行,反而使得effectFn2重新执行了,这显然不是我们想要的。
发现问题
- 那么出现问题了,问题在哪呢?
观察下面的代码:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
const effect = () => {
// 调用 cleanup 函数完成清楚工作
cleanup(effectFN) // 新增
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffct = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effrctFn.deps = []
// 执行副作用函数
effectFn()
}
用全局变量activeEffect来存储通过effect函数注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也会是内层副作用函数,这!就是问题所在!
解决问题
为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用执行时,将当前的副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现相互影响的情况,代码如下:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 栈
const effectStack = []
function effect(fn) {
const effect = () => {
// 调用 cleanup 函数完成清楚工作
cleanup(effectFN)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffct = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effrctFn.deps = []
// 执行副作用函数
effectFn()
}
如上面的代码所示,咱定义了effectStack数组,用它来模拟栈,activeEffect没有变化,它仍然指向当前在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数。
当内层副作用函数effectFn2执行完毕后,它会被弹出栈,并将副作用函数effectFn1设置为activeEffect。
过程如下面图像所示。
这样一来,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。
待续
响应式是 Vue.js 中重要的一部分,它也真的是座大山,如果看到这里而且看懂的掘友的话,我相信我们可以成为很好的朋友。