响应式 Reactivity 进阶优化
Vue3 分支切换
Vue3 Bug 复现
先看一个例子:
<body>
<div id="text"></div>
<button id="flag">change flag</button>
<button id="name">change name</button>
<button id="age">change age</button>
<script type="module">
import { ref, effect } from '../dist/reactivity.esm.js'
const flag = ref(true)
const age = ref(1)
const name = ref('刀刀')
const btnFlag = document.querySelector('#flag')
const btnAge = document.querySelector('#age')
const btnName = document.querySelector('#name')
const text = document.querySelector('#text')
effect(() => {
console.count('run effect' + count)
if (flag.value) {
text.innerText = name.value
} else {
text.innerText = age.value
}
})
btnFlag.addEventListener('click', () => {
flag.value = !flag.value
})
btnAge.addEventListener('click', () => {
age.value += 1
})
btnName.addEventListener('click', () => {
name.value += '1'
})
</script>
</body>上方代码中,我们通过点击按钮,切换 flag 的值,从而切换 text 的内容。当 flag 为 true 时,text 的内容为 name 的值,当 flag 为 false 时,text 的内容为 age 的值。这是一个很普通的分支切换,但是有一个小问题,如果点击按钮将 flag 切换为 false,此时页面展示的是 age 的值,与 name 值没有关系,点击修改 name 的按钮按理来说应该不会调用 effect 函数才对,但事实上 console.count 有打印,说明触发了 effect 函数。
这是为什么呢?下面来依次梳理步骤流程。
首先刚进入页面,创建了三个 ref 响应式变量,然后调用 effect 函数,此时:
触发
effect函数,保存activeSub,depsTail置为undefined运行第一行代码,打印计数
运行第二行代码,获取
flag.value的值,触发其RefImpl类的get方法,调用link方法获取
activeSub的depsTail(为undefined)赋值给nextDeptsconst currentDep = sub.depsTail // undefined获取当前
sub.deps,即副作用函数effect中的dep依赖项头节点(为undefined)赋值给nextDeptsconst nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep判断
nextDep是否有值且值等于当前的订阅者dep,此时没值,说明不可复用,跳过if判断,往下新建一个newLinktsif (nextDep && nextDep.dep === dep) { sub.depsTail = nextDep return } const newLink: Link = { sub, nextSub: undefined, prevSub: undefined, dep, nextDep: undefined, }判断当前的订阅者链表是否有尾节点,当前订阅者链表没有内容,直接将
newLink赋值给dep.subs和dep.subsTail,此时dep.subs和dep.subsTail都指向newLink。tsif (dep.subsTail) { // 如果有尾指针,说明当前已经存在链表,让链表最后一个节点的next指向当前节点,当前节点的prev指向最后一个节点。最后再移动尾指针 dep.subsTail.nextSub = newLink newLink.prevSub = dep.subsTail dep.subsTail = newLink } else { // 如果没有尾指针,说明当前链表为空,直接让头指针指向当前节点,尾指针指向当前节点 dep.subs = newLink dep.subsTail = newLink }判断当前的依赖项链表是否有尾节点,当前依赖项链表没有内容,直接将
newLink赋值给sub.deps和sub.depsTail,此时sub.deps和sub.depsTail都指向newLink。tsif (sub.depsTail) { // 如果有尾指针,说明当前已经存在链表,让链表最后一个节点的next指向当前节点,最后再移动尾指针 sub.depsTail.nextDep = newLink sub.depsTail = newLink } else { // 如果没有尾指针,说明当前链表为空,直接让头指针指向当前节点,尾指针指向当前节点 sub.deps = newLink sub.depsTail = newLink }link方法执行完毕,返回get方法,return this._value把值返回给flag.value,代码继续往下执行,由于flag.value为真,分支代码执行text.innerText = name.value调用
name.value的RefImpl类的get方法,触发link方法获取
activeSub的depsTail赋值给nextDep,拿到的是之前flag.value的newLink值,如下:ts{ // ReactiveEffect sub: { deps: { // flag.value 的link节点 subs: {sub: ReactiveEffect, nextSub: undefined, prevSub: undefined, dep: RefImpl, nextDep: {…}}, subsTail: {sub: ReactiveEffect, nextSub: undefined, prevSub: undefined, dep: RefImpl, nextDep: {…}}, __v_isRef: true, _value: true, value: true, }, depsTail: { // ... (同subs) }, }, nextSub: undefined, prevSub: undefined, // RefImpl dep: { subs: {}, subsTail: {}, }, nextDep: undefined, }因此
currentDep有值,值为flag.value的newLink。运行下一行,
currentDep有值,三元判断为假,nextDep值为currentDep.nextDep,即undefined(因为这里只绑定了flag.value,它既是头节点,也是尾节点,所以没有下一个节点)同第六步,没有可复用的,创建新节点
同第七步,判断当前的
RefImpl类是否有尾节点,此处没有,新建一个链表,头指针和尾指针都指向newLink同第八步,判断当前的
ReactiveEffect类是否有尾指针,前面已经添加了flag.value依赖项到链表内,有尾指针,让链表最后一个节点的next指向当前节点,最后再移动尾指针指向当前节点link方法执行完毕,返回this._value,effect函数执行完毕
一图流如下所示:

看看击了修改 flag 按钮后发生了什么,执行步骤如下:
修改
flag.value的值,触发RefImpl类的set方法,调用链表,依次调用ReactiveEffect类的run方法触发effect函数保存
activeSub,depsTail置为undefined,然后执行里面的fn函数运行第一行,打印计数
运行第二行,获取
flag.value的值,触发RefImpl类的get方法,调用link方法获取
activeSub的depsTail值,前面已经置为undefined,所以为undefined三元判断为真,获取
activeSub的deps值,为flag.value的newLink节点判断,
nextDep.dep是flag.value的RefImpl类,dep也是RefImpl类,判断为真,可以复用,把nextDep赋值给activeSub的depsTail,然后return代码不继续往下执行,不创建新节点link执行完毕,返回this._value,effect函数继续往下执行,此时flag.value的值为假,切换分支代码执行text.innerText = age.valueage.value获取值,触发RefImpl类的get方法,调用link方法,获取activeSub的depsTail值,前面在复用flag.value的newLink节点时,已经把activeSub的depsTail赋值为flag.value的newLink节点,所以此时currentDep的值为flag.value的newLink节点三元判断为假,
nextDep的值为flag.value的newLink节点的下一个节点,即name.value的newLink节点tsconst nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep判断,
nextDep.dep是name.value的,dep指向age,不相同,不可复用,往下创建新节点判断
dep是否有尾节点,没有,头节点和尾节点都指向当前的newLink节点判断
sub是否有尾节点,有,尾节点指向flag的link,然后sub.depsTail.nextDep指向newLink节点,sub.depsTail也指向newLink节点link执行完毕,返回this._value,effect函数执行完毕
一图流如下所示: 
从图中不难看出,此时 effect 的 deps 订阅者链表只包括 flag 和 age ,已经不包括 name 了。但是 name 的 sub 还指向了 effect ,导致点击修改 name 的值时,effect 依然会执行。解决方法是切换分支后清理 name 的 sub 中的 effect 节点。
如何实现呢?
Vue3 方法实现
以上方的示例代码为例,解决思路是把 age 的 nextDep 指向 name 的 newLink 节点,此时 depsTail 尾指针指向的是 age ,而 age 的下一个节点 nextDep 还有值,在运行完 fn 后就把 depsTail 后面的节点都清掉,这样 name 的 sub 就没有 effect 了。
一图流如下所示: 
修改原来的 newLink 写法,原本是直接给 nextDep 赋值为 undefined,现在则要赋值 activeSub 的尾节点 depsTail 的 nextDep 节点。
如果 depsTail 存在且还有 nextDep,则判断以下情况:
depsTail.nextDep是否有prevSub,如果没有说明它是subs的头节点,修改头节点subs指向nextSub节点;如果有,说明它不是第一个节点,则让它前一个节点的下一个节点指向nextSub节点,然后清掉自身的nextSub。depsTail.nextDep是否有nextSub,如果没有说明它是subs的尾节点,修改尾节点subsTail指向prevSub节点;如果有,说明它不是最后一个节点,则让它后一个节点的前一个节点指向prevSub节点,然后清掉自身的prevSub。- 清掉
depsTail.nextDep的sub和dep、nextDep,然后继续循环,直到清掉后面所有的节点。
代码改动如下:
class ReactiveEffect {
deps: Link | undefined
depsTail: Link | undefined
constructor(public fn) {}
run() {
// 把当前的 effect 保存,后面执行完 fn 函数后再获取
let prevSub = activeSub
// 每次执行都把 fn 放到 activeSub 中,让 reactivity 收集依赖
activeSub = this
this.depsTail = undefined
startTrack(this)
try {
return this.fn()
} finally {
endTrack(this)
activeSub = prevSub
}
}
// ..,
}export const link = (dep, sub) => {
const currentDep = sub.depsTail
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep
return
}
const newLink: Link = {
sub,
nextSub: undefined,
prevSub: undefined,
dep,
nextDep: undefined,
nextDep,
}
// ...
}
/**
* 开始追踪依赖, 将 depsTail 设置为 undefined
*/
export function startTrack(sub) {
sub.depsTail = undefined
}
/**
* 结束追踪,找到需要清理的依赖,并断开关系
*/
export function endTrack(sub) {
const depsTail = sub.depsTail
if (depsTail) {
if (depsTail.nextDep) {
clearTracking(depsTail.nextDep)
depsTail.nextDep = undefined
}
}
}
export function clearTracking(link: Link) {
while (link) {
const { nextDep, prevSub, dep, nextSub } = link
/**
* 如果 prevSub 有,说明当前不是第一个节点,把 prevSub 的 nextSub 指向当前节点的下一个节点,然后清掉自身的 nextSub。
* 如果没有,说明是头节点,把 dep.sub 指向当前节点的下一个
*/
if (prevSub) {
prevSub.nextSub = nextSub
link.nextSub = undefined
}
else {
dep.subs = nextSub
}
/**
* 如果 nextSub 有,说明当前不是最后一个节点,把 nextSub 的 prevSub 指向当前节点的上一个节点,然后清掉自身的 prevSub。
* 如果没有,说明是尾节点,把 dep.subsTail 指向当前节点的上一个节点
*/
if (nextSub) {
nextSub.prevSub = prevSub
link.prevSub = undefined
}
else {
dep.subsTail = prevSub
}
link.dep = link.sub = undefined
link.nextDep = undefined
link = nextDep
}
} Vue3 依赖清理
目前还有一个问题,还是以上方的示例代码为例,添加一点判断条件,代码如下:
<body>
<div id="text"></div>
<button id="flag">change flag</button>
<button id="name">change name</button>
<button id="age">change age</button>
<script type="module">
import { ref, effect } from '../dist/reactivity.esm.js'
const flag = ref(true)
const age = ref(1)
const name = ref('刀刀')
const btnFlag = document.querySelector('#flag')
const btnAge = document.querySelector('#age')
const btnName = document.querySelector('#name')
const text = document.querySelector('#text')
let count = 0
effect(() => {
console.count('run effect' + count)
if (count > 1) return
count++
if (flag.value) {
text.innerText = name.value
} else {
text.innerText = age.value
}
})
btnFlag.addEventListener('click', () => {
flag.value = !flag.value
})
btnAge.addEventListener('click', () => {
age.value += 1
})
btnName.addEventListener('click', () => {
name.value += '1'
})
</script>
</body>改动很简单,我们添加了一个 count 变量,用于记录 effect 函数执行的次数。在 effect 函数中,我们添加了一个判断,如果 count 大于 1,则直接返回,不再执行后续的代码。
一进入页面,执行了一次 effect 函数,打印了一次 console ,count 自增 1,此时值为 1。点击 flag 或者 name 按钮,执行 effect 函数,打印了一次 console ,count 自增 1,此时值为 2。再次点击按钮时,判断条件成立,return 返回不继续往下执行,按理来说应该触发不了 RefImpl 类收集不了依赖,但是没有清掉依赖,导致依赖还是触发了。
下面来梳理一下流程,第二次点击按钮后,修改 flag.value 的值,触发 effect.run() 方法,depsTail 值为 undefined,保存当前 activeSub,然后运行 fn。此时 count 值为 2,return 返回,effect 函数执行完毕。
解决方法是在 endTrack 函数中,判断当前 depsTail 值是否为 undefined,因为它没有继续往下执行,没法收集到依赖,也就不会有 depsTail 值,所以可以依靠这个作为判断条件,如果 depsTail 值是 undefined ,说明没有依赖收集,如果 deps 链表有节点,就清掉 deps 所有的节点,避免依赖触发。
export function endTrack(sub) {
const depsTail = sub.depsTail
if (depsTail) {
if (depsTail.nextDep) {
clearTracking(depsTail.nextDep)
depsTail.nextDep = undefined
}
} else if (sub.deps) {
clearTracking(sub.deps)
sub.deps = undefined
}
}Vue3 小结
本次在创建节点时复用了之前获取到的 nextDep,如果是第一次运行 effect 函数,则 nextDep 值为 undefined;如果是后续运行 effect 函数,会出现分支切换的情况,此时 nextDep 值旧分支,以上方示例代码为例,切换了 flag.value 后新分支是 age.value,旧分支是 name.value,因此 age.value 节点的 nextDep 值为 name.value 节点。
运行完 fn 函数后,清掉不需要的依赖,避免依赖触发。判断条件为如果有尾节点 depsTail,且尾节点还有下一个节点 nextDep,则循环清掉尾节点后面所有节点。
清理方式为判断它们在 subs 链表中是否是头节点,如果是则把头节点设置为下一个节点;如果不是则把当前节点的上一个节点的 nextSub 设置为下一个节点;如果是尾节点,则尾节点设置为上一个节点;如果不是,则把上一个节点的 nextSub 指向下一个节点。
最后清掉当前节点的 dep 和 sub 和 nextDep,继续循环。
如果 activeSub 没有 depsTail 尾节点,则判断当前是否有链表 deps ,如果有说明没有依赖收集,直接循环清掉 deps 链表,避免依赖触发。
Vue3 节点复用
还是以上方的代码为例,目前每次切换 flag ,都会清掉旧节点依赖,生成 newLink 新节点依赖,性能会有一定的消耗。如果每次清掉节点依赖后,把节点保存起来,下次复用,性能会有所提升。
新建一个 linkPool 变量,用于保存旧节点,默认为 undefined,在 cleaTracking 函数中,把旧节点保存起来。
在 link 函数中,判断当前 linkPool 是否有值,如果有值,复用 linkPool,修改 nextDep 、sub 和 dep 的值。
export const link = (dep, sub) => {
// 获取订阅者链表尾节点
const currentDep = sub.depsTail
// 如果没有尾节点,则从头节点拿取;如果有尾节点,则拿尾节点的下一个节点做复用
const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
if (nextDep && nextDep.dep === dep) {
sub.depsTail = nextDep
return
}
const newLink = {
sub,
nextSub: undefined,
prevSub: undefined,
dep,
nextDep,
}
let newLink: Link
/**
* 有无 linkPool 节点,如果有则复用,否则新建新节点
*/
if (linkPool) {
// 从对象池中拿取
newLink = linkPool
linkPool = linkPool.nextDep
newLink.nextDep = nextDep
newLink.dep = dep
newLink.sub = sub
}
else {
newLink = {
sub,
nextSub: undefined,
prevSub: undefined,
dep,
nextDep,
}
}
// ...
}
let linkPool: link | undefined = undefined
export function clearTracking(link: Link) {
while (link) {
const { nextDep, prevSub, dep, nextSub } = link
// ...
// 将当前节点从依赖项链表中移除,并将其放入linkPool中,以便下次使用
link.dep = link.sub = undefined
/**
* 把不要的节点给 linkPool,以便下次使用
*/
link.nextDep = undefined
link.nextDep = linkPool
linkPool = link
link = nextDep
}
}提示
这里节点复用的本质实际上是不需要重新创建一个新对象,直接使用之前创建好的对象,修改对象的属性值,从而实现复用。
不要小看这点性能,当项目足够大时,节点复用可以显著提升性能。
Vue3 避免递归
下面先来看一个例子:
import { ref, effect } from '../dist/reactivity.esm.js'
let count = ref(0)
effect(() => {
console.log('count.value++', count.value++)
})运行这段代码会出现死循环的情况,因为 effect 函数中会调用 count.value++,而 count.value++ 会触发 count 的 set 方法实现赋值,set 方法会调用 run 方法,又再次调用 effect 函数,导致递归调用。
解决方法是添加一个 trackShaking 属性,用来标记当前是否正在收集依赖,默认值为 false,如果是,则不进行递归调用。
在 propagate 函数中,之前是直接把 link.sub 添加到数组中,现在改为先判断 trackShaking 是否为 false,如果是才把 link.sub 添加到数组中。
export const propagate = (subs) => {
let link = subs
let queueEffects = []
while (link) {
let sub = link.sub
if (!sub.trackShaking) queueEffects.push(link.sub)
queueEffects.push(link.sub)
link = link.nextSub
}
queueEffects.forEach((effect) => effect?.notify())
}
export function startTrack(sub) {
sub.depsTail = undefined
sub.trackShaking = true
}
export function endTrack(sub) {
const depsTail = sub.depsTail
sub.trackShaking = false
if (depsTail) {
if (depsTail.nextDep) {
clearTracking(depsTail.nextDep)
depsTail.nextDep = undefined
}
} else if (sub.deps) {
clearTracking(sub.deps)
sub.deps = undefined
}
}Vue3 总结
上一章实现了基础的 ref 功能,实现了依赖收集、链表存储、依赖更新等功能。本章节重点修改完善了部分功能和 Bug 。
Vue3 分支切换
如果在 effect 回调函数内有一个 if 判断,点击修改按钮让判断走 else 部分,修改 if 为 true 部分的响应式变量,发现还是触发了 effect,这是因为该响应式变量的 RefImpl 类的 subs 链表节点上还存在该 effect 的依赖项。
为了解决这个问题,之前在新建 newLink 时直接把 nextDep 设置为 undefined,现在要把 nextDep 设置为当前的 nextDep 变量,在切换分支后旧分支的响应式变量节点 link 就会作为 newLink 的 nextDep。
这么做的好处是,后续执行完 fn 回调函数后,可以在 finally 代码块,清除链表内无用的依赖项。清除判断依据是当前链表是否有尾节点,如果有,尾节点是否还有 nextDep,如果有,说明该节点还有旧依赖项,清除后面的依赖项。
清除依赖项的方式为:
- 当前节点是否有上一个节点,如果没有说明是头节点,把头节点指针指向下一个;如果有,则把上一个节点的
nextSub指向下一个节点。 - 当前节点是否有下一个节点,如果没有说明是尾节点,把尾节点指针指向上一个;如果有,则把下一个节点的
prevSub指向上一个节点。 - 清除
dep和sub和nextDep - 继续循环下一个节点
Vue3 依赖清理
如果在 effect 回调函数内有一个变量判断如 if(a > 2) return,此时会出现前面此时不会往下执行了,本来依赖是不会收集了,但是之前的依赖节点还在链表内,修改了响应式变量后还是会触发 effect 回调函数。
前面已经实现了依赖清除,不过只是在尾节点 depsTail 存在且 nextDep 不为 undefined 时才会清除,因此可以再添加一个判断,如果尾节点不存在,那么就判断它是否有头节点,如果尾节点不存在但有头节点,那么说明 effect 回调函数执行过程被 return 阻止继续执行,此时需要清除链表内所有依赖。
Vue3 节点复用
每次清掉节点依赖,需要用时创建新的节点,性能会有一定的消耗。如果每次清掉节点依赖后,把节点保存起来,下次复用,性能会有所提升。
新建一个 linkPool 变量,用于保存旧节点,默认为 undefined,在清除依赖函数中,把旧节点保存起来。在 link 函数中,判断当前 linkPool 是否有值,如果有值,创建新节点 newLink 时可以复用 linkPool 的 nextSub 和 prevSub 。
Vue3 避免递归
为了避免递归调用 effect 函数,会给 activeSub 添加一个 trackShaking 属性,默认值为 false,触发 effect 后设置为 true ,执行完毕后才设为 false 。在 propagate 函数中,判断 trackShaking 是否为 false,如果是才把 link.sub 添加到数组中。
Vue3 时序图
