Open
Description
问题
响应式中考虑如下的 case,为何可能会陷入死循环,以及如何解决?
it('should avoid implicit infinite recursive loops with itself', () => {
const counter = reactive({ num: 0 })
effect(() => counter.num++)
expect(counter.num).toBe(1)
})
分析
counter.num++
实际可以写成这种展开形式
counter.num = counter.num + 1
意味着这条语句同时有 getter
和 setter
,先 getter
后执行 setter
。这时候应该有直觉会陷入死循环里了,所以我们可以开个 debug 看下。会发现执行顺序是:
- 进入
effect
函数中执行run()
run
函数中调用 this.fn(),即() => counter.num++
getter
中执行track
,返回当前值- 加 1 后
setter
中执行trigger
trigger
中拿到 target -> key -> dep -> effect 实例 执行- 再次进入 run
从而发生了死循环。
分析结束可以得出结论,在当前的 activeEffect 正在执行的过程中,如果再次执行该 effect,即会陷入死循环。因此可以在 trigger
内部中判断当前的 effect 和 activeEffect 是否相等即可。
解决方案
function triggerEffect(effect: any) {
if (effect !== activeEffect) {
// 添加这个判断以避免死循环
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
继续深入
在知道了避免死循环的方案后,我们按照尤大的方法继续把 case 变得更加严格:
it('should avoid implicit infinite recursive loops with itself', () => {
const counter = reactive({ num: 0 })
const counterSpy = jest.fn(() => counter.num++)
effect(counterSpy)
expect(counter.num).toBe(1)
expect(counterSpy).toHaveBeenCalledTimes(1)
counter.num = 4
expect(counter.num).toBe(5)
expect(counterSpy).toHaveBeenCalledTimes(2)
})
你会发现按照崔大的写法 case 中的这句过不去了
expect(counter.num).toBe(5)
原因也很简单,我们忘记在 ReactiveEffect
的 run
方法中,在执行结束后清理现场了。否则 activeEffect 明明已经执行结束了,却还保留有值,导致新的 effect 无法进入。修改为下述代码即可
run() {
if (!this.active) {
return this._fn()
}
shouldTrack = true
activeEffect = this
const result = this._fn()
shouldTrack = false
// 除了 shouldTrack 需要改为 false 外
// activeEffect 也需要清理现场为 undefined
activeEffect = undefined
return result
}
总结
响应式自增自减产生死循环的情况在 HCY 的书中和群里都有人提及,因此在这里写篇文章记录一下。
其实 activeEffect 这样写还是存在问题,因为如果只有一个 activeEffect,如果是嵌套的 effect 的话,就会丢失上下文环境,因此最好要写成栈的形式,我后续会继续补充。