blog-frontend
blog-frontend copied to clipboard
Vue3 EffectScope 分析及应用
Vue3 EffectScope 分析及应用
最好的文档
在Vue组件的setup()
中,副作用(例如effect
,computed
,watch
,watchEffect
)会被自动收集、绑定到当前实例,并随着组件的销毁而自动清理,这是方便且符合直觉的。
但是在组件外没有这个功能,需要手动收集副作用,因此这个RFC抽象了组件内收集副作用、清理副作用的过程,提供一组API使得组件外部环境也能自动收集副作用并统一清理。
它属于一个底层API,供进阶用户or库作者。
简单使用
// 创建一个scope
const scope = effectScope()
// 其中的副作用会被收集
scope.run(() => {
const doubled = computed(() => counter.value * 2)
watch(doubled, () => console.log(doubled.value))
watchEffect(() => console.log('Count: ', doubled.value))
})
// 清理副作用
scope.stop()
嵌套使用
it('should collect nested scope', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
// nested scope
new EffectScope().run(() => {
effect(() => (doubled = counter.num * 2))
})
})
expect(scope.effects.length).toBe(1)
expect(scope.scopes!.length).toBe(1)
expect(scope.scopes![0]).toBeInstanceOf(EffectScope)
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
// stop the nested scope as well
scope.stop()
counter.num = 6
expect(dummy).toBe(7)
expect(doubled).toBe(14)
})
通过detached
参数,标明该作用域是否独立,如果是独立的,就不会被父级"控制"
it('nested scope can be escaped', () => {
let dummy, doubled
const counter = reactive({ num: 0 })
const scope = new EffectScope()
scope.run(() => {
effect(() => (dummy = counter.num))
// nested scope but detached true
new EffectScope(true).run(() => {
effect(() => (doubled = counter.num * 2))
})
})
expect(scope.effects.length).toBe(1)
expect(dummy).toBe(0)
counter.num = 7
expect(dummy).toBe(7)
expect(doubled).toBe(14)
scope.stop()
counter.num = 6
expect(dummy).toBe(7)
// nested scope should not be stopped
expect(doubled).toBe(12)
})
EffectScope 源码
主要结构
import { ReactiveEffect } from './effect'
import { warn } from './warning'
let activeEffectScope: EffectScope | undefined
export class EffectScope {
// 见下文
}
// 创建一个scope
export function effectScope(detached?: boolean) {
return new EffectScope(detached)
}
// 更新scope中的副作用effects
export function recordEffectScope(
effect: ReactiveEffect,
scope: EffectScope | undefined = activeEffectScope
) {
if (scope && scope.active) {
scope.effects.push(effect)
}
}
// 返回当前的scope
export function getCurrentScope() {
return activeEffectScope
}
// 当scope被清理时触发,类似于组件的onUnmounted
export function onScopeDispose(fn: () => void) {
if (activeEffectScope) {
activeEffectScope.cleanups.push(fn)
}
}
class EffectScope
实现:很常见的"双向链表"数据结构了,父节点通过一个"children
"字段记录所有子节点,子节点存储一个parent
指针,再维护一个index
索引实现O(1)的删除
export class EffectScope {
// scope的active状态
private _active = true
// scope收集的副作用
effects: ReactiveEffect[] = []
// scope的清理回调
cleanups: (() => void)[] = []
// 父指针
parent: EffectScope | undefined
// 子scope指针
scopes: EffectScope[] | undefined
// 用于性能优化,记录在父级scopes中的索引,实现O(1)的删除
private index: number | undefined
constructor(public detached = false) {
this.parent = activeEffectScope
if (!detached && activeEffectScope) {
// 初始化父级的scopes数组,添加自身,记录自身的索引
this.index =
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
this
) - 1
}
}
get active() {
return this._active
}
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn()
} finally {
activeEffectScope = currentEffectScope
}
}
}
on() {
activeEffectScope = this
}
off() {
activeEffectScope = this.parent
}
stop(fromParent?: boolean) {
if (this._active) {
let i, l
// 清理effects
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop()
}
// 执行scope的onScopeDispose钩子
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]()
}
// 递归清理子scope
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true)
}
}
// 从this.parent.scopes中删除自身
if (!this.detached && this.parent && !fromParent) {
// 数组删除O(1)优化
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
last.index = this.index!
}
}
this.parent = undefined
this._active = false
}
}
}
runtime-core/src/component/createComponentInstance
中,创建一个组件实例时,会创建一个独立的组件scope
export function createComponentInstance() {
const instance: ComponentInternalInstance = {
// ...
scope: new EffectScope(true /* detached */),
}
return instance
}
// 调用setCurrentInstance时,也会维护activeEffectScope
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
currentInstance = instance
// 此时 activeEffectScope = instance.scope
instance.scope.on()
}
调用watch
等副作用API时,watch -> doWatch -> new ReactiveEffect
,完成副作用收集
function doWatch() {
const instance =
getCurrentScope() === currentInstance?.scope ? currentInstance : null
// ...
const effect = new ReactiveEffect(getter, scheduler)
}
class ReactiveEffect {
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope
) {
// 这个scope显然是 currentInstance.scope
recordEffectScope(this, scope)
}
}
组件卸载时,执行scope.stop
清理副作用
const unmountComponent = (instance: ComponentInternalInstance, /* ... */) => {
const { scope } = instance
scope.stop()
}
应用
useEventListener
这个方法可以作为可复用的组合式函数中 onUnmounted
的替代品,它并不与组件耦合,例如useEventListener
部分源码
export function useEventListener(...args: any[]) {
// ...
const stop = () => {
stopWatch()
cleanup()
}
// 使用onScopeDispose而不是onUnmounted进行清理
tryOnScopeDispose(stop)
return stop
}
createSharedComposable
解决的问题:
import { createSharedComposable, useMouse } from '@vueuse/core'
// useMouse会给window添加mousemove事件,如果多个vue组件使用useMouse(),必定会带来额外的事件监听
// 此时使用useSharedMouse包装,实现一个共享的composable function
const useSharedMouse = createSharedComposable(useMouse)
// CompA.vue
const { x, y } = useSharedMouse()
// CompB.vue - will reuse the previous state and no new event listeners will be registered
const { x, y } = useSharedMouse()
要实现多个实例共享的数据,显然时通过effectScope(true)
创建一块独立的scope,并将要共享的composable function
交给这个scope来执行
export function createSharedComposable<Fn extends((...args: any[]) => any)>(composable: Fn): Fn {
// 订阅的组件
let subscribers = 0
let state: ReturnType<Fn> | undefined
let scope: EffectScope | undefined
const dispose = () => {
subscribers -= 1
// 没有组件订阅时,才执行scope.stop
if (scope && subscribers <= 0) {
scope.stop()
state = undefined
scope = undefined
}
}
return <Fn>((...args) => {
subscribers += 1
if (!state) {
scope = effectScope(true)
state = scope.run(() => composable(...args))
}
tryOnScopeDispose(dispose)
return state
})
}
Pinia源码
最开始就使用Pinia源码中了解到effectScope
的,到这里也算是完成了闭环
1. createPinia
核心:通过effectScope(true)
,创建了一个独立的作用域,其他install
、use
方法没什么特别的
export function createPinia(): Pinia {
const scope = effectScope(true)
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!
let _p: Pinia['_p'] = []
// plugins added before calling app.use(pinia)
let toBeInstalled: PiniaPlugin[] = []
const pinia: Pinia = markRaw({
install(app: App) {
// this allows calling useStore() outside of a component setup after
// installing pinia's plugin
setActivePinia(pinia)
if (!isVue2) {
pinia._a = app
app.provide(piniaSymbol, pinia)
app.config.globalProperties.$pinia = pinia
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},
use(plugin) {
if (!this._a && !isVue2) {
toBeInstalled.push(plugin)
} else {
_p.push(plugin)
}
return this
},
_p, // 已安装的插件
// @ts-expect-error
_a: null, // 关联的App实例
_e: scope, // 关联的effctScope实例
_s: new Map<string, StoreGeneric>(), // 注册的store
state, // root state
})
return pinia
}
2. defineStore
此处选择,Setup Store 分支进行分析,即defineStore(id, () => {})
的用法,简化相关代码如下:
export function defineStore(
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
let id: string
let options: DefineSetupStoreOptions<string, StateTree, _GettersTree<StateTree>, _ActionsTree>
// 简化相关参数类型判断的代码
id = idOrOptions
options = setupOptions
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
const currentInstance = getCurrentInstance()
pinia = currentInstance && inject(piniaSymbol, null)
if (pinia) setActivePinia(pinia)
pinia = activePinia!
if (!pinia._s.has(id)) {
// 创建store并向 pinia._s 进行注册
createSetupStore(id, setup, options, pinia)
}
const store: StoreGeneric = pinia._s.get(id)!
return store as any
}
useStore.$id = id
return useStore
}
createSetupStore
源码,简化了大部分代码以及函数声明,主流程如下:
function createSetupStore<
Id extends string,
SS extends Record<any, unknown>,
S extends StateTree,
G extends Record<string, _Method>,
A extends _ActionsTree
>(
$id: Id,
setup: () => SS,
options:
| DefineSetupStoreOptions<Id, S, G, A>
| DefineStoreOptions<Id, S, G, A> = {},
pinia: Pinia,
): Store<Id, S, G, A> {
// 该store的effectScope
let scope!: EffectScope
// 在pinia.state中初始化
const initialState = pinia.state.value[$id] as UnwrapRef<S> | undefined
if (!initialState) {
pinia.state.value[$id] = {}
}
const partialStore = {
_p: pinia,
$id,
$onAction,
$patch,
$reset,
$subscribe,
$dispose,
} as _StoreWithState<Id, S, G, A>
// reactive包装
const store: Store<Id, S, G, A> = reactive(partialStore) as unknown as Store<Id, S, G, A>
// 向pinia._s进行注册
pinia._s.set($id, store)
// pinia._e 是 pinia实例上的effectScope
const setupStore = pinia._e.run(() => {
// 再创建一个store的effectScope,与pinia._e产生嵌套关系
scope = effectScope()
// 执行setup函数,拿到返回值
return scope.run(() => setup())
})!
// 遍历返回值
for (const key in setupStore) {
const prop = setupStore[key]
// 如果是 ref / reactive
if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
pinia.state.value[$id][key] = prop
}
// 如果是函数,包装一下
else if (typeof prop === 'function') {
setupStore[key] = wrapAction(key, prop)
}
}
assign(store, setupStore)
// 应用插件
pinia._p.forEach((extender) => {
assign(
store,
scope.run(() =>
extender({
store,
app: pinia._a,
pinia,
options: {},
})
)!
)
})
return store
}