blog-frontend icon indicating copy to clipboard operation
blog-frontend copied to clipboard

Vue3 EffectScope 分析及应用

Open Caaalabash opened this issue 2 years ago • 0 comments

Vue3 EffectScope 分析及应用

最好的文档

effectScope - RFC

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

Vue文档 - onScopeDispose

这个方法可以作为可复用的组合式函数中 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),创建了一个独立的作用域,其他installuse方法没什么特别的

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
}

Caaalabash avatar Feb 03 '23 08:02 Caaalabash