前言
我们通过上一篇文章知道在 Vue3.3 及之前,计算属性的依赖跟踪和触发存在以下问题:
-
依赖跟踪不精确:在计算属性的 getter 中,如果依赖发生变化,会立即标记为脏(dirty),但有时这种变化可能并不影响最终结果,导致不必要的重新计算。
-
过度触发:当一个计算属性依赖另一个计算属性时,内部计算属性的变化可能导致外部计算属性被重新计算,即使外部计算属性的值实际上没有变化。
为了解决这些问题,Vue 3.3 引入了一个延迟计算属性 deferredComputed 的 API 来解决。deferredComputed 是一种针对高频更新场景优化的计算属性,其核心目标是通过延迟计算和异步批量更新减少不必要的计算与渲染开销,从而提升性能。但传统的 computed API 依然还是存在上述问题,所以在 Vue3.4 中对响应式系统进行了重构,主要优化了计算属性(computed)的性能,引入了多级脏检查机制,解决在计算属性中调用其他计算属性时的依赖跟踪问题,从而减少不必要的重新计算和依赖触发。
那么本篇就跟大家一起探讨在 Vue3.4 中是如何优化计算属性(computed)的性能。
多级脏检查机制的本质
如果大家有了解过 Vue3.4 的多级脏检查机制就知道这个多级脏检查机制是非常复杂的,但即便再复杂,我们也需要从它的本质出发,只有知道一件事物的本质才算真正的了解它。
那么我们现在有以下代码:
js
const state = reactive({ count: 0 })
const computedState = computed(() => state.count % 2)
effect(() => {
console.log('观察计算属性', computedState.value)
})
state.count = 2
上述例子中,即便更新的 tate.count 值并没有改变计算属性的 computedState 的值,但相关的依赖还是更新了。也就是上述代码运行之后,effect 里面的打印日志最终打印了两次。
观察计算属性 0
观察计算属性 0
那么根据我们所学的知识,如果让你去修复这个 Vue3 的 BUG,你会怎么做呢?
我们知道 effect 的基本原理就是传入一个副作用函数,然后初始化的时候执行一遍,然后触发依赖收集,其中就包括计算属性,之后在依赖变量发生更新的时候,就会通过 effect 的调度器重新执行副作用函数。这是我们在前面的文章所学到的知识。下面就是 effect 函数的基本实现。

我们在上一篇文章中知道 deferredComputed 的 API 解决不必要的计算从而提升性能的核心就是仅在实际变化时触发依赖更新。具体来说就是在计算属性的相关依赖进行更新之前会对比计算属性的旧值和新值是否相等,只有在不相等的时候才会去执行更新。
所以我们只需要在 effect 的调度器重新执行副作用函数 _effect.run() 之前去判断相关计算属性的旧值和新值是否相等,只有在不相等的时候才会去执行 _effect.run()。
diff
function effect(fn) {
const _effect = new ReactiveEffect(fn, () => {
+ // 是否执行开关
+ let dirty = false
+ for (const dep of _effect.deps) {
+ if (dep.computed) {
+ // 只有在不相等的时候才会去执行
+ if(!Object.is(dep.computed._value, dep.computed.value)) {
+ dirty = true
+ break
+ }
+ }
+ }
+ if (dirty) {
_effect.run()
+ }
})
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
因为我们使用到计算属性,所以我们还要在计算属性部分添加计算属性对象的标记。
diff
class ComputedRefImpl {
// 省略...
get value() {
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
+ // 添加计算属性对象标记
+ this.dep.computed = this
// 在读取计算属性值的时候,手动进行依赖收集
trackEffects(this.dep)
}
return this._value
}
}
这时我们再执行上面的测试代码,我们发现最终只执行了一次打印。

所以我们可以得出总结 ------ 多级脏检查机制的本质就是仅在计算属性实际变化时才触发依赖更新。具体来说就是在计算属性的相关依赖进行更新之前会对比计算属性的旧值和新值是否相等,只有在不相等的时候才会去执行更新。
链式依赖的更新优化
从上一节的知识我们知道所谓的链式依赖就是链式计算属性,也就是一个计算属性是依赖另一个计算属性的计算属性就是链式计算属性。例如下面的例子:
js
const state = reactive({ count: 0 })
const computedState1 = computed(() => {
console.log('computed1')
return state.count % 2
})
const computedState2 = computed(() => {
console.log('computed2')
return computedState1.value + 1
})
effect(() => {
console.log('观察计算属性', computedState2.value)
})
state.count = 2
上述例子中的 computedState2 就是依赖 computedState1 的值,那么 computedState2 就是链式计算属性。那么上述的例子会存在什么问题呢?我们来看看目前的执行结果:
computed2
computed1
观察计算属性 1
computed2
computed1
那么上述的执行结果有什么问题呢?按常理来说 computedState1 的值没有发生变化,那么 computedState2 就不应该再执行,所以呢,我们在重新执行计算属性的副作用函数前也需要去比较一下,相关联的计算属性的值有没有发生变化,只有在发生变化了之后才重新去执行副作用函数获取最新的计算值。代码迭代如下:
diff
class ComputedRefImpl {
// 省略...
get value() {
+ for (const dep of this.effect.deps) {
+ if (dep.computed) {
+ if(!Object.is(dep.computed._value, dep.computed.value)) {
+ this._dirty = true
+ break
+ }
+ }
+ }
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
}
return this._value
}
}
我们再执行上述测试例子,结果如下:
computed2
computed1
观察计算属性 1
computed2
computed1
我们发现并没有变化。这是因为在计算属性的依赖发生变化之后,就会去执行其调度器,在调度器里面已经把 this._dirty 设置为 true 了。所以后面在读取计算属性 value 访问器 的时候 this._dirty 就一直是 true 了。所以我们只需要进行以下修改:
diff
class ComputedRefImpl {
// 省略...
get value() {
for (const dep of this.effect.deps) {
if (dep.computed) {
if(!Object.is(dep.computed._value, dep.computed.value)) {
this._dirty = true
break
- }
+ } else {
+ // 如果新旧值相等就说明没有变化,就不需要重新计算了
+ this._dirty = false
+ }
}
}
if (this._dirty) {
this._dirty = false
this._value = this.effect.run()
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
}
return this._value
}
}
这时我们再执行上述测试代码,执行结果如下:
computed2
computed1
观察计算属性 1
computed1
这时我们发现在计算属性 computedState1 的值没有发生变化的时候,它关联的计算属性也不会重新计算了。
引入多级脏检查机制
我们继续测试一个场景,测试代码如下:
js
const state = reactive({ count: 0 })
effect(() => {
console.log('普通响应式', state.count)
})
state.count = 2
测试结果如下:
普通响应式 0
我们发现目前普通响应式变量触发不了更新了。这是因为目前我们的 effect 的实现中,只在计算属性的值发生了变化之后才执行更新,而当只有普通响应式变量的时候就触发不了更新了,因为不存在计算属性。
所以我们要设置一个标识来区分是否需要验证计算属性是否脏状态,还是普通响应式变量不需要验证。因此我们给 ReactiveEffect 类设置一个 _dirtyLevel 属性,如果其等于 1 那么就需要验证计算属性是否脏状态,如果其等于 2 那么就是普通响应式变量,无需验证强制更新。迭代代码如下:
diff
function effect(fn) {
const _effect = new ReactiveEffect(fn, () => {
let dirty = false
+ // 需要验证计算属性是否脏状态
+ if (_effect._dirtyLevel === 1) {
for (const dep of _effect.deps) {
if (dep.computed) {
if(!Object.is(dep.computed._value, dep.computed.value)) {
dirty = true
break
}
}
}
+ } else if (_effect._dirtyLevel === 2) {
+ // 无需验证强制更新
+ dirty = true
+ }
if (dirty) {
_effect.run()
}
})
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
ReactiveEffect 类的迭代:
diff
class ReactiveEffect {
+ _dirtyLevel = 2 // 默认无需验证,强制更新
// 省略...
constructor(fn, scheduler) {
// 省略...
}
// 省略...
}
这个时候,我们再执行上述测试例子,执行结果如下:
普通响应式 0
普通响应式 2
这时我们发现普通响应式变量触发更新了。
现在脏标记默认是 2,那么什么时候需要将它设置成 1,表明需要验证计算属性是否脏状态呢?我们需要在计算属性更新的时候去设置脏标记为 1。
diff
class ComputedRefImpl {
// 省略...
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
// 当计算属性依赖的响应式数据发生变化时,手动进行依赖触发
- triggerEffects(this.dep)
+ triggerEffects(this.dep, 1)
}
})
}
// 省略...
}
接着迭代 triggerEffects 函数:
diff
- function triggerEffects(deps){
+ function triggerEffects(deps, dirtyLevel) {
deps && deps.forEach(effect => {
+ effect._dirtyLevel = dirtyLevel
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run();
}
})
}
同时触发普通响应式变量更新的 trigger 方法则需要将脏标记设置为 2,代码迭代如下:
diff
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
- triggerEffects(effects)
+ triggerEffects(effects, 2)
}
这时我们执行以下测试代码:
js
const state = reactive({ count: 0 })
const computedState1 = computed(() => {
console.log('computed1')
return state.count % 2
})
const computedState2 = computed(() => {
console.log('computed2')
return computedState1.value + 1
})
effect(() => {
console.log('观察计算属性', computedState2.value)
})
effect(() => {
console.log('普通响应式', state.count)
})
state.count = 2
执行结果如下:
computed2
computed1
观察计算属性 1
普通响应式 0
computed1
普通响应式 2
这个执行结果是正确的,说明我们的代码迭代是正确的。
为什么引入多级脏检查机制
至此,我们可以作一个小结,为什么 Vue3.4 要引入多级脏检查机制呢?首先是因为传统方案(如 Vue 3.3)会在计算属性依赖变更时递归触发整个链路的更新,即使中间计算结果未变化。解决的方案也很简单,就如我们上述的 多级脏检查机制的本质 的小节中讲到的,就是在副作用函数执行更新之前去检验一下副作用函数中所使用到的计算属性的值是否发生了变化,如果发生了变化就进行更新,没有变化则不进行更新。但由此也产出了另一个问题,每一次在副作用函数执行前都去检查计算属性是否发生了变化,就会显得不智能,所以我们需要区分,存在计算属性或者只是普通响应式变量的情况,只有存在计算属性的依赖的副作用在更新的时候,我们才去检查计算属性是否发生了变化,如果只是存在普通响应式变量的副作用在更新的时候,则直接更新即可。
脏检查验证功能重构
我们现在在 effect 函数中会进行脏检查:

同时也在计算属性中进行脏检查:

很明显这两个功能是相同的,若代码中包含相同代码块,可将重复部分提取为独立函数,这就是重构方法中很重要的一个手段,叫做:提炼共用函数。
很明显我们可以看到这个脏检查的功能其实是跟 ReactiveEffect 类有关的,所以我们把该功能提取到 ReactiveEffect 类中。那么 ReactiveEffect 类重构如下:
diff
class ReactiveEffect {
// 省略...
+ dirty() {
+ let dirty = false
+ // 需要验证计算属性是否脏状态
+ if (this._dirtyLevel === 1) {
+ for (const dep of this.deps) {
+ if (dep.computed) {
+ if(!Object.is(dep.computed._value, dep.computed.value)) {
+ dirty = true
+ break
+ }
+ }
+ }
+ } else if (this._dirtyLevel === 2) {
+ // 无需验证强制更新
+ dirty = true
+ }
+ return dirty
+ }
run() {
// 省略...
}
}
effect 函数重构如下:
diff
function effect(fn) {
const _effect = new ReactiveEffect(fn, () => {
- let dirty = false
- // 需要验证计算属性是否脏状态
- if (_effect._dirtyLevel === 1) {
- for (const dep of _effect.deps) {
- if (dep.computed) {
- if(!Object.is(dep.computed._value, dep.computed.value)) {
- dirty = true
- break
- }
- }
- }
- } else if (_effect._dirtyLevel === 2) {
- // 无需验证强制更新
- dirty = true
- }
- if (dirty) {
+ if (_effect.dirty()) {
_effect.run()
}
})
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
计算属性的重构:
diff
class ComputedRefImpl {
dep = new Set() // 计算属性的依赖存储中心
_value
_dirty = true
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
- if (!this._dirty) {
- this._dirty = true
// 当计算属性依赖的响应式数据发生变化时,手动进行依赖触发
triggerEffects(this.dep, 1)
- }
})
}
get value() {
- for (const dep of this.effect.deps) {
- if (dep.computed) {
- if(!Object.is(dep.computed._value, dep.computed.value)) {
- this._dirty = true
- break
- } else {
- // 如果新旧值相等就说明没有变化,就不需要重新计算了
- this._dirty = false
- }
- }
- }
- if (this._dirty) {
- this._dirty = false
+ if (this.effect.dirty()) {
this._value = this.effect.run()
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
}
return this._value
}
}
我们可以看到重构后的计算属性的脏状态由 ReactiveEffect 的 _dirtyLevel 内部状态进行管理,不再由原来的 ComputedRefImpl 的 _dirty 属性管理。
响应式调度系统深度解析
实现任务调度
我们知道在 Vue3 中本身就存在一个调度系统,当多个响应式数据变化时,Vue 系统批量处理这些变化,具体就是 Vue 的调度器(scheduler)会将更新任务推入微任务队列,在同步代码执行完毕后,再一次性执行更新,避免重复执行副作用。比如当你修改了响应式数据后,Vue 并不会立即更新 DOM,而是将这些更新操作放入一个队列中,等待下一次事件循环时统一处理。但这个调度系统并不能像响应式系统那样独立于 Vue 整个系统而使用,所以如果我们独立使用 Vue3 的响应式系统,例如如下例子:
js
const state = reactive({ count: 0 })
effect(() => {
console.log('观察普通响应式变量', state.count)
})
state.count = 2
state.count = 3
上述测试例子的执行结果如下:
观察普通响应式变量 0
观察普通响应式变量 2
观察普通响应式变量 3
我们可以看到每修改一次响应式数据,副作用函数就会重新执行一次,这将大大浪费执行性能,我们希望在独立使用 Vue3 响应式系统的时候,也能像在 Vue3 组件中使用响应式系统一样,不管响应式数据发生多次变更,最终只执行一次更新,避免重复执行副作用。
如果让你来实现这个功能你会怎么做呢?其实我们在上一篇文章中已经实现过了,但那种方式比较笨重,我们可以采用一种相对轻量的实现方式。首先实现思路跟普通 Vue3 组件的调度系统相同,都先把需要执行的副作用函数存储起来,等到最后再把存储的副作用函数取出来执行即可。
代码迭代如下:
diff
+ // 定义一个全局变量标记是否可以执行副作用函数
+ let pauseSchedule = false
+ // 定义一个全局变量存储副作用函数
+ const queueEffectSchedulers = []
function triggerEffects(deps, dirtyLevel) {
deps && deps.forEach(effect => {
effect._dirtyLevel = dirtyLevel
if (effect.scheduler) {
- effect.scheduler()
+ // 收集所有调度任务,最后批量执行
+ queueEffectSchedulers.push(effect.scheduler)
}
})
}
然后执行的测试例子进行修改:
diff
const state = reactive({ count: 0 })
effect(() => {
console.log('观察普通响应式变量', state.count)
})
state.count = 2
state.count = 3
+ // 取出所有副作用函数并执行
+ while(queueEffectSchedulers.length) {
+ // 使用 FIFO(先进先出)队列确保执行顺序
+ queueEffectSchedulers.shift()()
+ }
执行结果如下:
观察普通响应式变量 0
观察普通响应式变量 3
观察普通响应式变量 3
这时我们发现执行结果还是打印了两次,但打印结果跟上一次已经不一样了,两次打印结果都是最终的变量值的结果,多打了一次说明我们每次响应式变量发生变化都进行了任务搜集,而实际上同一个副作用函数,只收集一次即可。
防重机制实现
我们进行以下迭代,迭代的功能为:确保只在副作用只从"干净"状态转变为"脏"状态时执行触发操作。
ReactiveEffect 类迭代如下:
diff
class ReactiveEffect {
// 省略...
// 执行副作用函数,并触发依赖收集
run () {
+ // 更新脏标记,代表无需更新
+ this._dirtyLevel = 0
// 省略...
}
// 省略...
}
triggerEffects 函数迭代如下:
diff
function triggerEffects(deps, dirtyLevel) {
deps && deps.forEach(effect => {
+ const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
+ // 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
+ if (lastDirtyLevel === 0) {
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
+ }
})
}
这时再执行测试例子结果如下:
观察普通响应式变量 0
观察普通响应式变量 3
这时我们发现测试执行结果如期了,说明我们的功能迭代是正确的。其中防重核心就是已标记为脏的副作用不会重复添加。
我们现在的调度策略就是收集所有调度任务,最后批量执行,我们要封装一下最后执行的代码,方便调用。
diff
+ function resetScheduling() {
+ // 取出所有副作用函数并执行
+ while(queueEffectSchedulers.length) {
+ // 使用 FIFO(先进先出)队列确保执行顺序
+ queueEffectSchedulers.shift()()
+ }
+ }
const state = reactive({ count: 0 })
effect(() => {
console.log('观察普通响应式变量', state.count)
})
state.count = 2
state.count = 3
+ resetScheduling()
- // 取出所有副作用函数并执行
- while(queueEffectSchedulers.length) {
- // 使用 FIFO(先进先出)队列确保执行顺序
- queueEffectSchedulers.shift()()
- }
现在我们还存在一个问题,如果我不想每次都要执行 resetScheduling 函数呢?比如我就改变一次 state.count 的值的情况,我希望它是可以直接执行的副作用函数的。这时我们把 resetScheduling 函数放在 triggerEffects 方法的最后即可。代码如下:
diff
function triggerEffects(deps, dirtyLevel) {
deps && deps.forEach(effect => {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
if (lastDirtyLevel === 0) {
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
})
+ resetScheduling()
}
但这样又会造成不能批量处理多个更新,这就要靠暂停深度计数器了。
暂停深度计数器
具体实现如下:
diff
+ // 暂停深度计数器
+ let pauseScheduleStack = 0
// 定义一个全局变量存储副作用函数
const queueEffectSchedulers = []
+ // 暂停调度
+ function pauseScheduling() {
+ pauseScheduleStack++
+ }
+ // 开启调度
+ function resetScheduling() {
+ pauseScheduleStack--
// 取出所有副作用函数并执行
- while(queueEffectSchedulers.length) {
+ while(!pauseScheduleStack && queueEffectSchedulers.length) {
// 使用 FIFO(先进先出)队列确保执行顺序
queueEffectSchedulers.shift()()
}
}
function triggerEffects(deps, dirtyLevel) {
+ pauseScheduling()
deps && deps.forEach(effect => {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
if (lastDirtyLevel === 0) {
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
})
+ resetScheduling()
}
这个暂停深度实际上是每个 triggerEffects 调用的一个标记,用于表示当前是否处于一个 triggerEffects 的上下文中。每进入一个 triggerEffects,暂停深度加 1,退出时减 1。当减到 0 时,说明回到了最顶层,此时执行队列。
这时我们再执行一个普通更新测试时,代码如下:
js
const state = reactive({ count: 0 })
effect(() => {
console.log('观察普通响应式变量', state.count)
})
state.count = 2
执行结果:
观察普通响应式变量 0
观察普通响应式变量 2
就可以实现优雅地更新了。但如果此时我们又想实现多次连续更新时,我们可以这样做:
js
const state = reactive({ count: 0 })
effect(() => {
console.log('观察普通响应式变量', state.count)
})
// 暂停调度
pauseScheduling()
state.count = 2
state.count = 3
state.count = 4
// 开启调度
resetScheduling()
执行结果如下:
观察普通响应式变量 0
观察普通响应式变量 4
即便多次更新,最终还是只执行了一次更新。因为我们通过暂停深度会累加,实现只有在最外层的更新结束后才执行队列。
小结:这个简单而强大的机制确保了新的响应式系统在各种场景下的稳定性和高性能,特别是在复杂状态更新和计算属性依赖链中表现卓越。
ReactiveEffect 类新增 trigger 参数
我们来看一个下面的测试例子:
js
// 普通响应式变量
const userId = reactive({ value: 1 })
const isAdmin = reactive({ value: false })
// 权限集合计算属性
const userPermissions = computed(() => {
return isAdmin.value ? ['create', 'delete'] : ['read']
})
// 是否能删除计算属性
const canDelete = computed(() => {
return userPermissions.value.includes('delete')
})
// UI渲染
effect(() => {
console.log(`User ${userId.value} can delete: ${canDelete.value}`)
})
// 暂停调度
pauseScheduling()
userId.value = 2
isAdmin.value = true
// 开启调度
resetScheduling()
测试结果如下:
sql
User 1 can delete: false
User 2 can delete: false
User 2 can delete: true
我们发现用户 ID 是 2 的情况打印了两次。这是因为 userId 改变的时候触发了 triggerEffects 进行了一次任务收集,收集的是 effect 中的更新任务,然后 isAdmin 改变的时候又触发了 triggerEffects 进行了一次任务收集,收集的是计算属性 userPermissions 的更新任务,最后执行调度任务的时候,先执行了 effect 中的更新任务,这时 User 是 2,因为计算属性 userPermissions 还没更新,所以计算属性 userPermissions 读取的是上次执行的缓存结果 ['read'],所以计算属性 canDelete 的值还是 false。执行完 effect 中的更新任务后,继续执行计算属性 userPermissions 的更新任务,并且再次触发了 triggerEffects 进行了计算属性 canDelete 的更新任务的收集,再执行执行计算属性 canDelete 的更新任务,并且再次触发了 triggerEffects 进行了effect 中的更新任务的收集,最后执行最后收集的 effect 中的更新任务,这时计算属性 userPermissions 和计算属性 canDelete 的值都更新了,所以此时 canDelete.value 的值就是 true。
目前这个执行逻辑肯定是不行的,我们希望在一个调度范围内还是只执行一次更新。其次在同一个副作用函数的执行任务不管是普通响应式变量触发的还是计算属性触发的都应只收集一次任务。
为了实现这个功能,计算属性的调度器不能延迟执行,要立即执行,先要标记状态为"可能脏了",但又不立即重新计算,等到访问技术属性值的时候才进行计算。
为了实现这个功能,我们要给 ReactiveEffect 类新增 trigger 参数,具体代码实现如下:
ReactiveEffect 类改动如下:
diff
class ReactiveEffect {
// 省略...
- constructor(fn, scheduler) {
+ constructor(fn, trigger, scheduler) {
// 包装的副作用函数(开发者传入的原始函数)
this._fn = fn
+ this.trigger = trigger
this.scheduler = scheduler
}
// 省略...
}
首先 triggerEffects 函数修改如下:
diff
function triggerEffects(deps, dirtyLevel) {
pauseScheduling()
deps && deps.forEach(effect => {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
if (lastDirtyLevel === 0) {
+ effect.trigger()
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
})
resetScheduling()
}
effect 函数修改如下:
diff
function effect(fn) {
- const _effect = new ReactiveEffect(fn, () => {
+ const _effect = new ReactiveEffect(fn, () => {}, () => {
if (_effect.dirty()) {
_effect.run()
}
})
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
修改后,我们再执行上述测试例子,结果如下:
sql
User 1 can delete: false
User 2 can delete: true
这时的执行结果就是我们希望的结果了。
那么我们来个小结,给 ReactiveEffect 类新增 trigger 参数,主要是给计算属性进行特别优化,这使得依赖变化时不立即计算,解耦了依赖变更通知和实际计算,只标记为"可能脏",等到真正问时再决定是否计算,减少了不必要的计算。
我们在上一个小结的测试例子中再加一个测试点,代码如下:
diff
// 普通响应式变量
const userId = reactive({ value: 1 })
const isAdmin = reactive({ value: false })
// 权限集合计算属性
const userPermissions = computed(() => {
return isAdmin.value ? ['create', 'delete'] : ['read']
})
// 是否能删除计算属性
const canDelete = computed(() => {
return userPermissions.value.includes('delete')
})
// UI渲染
effect(() => {
console.log(`User ${userId.value} can delete: ${canDelete.value}`)
})
+ effect(() => {
+ console.log('观察', userPermissions.value)
+ })
// 我们希望计算属性
pauseScheduling()
userId.value = 2
isAdmin.value = true
resetScheduling()
我们设置了一个 effect 来独立观察计算属性 userPermissions 的变化情况。我们执行测试代码,结果如下:
sql
User 1 can delete: false
观察 ['read']
User 2 can delete: true
从测试结果我们看到计算属性 userPermissions 的变化了,并没有让独立引用它的 effect 副作用函数发生更新。这是因为我们的计算属性的实现存在 BUG,我们应当在读取计算属性值的时候,就进行依赖收集。迭代代码如下:
diff
class ComputedRefImpl {
// 省略...
get value() {
+ // 在读取计算属性值的时候,手动进行依赖收集
+ this.dep.computed = this
+ trackEffects(this.dep)
if (this.effect.dirty()) {
this._value = this.effect.run()
- // 在读取计算属性值的时候,手动进行依赖收集
- this.dep.computed = this
- trackEffects(this.dep)
}
return this._value
}
}
我们再执行测试代码,结果如下:
sql
User 1 can delete: false
观察 ['read']
User 2 can delete: true
我们发现结果还是没变。这是因为每次执行更新之前需要去判断是否需要更新,主要下面的代码逻辑:

而因为上一次判断执行之后,计算属性 userPermissions 就没有发生变化了,所以 dirty 变量就一直是 false 了,所以就执行不了观察计算属性 userPermissions 的 effect 更新了。
基于此,我们应该在计算属性脏了之后,也要给对应的依赖进行更新脏标记,表示计算属性脏了,需要重新计算。
diff
class ComputedRefImpl {
// 省略...
get value() {
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
if (this.effect.dirty()) {
this._value = this.effect.run()
+ triggerEffects(this.dep, 2)
}
return this._value
}
}
此时运行报错了。

这是因为在计算属性验证过程中收集到新的依赖,导致死循环了。所以我们要计算属性验证的过程中暂停依赖收集。迭代代码如下:
diff
class ReactiveEffect {
// 省略...
dirty() {
let dirty = false
// 需要验证计算属性是否脏状态
if (this._dirtyLevel === 1) {
+ shouldTrack = false
for (const dep of this.deps) {
if (dep.computed) {
if(!Object.is(dep.computed._value, dep.computed.value)) {
dirty = true
break
}
}
}
+ shouldTrack = true
} else if (this._dirtyLevel === 2) {
// 无需验证强制更新
dirty = true
}
return dirty
}
}
我们再次执行测试代码,结果如下:
sql
User 1 can delete: false
User 1 can delete: false
User 1 can delete: false
观察 ['read']
User 2 can delete: true
结果显示虽然不再出现死循环,但结果还是不如期。我们希望的观察计算属性并没有更新,且多了两个 User 1 的打印结果。这是因为在执行第一个 effect 的副作用函数的时候,读取计算属性 canDelete 时又触发了依赖更新。所以我们只需要加一个运行状态(_runnings)判断,只要是在运行状态,就不进行依赖更新。
首先是 ReactiveEffect 类的迭代:
diff
class ReactiveEffect {
deps = []
+ _runnings = 0 // 运行状态
_dirtyLevel = 2 // 默认无需验证,强制更新
// 省略...
// 执行副作用函数,并触发依赖收集
run () {
// 省略...
try {
// 省略...
+ this._runnings++
// 省略...
return this._fn(); // 返回函数执行结果(支持 computed 等场景)
} finally {
// 省略...
+ this._runnings--
// 省略...
}
}
// 省略...
}
triggerEffects 的迭代:
diff
function triggerEffects(deps, dirtyLevel) {
pauseScheduling()
deps && deps.forEach(effect => {
+ // 只有不是运行状态才能触发
+ if (!effect._runnings) {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
if (lastDirtyLevel === 0) {
effect.trigger()
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
+ }
})
resetScheduling()
}
再执行测试例子,结果如下:
sql
User 1 can delete: false
观察 ['read']
观察 (2) ['create', 'delete']
User 2 can delete: true
这个执行结果是我们期待的,接下我们可以优化一下我们的代码。

这个对比新旧值相不相同的功能可以移植到计算属性 ComputedRefImpl 类中。ComputedRefImpl 类功能迭代如下:
diff
class ComputedRefImpl {
// 省略...
get value() {
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
if (this.effect.dirty()) {
- this._value = this.effect.run()
- triggerEffects(this.dep, 2)
+ const newValue = this.effect.run()
+ if(!Object.is(this._value, newValue)) {
+ this._value = newValue
+ triggerEffects(this.dep, 2)
+ }
}
return this._value
}
}
ReactiveEffect 类修改如下:
diff
class ReactiveEffect {
// 省略...
dirty() {
let dirty = false
// 需要验证计算属性是否脏状态
if (this._dirtyLevel === 1) {
+ // 已经响应了依赖的变化,因此需要重置其脏标记,表示当前副作用的状态是最新的,无需再次运行
+ this._dirtyLevel = 0
shouldTrack = false
for (const dep of this.deps) {
if (dep.computed) {
- if(!Object.is(dep.computed._value, dep.computed.value)) {
- dirty = true
- break
- }
+ dep.computed.value
+ if (this._dirtyLevel === 2) {
+ dirty = true
+ break
+ }
}
}
shouldTrack = true
} else if (this._dirtyLevel === 2) {
// 无需验证强制更新
dirty = true
}
return dirty
}
// 省略...
}
再执行测试例子,结果如下:
sql
User 1 can delete: false
观察 ['read']
观察 (2) ['create', 'delete']
User 2 can delete: true
证明我们的修改是没有问题的。
脏标记级别设计原理
避免不必要的升级
我们来看看下面的测试例子:
js
const count1 = reactive({ value: 0 })
const count2 = reactive({ value: 0 })
const c1 = computed(() => count1.value)
effect(() => {
console.log('UI渲染', c1.value, count2.value);
})
pauseScheduling()
count2.value = 1
count1.value = 2
resetScheduling()
然后在下面的代码设置一个打印日志进行跟踪一下:

然后我们执行一下测试代码,结果如下:
swift
UI渲染 0 0
验证
UI渲染 2 1
从打印日志可以看出,最终执行UI渲染的副作用的时候,还是去验证了计算属性是否是脏状态。但其实是不用验证计算属性是否是脏的了,因为普通响应式变量 count2 改变了之后,就代表了UI渲染副作用函数是必须要执行更新的了。
这是因为 count2 改变之后,UI渲染的副作用的脏标记变为了 2,但后面的 count1 改变之后,又通过它关联的计算属性 c1 将UI渲染的副作用的脏标记变为了 1,所以最终在执行UI渲染的副作用之前还是去验证了计算属性是否是脏了。
所以如果副作用当前的脏标记级别已经高于或等于本次触发的 dirtyLevel,说明该副作用已经处于一个"更脏"的状态,无需再次升级。所以我们需要对 triggerEffects 函数进行功能迭代一下,代码如下:
diff
function triggerEffects(deps, dirtyLevel) {
pauseScheduling()
deps && deps.forEach(effect => {
// 只有不是运行状态才能触发
- if (!effect._runnings) {
+ if (effect._dirtyLevel < dirtyLevel && !effect._runnings) {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
if (lastDirtyLevel === 0) {
effect.trigger()
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
}
})
resetScheduling()
}
我们再执行上述测试代码,结果如下:
swift
UI渲染 0 0
UI渲染 2 1
从测试结果我们可以看到已经跳过标记为更高级别的副作用,减少不必要的处理从而实现性能优化。
避免计算属性运行期间触发更新
我们来给 resetScheduling 函数设置了一个打印日志代码:
diff
function resetScheduling() {
pauseScheduleStack--
// 取出所有副作用函数并执行
while(!pauseScheduleStack && queueEffectSchedulers.length) {
+ console.log('执行更新任务')
// 使用 FIFO(先进先出)队列确保执行顺序
queueEffectSchedulers.shift()()
}
}
接下来我们设置以下测试代码:
js
const state = reactive({ count: '0' })
// 计算属性 A
const A = computed(() => {
return state.count + '2'
})
effect(() => {
console.log('读取计算属性', A.value)
})
// 修改 state.count 触发更新
state.count = '1'
执行测试代码,结果如下:
读取计算属性 02
执行更新任务
执行更新任务
读取计算属性 12
我们发现虽然结果是正确的,但触发了两次执行更新任务,很明显是不正确的,我们来详细看一下。

我们目前的计算属性的功能设置是,在执行计算属性的副作用函数之后获得新值,如果新旧值不一样,就触发 triggerEffects 函数,主要目的是升级计算属性相关依赖的脏标记。而在我们上述测试例子中计算属性 A 的相关依赖就是读取计算属性的 effect,而 effect 在进行验证计算属性的时候已经把脏标记设置为 0,相应代码如下:

在计算属性新旧发生变化之后,执行 triggerEffects 函数升级计算属性相关依赖的脏标记时,又会重新错误地触发读取计算属性的 effect,所以我们要避免计算属性运行期间触发更新。
代码迭代如下:
diff
function triggerEffects(deps, dirtyLevel) {
pauseScheduling()
deps && deps.forEach(effect => {
// 只有不是运行状态才能触发
if (effect._dirtyLevel < dirtyLevel && !effect._runnings) {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
- if (lastDirtyLevel === 0) {
+ if (lastDirtyLevel === 0 && dirtyLevel !== 2) {
effect.trigger()
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
}
})
resetScheduling()
}
也就是我们要排除发生在计算属性运行或查询期间的任务收集。
而由此又引出了一个新的问题,因为目前我们的计算属性的确定脏了的标记和普通响应式变量的标记都是 2,而我们现在为了实现上述的功能已经把标记 2 进行排除了,这就会导致普通响应式变量触发了不了更新,所以我们为普通响应式变量触发的脏标记设置为 3。为了方便管理,我们把所有的脏标记设置到一个变量中,代码如下:
js
const DirtyLevels = {
NotDirty: 0, // 无需更新
ComputedValueMaybeDirty: 1, // 可能脏了,需要验证
ComputedValueDirty: 2, // 确定脏了,需要重新计算
Dirty: 3 // 一定脏,必须运行
}
ReactiveEffect 类的修改如下:
diff
class ReactiveEffect {
// 省略...
- _dirtyLevel = 2 // 默认无需验证,强制更新
+ _dirtyLevel = DirtyLevels.Dirty // 默认无需验证,强制更新
constructor(fn, trigger, scheduler) {
// 省略...
}
dirty() {
let dirty = false
// 需要验证计算属性是否脏状态
- if (this._dirtyLevel === 1) {
+ if (this._dirtyLevel === DirtyLevels.ComputedValueMaybeDirty) {
// 已经响应了依赖的变化,因此需要重置其脏标记,表示当前副作用的状态是最新的,无需再次运行
- this._dirtyLevel = 0
+ this._dirtyLevel = DirtyLevels.NotDirty
shouldTrack = false
for (const dep of this.deps) {
if (dep.computed) {
dep.computed.value
- if (this._dirtyLevel === 2) {
+ if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) {
dirty = true
break
}
}
}
shouldTrack = true
} else if (this._dirtyLevel === 2) {
+ } else if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) {
// 无需验证强制更新
dirty = true
}
return dirty
}
// 执行副作用函数,并触发依赖收集
run () {
// 更新脏标记,代表无需更新
- this._dirtyLevel = 0
+ this._dirtyLevel = DirtyLevels.NotDirty
// 省略...
}
}
// 省略...
}
trigger 函数的修改如下:
diff
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
- triggerEffects(effects, 2)
+ triggerEffects(effects, DirtyLevels.Dirty)
}
triggerEffects 函数的修改如下:
diff
function triggerEffects(deps, dirtyLevel) {
pauseScheduling()
deps && deps.forEach(effect => {
// 只有不是运行状态才能触发
if (effect._dirtyLevel < dirtyLevel && !effect._runnings) {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
// 确保只在副作用从"干净"状态转变为"脏"状态时执行触发操作
- if (lastDirtyLevel === 0 && dirtyLevel !== 2) {
+ if (lastDirtyLevel === DirtyLevels.NotDirty && dirtyLevel !== DirtyLevels.ComputedValueDirty) {
effect.trigger()
if (effect.scheduler) {
// 收集所有调度任务,最后批量执行
queueEffectSchedulers.push(effect.scheduler)
}
}
}
})
resetScheduling()
}
ComputedRefImpl 类的修改如下:
diff
class ComputedRefImpl {
dep = new Set() // 计算属性的依赖存储中心
_value
_dirty = true
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
// 当计算属性依赖的响应式数据发生变化时,手动进行依赖触发
- triggerEffects(this.dep, 1)
+ triggerEffects(this.dep, DirtyLevels.ComputedValueMaybeDirty)
})
}
get value() {
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
if (this.effect.dirty()) {
const newValue = this.effect.run()
if(!Object.is(this._value, newValue)) {
this._value = newValue
- triggerEffects(this.dep, 2)
+ triggerEffects(this.dep, DirtyLevels.ComputedValueDirty)
}
}
return this._value
}
}
再次执行测试代码,结果如下:
读取计算属性 02
执行更新任务
读取计算属性 12
上述的执行结果是如期的。
为什么要区分脏标记
至此 Vue3.4 响应式系统中脏标记级别的设计就完成了,分别如下:
- NotDirty 数据最新,无需更新
- ComputedValueMaybeDirty 计算属性可能需更新
- ComputedValueDirty 计算属性确定需更新
- Dirty 普通响应式变量必须更新
那为什么要这么设计呢?首先根据上文可以知道多级脏检查机制的本质就是仅在计算属性实际变化时才触发依赖更新。具体来说就是在计算属性的相关依赖进行更新之前会对比计算属性的旧值和新值是否相等,只有在不相等的时候才会去执行更新。
所以我们要设置一个标识来区分是否需要验证计算属性是否脏状态,还是普通响应式变量不需要验证。至此,我们至少要设置两个脏标记,因此我们给 ReactiveEffect 类设置一个 _dirtyLevel 属性,如果其等于 1 那么就需要验证计算属性是否脏状态,如果其等于 2 那么就是普通响应式变量或者是确定脏的计算属性,无需验证强制更新。
同时当副作用(effect)运行后,它已经响应了依赖的变化,因此需要重置其脏标记,表示当前副作用的状态是最新的,无需再次运行,直到有新的变化发生,所以我们又需要引入一个表示 数据最新,无需更新 的脏标记 0。
同时为了避免计算属性运行期间触发更新,我们要排除发生在计算属性运行或查询期间的任务收集,也就是把标记 2 进行排除了,但又因为之前脏标记 2 同时代表普通响应式变量或者是确定脏的计算属性,这就会导致普通响应式变量触发了不了更新,所以我们为普通响应式变量触发的脏标记设置为 3。
设计优势
根据这些脏标记我们就可以确保依赖链的传播方向的正确,在 Vue3.4 响应式系统中,依赖关系从底层到高层形成一条链条:普通变量 → 计算属性A → 计算属性B → 组件副作用。普通响应式变量位于依赖链的最底层,计算属性位于中间层,组件副作用位于最顶层。脏标记级别的设计遵循这个依赖链方向:普通变量变化 → 触发计算属性 → 触发组件副作用。这样设计可以确保副作用的脏标记只升级到必要的最低级别,同时确保脏标记只向更高级别升级 ,避免不必要的计算和更新。具体的核心关键实现是在 triggerEffects 函数中的 effect._dirtyLevel < dirtyLevel 的判断代码。
这样就可以确保高级别更新可以向下传播,低级别更新不能向上传播。
我们可以作如下类比解释。
想象一个火警系统:
DirtyLevels.NotDirty(0) = 安全状态ComputedValueMaybeDirty(1) = 烟雾警报(可能着火)ComputedValueDirty(2) = 确认着火(局部)Dirty(3) = 全面火灾警报
判断代码 effect._dirtyLevel < dirtyLevel 就像:
- 如果已经是全面警报(3),不需要再触发烟雾警报(1)
- 如果确认着火(2),不需要再触发可能着火警报(1)
优化组件更新
在 Vue3 中组件的更新逻辑可以简单理解为是通过比对前后组件的 props 是否相等而进行更新的,如果确定更新了再去执行组件副作用函数,根据我们上述的迭代,在执行组件副作用函数之前需要去查验计算属性是否发生改变,但在前面我们已经确定了要执行副作用函数了,所以我们希望不要再去查验计算属性是否发生改变这一个步骤,从而达到优化性能。
像普通响应式变量发生了更改,那么我们就会升级对应副作用的脏标记等级为 Dirty,同样地如果确定更新了要去执行组件副作用函数,那么我们也应该给对应的组件副作用的脏标记等级升级为 Dirty。像下面这样:
js
instance.effect._dirtyLevel = DirtyLevels.Dirty
而源码中是这样实现的:

这个实现也很简单,就是把 ReactiveEffect 类中原来的 dirty 方法变成一个 dirty 属性访问器。
ReactiveEffect 类修改如下:
diff
class ReactiveEffect {
// 省略...
- dirty() {
+ get dirty() {
// 省略...
}
+ set dirty(v) {
+ this._dirtyLevel = v ? DirtyLevels.Dirty : DirtyLevels.NotDirty
+ }
// 省略...
}
effect 修改如下:
diff
function effect(fn) {
const _effect = new ReactiveEffect(fn, () => {}, () => {
- if (_effect.dirty()) {
+ if (_effect.dirty) {
_effect.run()
}
})
_effect.run()
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
ComputedRefImpl 类修改如下:
diff
class ComputedRefImpl {
// 省略...
get value() {
// 在读取计算属性值的时候,手动进行依赖收集
this.dep.computed = this
trackEffects(this.dep)
- if (this.effect.dirty()) {
+ if (this.effect.dirty) {
// 省略...
}
return this._value
}
}
同样地 Vue3 源码也需要做对应的修改:

因为响应式系统引入多级脏检查机制进行性能优化,所以对应的 Vue3.4 运行时也需要做相应的修改。
总结
在本文中我们深入了解和分析了在 Vue3.4 版本中的响应式系统是如何引入多级脏检查机制的及其实现原理。多级脏检查机制的本质就是仅在计算属性实际变化时才触发依赖更新。具体来说就是在计算属性的相关依赖进行更新之前会对比计算属性的旧值和新值是否相等,只有在不相等的时候才会去执行更新。
而基于此原理,引入了四个脏标记,分别如下:
- NotDirty 数据最新,无需更新
- ComputedValueMaybeDirty 计算属性可能需更新
- ComputedValueDirty 计算属性确定需更新
- Dirty 普通响应式变量必须更新
这种设计使 Vue3.4 能够智能处理复杂依赖场景,特别是在大型应用和深度嵌套的计算属性中,相比 Vue3.3 有显著的性能提升。具体表现为:
计算属性依赖变化时,不会立即重新计算,而是标记为 ComputedValueMaybeDirty(级别1)。只有当计算属性的值被访问时,才会检查脏标记。如果是 ComputedValueMaybeDirty,则进行验证,再根据验证结果进行重新计算或使用缓存。如果重新计算后发现值未变化,则不会触发后续更新。从而实现了减少不必要的计算或者渲染。
在检查计算属性是否发生变化中,当遍历依赖的计算属性时,如果发现当前计算属性已经升级为ComputedValueDirty(级别2),则提前终止循环。避免了对剩余依赖的检查,减少计算量,从而实现短路优化(Short-Circuiting)。
通过实现响应式调度系统,在多个依赖同时变化时,通过暂停调度和批量处理将多个更新合并,从而实现减少重复计算和渲染。
最后脏标记的升级机制确保系统以最小开销维持最新状态,同时防止不必要的计算和渲染。