【翻译】Vue 中的副作用作用域是什么?

6 阅读6分钟

原文链接:michaelnthiessen.com/what-are-ef…

作者:Michael Thiessen

你其实一直都在使用副作用作用域,只是自己没察觉到而已。

它是 Vue 中的一项核心特性,正是依靠它,整个响应式系统才能高效、正常地运转。

接下来我会为你讲清它的定义、工作原理,以及一些能巧用它实现的实用功能。

副作用(Effects)

简单来说,副作用作用域(Effect Scopes) 的作用,就是为副作用划定作用域范围。

但这个解释太过笼统,我先退一步,讲讲什么是副作用

一个函数除了返回值之外产生的所有行为,都属于副作用。如果一个函数仅返回值,不产生任何其他行为,我们就称它为纯函数

function add(a, b) {
  return a + b
}

纯函数只有输入和输出,背后没有任何额外逻辑、花哨的操作,也不会产生任何隐性影响。

我们可以通过多种方式让函数产生副作用,比如:

  1. 修改函数作用域外部的变量
let counter = 0

function add(a, b) {
  counter++
  return a + b
}
  1. 发起数据请求,或使用 Promise/async/await 执行的各类异步操作
  2. 处理用户输入
  3. 使用定时器
  4. 向页面渲染内容

除此之外还有诸多场景,而 Vue 的响应式系统(乃至所有信号类库)的实现,本质上完全基于副作用:

const count = ref(0)
const double = computed(() => count.value * 2)
watchEffect(() => console.log(double.value))

这个示例中出现了两处副作用:一是computed计算属性,它会在count的值更新时自动重新计算;二是watchEffect监听器,它会在double的值更新时打印出新值。

(其实还有第三处 —— 我们向控制台打印信息的行为本身,也属于副作用。)

要触发这两处副作用,我们只需要写一行代码:count.value = 4,剩下的所有逻辑都会在底层自动执行。我们无需手动为double赋值新值,只需要依赖 Vue 的响应式系统,它就会自动完成所有相关操作。

副作用作用域(Effect Scopes)

副作用作用域是一种将一组相关的副作用包裹、分组管理的方式,能把这些副作用整合为一个 “整体”。它的核心价值在于,能让我们轻松地停止这一组副作用的执行—— 这也是 Vue 最初引入副作用作用域的主要原因

绝大多数情况下,Vue 组件执行setup函数时,会自动创建并使用副作用作用域:

<script setup>
// 整个 setup 函数的执行过程,都处于一个副作用作用域中
</script>

当组件被卸载时,Vue 会直接调用scope.stop()方法,此时该作用域内的所有副作用都会被清理、销毁。如果没有这个机制,下面的代码会在整个应用的生命周期中一直打印日志(这会造成严重问题,显然不是我们想要的结果):

const count = ref(0)

watchEffect(() => console.log(count.value))

setInterval(() => count.value += 1, 1000)

如果无法清理副作用,不仅会造成内存和资源泄漏,还可能引发一些难以排查和修复的严重 Bug。

但副作用作用域并非只在 Vue 内部发挥作用,我们开发业务应用时,它也有两个极具价值的核心使用场景。第一个是临时副作用作用域

临时副作用作用域

默认情况下,我们创建的监听器会和组件共存亡,只要组件存在,监听器就会一直生效。如果想更灵活地控制监听器,比如停止或暂停其执行,我们可以通过以下几种方式实现:

// 让监听器只执行一次
watch(
  [],
  () => {},
  { once: true }
)

// 暂停和恢复监听器的执行
const { pause, resume } = watch(...)
pause()
resume()

// 彻底停止监听器
const stopWatcher = watch(...)
stopWatcher()

但即便通过上述方式处理,监听器的实例依然会在组件的生命周期中一直存在,并不会被彻底销毁。

而使用临时副作用作用域,我们可以在任意时间彻底清理监听器可在线演示):

const count = ref(0)
const scope = effectScope()

scope.run(() => {
  watch(count, () => {
    // 执行一些业务逻辑
    console.log(count.value)

    // 立即停止当前作用域,销毁监听器
    scope.stop()
  })
})

// 监听器执行一次
count.value = 3

// 监听器已被销毁,不会再执行
setTimeout(() => count.value = 6, 500)

在这个示例中,我们实现了和{ once: true }完全相同的效果:将count的值设为 3 时,会触发监听器并在控制台打印 3,随后我们立即停止了这个临时作用域,监听器也被随之清理。

后续当我们将count的值更新为 6 时,页面会正常渲染新值,但监听器不会再执行 —— 因为它已经被彻底销毁了。

在我的《高级响应式》课程中,还讲解了如何利用这一特性实现一个waitFor工具函数:它接收一个响应式的判断条件,当条件为true时,会解析对应的 Promise:

const isReady = ref(false)

// 只有当 isReady.value 为 true 时,Promise 才会解析
await waitFor(() => isReady.value === true)

console.log('isReady 现在为 true 了')

持久化副作用作用域

除了创建生命周期比组件更短的临时副作用作用域,我们还能创建生命周期更长、完全独立于任何组件的副作用作用域。

这一特性在编写组合式函数(Composable)时格外实用,VueUse 中的createSharedComposable函数,正是基于这个原理实现的。

下面是它的核心实现思路:

function createSharedComposable(fn) {
  let scope, cached
  return (...args) => {
    if (!scope) {
      // 创建一个分离的副作用作用域,在多次调用间做缓存
      scope = effectScope(true)

      // 在分离的作用域中执行组合式函数
      cached = scope.run(() => fn(...args))
    }
    return cached
  }
}

// 使用 createSharedComposable 处理缓存和作用域管理
const useStore = createSharedComposable(() => {
  const count = ref(0)
  watch(count, (v) => console.log('watch', v))
  return { count }
})

effectScope函数传入true,会创建一个分离的副作用作用域(detached effect scope) ,它不会挂载到任何父级副作用作用域上。

默认情况下,所有新创建的副作用作用域,都会挂载到它的创建者所在的作用域中。这样一来,当父级作用域停止执行时,子级作用域也会被一并清理(因为副作用作用域本身也属于一种副作用)。这意味着,我们之前创建的临时副作用作用域,会在其所在的组件被卸载时自动清理 —— 毕竟此时我们已经不再需要它了。

(有趣的是,Vue 组件为setup函数创建的副作用作用域,默认都是分离的。)

在高级响应式课程中,我们会深入解析createSharedComposable的实现细节,并亲手实现这个函数。看似简短的几行代码,背后藏着很多精妙的设计(VueUse 的代码大多如此)。

总结

尽管副作用作用域属于 Vue 中的进阶知识点,但理解它的定义和工作原理,能让你更轻松地理解自己编写的 Vue 代码。

它的适用场景也十分丰富,虽然你可能不会经常用到 Vue 的这个 API,但多掌握一种工具,总归是件好事。