
今天我们来探讨一个棘手的边界情况:嵌套 effect。
当一个 effect
内部又定义了另一个 effect
时,我们的系统会如何运作呢?
JavaScript
import { ref, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const effect1 = effect(() => {
const effect2 = effect(() => {
console.log('内层的 Effect', count.value)
})
console.log('外层的 Effect', count.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
在这种情况下,我们预期内外层都有输出,但是我们得到的结果如下:
arduino
console.log('内层的 Effect', 0)
console.log('外层的 Effect', 0)
// 1秒后
console.log('内层的 Effect', 1)
官方不建议使用嵌套 effect
,你可能会想:"既然官方不建议,我只要不这么写就好了。"
但是遇到这种"嵌套执行"的场景比想象中更常见。比方说,当一个 effect
依赖了一个 computed
属性时,就会隐式触发嵌套执行:
JavaScript
const count = ref(0);
// computed 内部会为计算函数创建一个 effect (我们先称之为 effect B)
const double = computed(() => count.value * 2);
// 这是我们手动创建的 effect (我们称之为 effect A)
effect(() => {
// 当 effect A 执行,并在这里读取 double.value 时...
// effect B 就必须先执行并返回计算结果。
// 这就形成了 effect A 内部触发了 effect B 执行的嵌套情况。
console.log('The double value is:', double.value);
});
因此,为了处理这种隐式触发的问题,我们就需要解决嵌套 effect
的触发机制。
问题解析
初始化页面

-
执行
effect1
(ReactiveEffect A
):-
activeSub
设为A
。 -
开始执行
effect1
的函数fnA
。 -
进入
fnA
内部,遇到effect2
(ReactiveEffect B
):activeSub
被覆盖 ,更新为B
。- 开始执行
effect2
的函数fnB
。 - 在
fnB
中,读取count.value
,触发getter
。 - 依赖收集:
count
的依赖列表中,只收集了当前的activeSub
,也就是B
。 console.log
输出内层的 Effect 0
。fnB
执行完毕,activeSub
被清空 (undefined
)。
-
-
回到
effect1
的fnA
继续执行:- 此时,程序读取
count.value
。 - 依赖收集失败: 因为
activeSub
已经是undefined
,所以A
无法被count
收集。 console.log
输出外层的 Effect 0
。
- 此时,程序读取
-
结果:
count
的依赖链表上,只有B
(effect2
),没有A
(effect1
)。
关键问题:执行外层匿名函数 fn
时,activeSub
就被覆盖,导致外层没有进行依赖收集。
一秒后执行 count.value = 1
由于依赖收集只收集了内层的 ReactiveEffect(也就是 ReactiveEffect B),因此它在触发更新时,只会执行
B
的 run
方法。

核心思路
后来的 effect
覆盖了前面的 effect
,这个情况是不是跟函数的"调用栈 (Stack)"有点像?
调用栈 (Stack) 有两个主要特性:
- 后进先出 (Last-In, First-Out)。
- 一维线性结构。
函数在层层调用时,就是被放入一个"调用栈"中,我们也可以利用这个特性来管理 activeSub
。
- 在进入内层
effect
时,将外层的effect
暂存起来。 - 在内层结束后,再从栈中"弹出"并还原外层的
effect
。
要完成这个方法,可以通过一个暂存变量来模拟。
解决方法
具体做法
-
外层
effect
开始:activeSub = ReactiveEffect A
。 -
外层
effect
执行,遇到内层effect
。 -
在内层
effect
执行之前:- 我们先检查
activeSub
是不是有值。 - 如果有值,我们可以先把它存储起来。
- 我们先检查
-
内层
effect
执行完成后:- 不再简单地设置
activeSub = undefined
。 - 而是将
activeSub
恢复成执行之前的状态。
- 不再简单地设置
于是我们这样写:
TypeScript
export let activeSub;
class ReactiveEffect {
constructor(public fn){}
run(){
// 先将当前的 Effect 存储,用于处理嵌套逻辑
const prevSub = activeSub
activeSub = this
try {
return this.fn()
} finally {
// 执行完毕后,恢复之前的 activeSub
activeSub = prevSub
}
}
}
export function effect(fn){
const e = new ReactiveEffect(fn)
e.run()
}
它的运作模式是这样:
此时你会发现,在触发更新的时候,内层会多输出一次:
触发更新为何会多输出一次?

初始状态
内层的 Effect 0
外层的 Effect 0
分别输出一次,这里没什么问题。
初始化后的链表结构

setTimeout 触发更新后
-
count.value = 1
触发 setter,执行propagate
。 -
propagate
遍历依赖链表。 -
执行
B.run() (effect2)
:console.log
输出内层的 Effect 1 (第一次)
。
-
执行
A.run() (effect1)
:-
console.log
输出外部的 Effect 1
。 -
在
A
的函数内部,会重新创建并执行 一个全新的内层effect
。 -
执行这个新的内层
effect.run()
:console.log
输出内层的 Effect 1
(第二次)。
-
因为这样,所以内层会执行两次。
乍看之下,内层 effect
多执行一次似乎没什么关系。
但思考一下,如果现在内层的 effect
执行的不是 console.log
,而是更耗费资源的操作呢?
像是:
- 网络请求
- 复杂且大量的计算
- DOM 的重新布局
因此我们知道,不必要的重复执行会导致性能浪费,甚至有可能引发无法预期的 Bug。
这也就是为什么官方不推荐我们写嵌套 effect
。
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。