聊一聊 Effect Scope API

5,280 阅读4分钟

在vue3.2中新增了一个属性 EffectScope,官方文档的解释比较简单,只说是一个高级属性,并没有具体的示例。

最近在看 antfu 大神的 vueuse 框架源码,里面大量使用EffectScope,所以研究了一下这个属性的使用方法。

什么是EffectScope?

下面是官方文档解释,感觉有点敷衍

Effect scope is an advanced API primarily intended for library authors. For details on how to leverage this API, please consult its corresponding RFC(opens new window).

RFC关于EffectScopeApi的解释

在Vue的setup中,响应会在开始初始化的时候被收集,在实例被卸载的时候,响应就会自动的被取消追踪了,这时一个很方便的特性。但是,当我们在组件外使用或者编写一个独立的包时,这会变得非常麻烦。当在单独的文件中,我们该如何停止computed & watch的响应式依赖呢?

实际上EffectScope按我的理解就是副作用生效的作用域。

vue3对响应式的监听是通过effect实现的,当我们的组件销毁的时候vue会自动取消该组件的effect。

那么如果我们想要自己控制effect生效与否呢? 比如我只想在莫种特定情况下才监听摸个ref,其他情况下不想监听该怎么做?

vue3.2之前

//(vue-RFC示例代码)
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)

EffectScope如何实现

// effect, computed, watch, watchEffect created inside the scope will be collected

const scope = effectScope()

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

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

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

// to dispose all effects in the scope
scope.stop()

示例

const scope = effectScope()
let counter = ref(0)
setInterval(() => {
  counter.value++
}, 1000);
scope.run(() => {
  watchEffect(() =>console.log(`counter: ${counter.value}`))
})
/*log:
counter: 0
counter: 1
counter: 2
counter: 3
counter: 4
counter: 5
*/
const scope = effectScope()
let counter = ref(0)
setInterval(() => {
  counter.value++
}, 1000);
scope.run(() => {
  watchEffect(() =>console.log(`counter: ${counter.value}`))
})
scope.stop()
/*log:
counter: 0
*/

基本使用

新建一个scope:

const scope = effectScope()

一个 scope 可以执行一个 run 函数(接受一个函数作为参数,并返回该函数的返回值),并且捕获所有在该函数执行过程中创建的 effect ,包括可以创建 effect 的API,例如 computed , watch , watchEffect :

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

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

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

// the same scope can run multiple times
scope.run(() => {
  watch(counter, () => {
    /*...*/
  })
})

当调用 scope.stop(), 所有被捕获的effect都会被取消,包括 nested Scopes 也会被递归取消

Nested Scopes

嵌套scope也会被他们的父级scope收集。并且当父级scope销毁的时候,所有的后代scope也会被递归销毁。

const scope = effectScope()

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

  // not need to get the stop handler, it will be collected by the outer scope
  effectScope().run(() => {
    watch(doubled, () => console.log(doubled.value))
  })

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

// dispose all effects, including those in the nested scopes
scope.stop()

Detached Nested Scopes

effectScope 接受一个参数可以在分离模式(detached mode)下创建。 detached scope不会被父级collect。

这一特性同时解决了一个 Issues lazy Initialization

let nestedScope

const parentScope = effectScope()

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

  // with the detected flag,
  // the scope will not be collected and disposed by the outer scope
  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()

onScopeDispose

全局钩子函数 onScopeDispose 提供了类似于 onUnmounted 的功能,不同的是它工作在scope中而不是当前instance。

这使得 composable functions 可以通过他们的scope清除他们的副作用。

由于 setup() 默认会为当前 instance 创建一个 scope,所以当没有明确声明一个scope的时候,onScopeDispose等同于onUnmounted。

import { onScopeDispose } from 'vue'

const scope = effectScope()

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

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

Getting the current Scope

通过 getCurrentScope() 可以获取当前 scope

import { getCurrentScope } from 'vue'

getCurrentScope() // EffectScope | undefined

实战

示例:Shared Composable

一些 composables 会设置全局副作用,例如如下的 useMouse() function:

function useMouse() {
	const x = ref(0)
  const y = ref(0)
  
  window.addEventListener('mousemove', handler)
  
  function handler(e) {
  	x.value = e.x
    y.value = e.y
  }
  
  onUnmounted(() => {
  	window.removeEventListener('mousemove', handler)
  })
  
  return {x,y}
  
}

如果在多个组件中调用 useMouse () ,则每个组件将附加一个 mouseemove 监听器,并创建自己的 x 和 y refs 副本。我们应该能够通过在多个组件之间共享相同的侦听器集和 refs 来提高效率,但是我们做不到,因为每个 onUnmounted 调用都耦合到一个组件实例。

我们可以使用分离作用域和 onScopeDispose 来实现这一点, 首先,我们需要用 onScopeDispose 替换 onUnmounted

- onUnmounted(() => {
+ onScopeDispose(() => {
  window.removeEventListener('mousemove', handler)
})

这仍然有效,因为 Vue 组件现在也在作用域内运行其 setup () ,该作用域将在组件卸载时释放。

然后,我们可以创建一个工具函数来管理父范围订阅:

function createSharedComposable(composable) {
  let subscribers = 0
  let state, scope

  const dispose = () => {
    if (scope && --subscribers <= 0) {
      scope.stop()
      state = scope = null
    }
  }
	
 	// 这里只有在第一次运行的时候创建一个state, 后面所有的组件就不会再创建新的state,而是共用一个state
  return (...args) => {
    subscribers++
    if (!state) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    onScopeDispose(dispose)
    return state
  }
}

现在我们就可以使用这个 shared 版本的 useMouse

const useSharedMouse = createSharedComposable(useMouse)

通过这个例子,不禁想到,是否可以通过这种模式模拟vuex的能力?我们是否可以通过 shared composables 更加灵活的达到全局状态管理的目的呢?