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

0 阅读24分钟

前言

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 应用开发。