⚡ VueUse createGlobalState 之 effectScope 全面解读 🚀

761 阅读7分钟

前言

前几天发了一篇关于讨论 vueuse 的 createGlobalState 为什么使用 effectScope 实现的文章,在做项目的过程中,发现 VueUse 一个很鸡肋的 hook 🧐,评论区大佬云集,给出了各方面的解答,小佬弟听了之后真是如拨云见日,茅塞顿开。标题过于嚣张,jym请见谅😁,口出狂言不是目的,学习进步才是初衷😄。由于精华都在评论区,怕各位看官浏览不方便。特在此总结一篇,方便查看学习。
首先还是以场景打开话题,我们项目当中有一个场景需要在 N 多个组件内共用同一个状态,使用到了vueuse 的 createGlobalState

createGlobalState 详解

官方对它的解释是:将状态保留在全局范围内,以便可以跨 Vue 实例重用。

createGlobalState 的使用

import { createGlobalState } from '@vueuse/core'
// store.js
import { computed, ref } from 'vue'

export const useGlobalState = createGlobalState(
  () => {
    // state
    const count = ref(0)

    // getters
    const doubleCount = computed(() => count.value * 2)

    // actions
    function increment() {
      count.value++
    }

    return { count, doubleCount, increment }
  }
)

// component.js
import { useGlobalState } from './store'

export default defineComponent({
  setup() {
    const state = useGlobalState()
    return { state }
  },
})

createGlobalState 这个组合式函数接收一个状态工厂函数,用来定义状态,它会返回一个组合式函数,然后在组件中使用这个组合式函数就能拿到我们刚才在状态工厂函数中定义的状态。重点:这些状态是全局唯一的。详见createGlobalState

createGlobalState 的实现

了解完使用我们来看看他是怎么实现的,下面是 vueuse 的源码:createGlobalState image.png
他是使用一个闭包把状态保存到内部作用域中,然后返回了出去,这一点好理解,但是他又包了一层 effectScope,这又是个什么鬼,到底有什么用处,没错,它就是今天的主角,让我们一点点揭开它的面纱。

effectScope 详解

官方对它的解释是:创建一个 effect 作用域,可以捕获其中所创建的响应式副作用 (即计算属性和侦听器),这样捕获到的副作用可以一起处理。
它是为 @vue/reactivity 引入的一个新的 API。EffectScope 实例可以自动收集在同步函数中运行的效果,以便稍后可以一起处理这些效果。

基本使用

effectScope 函数会返回一个作用域对象,这个作用域对象上有一个 run 方法和 stop 方法。
运行 run 方法捕获函数内同步执行期间创建的所有副作用,包括任何在内部创建副作用的 API,例如computed, watch 和 watchEffect。
run 方法会返回被执行函数的返回值。
stop 方法被调用时,它将递归地停止所有捕获的副作用和 嵌套的作用域

// 在作用域中创建的 effect, computed, watch, watchEffect 将会被收集

const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  watch(doubled, () => console.log(doubled.value))

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 同一个scope能运行多次
scope.run(() => {
  watch(counter, () => {
    /*...*/
  })
})

// 处理作用域中的所有副作用
scope.stop()

vue 为什么提供这个API

在 Vue 的组件 setup() 中,effects 会被收集并绑定到当前实例上。当实例被卸载时,effects 会被自动释放。 但是,当我们在组件之外或作为独立包使用它们时,事情就没那么简单了。具体看下面这个例子:

const disposables = []

const counter = ref(0)
const doubled = computed(() => counter.value * 2)

disposables.push(() => stop(doubled.effect))

const stopWatch1 = watchEffect(() => {
  console.log(`counter: ${counter.value}`)
})

disposables.push(stopWatch1)

const stopWatch2 = watch(doubled, () => {
  console.log(doubled.value)
})

disposables.push(stopWatch2)

// 为了停止这些副作用
disposables.forEach((f) => f())
disposables = []

在这个例子中,我们想要自己控制副作用何时被停止时,就要手动去收集起来,然后手动去处理。
尤其是当我们有一些长而复杂的可组合代码时,手动收集所有副作用会很费力。也很容易忘记收集它们(或者您无法访问在可组合函数中创建的副作用),这可能会导致内存泄漏和意外行为。
再回去看看基本用法那段代码对比一下,是不是要比自己手动方便很多,而且也不会遗漏处理,因为 effectScope 是统一收集统一处理的。
effectScope 其实就是 vue 将组件的 setup() 依赖收集和处理能力抽象成了一个可在组件模型之外重用的更通用的 API。

作用域嵌套

effectScope 接受一个是否分离作用域的参数(布尔值,默认值为true)。
true 时创建嵌套作用域,嵌套作用域会被它们的父作用域收集。当父作用域被释放时,它的所有后代作用域也将停止。
false 时创建分离的作用域,分离的作用域不会被其父作用域收集。

嵌套作用域
const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  // 这里不需要获取stop函数,它将被外部作用域收集,并且与父作用域一块被处理
  effectScope().run(() => {
    watch(doubled, () => console.log(doubled.value))
  })

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 处置所有副作用,包括嵌套作用域中的
scope.stop()
分离作用域
let nestedScope

const parentScope = effectScope()

parentScope.run(() => {
  const doubled = computed(() => counter.value * 2)

  // with the detected flag,
  // 该作用域不会被外部作用域收集和处理
  nestedScope = effectScope(true /* detached */)
  nestedScope.run(() => {
    watch(doubled, () => console.log(doubled.value))
  })

  watchEffect(() => console.log('Count: ', doubled.value))
})

// disposes all effects, but not `nestedScope`
parentScope.stop()

// stop the nested scope only when appropriate
nestedScope.stop()

创建独立作用域

它还提供了从组件的 setup() 范围或用户自定义范围中创建 “分离” 副作用的功能(因为它是凌驾于vue实例之上的),这就可以避免以下问题的出现。
详见:Option to not stop watch when component is unmounted

// global shared reactive state
let foo

function useFoo() {
  if (!foo) { // lazy initialization
      foo = ref()
      watch(foo, ...) // <- 当地一个使用它的组件被卸载时,watch会停止
      // make some http calls etc
  }
  return foo
}

component1 = {
    setup() {
        useFoo() // lazily initialize
    }
}

component2 = {
    setup() {
        useFoo() // lazily initialize
    }
}

辅助函数

其实与 effectScope 一起诞生的还有 getCurrentScopeonScopeDispose

onScopeDispose

全局钩子 onScopeDispose 提供了与 onUnmounted 类似的功能,但适用于当前作用域而不是组件实例。这有助于组合函数清理它们的副作用及其作用域。由于 setup 也会为组件创建作用域,所以当没有创建显式作用域时,它与 onUnmounted 等效。

import { onScopeDispose } from 'vue'

const scope = effectScope()

scope.run(() => {
  onScopeDispose(() => {
    console.log('cleaned!')
  })
})

scope.stop() // logs 'cleaned!'
getCurrentScope

如果有的话,返回当前活跃的 effect 作用域

import { getCurrentScope } from 'vue'

getCurrentScope() // EffectScope | undefined

总结

我之前的文章中在做项目的过程中,发现 VueUse 一个很鸡肋的 hook 🧐,提出的质疑,在本文中都可以得到解释,为什么 vueuse 的 createGlobalState 使用 effectScope 去实现而不是一个简单的闭包?

  1. createGlobalState 内部没有调用stop方法去取消副作用,也没暴露scope,用户可以通过 getCurrentScope 拿到 scope 并且在需要的时候销毁它。
  2. 为了创建一个不受任何 vue 实例影响的(独立的)作用域,来应对多 vue 实例共享状态的场景。(因为大多数项目只有一个 vue 实例(createApp),只有在多组件中共享状态的场景,所以容易忽略这一点)
  3. 做了延迟初始化,在 useGlobalState = createGlobalState() 的时候回调函数并没有执行,等到代码调用useGlobalState() 再执行,可以异步初始化状态(eg:请求接口)
  4. 在服务端渲染的时候避免安全漏洞,参考为什么你应该使用 Pinia?

大家如果有更多的见解可以补充。
最后感谢参与讨论的各位大佬🤞
特别鸣谢:Vue & VueUse官方成员 远方os、匿名大佬 ylzc 两位大佬的解答

ps:
2024-12-31 更新:假如你的页面上有几十个弹窗,你会怎样优雅地展示它们?🧐
2025-01-10 更新:史诗级 ⚡ 宇宙最强 🏆 vue3 函数式弹窗 🚀

参考文章

在做项目的过程中,发现 VueUse 一个很鸡肋的 hook 🧐
VueUse createGlobalState
Vue effectScope
reactivity-effect-scope
Option to not stop watch when component is unmounted