阅读视图

发现新文章,点击刷新页面。

10.响应式系统演进:通过位运算优化动态依赖收集(Vue3.2)

前言

在 Vue3.2 的版本里面还通过位运算优化动态依赖收集的性能,那么具体是怎么做的呢?首先我们来看看原来为什么会存在性能问题,我们回顾一下第5篇文章讲解 Vue3 响应式原理的时候,在收集依赖的时候有以下一段代码。

image.png

首先是只要存在 activeEffect 变量,我们就会往 deps 中添加依赖,如果存在重复的依赖,会利用 Set 数据的特性来去重。目前这种依赖管理方式在高频更新或深层递归场景下存在性能瓶颈。具体表现为副作用函数(effect)的依赖可能随条件分支动态变化。例如:

const state = reactive({ a: '掘金签约作者', b: 'Cobyte', flag: true })

effect(() => {
  if (state.flag) {
    // 依赖 state.a
    console.log(state.a);
  } else {
    // 依赖 state.b
    console.log(state.b);
  }
});

state.flag = false
state.a = '小前端'

我们运行上述例子,结果如下:

掘金签约作者
Cobyte
Cobyte

从上述测试结果我们可以看到当设置 state.flag 为 true 时,打印了 Cobyte,这是正确的,但当改变state.a 值时,也打印了 Cobyte,其实当 state.flag 为 true 时,该副作用就跟 state.a 没有关系了,因为不管 state.a 的值怎么变,副作用的打印结果都是一样的,所以此时当 state.a 改变就触发副作用更新的行为就是浪费性能。

所以我们目前的实现存在以下问题,当 state.flag 变化时,依赖需从 state.a 切换到 state.b 时无法自动清理过期依赖,导致冗余触发而引发性能瓶颈。

对此 Vue3.2 创新性地引入 位运算(Bitwise Operations)优化依赖收集,解决了动态依赖切换导致的冗余依赖问题,从而大幅提升了响应式系统的性能。本文将从设计背景、实现原理、性能优势等方面展开分析,揭示位运算在这一场景下的核心价值。

此外对位运算还不熟的同学,可以先复习一下位运算相关知识

为什么要使用位运算来设计依赖优化?

我们在前言的例子中讲到当 state.flag 变化时,依赖需从 state.a 切换到 state.b,传统 Set 数据结构无法自动清理过期依赖,导致冗余依赖。那么怎么实现自动清理过期的依赖呢?

普通实现方案

原来的数据结构如下:

image.png

那么实现这个清除失效的依赖,按我们普通的实现方案可以这样设计,设计一个记录该依赖在 之前的层级 是否被追踪的变量 wasSet = new Set();再设计一个记录该依赖在 当前层级 是否被追踪的变量 newSet = new Set();这样我们在一轮循环中判断是否记录新的依赖的时候,先往变量 newSet 中添加该依赖,再从 wasSet 变量中判断是否已经存在该依赖,如果已经存在,那么就不再记录,如果不存在,那么就需要往原来记录依赖的变量 deps 中添加新的依赖。这样在一轮循环的最后,再去判断该依赖如果只存在 wasSet 变量中,而没有在 newSet 变量中时,则说明该依赖需要从 deps 变量中清除掉了,这样将来该依赖发生变化都不会响应式到渲染函数的重新执行。那么 wasSet 中的数据怎么来呢?可以在初始化的时候从 deps 中进行赋值。

我们上面通过文字描述大概讲了一遍普通方案的实现,那么我现在通过伪代码再还原展示一偏。

状态记录相关变量:

  • wasSet: Set<Dep> :记录上一轮执行中所有被追踪的依赖。
  • newSet: Set<Dep> :记录当前轮次执行中所有被追踪的依赖。
  • deps: Dep[] :实际存储依赖的集合。

初始化阶段:

wasSet = new Set(deps); // 初始化为上一轮的依赖  
newSet = new Set();  

依赖收集阶段:

if (!newSet.has(dep)) {  
  newSet.add(dep);  
  if (!wasSet.has(dep)) {  
    deps.push(dep); // 新增依赖  
  }  
}  

依赖清理阶段:

for (const dep of wasSet) {  
  if (!newSet.has(dep)) {  
    deps.splice(deps.indexOf(dep), 1); // 移除失效依赖  
  }  
}  
wasSet = newSet; // 更新历史状态

从上述伪代码可以清晰看出通过比对 wasSet 和 newSet 的差异,移除不再被使用的依赖,从而实现了条件分支的支持。

但这种普通方案存在以下性能瓶颈:

  1. 内存开销

    • 需维护多个 Set 实例(wasSetnewSet),存储大量依赖时内存占用高。
    • 每次递归层级变化需复制依赖集合(如 wasSet = new Set(deps))。
  2. 操作效率

    • 集合操作hasadddelete 的时间复杂度为 O(1),但哈希表操作仍存在性能损耗(如哈希碰撞)。
    • 清理阶段:遍历 wasSet 并检查 newSet 的时间复杂度为 O(n²)。
  3. 递归层级管理

    • 深层递归时需为每层维护独立的 Set,内存和计算开销指数级增长。

所以 Vue3 并没有采用这种实现方式,那么接下来让我们继续探讨 Vue3 的实现方案吧。

位运算优化方案(Vue3 实现)

在 Vue3 中则巧妙地创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构的变量。设计如下:

image.png

通过扩展 Set 而非创建全新数据结构,复用 Set 的高效存储,仅添加 wasTrackednewTracked 两个整数字段,就创建一个兼具 依赖存储 和 追踪状态标记 的复合数据结构了。具体 wasTrackednewTracked 两个字段的作用是:

  • wasTracked:记录该依赖在 之前的层级 是否被追踪。
  • newTracked:记录该依赖在 当前层级 是否被追踪。

wasTrackednewTracked 的值都是一个二进制数字,例如:若某依赖在之前的层级(如父组件渲染)中被访问过,wasTracked 对应的位会被标记;newTracked 则是在当前渲染中如果被访问了,对应的位也会被标记。

那么为什么要使用位运算来设计呢?我们从传统的权限管理的痛点说起,因为上述的依赖优化管理机制与权限系统的位掩码设计异曲同工。

假设需要为一个用户管理系统设计权限控制,包含以下权限:

  • 读(R)0b001(二进制) → 1(十进制)
  • 写(W)0b010 → 2
  • 执行(X)0b100 → 4

传统实现方式:

const userPermissions = {
  read: true,
  write: false,
  execute: true
};

// 检查是否有读权限
if (userPermissions.read) { /* ... */ }

这种方案存在以下问题:

  • 存储冗余:每个权限需独立布尔字段,内存占用高。
  • 组合权限复杂:判断用户是否同时有读和执行权限需多次检查。
  • 扩展性差:新增权限(如 admin)需修改数据结构。

使用位运算设计权限管理系统:

通过 位掩码(Bitmask)  将权限编码为单个整数:

// 权限定义
const PERMISSIONS = {
  READ: 0b001,   // 1
  WRITE: 0b010,  // 2
  EXECUTE: 0b100 // 4
};

用户初始权限:

// 用户权限(初始为 0)
let userPermissions = 0;

添加读和执行权限:

// 添加读和执行权限
userPermissions |= PERMISSIONS.READ;    // 0b001 → 1
userPermissions |= PERMISSIONS.EXECUTE; // 0b101 → 5

检查是否有写权限:

const hasWrite = (userPermissions & PERMISSIONS.WRITE) > 0; // false

检查是否有读和执行权限:

const hasReadAndExecute = 
  (userPermissions & (PERMISSIONS.READ | PERMISSIONS.EXECUTE)) 
  === (PERMISSIONS.READ | PERMISSIONS.EXECUTE); // true

优势分析

(1) 内存高效

  • 传统方式:每个权限占用一个布尔值(通常 4 字节)。
  • 位运算:所有权限压缩为单个整数(4 字节),内存占用减少 75%

(2) 操作快速

  • 添加权限userPermissions |= PERMISSIONS.WRITE(O(1))。
  • 移除权限userPermissions &= ~PERMISSIONS.WRITE(O(1))。
  • 检查权限:按位与操作(O(1))。

(3) 组合权限灵活

// 检查是否同时有读和写权限
const required = PERMISSIONS.READ | PERMISSIONS.WRITE;
const hasAll = (userPermissions & required) === required;

那么根据上述权限系统的实现的启发,我们就可以设计如果当前依赖层级为 1,那么历史层级的追踪状态变量 wasTracked 就会被设置为 0b1,当前层级为 2 那么 wasTracked 就会被设置为 0b10,同样地 3,4 ... 层就会被设置为 0b1000b1000,如果一个变量在1、2、3、4层都被引用,那么 wasTracked 就会被设置为:0b1111。同样地当前层级的追踪状态 newTracked 也是如此设计。

同样地,层级变量也可以使用二进制表示,比如,1层为:0b1;2层为:0b10;3层为:0b100。这样标记和判断等相关操作都可以通过位运算进行。比如当前层级为2,那么 层级变量 = 0b10,那么标记添加则是 wasTracked = wasTracked | 0b10;而判断当前历史层级是否已被标记则是 has = wasTracked & 0b10

位运算的原子性操作(如 |=&)速度远超传统 Set 的操作(如遍历、过滤),且位运算具有极致的性能优势,这就是为什么使用为什么要使用位运算来设计依赖优化。

组件嵌套的 effect 实现原理

我们前面讲到多层嵌套的 effect,会存在内存占用高操作缓慢的缺点。而我们前面实现的 Vue3 响应式源码是还没实现嵌套 effect 的,所以我们先要实现嵌套 effect。例如下面的例子:

window.state = reactive({ parent: 'parent', child: 'child' })
effect(() => {
    effect(() => {
        console.log(`我是子组件:${state.child}`)
    })
  console.log(`我是父组件:${state.parent}`)
})

执行结果如下:

image.png

我们给 state.child 重新赋值:

image.png

这时子组件的 effect 执行了,这是正常的。

接著我们给 state.parent 重新赋值:

image.png

这时我们发现父组件的 effect 不执行了。这是为什么呢?我们来观察一下我们之前实现的 ReactiveEffect 类:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
    }
    run () {
        activeEffect = this
        this._fn()
        activeEffect = null
    }
    stop () {
      this.deps.forEach(dep => dep.delete(this))
    }
} 

我们知道 activeEffect 变量是唯一的,当嵌套之后,子组件执行完之后,activeEffect 将被设置了 null,这时父组件如果还有响应式数据需要收集的时候,由于 activeEffect 为 null 而会导致父组件的响应式数据的依赖收集不到。

为了解决这个问题,Vue3 底层设置了一个副作用函数栈变量 effectStack,我们要确保 activeEffect 始终指向当前正在运行的响应式副作用 effect。实现代码如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
} 

主要的实现思路也很简单,就是在执行原始函数之前,先把当前的响应式副作用压入 effectStack 调用栈,通过使用 try...finally 确保无论 this._fn() 是否抛出异常,effectStack 都会被正确弹出,activeEffect 会被恢复为上一个响应式副作用 effect 或 undefined。这样通过维护 effectStack,确保嵌套的响应式副作用 effect 的执行顺序正确,activeEffect 变量始终指向当前正在运行的响应式副作用 effect。

我们再来看看迭代后的执行结果:

image.png

我们可以看到当父组件的响应式变量 parent 被改变后,相关的嵌套代码都被执行了。

到此,我们就实现了嵌套 effect

依赖标记流程

初始化依赖的追踪状态标记

初始化依赖的追踪状态标记的核心逻辑就是在副作用函数执行前,记录所有 已有依赖 的追踪状态,即某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中。具体就是将每个依赖的 wasTracked 字段的 当前层级对应位 设为 1。我们可以设置一个全局变量 effectTrackDepth 来表示当前副作用执行的 递归深度,也就是所谓层级,初始为 0,每递归一次就增加 1。在每一轮的副作用函数执行前,将全局递归深度加 1,表示进入新一层级,执行完副作用函数后,将全局递归深度减 1,表示返回到上一层级的执行环境。

然后通过位运算 1 << effectTrackDepth 生成一个二进制掩码,也就是 第 effectTrackDepth 位为 1,其余位为 0。例如,若 effectTrackDepth = 2,则掩码为 0b100(十进制 2)。这样每个递归层级 effectTrackDepth 对应独立的二进制位,避免嵌套 effect 的依赖状态相互干扰。最后通过按位或操作(|),将 wasTracked 的对应二进制位设为 1,其他位保持不变。

具体代码实现如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
+ // 当前副作用执行的递归深度
+ let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
+            // 将全局递归深度加 1,表示进入新一层级
+            effectTrackDepth++;
+            // 初始化标记
+            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            // 将全局递归深度减 1,表示返回到上一层级的执行环境 
+            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
+    // 初始化依赖的追踪状态标记
+    initDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
+                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
+            }
+        }
+    }
} 

小结一下:当副作用函数 effect 执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过 initDepMarkers 方法设置对应依赖的 wasTracked 属性的位,表示上一轮这个依赖是否被跟踪。

通过位运算判断是否收集依赖

我们在之前的依赖收集的判断逻辑是这样的,判断全局变量 activeEffect 是否存在,存在就进行收集, 那么现在我们要判断当前依赖的当前层级是否标记该依赖为已追踪,也就是 deps.newTracked 的对应层级 (1 << effectTrackDepth) 是否为 1。这就要通过与运算(&)来判断。我们通过封装一个函数来实现这个功能,代码如下:

function newTracked(dep) {
  return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
}

若当前层级未标记该依赖为已追踪(!newTracked(dep)),则需要将当前依赖 newTracked 设置为当前层级 (1 << effectTrackDepth) ,也就是标记为 1。我们通过封装一个函数来实现这个功能,代码如下:

function setNewTracked(dep) {
  dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
}

最后我们还要检查依赖的 wasTracked 字段的当前层级(1 << effectTrackDepth) 对应 是否为 1(即是否在上一轮执行中被追踪过)。我们通过封装一个函数来实现这个功能,代码如下:

function wasTracked(dep) {
  return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
}

整体代码迭代如下:

function track(target, key) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {
      deps = new Set()
      // 标记依赖在 上一轮执行周期 中是否被追踪
      deps.wasTracked = 0
      // 标记依赖在 当前执行周期 中是否被追踪
      deps.newTracked = 0
      depsMap.set(key, deps)
    }
-    if (activeEffect) {
-        deps.add(activeEffect)
-        activeEffect.deps.push(deps)
-    }
+    trackEffects(deps)
}

+ function trackEffects(dep) {
+     let shouldTrack = false
+     if (!newTracked(dep)) {
+      setNewTracked(dep)
+      shouldTrack = !wasTracked(dep)
+    }

+    if (shouldTrack) {
+        dep.add(activeEffect)
+        activeEffect.deps.push(dep)
+    }
+ }

+ function newTracked(dep) {
+   return (dep.newTracked & (1 << effectTrackDepth)) !== 0;
+ }

+ function setNewTracked(dep) {
+   dep.newTracked |= (1 << effectTrackDepth); // 按位或操作
+ }

+ function wasTracked(dep) {
+   return (dep.wasTracked & (1 << effectTrackDepth)) !== 0;
+ }

在执行 effect 函数的过程中,当访问响应式属性时,会调用 track 函数,进而调用 trackEffects,设置 newTracked 的位,表示当前层级这个 dep 被跟踪了。

接着我们测试一下我们写的代码,测试代码如下:

window.state = reactive({ flag: false,  a: 'parent', b: 'child' })
effect(() => {
  if (state.flag) {
    console.log(`条件一:${state.a}`);
  } else {
    console.log(`条件二:${state.b}`);
  }
});

我们运行上面的测试代码,结果输出:条件二:child。这是正确的输出结果。说明我们上述的迭代代码是正确的。

我们现在改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果不是我们期待的,因为 b 属性我们已经不再使用了,b 属性值的改变不应该再触发更新才对。所以我们还要实现最后一个功能,通过位运算实现动态依赖的精准管理。

实现动态依赖精准管理

我们通过上文知道当effect执行时,会进入不同的递归层级,每个层级对应一个位。在初始化时,会通过initDepMarkers方法设置wasTracked的位,表示上一轮这个dep是否被跟踪。然后在执行effect函数的过程中,当访问响应式属性时,会调用track函数,进而调用trackEffects,设置newTracked的位,表示当前层级这个dep被跟踪了。

我们现在需要做的就是比较这两个标记,如果一个dep在之前被跟踪(wasTracked为真),但在当前没有被跟踪(newTracked为假),说明这个dep在当前层级不再被需要,因此需要从dep的集合中移除这个effect。这样我们就可以实现清理那些不再被依赖的effect,防止内存泄漏和无效的触发。

代码迭代如下:

// 用于管理嵌套 effect 的调用栈
const effectStack = []
let effectTrackDepth = 0
class ReactiveEffect {
    // 存储所有包含本 effect 的依赖集合(Set)
    // 用于实现 stop 功能时快速清理依赖
    deps = []
    constructor(fn) {
        // 包装的副作用函数(开发者传入的原始函数)
        this._fn = fn
    }
    // 执行副作用函数,并触发依赖收集
    run () {
        // 这里为什么要用try...finally呢?比如如果_fn中有错误,finally块仍然会执行,保证栈的平衡。
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            // 
            effectTrackDepth++;
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
+            this.finalizeDepMarkers();
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // 停止当前 effect 的响应式追踪
    stop () {
      // 遍历所有关联的依赖集合,从中删除本 effect
      this.deps.forEach(dep => dep.delete(this))
    }
    // 初始化依赖的追踪状态标记
    initDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                // 若某个依赖在 上一轮执行 中被追踪过,其对应的位会被标记到 wasTracked 中
                deps[i].wasTracked = deps[i].wasTracked | 1 << effectTrackDepth
            }
        }
    }
+    // 清理无效依赖 并 优化依赖集合
+    finalizeDepMarkers() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                const dep = deps[i]
+                // 根据依赖的跟踪状态,清理不再需要的依赖
+                if (wasTracked(dep) && !newTracked(dep)) {
+                    // 移除当前 effect 对该 dep 的依赖
+                    dep.delete(this)
+                }
+            }
+        }
+    }
} 

我们再运行上面的测试代码,结果输出:条件二:child。我们接着改变 flag 的值, state.flag = true,结果输出:条件一:parent。这也是正确的输出结果。这时我们再改变 b 的值, state.b = '掘金签约作者',结果输出:条件一:parent,这个结果还是不是我们期待的,为什么呢?

主要是因为现在只要我们的依赖的层级只要被标记上了,就一直是这个状态了。假设当前层级为 2,上述测试代码中需要删除的 b 属性依赖的层级初始标记状态为:wasTracked = 0b100, newTracked = 0b100,那么后续 b 属性的层级状态就一直是这个状态了,当判断是否需要删除的时候,我们需要判断 wasTracked 是否为 true,因为已经被标记过了,所以为 true,同样判断 newTracked 是否为 false 时,因为已经被标记过了,所以为 true

所以在退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。具体代码实现如下:

class ReactiveEffect {
    // ...
    // 清理无效依赖 并 优化依赖集合
     finalizeDepMarkers() {
        const { deps } = this
        if (deps.length) {
            for (let i = 0; i < deps.length; i++) {
                const dep = deps[i]
                // 根据依赖的跟踪状态,清理不再需要的依赖
                if (wasTracked(dep) && !newTracked(dep)) {
                    // 移除当前 effect 对该 dep 的依赖
                    dep.delete(this)
                }
+                // 清除该层级对应的位掩码
+                const trackOpBit = 1 << effectTrackDepth
+                dep.wasTracked = dep.wasTracked & ~trackOpBit
+                dep.newTracked = dep.newTracked & ~trackOpBit
            }
        }
    }
}

总的来说就是当 effect 执行完成后,通过比较 wasTrackednewTracked 的位掩码,可以快速确定哪些依赖在本次执行中没有被访问,从而进行清理。同时退出当前层级前,清除该层级对应的位掩码,确保下一层级的标记从干净状态开始。

递归层级限制30层的设计原因

Vue3 底层选择 30 层作为最大递归层级,因为 V8 引擎对 31/32 位整数直接存储于指针,无需堆分配,读写速度提升 10 倍,30 层限制确保位运算结果始终为 SMI,避免退化为堆内存对象导致性能退化,所以选择 30 层是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。当超出 30 层时,回退到全量清理,保障极端场景稳定性。

代码优化迭代如下:

+ const maxMarkerBits = 30
class ReactiveEffect {
    // ...
    run () {
        try {
            // 1. 设置当前激活的 effect 为自身
            activeEffect = this;
            // 2. 压入 effect 调用栈(处理嵌套 effect 的关键)
            effectStack.push(this);
            effectTrackDepth++;
-            this.initDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.initDepMarkers()
+            } else {
+                // 当递归深度超过30层时,回退到完全清理模式
+                this.cleanup()
+            }
            // 初始化标记
            this.initDepMarkers();
            // 3. 执行原始函数,触发响应式属性的 getter,进行依赖收集
            return this._fn(); // 返回函数执行结果(支持 computed 等场景)
        } finally {
-            this.finalizeDepMarkers()
+            if (effectTrackDepth <= maxMarkerBits) {
+                this.finalizeDepMarkers()
+            }
            effectTrackDepth--;
            // 4. 无论执行是否抛出异常,确保以下清理逻辑一定执行
            effectStack.pop(); // 当前 effect 出栈
            // 5. 恢复 activeEffect 为上一个 effect(栈顶元素)或 undefined
            activeEffect = effectStack.length > 0 ? effectStack[effectStack.length - 1] : undefined;
        }
    }
    // ...
+    // 完全清理模式
+    cleanup() {
+        const { deps } = this
+        if (deps.length) {
+            for (let i = 0; i < deps.length; i++) {
+                deps[i].delete(this)
+            }
+            deps.length = 0
+        }
+    }
} 

SMI(Small Integer)优化的核心原理

我们上面提到 Vue3 底层选择 30 层作为最大递归层级,是为了确保现代JS引擎在所有平台上都能使用 SMI(小整数)优化。

首先,我们得看一下 SMI 的概念。SMI 代表 Small Integer,是 V8 引擎对特定范围内整数的优化存储方式。在 V8 引擎中,SMI(Small Integer)优化 的核心原理是通过 指针标签(Pointer Tagging)  技术,将小整数直接嵌入指针值中,而非存储在堆内存中。以下是其性能优势的详细解析:

指针的结构

  • 指针的本质
    指针是一个内存地址,通常用 32 位(32 位系统)或 64 位(64 位系统)表示。

  • 标签位(Tagging Bits)
    V8 利用指针的低位(如最低 1~2 位)作为 类型标记,例如:

    • 表示该指针是一个 SMI(直接存储整数值)。
    • 表示该指针是一个 堆对象地址(需要解引用获取实际值)。

SMI 的存储方式

直接嵌入指针
V8 将小整数的二进制值 左移 1 位(腾出最低位作为标签),然后存入指针。

堆分配的数字
若数字超出 SMI 范围(如大整数、浮点数),V8 会在堆内存中分配一个 Number 对象,并将指针指向该对象。

内存访问开销

  • SMI(指针存储)
    值直接存储在指针中,读取时 无需访问堆内存,直接解析指针值即可。

  • 堆分配的数字
    需要 两次内存访问

    1. 读取指针地址。
    2. 根据指针地址访问堆内存中的 Number 对象。

内存分配开销

  • SMI
    无堆内存分配和释放操作,避免 内存管理开销(如垃圾回收)。
  • 堆分配的数字
    需调用内存分配器,可能触发 垃圾回收(GC) ,增加延迟。

CPU 缓存友好性

  • SMI
    数值直接存储在指针中,与其他指针数据一起被 CPU 缓存,缓存命中率高
  • 堆分配的数字
    Number 对象分散在堆内存中,缓存局部性差,缓存未命中率高

指令优化

SMI 操作
通过简单的位运算(如移位、掩码)即可完成数值解析,CPU 指令周期短

堆分配数字操作
需要额外的解引用指令和类型检查,指令周期长

设计哲学

空间换时间

  • SMI:牺牲 1 位指针空间(用于标签),换取极致性能。
  • 堆分配:以内存和速度为代价,支持更大数值范围。

高频场景优化

  • 现实场景
    大多数 JavaScript 程序中的整数是小范围的(如循环计数器、数组索引),SMI 覆盖了 99% 的用例。
  • 收益最大化
    对高频操作(如依赖收集、循环计数)进行极致优化,显著提升整体性能。

综上所述,V8 通过 指针标签技术 将小整数(SMI)直接存储在指针中,实现了以下优势:

  1. 零内存分配:避免堆操作和垃圾回收开销。
  2. 直接访问:无需解引用,减少内存访问次数。
  3. CPU 友好:位运算指令快,缓存命中率高。

这些优化使得 SMI 的读写速度比堆分配的数字快 10 倍以上,成为 JavaScript 高性能引擎的核心技术之一。Vue3 的依赖收集系统正是基于此特性,通过位运算和层级限制,实现了高效的响应式更新。

总结

最后我们来总结一下,Vue3 通过位运算设计实现以下响应式系统的优化:

  • 层级化状态标记:通过位掩码精准管理递归层级依赖。
  • 动态清理机制:按位比对移除失效依赖,避免冗余触发。
  • 性能与内存平衡:SMI 优化保障操作速度,30 层限制避免边界问题。

这一机制在复杂组件、高频更新及深层嵌套场景下表现卓越,是 Vue3 响应式系统的核心创新之一。

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

9.响应式系统演进:effectScope 的作用与实现原理(Vue3.2)

前言

effectScope 是 Vue3.2 引入的一个强大响应式副作用管理工具,用于自动收集在同一个作用域内的响应式副作用(effect),以便在需要的时候可以一起销毁这些响应式副作用(effect),防止内存泄漏和意外行为。effectScope 简化了复杂代码中的响应式副作用的管理,提高了代码的可维护性,同时,effectScope 还支持嵌套作用域和独立的子作用域,即隔离副作用,总的来说它主要作用为开发者提供了灵活的响应式副作用管理方式。

effectScope 是一个底层的高级进阶 API,对于普通应用开发者一般使用不到它,但如果我们想进阶,那么就必须了解它的实现原理。如果我们想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共 Hooks 库,我们就有可能需要使用到 effectScope API, 比如 vueuse 就使用到了 effectScope API。同时如果我们想要了解 Vue3.2 以后的源码也必须要了解 effectScope 的实现原理,另外还有 Vue3 状态管理库 Pinia 的源码也使用到了 effectScope API。所以说我们还是非常有必要了解它的。

在 Vue RFC 也有对其详细的解释,也可以了解一下。

注意:本篇文章实现的代码例子是在第五篇的基础上的,所以你还没看第五篇,可以先学习第五篇的内容。

在 Vue3 中什么时候需要清除响应式副作用

现在我们要实现以下这样的一个计数功能:

image.png

我们具体要实现的功能就是按 + 按钮就累计加 1,点击 清除计算结果 按钮则清除计算结果,且我们希望再次点击 + 按钮的时候也不再进行计算。

HTML 部分的代码如下:

<div>计算结果:<span id="counter"></span></div>
<button id="add">+</button>
<button id="delete">清除计算结果</button>

功能实现部分代码如下:

// 获取真实 DOM
const counterEl = document.getElementById('counter')
const addEl = document.getElementById('add')
const delEl = document.getElementById('delete')

// 利用响应式创建数据
const count = ref(0)
// 利用响应式动态变更 DOM 内容
effect(() => {
    counterEl.textContent = count.value
})
// 添加
addEl.addEventListener('click', () => {
    count.value++
})
// 清除计算结果
delEl.addEventListener('click', () => {
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

值得注意的是我们清除计算结果是直接删除相关 DOM 内容的。

实现结果如下:

tutieshi_494x218_7s.gif

我们从上面的实现效果来看,似乎没什么问题。

我们在动态更新 DOM 内容的 effect 执行的副作用函数中添加一个打印日志来观察一下实现效果:

effect(() => {
    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
})

观察结果如下:

tutieshi_504x222_5s.gif

这时我们发现,即便我们已经删除了显示计算结果的 DOM,但重新点击 + 按钮的时候,effect 的副作用函数还是继续执行。如果我们有大量这样的功能的话,那么会对我们的内存性能带来影响,所以我们需要及时释放不需要的内存,在上述例子中就是当显示计算结果的 DOM 被删除后,那么对应的响应式副作用也需要被删除,在上述例子中就是 effect 中副作用函数需要被删除。如果从发布订阅模式的角度来看,就是对应的订阅者要被删除。

删除 effect 中的副作用函数这个功能我们已经在第五篇中已经实现了,现在我们实现起来就很简单了,代码如下:

- effect(() => {
-    counterEl.textContent = count.value
-    console.log('动态变更 DOM 内容', count.value)
- })
+ const runner = effect(() => {
+    counterEl.textContent = count.value
+    console.log('动态变更 DOM 内容', count.value)
+ })
delEl.addEventListener('click', () => {
+    runner.effect.stop()
    const parent = counterEl.parentNode
    parent.removeChild(counterEl)
})

我们再来看看修改后的执行效果:

tutieshi_510x166_4s.gif

这时我们发现在删除相关 DOM 的时候同时清除相关的副作用函数,即便对应的响应式数据发生变化,那些已经被删除的副作用函数就不再执行了,这样就达到优化内存,提高响应式框架程序性能的作用了。

如果上述功能是一个 Vue3 的应用的话,计算结果可以使用一个组件来实现,那么当清除计算结果的时候,可以看作卸载计算结果的组件,那么也就是说在卸载组件的时候需要清除对应组件的响应式副作用函数

Vue3 组件的响应式副作用的收集与清除

在 Vue3.15 的版本的源码中,也就是 effectScope 相关代码提交的前一个版本,我们可以看到 Vue3 组件的响应式副作用收集过程是如下的:

image.png

首先在组件初始化的时候,会通过实例化 ReactiveEffect 类创建一个副作用对象,并且赋值给组件实例 instance.effect 上。

组件卸载的时候:

image.png

我们可以看到组件卸载的时候,又会从组件实例对象上取 ReactiveEffect 类的实例对象,然后执行 stop 方法清除组件的响应式副作用。

上述通过 ReactiveEffect 类创建的副作用对象主要应用于组件的 render 函数的包装函数,是 Vue3 系统底层自动创建的。而一个组件的响应式副作用并不止组件的 render 函数的包装函数,还有用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。

例如 watch API:

image.png

在 watch API 的实现中也是通过实例化 ReactiveEffect 类创建一个副作用对象,然后再通过 recordInstanceBoundEffect 函数保存起来。recordInstanceBoundEffect 函数实现如下:

image.png

recordInstanceBoundEffect 函数实现的实现很简单,就是将用户通过 watch、watchEffect、computed API 手动创建的 ReactiveEffect 类的实例对象存储到组件实例对象的 effects 属性上。这样在组件卸载的时候,就可以通过获取组件实例上 effects 属性的值进行执行达到取消相关响应式副作用的目的。相关实现如下:

image.png

这个就是 Vue3 组件的响应式副作用是如何收集与清除的实现原理。在 Vue3 源码底层已经自动帮我们实现了在 Vue 组件的 setup 中,初始化的时候响应式副作用将被收集并绑定到当前实例,在实例被卸载的时候,响应式副作用则会自动的被取消追踪了。注意上述的实现是 Vue3.15 中的实现。在 Vue3.2 以后就通过 effectScope 进行实现了,那么为什么要通过 effectScope 进行实现呢?

手动处理响应式副作用的弊端

经过上文我们知道响应式副作用失效之后需要及时把它们销毁掉,否则会存在内存泄漏和意外行为的风险。而在 Vue3 的底层已经自动帮我们实现了响应式副作用的处理,我们在平时写应用的时候无需担心。但我们如果想实现一些基于 Vue3 响应式库 @vue/reactivity 的公共库的时候,我们可能就需要手动处理响应式副作用了。

例如下面的代码例子:

const count1 = ref(0)
const count2 = ref(0)
// 用于存储副作用对象,以便后续可以停止它们
const effectStacks = []
// 观察响应式变量 count1 的变化情况
const effect1 = effect(() => {
    console.log(`effect1:${count1.value}`)
})
// 手动收集 effect1 的副作用
effectStacks.push(effect1)
// 观察响应式变量 count2 的变化情况
const effect2 = effect(() => {
    console.log(`effect2:${count2.value}`)
})
// 手动收集 effect2 的副作用
effectStacks.push(effect2)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
    }
}, 1000)

我们上述代码使用 ref 创建了两个响应式变量 count1 和 count2,初始值都为 0,然后通过 effect 函数定义了两个响应式副作用 effect1 和 effect2 用来分别观察响应式变量 count1 和 count2 的变化情况,并且将这两个响应式副作用对象手动收集到 effectStacks 数组中。然后使用 setInterval 设置了一个定时器,每隔 1 秒执行一次,在定时器的回调函数中检查 count1 的值是否等于 2,如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们,否则递增 count1 和 count2 的值。

总的来说就是通过手动收集副作用对象,可以在特定条件下(如 count1 达到 2)停止这些副作用,从而控制程序的执行流程。

现在我们再增加两个响应式变量 count3 和 count4,再分别观察它们的变化情况。

// 省略...
+ const count3 = ref(0)
+ const count4 = ref(0)
// 省略...

+ // 观察响应式变量 count3 的变化情况
+ const effect3 = effect(() => {
+    console.log(`effect1:${count3.value}`)
+ })
+ // 手动收集 effect3 的副作用
+ effectStacks.push(effect3)
+ // 观察响应式变量 count4 的变化情况
+ const effect4 = effect(() => {
+     console.log(`effect2:${count4.value}`)
+ })
+ // 手动收集 effect4 的副作用
+ effectStacks.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks 数组,调用每个副作用对象的 stop 方法来停止它们。
        effectStacks.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
+        count3.value++
+        count4.value++
    }
}, 1000)

现在我们想实现当 count1 的值等于 2 的时候停止对 count3count4 的观察,也就是要停止 effect3effect4 的副作用。这时我们发现要实现这个比较麻烦,需要我们重新定义一个全局存储 effect3effect4 的副作用的变量。

+ const effectStacks2 = []

// 观察响应式变量 count3 的变化情况
const effect3 = effect(() => {
    console.log(`effect1:${count3.value}`)
})
// 手动收集 effect3 的副作用
- effectStacks.push(effect3)
+ effectStacks2.push(effect3)
// 观察响应式变量 count4 的变化情况
const effect4 = effect(() => {
    console.log(`effect2:${count4.value}`)
})
// 手动收集 effect4 的副作用
- effectStacks.push(effect4)
+ effectStacks2.push(effect4)

// 通过设置定时器每一秒让 count1 和 count2 自动增加 1
const t = setInterval(() => {
    if (count1.value === 2) {
        // 如果等于 2,则遍历 effectStacks2 数组,调用每个副作用对象的 stop 方法来停止对 `count3` 和 `count4` 的观察。
-        effectStacks.forEach(effect => effect.effect.stop())
+        effectStacks2.forEach(effect => effect.effect.stop())
        clearInterval(t)
    } else {
        count1.value++
        count2.value++
        count3.value++
        count4.value++
    }
}, 1000)

我们发现目前我们对响应式副作用的管理是非常麻烦的,怎么可以实现非常方便地管理响应式副作用呢?这时我们的 effectScope 就要登场了。

effectScope 的实现原理

我们在上一小节遇到的问题就是目前我们对响应式副作用的管理是非常的麻烦,我们希望可以很方便地把响应式副作用 effect1effect2 归一组,把 effect3effect4 归一组。其实在 Vue3 组件的响应式副作用的收集与清除 那小节中可以知道,每个组件的响应式副作用都自动收集到组件实例对象上了,所以在组件卸载的时候,也就很方便把相关的副作用也卸载了。那么有什么方案呢?

其实对发布订阅模式理解透彻的同学,可以很清楚地知道,我们在上一小节中实现的手动进行处理响应式副作用的方法,本质就是一个发布订阅模式的应用。

首先是创建一个订阅者存储中心的变量:

const effectStacks = []

然后所谓手动收集每个响应式副作用对象,其实是订阅的动作。

effectStacks.push(effect1)

最后在需要的时候,去通知每一个订阅者。

effectStacks.forEach(effect => effect.effect.stop())

这其实就是发布订阅模式的最核心的要义。

通过我们前面章节对发布订阅模式的学习,我们知道订阅者存储中心可以由一个叫消息代理中心类来实现,例如我们前面实现的 EventBus,通过 new EventBus() 我们就可以创建不同分组的事件总线,很明显这个模式同样适合我们上面的需求。那么如果你熟悉发布订阅模式的话,你可以很快写出我们现在需要实现的消息代理中心类 EffectScope 的基本框架代码。

那么根据我们前面实现 EventBus 类或者消息代理类的实现,我们可以得出以下代码:

class EffectScope {
    // 响应式副作用对象存储中心
    effects = []
    constructor() {

    }
    // 订阅,也就是收集响应式副作用对象
    sub() {

    }
    // 通知,也就是停止收集到的响应式副作用对象
    notify() {
        this.effects.forEach(e => e.stop())
    }
}

现在我们就可以通过以下方式创建不同的响应式副作用分组了。代码如下:

const scope = new EffectScope()

那么接下来就需要思考怎么去实现把响应式副作用对象收集到 EffectScope 类内部的 effects 属性上。在代码实现上我们可以参考 effect 函数的实现,代码如下:

const count1 = ref(0)
const count2 = ref(0)
scope.sub(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })
    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})

就是给 sub 方法传递一个包装函数,那么在 EffectScope 类中的 sub 方法最终需要执行一下这个包装函数。

class EffectScope {
    // 省略...
    sub(fn) {
       fn()
    }
   // 省略...
}

通过前面对 Vue3 响应式原理的学习,我们知道所谓响应式副作用对象其实就是 ReactiveEffect 类的实例对象。那么也就是说在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到 EffectScope 类的 effects 属性上。

首先我们需要创建一个记录当前激活的作用域对象的全局变量。代码如下:

+ // 记录当前激活的作用域对象
+ let activeEffectScope
class EffectScope {
    // 省略...
    sub(fn) {
+        activeEffectScope = this
        fn()
+        activeEffectScope = null
    }
   // 省略...
}

如果还记得 Vue 响应式原理的实现的同学,应该对上述代码的套路很熟悉,所以我们真的彻底理解底层的知识,那么学习其他相关的知识就能达到触类旁通的效果,这也是为什么有些人学习新知识学得那么快的原因。

接下来我们就可以在实例化 ReactiveEffect 类的时候就需要去把 ReactiveEffect 类的实例对象添加到全局变量 activeEffectScopeeffects 属性上即可。代码实现如下:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
+        // 在定义副作用时,自动将它们关联到当前的作用域。
+        if (activeEffectScope) {
+            activeEffectScope.effects.push(this)
+        }
    }
    // 省略...
} 

这样我们就可以进行重新测试了,测试代码如下:

setInterval(() => {
    console.log('=====')
    if (count1.value === 2) {
        scope1.notify()
    }
    count1.value++
    count2.value++
}, 1000)

测试结果如下:

tutieshi_454x284_6s.gif

从测试结果可以看到,我们实现了通过作用域对响应式副作用对象的收集和卸载是成功的。

为了我们的代码更有语义,我们对上述代码进行迭代优化:

class ReactiveEffect {
    deps = []
    constructor(fn) {
        this._fn = fn
-        if (activeEffectScope) {
-            activeEffectScope.effects.push(this)
-        }
+        recordEffectScope(this)
    }
    // 省略...
} 

// 省略...

+ function recordEffectScope(effect) {
+     if (activeEffectScope) {
+         activeEffectScope.effects.push(effect)
+     }
+ }

封装一个在定义副作用时,自动将它们关联到当前的作用域的函数:recordEffectScope

同时修改 EffectScope 类中的相关方法的名称让它们更具有语义性。具体修改如下:

class EffectScope {
    // 省略...
-    sub() {
+    run(fn) {
    // 省略...
    }
    
-    notify() {
+    stop() {
        // 省略...
    }
}

+ // 创建作用域的工厂函数
+ function effectScope() {
+     return new EffectScope()
+ }

同时封装了一个创建作用域的工厂函数 effectScope

这时我们再实现我们之前的需求就很方便了。代码实现如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
// 作用域1
const scope1 = effectScope()
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
})
// 作用域2
const scope2 = effectScope()
scope2.run(() => {
    effect(() => {
        console.log(`effect3:${count4.value}`)
    })

    effect(() => {
        console.log(`effect4:${count4.value}`)
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 当 count1 等于 1 时停止作用域2的依赖追踪
        scope2.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

测试结果如下:

tutieshi_460x444_4s.gif

自此我们就实现了 effectScope 的最核心的功能,本质上就是一个发布订阅模式的应用,effectScope 函数是一个工厂函数,通过实例化 EffectScope 类,创建不同的作用域对象,而 EffectScope 类本质上是发布订阅模式中的消息代理类或者我们经常说的事件总线类,然后通过 run 方法运行一个包装函数,本质上是在订阅响应式副作用对象,最后可以通过 stop 方法通知每个订阅的响应式副作用对象进行停止追踪响应式依赖。所以如果你对发布订阅模式非常熟悉,那么你对 effectScope 的实现原理也非常容易理解了。

嵌套作用域

我们目前想实现这样的功能,在一个作用域里面嵌套一个作用域,代码如下:

const count1 = ref(0)
const count2 = ref(0)
const count3 = ref(0)
const count4 = ref(0)
const scope1 = effectScope()
// 作用域1
scope1.run(() => {
    effect(() => {
        console.log(`effect1:${count1.value}`)
    })

    effect(() => {
        console.log(`effect2:${count2.value}`)
    })
    // 嵌套作用域
    const scope2 = effectScope()
    scope2.run(() => {
        effect(() => {
            console.log(`effect3:${count4.value}`)
        })

        effect(() => {
            console.log(`effect4:${count4.value}`)
        })
    })
})

setInterval(() => {
    console.log('=====')
    if (count1.value === 1) {
        // 停止外层作用域的依赖追踪
        scope1.stop()
    }
    count1.value++
    count2.value++
    count3.value++
    count4.value++
}, 1000)

我们想当停止外层作用域的依赖追踪后,嵌套的作用域中的依赖也停止追踪。目前测试结果如下:

tutieshi_444x392_4s.gif

我们发现当我们停止了外层作用域的依赖追踪后,嵌套的作用域中的依赖还是能够进行追踪的,这是因为我们目前是已经实现了作用域隔离,也就是不同作用域中的依赖是互不干扰的,但有些场景可能我们又需要嵌套作用域是能够关联的,也就是停止了外层作用域,嵌套的作用域也应该停止。

要实现这个功能,其实也很简单,还是通过发布订阅模式的应用去实现,从上文可以知道,effectScope 的实现原理本质就是发布订阅模式的应用,EffectScope 类就是消息代理中心,所谓订阅者就是 ReactiveEffect 类的实例对象。从在们前面所学的知识可以知道,订阅者也可以是发布者,发布者也可以是订阅者,或者说观察者也可以是被观察者,被观察者也可以是观察者。

所以根据这个规则,我们可以让父级的 EffectScope 订阅嵌套的 EffectScope。代码实现如下:

class EffectScope {
    effects = []
    constructor() {
        // 订阅嵌套的 EffectScope
+        recordEffectScope(this)
    }
    // 省略...
}

而 EffectScope 类上有个 stop 方法,而 ReactiveEffect 类上也有一个 stop 方法,所以在执行父级作用域的 stop 方法循环 effects 属性上的订阅者的时候,有可能是嵌套的作用域,而因为都共同拥有一个 stop 方法,所以在执行嵌套作用域的实例对象的 stop 方法的时候又会去循环嵌套作用域中 effets 属性中订阅者,这样就实现了父作用域与嵌套作用域的依赖的共同管理了。

这时我们再来测试一下上述的嵌套作用域的测试代码。测试结果如下:

tutieshi_444x324_4s.gif

这时我们发现清除父级作用域的时候,嵌套作用域的响应式副作用也被清除了。

我们还需要继续迭代一下我们的功能,现在是默认就关联收集了嵌套作用域了,这样就失去了隔离作用域的作用了。那么我们希望做一个开关,开关开启的时候就进行作用域隔离,默认就收集嵌套作用域的响应式副作用。

实现代码如下:

class EffectScope {
    // 省略...
-    constructor() {
+    constructor(detached = false) {
+        if (!detached) {
            recordEffectScope(this)
+        }
    }
    // 省略...
}

// 创建作用域的工厂函数
- function effectScope() {
+ function effectScope(detached) {
-    return new EffectScope()
+    return new EffectScope(detached)
}

这样我们就初步实现了 effectScope 功能了。

在 Vue3 底层应用 effectScope

在 Vue3.2 以后 Vue3 组件的响应式副作用的收集与清除的实现就通过 effectScope 进行了。通过上文我们知道一个组件的响应式副作用是有两种类型的,分别是由组件的 render 函数的包装函数和用户通过 watch、watchEffect、computed API 手动创建的响应式副作用。在 Vue3.2 以前,它们分别收集在组件实例的 effect 和 effects 两个属性上。在 Vue3.2 以后实现就通过 effectScope 进行实现了,就只需需要一个 scope 属性来存储 EffectScope 实例对象即可。

image.png

从上图我们可以看到在 Vue3.2 以后组件实例化后,也会在组件实例对象的 scope 属性实例化一个 EffectScope 实例对象。

然后我们知道一个组件的响应式变量是在 setup 方法中创建的,然后在 render 方法中使用,当响应式变量发生变化的时候,render 函数重新执行,而要实现这个功能是通过 ReactiveEffect 来实现的。

image.png

然后通过上文对 effectScope 的实现原理的讲解我们知道,在实例化 ReactiveEffect 的时候,会把 ReactiveEffect 实例对象收集到 EffectScope 的实例对象的 effects 属性上。然后在组件卸载的时候,就可以通过组件实例对象上的 scope 属性的 stop 方法进行卸载相关的副作用了。

image.png

隔离副作用的实际应用

我们使用 Vue3 Composition API 编写一个自定义钩子(hook)函数,名为 useCounter。它的功能是实现一个简单的计数器,并附带了一个额外的特性:当计数器的值是偶数时,计算并存储这个值的两倍。

以下是 useCounter 的代码实现:

import { ref, watch } from "vue"

export function useCounter() {
    // 定义计数器
    const counter = ref(0)
    // 增加
    const increment = () => counter.value++
    // 减少
    const decrement = () => counter.value--
    // 计数器的偶数双倍值
    const doubleCount = ref(0)
    // 监听计数器值的变化
    watch(() => counter.value, (newVal) => {
        // 当计数器的值是偶数时,计算并存储这个值的两倍
        if (newVal % 2 === 0) {
            doubleCount.value = newVal * 2 
        }
    })

    return {
      counter,
      doubleCount,
      increment,
      decrement
    }
}

接着我们在两个组件中使用它。

Counter1.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

Counter2.vue

<template>
    <div>
        <p>当前值: {{state.counter}}</p>
        <p>偶数双倍值:{{state.doubleCount}}</p>
        <button @click="state.increment">+</button>
        <button @click="state.decrement">-</button>
    </div>
</template>
<script setup>
import { useCounter } from '../hooks/useCounter';
const state = useCounter()
</script>

接着在 App.vue 中引用它们。

App.vue

<script setup>
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'
</script>

<template>
  <Counter1 />
  <Counter2 />
</template>

实现效果如下:

tutieshi_442x432_12s.gif

我们当前的实现是两个组件的状态是不共享的,分别各自计算各自的值,现在我们希望它们是互相共享状态的,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

通常要在多个组件之间共享数据状态,我们一般在最上层的父组件创建响应式变量,然后通过层层传递进行使用,这种很明显层级过多时候很不方便;或者使用 Vuex 或者 Pinia,但一般在小型项目中,比如我们上述的计数器功能,如果我们也引用这种第三方库,代码就显得很臃肿了。所以我们可以自己实现一个小型的状态管理工具函数。

那么我们要实现在多个组件共享数据状态,本质是要创建一个单例的数据状态变量,也就是单例模式的应用。

单例模式是一种设计模式,目的是确保一个类或者对象在整个应用生命周期中只被实例化一次,并提供全局访问点。

在 JavaScript 中,单例模式通常通过闭包来实现,利用闭包保存一个私有的实例变量,同时通过一个函数来控制创建和访问这个实例。

具体代码实现如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
    return ((...args) => {
      if (!initialized) {
        state = stateFactory(...args);
        initialized = true;
      }
      return state;
    });
}

上面的 JavaScript 代码通过闭包和函数表达式实现了一个简单的单例模式,确保某个状态(state)对象只会被创建一次,并始终返回同一个实例。

createGlobalState 是一个工厂函数,它接受一个参数 stateFactory,这个参数也是一个工厂函数,负责生成状态对象。也就是说,我们把状态对象的创建逻辑封装在 stateFactory 中。对于我们上面的计算器的实现例子,那么这个参数就是 useCounter 函数。使用例子如下:

export const useCounterState = createGlobalState(useCounter)

createGlobalState 返回的是一个匿名函数(箭头函数),从上述例子可以知道变量 useCounterState 就是一个函数,这个函数会被用来获取状态对象。

在 createGlobalState 函数内部,声明了两个私有变量:initialized 标记状态对象是否已经被初始化(默认值是 false), state 变量存储状态对象的引用。只有当 initialized 是 false 时,才会调用 stateFactory 创建状态对象,并将其赋值给 state。同时将 initialized 设置为 true,表示状态对象已经被创建。这样每次调用匿名函数时,都会返回同一个 state 对象,从而实现单例模式的效果。

接下来我们在两个组件 Counter1.vue 和 Counter2.vue 中进行以下引用:

import { useCounterState } from '../hooks/useCounter';
const state = useCounterState();

然后测试结果如下:

tutieshi_420x408_8s.gif

这时,我们可以看到两个组件的状态实现了互相共享,也就是点击 组件1 中的按钮进行计算的时候,组件2 中的状态也是同时改变的,同样地点击组件2中的按钮进行计算的时候,组件1中的状态也是同时改变的。

至此我们好像还没讲到实现副作用隔离的作用是什么。接下来我们再实现一个小功能,代码如下:

<script setup>
import { ref } from 'vue'
import Counter1 from './components/Counter1.vue'
import Counter2 from './components/Counter2.vue'

+ const isShow = ref(true)
+ const handleHide = () => {
+   isShow.value = false
+ }
</script>

<template>
-  <Counter1 />
+  <Counter1 v-if="isShow" />
  <Counter2 />
+  <button @click="handleHide">隐藏第一个组件</button>
</template>

实现效果如下:

tutieshi_392x384_10s.gif

我们可以看到当我们隐藏第一个组件之后,第二个组件的偶数双倍值失效了。这是为什么呢?首先是因为偶数双倍值的实现是通过 watch 来实现的,从而产生了一个副作用,并且因为第一个组件是最新执行的,所以这个副作用就被收集到了第一个组件的实例对象上,而又因为我们是通过单例模式实现了状态共享,所以第二个组件使用的状态变量实际上跟第一个组件使用的状态变量是同一个,所以第一个组件使用 watch 产生的副作用被隐藏从而删除之后,第二个组件的相关功能也就失效了。

所以这个时候,我们就要想办法,让这些第三方的库产生的副作用不要和组件进行绑定,而是要和组件进行隔离,这个时候很明显就需要用到 effectScope 功能了,也是 effectScope 功能的最大作用之一。所以我们对 createGlobalState 函数进行修改,具体修改如下:

function createGlobalState(stateFactory) {
    let initialized = false;
    let state;
+    const scope = effectScope(true)
    return ((...args) => {
      if (!initialized) {
-        state = stateFactory(...args);
+        state = scope.run(() => stateFactory(...args));
        initialized = true;
      }
      return state;
    });
}

通过上文我们知道 effectScope 函数传参为 true 时就会进行作用域隔离。

这时我们再进行测试:

tutieshi_274x374_9s.gif

这时我们发现当我们隐藏第一个组件的时候,第二个组件的偶数双倍值功能不再受影响了。

至此 Vue3 中新增的 effectScope API 功能的实现原理和相关作用我们都介绍得差不多了。

总结

effectScope 是 Vue 3.2 提供的高阶响应式副作用管理工具,其核心本质是发布订阅模式的应用。通过 EffectScope 类作为消息代理中心,run 方法负责收集当前作用域内的所有 ReactiveEffect 实例(即副作用),stop 方法则批量停止它们。它还支持嵌套作用域,通过 detached 参数控制父子作用域是否关联,实现了灵活的副作用隔离。

在 Vue 3.2 之后,组件内部使用 effectScope 统一管理渲染副作用和用户定义的 watch/computed 副作用,替代了之前分散在 instance.effect 和 effects 数组的手动管理方式,简化了代码并提升了内存安全。此外,在开发可复用的组合式函数(如 createGlobalState 实现全局状态共享)时,利用隔离的 effectScope 可以避免副作用被错误绑定到特定组件上,从而保证状态跨组件共享时的正确性。掌握 effectScope 有助于深入理解 Vue 3 响应式系统及构建更健壮的公共库。

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

❌