11.响应式系统演进:深入剖析 computed 实现原理与性能优化实践(Vue3.3)

前言

我们通过以下命令生成一个 Vue3 项目。

js 复制代码
npm create vite vue3-temp -- --template vue

然后再修改 package.json 文件,锁定 Vue3 版本为 3.2.25

js 复制代码
{
  "name": "vue-temp",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "3.2.25"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "3.2.0",
    "vite": "3.0.0"
  }
}

然后安装依赖

复制代码
pnpm install

再运行

复制代码
pnpm dev

然后我们在 App.vue 文件中运行以下代码:

js 复制代码
const count = ref(0)
const c = computed(() => count.value)
effect(() => {
  console.log('观察计算属性', c.value)
})
count.value = 1;
count.value = 2;
count.value = 3;

我们可以看到打印结果如下:

聪明的你,肯定注意到了,我们每次修改响应式数据,都触发了 effect 函数中的响应式副作用函数的执行。对 Vue 有一定基础的同学肯定联想到了 Vue 的异步更新功能,我们无论在 Vue 组件中更改多少次状态,最终都只执行一次更新。那么为什么上述的测试例子中还会存在每修改一次响应式数据都更新一次的情况呢?这就是我们本篇文章需要探讨的,以及了解相关的功能实现。

上述测试例子中,我们涉及到了计算属性 computed,所以我们需要实现 computed 的功能,而计算属性的基本原理是仅会在其响应式依赖更新时才重新计算,要实现这个功能则需要依赖 effect 函数的调度器功能。所以我们先在之前实现响应式系统的基础上实现 effect 函数的调度器功能。

effect 函数的调度执行功能实现

所谓调度执行是指可以改变程序的执行顺序,比如以下例子:

js 复制代码
const state = reactive({ count: 0 })
const fn = () => {
    console.log(`count: ${state.count}`)
}
effect(fn)
state.count++
console.log('执行完毕')

执行结果如下:

那么我们希望在没有改变程序代码顺序的情况下改变执行结果。比如我们希望执行结果如下:

makefile 复制代码
count: 0
执行完毕
count: 1

首先我们不急于去实现这个功能,我们首先得从我们日常的开发中去想,有什么方法是可以改变代码的执行顺序的呢?比如我们上述的例子改成下面的简单代码:

js 复制代码
let count = 0
const fn = () => {
    console.log(`count: ${count}`)
}
fn()
count++
fn()
console.log('执行完毕')

很明显这个例子的执行结果还是:

如果我们去修改上述简单代码例子的话,就容易多了。我们想要先执行 console.log('执行完毕') 再执行第二个 fn() 的话,只需要通过 setTimeout 延迟第二个 fn() 的执行就行了。代码修改如下:

diff 复制代码
let count = 0
const fn = () => {
    console.log(`count: ${count}`)
}
fn()
count++
- fn()
+ setTimeout(fn)
console.log('执行完毕')

修改后的执行结果如下:

makefile 复制代码
count: 0
执行完毕
count: 1

这样我们的例子代码执行结果就如我们所愿了。

那么我们再回到我们开头通过响应式系统执行的代码例子,我们知道在执行 state.count++ 的时候会重新执行副作用函数 fn。那么我们只要想办法在更新的时候将副作用函数延迟执行即可。我们可以进行以下设计:

js 复制代码
effect(fn, () => {
    setTimeout(fn)
})

我们给 effect 函数的第二个参数设置一个函数,在这个函数里面通过 setTimeout 延迟副作用函数 fn() 的执行。那么接下来我们只要修改我们的代码,让响应式系统在状态数据更新的时候判断 effect 函数如果存在第二个参数,就执行第二个参数的函数即可。

首先我们要去修改 effect 函数:

diff 复制代码
- function effect(fn) {
+ function effect(fn, scheduler) {
-    const _effect = new ReactiveEffect(fn)
+    const _effect = new ReactiveEffect(fn, scheduler)
    _effect.run()
    const runner = _effect.run.bind(_effect)
    runner.effect = _effect
    return runner 
}

接着我们去修改 ReactiveEffect 类:

diff 复制代码
class ReactiveEffect {
    deps = []
-    constructor(fn) {
+    constructor(fn, scheduler) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
+        this.scheduler = scheduler
    }
    // 省略...
}

最后我们去修改响应式触发更新的函数 trigger,判断如果存在用户传递的调度器函数 scheduler 就执行 scheduler,把控制权交给用户。代码修改如下:

diff 复制代码
function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
-    effects && effects.forEach(dep => dep.run())
+    effects && effects.forEach(dep => {
+        if (dep.scheduler) {
+            dep.scheduler()
+        } else {
+            dep.run()
+        }
+    })
}

修改完毕后,再执行我们的测试代码,很明显结果如期所愿了:

makefile 复制代码
count: 0
执行完毕
count: 1

上述例子可以告诉我们一个道理,很多所谓高级的系统的实现原理,都来自我们的日常开发例子,经过重构,设计,才最终变成强大的系统。

计算属性(computed)的实现

上述测试例子中,我们涉及到了计算属性 computed,所以我们需要实现 computed 的功能,而计算属性的基本原理是仅会在其响应式依赖更新时才重新计算,要实现这个功能则需要依赖 effect 函数的调度器功能。

我们通过前面的了解可以知道计算属性 computed 的实现需要依赖 effect 函数的调度器功能,现在我们已经实现了 effect 函数的调度器功能,现在也就可以去实现计算属性 computed 函数了。相信使用过 Vue3 的同学肯定也使用过 computed 函数,computed 函数的通常使用方式如下:

js 复制代码
const state = reactive({ count: 0 })
const computedState = computed(() => state.count + 1)
console.log(computedState.value)

computed 函数接收一个 getter 函数作为参数,生成一个跟 Ref 类似的对象,然后通过 .value 获取计算属性值。那么我们很容易设计出一个能实现此功能的基础函数,代码如下:

js 复制代码
function computed(getter) {
    return {
        get value() {
            return getter()
        }
    }
}

我们执行上述的测试代码执行结果如下:

复制代码
1

这个结果是正常的,但目前我们的计算属性还不能在其响应式依赖更新时进行重新计算。例如我们执行如下例子:

js 复制代码
console.log(computedState.value)
state.count++
console.log(computedState.value)

我们发现两个打印结果都是1。

为了方便代码组织管理,我们先将代码进行以下迭代:

js 复制代码
class ComputedRefImpl {
    constructor(getter) {
        this._getter = getter
    }

    get value() {
        return this._getter();
    }
}

function computed(getter) {
    return new ComputedRefImpl(getter)
}

接着我们可以将 getter 函数作为一个副作用函数通过 ReactiveEffect 类创建一个响应式副作用对象。然后在访问计算属性对象的 value 属性访问器的时候再执行 ReactiveEffect 类的 run 方法并把执行结果返回出去。代码迭代如下:

diff 复制代码
class ComputedRefImpl {
    constructor(getter) {
-        this._getter = getter
+       this.effect = new ReactiveEffect(getter)
    }

    get value() {
-        return this._getter()
+       return this.effect.run()
    }
}

这时我们再执行上述测试代码,则如期显示了。

但目前我们还没实现对计算属性值的缓存,比如说我们多次访问 computedState.value 的值,会导致响应式副作用函数的多次执行,即每次访问计算属性值 this.effect.run() 都会被执行,即便计算属性值本身并没有变化。我们可以设置一个开关,只在第一次访问 value 属性访问器的时候,执行 this.effect.run() 进行计算值,后续要根据开关是否打开进行判断是否要重新执行 this.effect.run() 进行计算值。代码迭代如下:

diff 复制代码
class ComputedRefImpl {
    _value
    _dirty = true
    constructor(getter) {
        this.effect = new ReactiveEffect(getter)
    }

    get value() {
+       if (this._dirty) {
+            this._dirty = false
+            this._value = this.effect.run()
+        }
-        return this.effect.run()
+       return this._value
    }
}

经过上述迭代后,只有 _dirty 为 true 的时候才会调用 this.effect.run() 进行重新计算,否则直接使用上一次的计算缓存在 _value 中的值。这样就达到了计算缓存的功能了。但现在只要我们的 _dirty 设置为 false 后,就再也变不回 true 了,我们应该设计计算属性在其响应式依赖更新的时候设置 _dirty 为 true 即可。这时我们上面实现的 scheduler 功能就派得上用场了。我们进行以下迭代:

diff 复制代码
class ComputedRefImpl {
    _value
    _dirty = true
    constructor(getter) {
-     this.effect = new ReactiveEffect(getter)
+       this.effect = new ReactiveEffect(getter, () => {
+           if (!this._dirty) {
+               this._dirty = true
+           }
+       }) 
    }
    // 省略...
}

我们给 ReactiveEffect 类传递了 scheduler 调度器函数,它会在计算属性依赖改变的时候执行。这样我们在 scheduler 调度器函数中就将 _dirty 重置为 true,等到再次访问计算属性对象的 value 属性访问的时候,就会重新计算了。

现在我们的计算属性功能还有一个功能还没实现,我们回顾一下文章开头的提出问题的例子,我们现在使用以下代码重现:

js 复制代码
const state = reactive({ count: 0 })
const computedState = computed(() => state.count + 1)
effect(() => {
  console.log('观察计算属性', computedState.value)
})
state.count++

我们执行上述代码发现只初始化的打印了一次 观察计算属性 1state.count++ 之后则没有重新执行副作用函数了。这是因为我们在读取 computedState.value 的时候目前并没有触发依赖收集,而在 computed 的 getter 函数中读取响应式数据 state.count 时触发的是 computed 中独立的响应式副作用域的依赖收集。具体就是 effect 的副作用函数是通过 ReactiveEffect 类的 run 方法去执行的,在读取的 computedState.value 的时候, computed 计算属性的 getter 又是在一个新的 ReactiveEffect 实例对象的 run 方法中去执行的,这就形成了嵌套副作用域,那么在读取 state.count 的时候触发的 computed 中的响应式副作用收集为依赖,收集完了就会退出当前作用域,返回上一层作用域,而在上一层作用域读取 computedState.value 的时候目前并没有进行依赖收集,因为这个功能还没实现。

实现这个功能也很简单,只要在读取计算属性值的时候,手动进行依赖收集,当计算属性依赖的响应式数据发生变化时,又手动进行依赖触发即可。

我们先将 trigger 函数进行拆分,让其功能聚焦,代码迭代如下:

diff 复制代码
function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
-    effects && effects.forEach(dep => {
-        if (dep.scheduler) {
-            dep.scheduler()
-        } else {
-            dep.run()
-        }
-    })
}

+ function triggerEffects(deps) {
+    deps && deps.forEach(dep => {
+        if (dep.scheduler) {
+            dep.scheduler()
+        } else {
+            dep.run()
+        }
+    })
+ }

接着我们对计算属性部分进行迭代:

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)
           }
       }) 
    }

    get value() {
        if (this._dirty) {
            this._dirty = false
            this._value = this.effect.run()
+            // 在读取计算属性值的时候,手动进行依赖收集
+            trackEffects(this.dep)
        }
        return this._value
    }
}

完成上述代码迭代后,再执行测试代码:

js 复制代码
const state = reactive({ count: 0 }) 
const computedState = computed(() => state.count + 1) 
effect(() => { console.log('观察计算属性', computedState.value) }) 
state.count++
state.count++
state.count++

打印结果如下:

复制代码
观察计算属性 1
观察计算属性 2
观察计算属性 3
观察计算属性 4

至此我们基本完成了 Vue3.2 及之前的计算属性功能,但同时也可以看到 Vue3.2 版本中的计算属性还是存在性能问题的。也就是文章开头说到的,每修改一次响应式数据都更新一次,并不像在 Vue 组件中无论更改多少次状态,最终都只执行一次更新。那么接下来我们就去探索实践怎么优化这个功能。

在 Vue3 的组件中是通过异步批量调度系统 来解决这个问题的,所以接下来我们先实现一个异步批量调度系统

简易异步批量调度系统实现

在 Vue3 的底层系统进行组件更新的时候就是通过异步调度实现,所以在 Vue 组件中无论更改多少次状态,最终都只执行一次更新。所以下面我们通过简单实现 Vue3 的组件异步更新逻辑来理解所谓的异步批量调度系统。

一个 Vue 组件本质上只是一个组件配置对象,例如一个简单的 Vue3 组件可以如下:

js 复制代码
const App = {
    setup() {
      const state = reactive({ count: 0 })
      return {
        state
      }
    },
    render(ctx) {
        console.log('执行渲染函数 count:', ctx.state.count) 
    }
}

每个组件都有一个 render 函数,通过执行 render 函数可以生成虚拟DOM,用于渲染组件。一个 Vue 组件初始化的时候,生成一个组件实例对象,对象属性结构简约如下:

js 复制代码
// 模拟组件实例
const instance = {
  effect: null, // ReactiveEffect 实例
  update: null, // 组件渲染副作用函数
  render: null, // 存储组件的 render 函数
  setupState: null, // 存储组件 setup 函数的返回值
  isMounted: false, // 组件是否已经挂载
} 

在初始化组件的时候,会把组件 setup 函数的返回值存储到组件实例对象的 setupState 属性上以及把组件的 render 函数设置到组件实例对象的 render 属性上。

js 复制代码
// 初始化组件实例
instance.setupState = App.setup()
instance.render = App.render

然后无论初始化还是更新都需要执行 instance.render(instance.setupState) 生成虚拟 DOM,渲染到页面上。但有些功能代码只在初始化的时候执行,有些则只在更新阶段执行,比如生命周期 mounted 钩子只在初始化阶段执行,updated 钩子只在更新阶段执行。所以我们要把这些功能封装成一个 componentUpdateFn 函数,简约代码如下:

js 复制代码
// 组件渲染副作用函数
const componentUpdateFn = () => {
    if (!instance.isMounted) {
        // 模拟组件初始化
        instance.isMounted = true
        console.log('执行beforeMount钩子')
        // 渲染组件
        instance.render(instance.setupState)
        console.log('执行 mounted')
    } else {
        // 模拟组件更新
        console.log('处理 beforeUpdate')
        // 重新渲染
        instance.render(instance.setupState)
        console.log('触发 updated 钩子')
    }
}

这个函数的主要逻辑分为两部分:组件初次挂载(instance.isMounted为false时)和组件更新(else分支)。初次挂载时会执行 beforeMount 钩子,渲染组件,然后执行 mounted 相关的钩子。更新的时候会处理 beforeUpdate,然后重新渲染,进行 patch,最后触发 updated 钩子。我们知道在 Vue 中,组件的渲染和更新是通过响应式系统来触发的,也就是组件的初始化和更新都需要执行 componentUpdateFn 函数。所以我们只要把 componentUpdateFn 函数设置为响应式系统的副作用函数即可。代码如下:

js 复制代码
// 创建响应式副作用包装器(建立依赖跟踪能力)
instance.effect = new ReactiveEffect(componentUpdateFn) 
// 建立组件更新触发器,后续可通过调用该方法强制重新渲染组件
instance.update = instance.effect.run.bind(instance.effect)
// 初始化执行
instance.update()

这样我们通过上述简单代码就清晰描述了 Vue3 组件响应式更新的流程,实现了数据变化到视图更新的自动化映射基本原理。我们的代码结构跟 Vue3 源码也是非常相似的,以下是 Vue3 源码截图:

此外从我们前面学习的发布订阅模式的角度来看,componentUpdateFn 函数就是订阅者。上述代码执行结果如下:

我们可以看到执行的结果跟普通组件初始化的结果是一致的。

接下来我们执行如下代码进行更新:

复制代码
instance.setupState.state.count++

执行结果如下:

我们可以看到执行的结果跟普通组件的更新结果是一致的。

当然很明显我们这里还有性能问题,当我们在一轮代码执行中,进行多次更新的时候,例如:

复制代码
instance.setupState.state.count++
instance.setupState.state.count++
instance.setupState.state.count++
instance.setupState.state.count++
instance.setupState.state.count++

执行结果如下:

很明显在 DOM 的渲染中我们只关心最后的执行结果而不关心过程,我们期望更新的过程只执行一次渲染函数。那么我们应该怎么优化性能呢?我们从前面学到的响应式原理可以知道,响应式数据更新的时候就会触发响应式系统的副作用函数的执行,在上面的例子中就是执行 componentUpdateFn 函数。

我们可以让响应式数据更新的时候将副作用函数的执行进行延迟,等当前执行栈的代码全部执行完毕之后再去执行,而根据上文实现的 effect 函数的调度执行功能,我们可以通过调度器函数 scheduler 和延迟函数 setTimeout 来实现。代码如下:

diff 复制代码
- instance.effect = new ReactiveEffect(componentUpdateFn)
+ instance.effect = new ReactiveEffect(componentUpdateFn, () => {
+    setTimeout(instance.update)
+ })

这个时候我们再执行测试代码,打印结果如下:

我们发现最后的执行结果是我们想要的 3 了,但我们的响应式数据更新了几次,最后更新函数也执行了几次,是因为每次执行调度函数的时候都通过 setTimeout 往异步队列里添加了一个更新函数,我们希望更新函数最后只执行一次。我们可以在第一次执行调度函数的时候,先把更新函数存储到一个数组里面,后续再执行调度函数的时候则去判断是否已经存在了该更新函数,如果已经存在了那么就不再添加到数组变量里面去,最后去执行异步任务的时候再通过循环执行数组里面的更新函数。代码迭代如下:

diff 复制代码
+ // 待执行任务队列
+ const queue = []
instance.effect = new ReactiveEffect(componentUpdateFn, () => {
-    setTimeout(instance.update)
+    if(!queue.includes(instance.update)){
+         queue.push(instance.update)
+    }
+    setTimeout(() => {
+        // 逐个执行任务
+        queue.forEach(fn => fn())
+        // 清空队列
+        queue.length = 0
+    })
})

执行结果如下:

我们发现更新函数虽然执行了一次,但异步任务却还是执行了三次,所以我们还需要继续迭代:

diff 复制代码
// 待执行任务队列
const queue = []
+ // 队列状态锁
+ let queued = false                    
instance.effect = new ReactiveEffect(componentUpdateFn, () => {
    if(!queue.includes(instance.update)){
         queue.push(instance.update)
    }
+    if (!queued) {
+        queued = true 
        setTimeout(() => {
            console.log('执行异步任务')
            // 逐个执行任务
            queue.forEach(fn => fn())
            // 清空队列
            queue.length = 0
+            queued = false
        })
+    }
})

在上述代码中我们通过设置队列状态锁来确保在同一事件循环内,无论触发多少次更新,只会安排一次异步任务setTimeout 回调),避免多次执行队列处理。当调度函数被调用时,如果 queued 为 false,就设置queued 为 true,并通过 setTimeout 设置一个异步任务。由于 queued 已经是 true 后续继续调用调度函数时,就不会再启动新的异步任务,最后执行所有队列中的任务,清空队列,最后重置 queued 为 false,允许新一轮调度。这种通过锁机制实现了 高频更新的批量合并处理,是性能优化中的经典设计模式。

上述实现的代码相对比较凌乱,结构不够清晰,我们进行封装迭代一下,增加代码职责清晰度,也方便我们其他模块使用。代码迭代如下:

diff 复制代码
// 待执行任务队列
const queue = []
// 队列状态标记
let queued = false
+ /**
+  * 调度器核心逻辑:将任务加入队列,并确保在异步阶段批量执行
+  * @param fn - 需要延迟执行的函数
+  */
+  const scheduler = (fn) => {
+   // 防止重复添加
+   if(!queue.includes(fn)){
+    queue.push(fn)
+   }
+   if (!queued) {                      // 避免重复调度
+     queued = true
+     setTimeout(flush)                  // 下一个任务阶段执行队列
+   }
+ }
+ /**
+  * 执行队列中所有任务并清空队列
+  */
+  const flush = () => {
+    console.log('执行异步任务')
+    // 逐个执行任务
+    queue.forEach(fn => fn())
+    // 清空队列
+    queue.length = 0
+    queued = false
+ } 
instance.effect = new ReactiveEffect(componentUpdateFn, () => {
-    if(!queue.includes(instance.update)){
-         queue.push(instance.update)
-    }
-    if (!queued) {
-        queued = true 
-        setTimeout(() => {
-            console.log('执行异步任务')
-            // 逐个执行任务
-            queue.forEach(fn => fn())
-            // 清空队列
-            queue.length = 0
-            queued = false
-        })
-    }
+    scheduler(instance.update)
})

我们上述的代码是通过使用 setTimeout(宏任务)添加的异步队列,它的执行阶段是在下一轮事件循环。我们可以改用微任务(如 Promise.resolve().then(flush))来添加异步任务队列,微任务队列的执行是在本轮事件循环的末尾执行的,相对宏任务更早执行。代码迭代如下:

diff 复制代码
+ // 微任务触发器
+ const tick = Promise.resolve()        
// 待执行任务队列
const queue = []
// 队列状态标记
let queued = false
/**
 * 调度器核心逻辑:将任务加入队列,并确保在异步阶段批量执行
 * @param fn - 需要延迟执行的函数
 */
 const scheduler = (fn) => {
  if(!queue.includes(instance.update)){
    queue.push(instance.update)
  }
  if (!queued) {                      // 避免重复调度
    queued = true
-    setTimeout(flush)
+    tick.then(flush)                  // 下一个任务阶段执行队列
  }
}

// 省略...

至此,我们简易异步批量调度系统就实现了,并且通过实现简易异步批量调度系统,我们重新复习了 Vue3 组件的更新流程。

延迟计算属性的实现

异步批量更新减少计算与渲染开销

我们在上一节中通过简易批量调度系统实现了在 Vue 组件中无论更改多少次状态,最终都只执行一次更新。而我们上面提到 Vue3.2 之前的版本中的计算属性 computed 是存在性能问题的,因为它每更改一次状态,就会更新一次。那么我们也可以通过上面的批量调度系统优化该性能问题。

代码迭代如下:

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)
+               scheduler(() => {
+                triggerEffects(this.dep)
+               })
           }
       }) 
    }
// 省略...
}

这时我们再执行如下测试代码:

js 复制代码
const state = reactive({ count: 0 })
const computedState = computed(() => state.count + 1)
effect(() => {
  console.log('观察计算属性', computedState.value)
})
state.count++
state.count++
state.count++

打印结果如下:

我们发现多次更新状态后,也只打印了最终的计算结果,这是我们所期望的。在 Vue3.3 中专门新增了一个延迟计算属性的 API:deferredComputed,来做这个优化。所以我们也新增一个 deferredComputed 的计算属性 API。代码迭代如下:

js 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
       this.effect = new ReactiveEffect(getter, () => {
           if (!this._dirty) {
               this._dirty = true
               scheduler(() => {
                // 当计算属性依赖的响应式数据发生变化时,手动进行依赖触发
                triggerEffects(this.dep)
               })
           }
       }) 
    }

    get value() {
        if (this._dirty) {
            this._dirty = false
            this._value = this.effect.run()
            // 在读取计算属性值的时候,手动进行依赖收集
            trackEffects(this.dep)
        }
        return this._value
    }
}

function deferredComputed(getter) {
    return new DeferredComputedRefImpl(getter)
}

在 Vue3 的响应式系统中,computed 属性是开发者处理复杂逻辑的利器。然而,在高频更新场景下(如实时数据流、动画渲染),传统的 computed 同步计算和触发副作用的机制可能导致性能问题。为此,Vue3 提供了 deferredComputed ------ 一种延迟计算、异步批量更新的高阶计算属性。我们在此小节中利用 Promise.resolve().then(flush) 将任务推迟到微任务阶段执行,确保同一事件循环内的多次更新被合并,从而避免高频依赖变更时的重复计算。

通过异步脏检查避免无效运算

接下来我们运行一下以下的例子:

js 复制代码
const state = reactive({ count: 0 })
const computedState = deferredComputed(() => state.count % 2)
effect(() => {
  console.log('观察计算属性', computedState.value)
})
state.count = 1
state.count = 2

运行结果如下:

上述的运行结果存在什么问题呢?我们知道计算属性 computedState 的值是根据响应式数据 state.count % 2 的运算得来的,意思是 state.count 对 2 的取模运算。初始运算结果是 0,所以打印的观察计算属性的结果也是 0,但最后响应式数据 state.count 被设置了为 2,2 对 2 的取模运算的结果还是 0,此时我们可以看到计算属性的值并没有发现变化,但依然触发了依赖更新,这就导致了无效的运算,所以我们要对此进行性能优化。优化的方案也很简单就是对比前后的值在只有变化了才触发更新。

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
       this.effect = new ReactiveEffect(getter, () => {
           if (!this._dirty) {
               this._dirty = true
+               // 先缓存当前值
+               const valueToCompare = this._value
               scheduler(() => {
-                    triggerEffects(this.dep)
+                    // 获取最新值和上一次缓存的值进行比较
+                    if (this.value !== valueToCompare) {
+                        triggerEffects(this.dep)
+                    }
               })
           }
       }) 
    }
    // 省略...
}

这时我们再运行测试代码,打印结果如下:

至此我们通过对比前后的值在只有变化了才触发更新,从而避免无效运算。

通过 for 循环动态响应队列变化

接下来我们再测试一下代码:

js 复制代码
const state = reactive({ count: 0 })
const computedState = deferredComputed(() => state.count + 1)
const computedState1 = deferredComputed(() => computedState.value + 1)
effect(() => {
  console.log('观察计算属性', computedState1.value)
})
state.count = 1

打印结果如下:

在初始化的时候,computedState.value 的值是通过 state.count + 1 运算得来的,也就是 0 + 1 得到 1,computedState1.value 的值是通过 computedState.value + 1 运算得来的,也就是 1 + 1 得到 2,最终初始化的时候打印的结果就是:观察计算属性 2。但现在后面又更新了 state.count 的值,本来应该要又继续打印一个结果 -- 观察计算属性 3 的,而现在并没有,所以我们的程序功能还得继续迭代完善,让它能适应更多的场景。

造成这个原因其实也很简单,我们在 flush 函数中冲刷异步任务队列的时候,使用了 forEach 循环,代码如下:

js 复制代码
 const flush = () => {
    // 逐个执行任务
    queue.forEach(fn => fn())
    // 清空队列
    queue.length = 0
    queued = false
} 

在执行了 state.count = 1 之后,就会往任务队列的 queue 函数中添加了计算属性 computedState 的调度任务函数并且开启了一个异步任务,在执行计算属性 computedState 的调度任务函数的时候又会往任务队列的 queue 函数中添加 computedState1 的调度任务函数,但由于我们在 flush 函数中冲刷异步任务队列的时候,使用了 forEach 循环导致 computedState1 的调度任务函数并没有被执行。

比如我们看看以下 forEach 方式循环的例子:

js 复制代码
const queue = [() => console.log(1), () => queue.push(() => console.log(2))];

// 通过 forEach 方式循环
queue.forEach(fn => fn()); // 输出: 1(新增的第二个任务不会执行)

这是因为通过 forEach 方式循环的时候新增或删除元素不影响当前遍历。我们可以使用传统的 for 循环来测试上述例子:

js 复制代码
const queue = [() => console.log(1), () => queue.push(() => console.log(2))];

// 通过 for 方式循环
for (let i = 0; i < queue.length; i++) {
  queue[i](); // 输出: 1 → 2(动态处理新增的任务)
}

我们可以看到 for 方式循环可以动态响应队列变化,这是因为在 for 循环中的 queue.length 属性是在每次迭代时都会重新计算的,而如果在循环体内修改了 queue.length,比如添加或删除了元素,这都会影响循环的次数。此外 for 循环的性能通常优于 forEach,尤其在数据量较大时差异更明显,但使用 forEach 代码层级结构更简洁优雅,符合函数式编程习惯。

所以我们之间修改 flush 函数即可:

diff 复制代码
 const flush = () => {
    // 逐个执行任务
-    queue.forEach(fn => fn())
+   for (let i = 0; i < queue.length; i++) {
+    queue[i]()
+   }
    // 清空队列
    queue.length = 0
    queued = false
} 

这时我们再执行这小节开始的测试代码,结果如下:

这时如期打印了我们期望的结果。这样我们通过选择 for 循环实现动态响应队列变化,即任务执行时触发了新的任务入队,新任务会在下一轮处理,这样就确保当前队列中的所有任务都被执行。

同步触发链式依赖的更新

首先什么是链式计算属性呢?就是一个计算属性是依赖另一个计算属性的计算属性就是链式计算属性,例如我们上一小节中的 computedState1 就是依赖 computedState 的值,那么 computedState1 就是链式计算属性。那么我们现在的实现还存在什么问题呢?我们来看看以下的测试代码:

js 复制代码
const state = reactive({ count: 0 })
const computedState = deferredComputed(() => state.count % 2)
const computedState1 = deferredComputed(() => computedState.value + 1)
effect(() => {
  console.log('观察计算属性', computedState1.value)
})
state.count = 1
console.log('computedState1.value', computedState1.value)

打印结果如下:

复制代码
观察计算属性 1
computedState1.value 1
观察计算属性 2

我们可以看到 state.count 的值变更为 1 后,在同步获取链式计算属性 computedState1 的值时,computedState1 的值并没有发生变化。所以我们需要确保链式计算属性同步访问时能获取到最新值。需要怎么做呢?

我们需要在计算属性的 scheduler 函数中,判断如果有其他计算属性依赖当前这个 deferredComputed,就需要立即触发它们的 scheduler,确保链式计算属性能够及时更新,避免延迟执行导致的错误。代码迭代如下:

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
       this.effect = new ReactiveEffect(getter, () => {
           if (!this._dirty) {
               this._dirty = true
               // 先缓存当前值
               const valueToCompare = this._value
               scheduler(() => {
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })
+               // 同步触发链式相关计算属性的更新,确保同步访问时值的正确
+               for (const e of this.dep) {
+                  e.scheduler()
+               }
           }
       }) 
    }
    // 省略...
}

再运算上面的测试代码,发生了报错:

javascript 复制代码
Uncaught TypeError: e.scheduler is not a function

这是因为有的依赖并没有 scheduler,例如 effect 函数产出的依赖就不存在 scheduler,所以我们要确保是计算属性的依赖才执行 scheduler。代码迭代如下:

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
       this.effect = new ReactiveEffect(getter, () => {
           if (!this._dirty) {
               this._dirty = true
               // 先缓存当前值
               const valueToCompare = this._value
               scheduler(() => {
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })
               // 同步触发链式相关计算属性的更新,确保同步访问时值的正确
-                e.scheduler()
+                // 仅处理计算属性依赖
+                if (e.computed) {
+                    e.scheduler()
+                }
               }
           }    
       })
+           // 标记计算属性为计算属性
+           this.effect.computed = true     
    }
    // 省略...
}

再执行测试代码打印结果如下:

复制代码
观察计算属性 1
computedState1.value 2
观察计算属性 2

这时我们就可以同步获取链式计算属性的最新值了。但这时我们的代码还存在性能问题,在通过触发链式依赖计算属性更新时是不应该往异步队列里面添加任务的,链式触发的本意是为了获取最新值,只需要将 this._dirty 的设置为 true 即可,这样再读取计算属性的时候,就会重新计算从而获取到最新值了。代码迭代如下:

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
-       this.effect = new ReactiveEffect(getter, () => {
+       this.effect = new ReactiveEffect(getter, (computedTrigger) => {
           if (!this._dirty) {
               this._dirty = true
+	 // 只有在非同步触发时才需要触发延迟计算属性
+               if (!computedTrigger) {
               // 先缓存当前值
               const valueToCompare = this._value
               scheduler(() => {
                    // console.log('执行延迟计算属性', this.value, valueToCompare)
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })
               // 同步触发链式相关计算属性的更新,确保同步访问时值的正确
               for (const e of this.dep) {
                // 仅处理计算属性依赖
                if (e.computed) {
-	   e.scheduler()
+                    e.scheduler(true /* 标记为同步触发 */)
                }
               }
            }
           }
       }) 
        // 标记计算属性为计算属性
        this.effect.computed = true
    }
    // 省略...
}

再执行测试代码打印结果如下:

复制代码
观察计算属性 1
computedState1.value 2

我们发现同步获取链式计算属性值是没有问题的,但链式计算属性依赖却没有触发了。这是因为在同步触发链式相关计算属性更新的时候,就已经把值更新了,等到普通模式下更新的时候相关值已经是最新的了,在获取最新值和上一次缓存的值进行比较的时候就触发不了依赖了。为了解决这个问题,我们需要进行以下迭代:

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
+        // 用于比较的旧值
+        let compareTarget
+        // 是否有有效旧值
+       let hasCompareTarget = false

       this.effect = new ReactiveEffect(getter, (computedTrigger) => {
           if (!this._dirty) {
               this._dirty = true
               // 只有在非同步触发时才需要触发延迟计算属性
-               if (!computedTrigger) {
+               if (computedTrigger) {
+                // 记录当前值作为比较基准
+                compareTarget = this._value
+                hasCompareTarget = true
+               } else {
+              // 先缓存当前值,不能通过 compareTarget 是否有值来判断,因为 compareTarget 有可能本身值就是空值
+               const valueToCompare = hasCompareTarget ? compareTarget : this._value
+               hasCompareTarget = false
-	  const valueToCompare = this._value
               scheduler(() => {
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })
               // 同步触发链式相关计算属性的更新,确保同步访问时值的正确
               for (const e of this.dep) {
                // 仅处理计算属性依赖
                if (e.computed) {
                    e.scheduler(true /* 标记为同步触发 */)
                }
               }
            }
           }
       }) 
        // 标记计算属性为计算属性
        this.effect.computed = true
    }
    // 省略...
}

这时我们再执行测试代码,打印结果如下:

复制代码
观察计算属性 1
computedState1.value 2
观察计算属性 2

此时打印结果是正确的了。我们在同步触发链式计算属性的 scheduler 的时候就把当前值缓存下来,然后在普通计算属性的正常触发的时候再根据 hasCompareTarget 的值去获取对应的缓存值,这样最终在异步任务中获取最新值和上一次缓存的值进行比较的时候就可以触发依赖了。

无依赖的计算属性场景性能优化

我们再看下面这个应用场景:

js 复制代码
const state = reactive({ count: 0 })
const computedState = deferredComputed(() => state.count + 1)
function init() {
    console.log('computedState.value', computedState.value)
}
init()
// 更新
state.count = 1

在上述场景中计算属性 computedState,它依赖一个响应式变量 state.count,但没有任何副作用或计算属性依赖它。

我们在 deferredComputed 的调度器中添加以下日志代码跟踪代码执行流程:

diff 复制代码
               scheduler(() => {
+                    console.log('延迟计算属性更新');
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })

然后我们执行上述测试代码,打印结果如下:

复制代码
computedState.value 1
延迟计算属性更新

我们可以看到即没有组件使用计算属性 computedState,也没有其他计算属性基于它计算。但每次 state.count 变更都会触发无效的计算属性的调度器执行,造成无意义的开销。我们可以通过检查改计算属性有没有依赖决定要不要更新,代码迭代如下:

diff 复制代码
class DeferredComputedRefImpl {
    dep = new Set() // 计算属性的依赖存储中心
    _value
    _dirty = true
    constructor(getter) {
        // 用于比较的旧值
        let compareTarget
        // 是否有有效旧值
        let hasCompareTarget = false

       this.effect = new ReactiveEffect(getter, (computedTrigger) => {
-           if (!this._dirty) {
-               this._dirty = true
+             if (this.dep.size) {
               // 只有在非同步触发时才需要触发延迟计算属性
               if (computedTrigger) {
                // 记录当前值作为比较基准
                compareTarget = this._value
                hasCompareTarget = true
               } else {
               // 先缓存当前值,不能通过 compareTarget 是否有值来判断,因为 compareTarget 有可能本身值就是空值
               const valueToCompare = hasCompareTarget ? compareTarget : this._value
               hasCompareTarget = false
               scheduler(() => {
                    // 获取最新值和上一次缓存的值进行比较
                    if (this.value !== valueToCompare) {
                        triggerEffects(this.dep)
                    }
               })
               // 同步触发链式相关计算属性的更新,确保同步访问时值的正确
               for (const e of this.dep) {
                // 仅处理计算属性依赖
                if (e.computed) {
                    e.scheduler(true /* 标记为同步触发 */)
                }
               }
            }
           }
+         this._dirty = true
       }) 
        // 标记计算属性为计算属性
        this.effect.computed = true
    }
    // 省略...
}

我们再执行上述测试代码,执行结果如下:

复制代码
computedState.value 1

这时我们可以看到不再打印计算属性的调度器中的日志了。因为 state.count 变更的时候会检查计算属性的依赖 if (this.dep.size) ,如果计算属性没有被任何副作用依赖,那么 this.dep 为空,就会跳过调度器内部逻辑,仅标记 this. _dirty = true,下次访问计算属性值时会重新计算,但由于没有依赖,计算属性的值变化无需通知任何人,直接跳过所有更新逻辑,完全避免了无依赖场景下的异步调度、值比较、依赖触发等冗余操作。这种设计使得 deferredComputed 在高频更新且无依赖的场景下(如临时计算中间值),几乎零性能开销。

总结

在本篇中,我们深入讲解了计算属性的实现原理以及计算属性的依赖跟踪和触发存在的问题和在 Vue3.3 中的解决方案。

我们先忘记代码的实现,单从概念上去阐述计算属性的实现原理,首先是要计算一个表达式(例如:count + 1)的结果,这个表达式的计算结果会缓存起来,在后续的执行过程中,直接读取上一次的计算结果,而不用重新执行表达式,从而达到优化性能的效果。

上述就是从概念层面阐述的计算属性的实现过程和作用。那么回到实际中,表达式 count + 1 中的 count 通常是一个响应式变量,所以我们需要通过 ReactiveEffect 去进行依赖跟踪和触发,并且通过属性访问器的方式实现延迟计算,也就是只有在读取计算属性的时候才进行计算并同时触发依赖跟踪,并通过 _dirty 标记实现惰性求值,仅在必要时重新计算,具体是通过 ReactiveEffect 的 scheduler 来实现,当计算表达式中的响应式变量发生了变化,会先触发 ReactiveEffect 的 scheduler,在 ReactiveEffect 的 scheduler 中改变 _dirty 标记,让重新读取计算属性的时候会重新进行计算。其次计算属性变量同时也是一个响应式变量,所以也需要在其读取时进行依赖跟踪和其变化时进行依赖触发

上述就是普通计算属性的实现原理,但在 Vue 3.3 及之前,计算属性的依赖跟踪和触发存在以下问题:

  1. 依赖跟踪不精确:在计算属性的 getter 中,如果依赖发生变化,会立即标记为脏(dirty),但有时这种变化可能并不影响最终结果,导致不必要的重新计算。

  2. 过度触发:当一个计算属性依赖另一个计算属性时,内部计算属性的变化可能导致外部计算属性被重新计算,即使外部计算属性的值实际上没有变化。

为了解决这些问题,Vue3.3 引入了一个延迟计算属性 deferredComputed 的 API 来解决。

deferredComputed 是一种针对高频更新场景优化的计算属性,其核心目标是通过延迟计算和异步批量更新减少不必要的计算与渲染开销,从而提升性能。

它的核心实现原理如下:

通过异步调度系统,将依赖变更后的计算延迟到微任务阶段,合并多次变更,具体就是利用Promise.resolve().then(flush) 将任务推迟到微任务阶段执行,合并同一事件循环内的多次更新,避免同步执行导致的计算冗余。即解决上述的依赖跟踪不精确的问题。而对于第二点则通过"值比对优化",即在微任务阶段比较新旧值,仅在实际变化时触发依赖更新,避免无效更新。

最后还有一个非常重要的点,就是涉及复杂计算属性依赖链的场景,例如当计算属性 A 依赖计算属性 B,B 又依赖 C 时,通过同步触发上游计算属性的更新(e.scheduler(true)),确保同步访问时值的正确性,而普通副作用只作异步处理。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

相关推荐
_Evan_Yao2 小时前
计算机大一新生如何选择方向(前端/后端/AI/运维)?
运维·前端·人工智能·后端
ZC跨境爬虫2 小时前
跟着MDN学HTML_day_46:(HTMLCollection与NodeList)
前端·javascript·ui·html·音视频
码途漫谈2 小时前
Scrapling:让爬虫在现代 Web 里“活下来”的自适应抓取框架
前端·爬虫·ai·开源
极梦网络无忧2 小时前
我开源了一个 Vue 3 动态表单组件库 —— real-vue3-easy-form
前端·vue.js·开源
ShyanZh2 小时前
【Claude基础】多代理协作:Agent Teams 与编排模式
前端·chrome·ai
下载居2 小时前
Google Chrome(谷歌浏览器64位) 148.0.7778
前端·chrome
MXN_小南学前端2 小时前
Vue + Quill:富文本的添加、传输、展示逻辑,以及 csReplyQuill 组件封装
前端·vue.js
XS0301062 小时前
Java Web实现简易CRUD操作笔记
java·前端·笔记
Shadow(⊙o⊙)2 小时前
qt内详解信号和槽的基本概念+实例演示
开发语言·前端·c++·qt·学习