阅读 7 分钟

使用 Pinia 的注意事项

Pinia 是一个拥有组合式 API 的 Vue 状态管理库。通过 Pinia 创建的状态支持跨组件之间共享状态。Pinia 的基本使用方式:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})

返回一个叫做 useCounterStore 的函数,特别是对于我这种常年写习惯了 React 的人来说,是很容易让人误解这是一个 Hooks,而 defineStore 的第二个参数就是这个 Hooks 的实现,这里把 defineStore 的第二个参数叫做 Pinia Store 初始化函数。但是其实并不是这样的, Pinia Store 的初始化函数并不会像 Hooks 那样在每个函数组件里初次使用的时候都调用一次(典型的 React 思维)。


这里说一下 Pinia Store 的生命周期:

  • 在你第一次调用 useCounterStore() 的时候,Pinia 会创建并注册这个 Store 实例。 此后,在任何地方调用 useCounterStore() 拿到的都是同一个 Store。
  • 创建的任何 Pinia Store 都是全局单例,它会一直存在,直到应用卸载(SPA 刷新页面、路由销毁不会导致 Store 销毁)。 仅有两种情况 Store 被销毁: 手动调用 store.$dispose() API(极少见,一般不主动销毁)。 SSR/多页面应用中,Pinia 可能根据页面实例创建/销毁 Store。

如果不了解 Pinia 的生命周期,在这样的误解下,很容易在初始化函数里使用 onMountedonUnmounted 这样的 Vue 组件生命周期 API,这样是不正确的写法。组件生命周期 Hooks 在 Pinia Store 的初始化函数里表现很诡异,因为 onMounted 或者 onUnmounted 这样的 Hooks 只被注册了一次,所以也就只会被触发一次。

从本质上来说,Pinia Store 就应该只是一个“存储共享状态并提供一些方法来以某些条件、逻辑更新状态的容器”,除此之外其他的任何功能都不应该在 Pinia Store 里实现。只不过由于 Pinia 设计了 Setup Store 这种模式,过于自由和与 Hooks 的相似,让开发者产生了误解。举一个简单的例子:CountdownStore

// CountdownStore.ts - script part

const useCountdownStore = defineStore('countdown', () => {
  const remainingTime = ref(1)

  let intervalId: number | null = null
  const start = (duration: number) => {
    remainingTime.value = duration
    if (intervalId) {
      clearInterval(intervalId)
    }
    intervalId = setInterval(() => {
      if (remainingTime.value > 0) {
        remainingTime.value -= 1
      } else {
        stop()
      }
    }, 1000)
  }
  const stop = () => {
    if (intervalId) {
      clearInterval(intervalId)
      intervalId = null
    }
  }

  return {
    remainingTime,
    start,
    stop
  }
})

// Home.vue - script part
const countdownStore = useCountdownStore()

onMounted(() => {
  countdownStore.start()
})
onUnmounted(() => {
  countdownStore.stop()
})

如果一个状态或者一个实例需要放入 Pinia Store 并且,需要在某个时机清理掉,那么就应该通过 Pinia Action 的形式提供创建和销毁能力的 Action,并在组件里实现创建和销毁的实际调用。

当然,如果希望 setInterval 在 store 初始化之后一直存在,那么可以直接在 storeSetup 回调函数内直接调用 const intervalId = setInterval(() => { /* ... */}, 1000),并且不需要考虑 clearInterval 的时机,因为我们假定在整个网页应用实例存在的时候都需要 interval 一直工作。这样理论上,将 setInterval 的调用时机放在 Store 也是能正常工作的,只是不建议这样做。


另外一个重点,Pinia 是支持动态创建 Store 的。我们只需自定义一个创建 Store 的方法,根据传入参数动态生成 store id,然后通过 defineStore 即可完成 Store 的定义。

const useStore = (id: string) =>
  defineStore(`store-${id}`, () => {
    const data = ref({});

    return { data };
  })();

// Usage example:
const tableOneStore = useStore("table1");
const tableTwoStore = useStore("table2");

Dynamic Pinia Stores in Vue 3

这确实能用,但是有性能隐患。原因就是前文提到的 Pinia Store 的生命周期,除非主动注销 store,不然一个 store 一般被第一次使用时创建,那么除非整个 window 被销毁,store 都会一直存在。想象一下,你有一个1000个元素的列表,每个列表元素点进去,都会使用一个带有这个元素id生成的store id,那么极端一点,假如每个 store 内部都有大量的state 和 event listener,并且用户在不停地点进入新的列表元素,那么就会出现内存泄漏的情况。

总的来说,因为“全局单例,它会一直存在,直到应用卸载”这一设定,理论上是不应该使用动态 Stores 这一方式的。


一个好消息是,Pinia 提供了手动销毁 Store 的 API;但是坏消息是 Pinia 并没有提供 Store 的引用计数改变的回调通知。用人话说就是:Pinia 没有“当一个 Store 在所以地方都没有再使用的时候”的通知能力。

与此同时, Jotai (React 的原子风格的全局状态库)的情况就好很多。Jotai 提供了一个 atomFamily 的方法,可以用来动态的创建 atom。从官方文档来看,其实实现上也很简单:一个 Map 用来存 id 对应的 Atom,检查 Map 里对应的 key 有没有 atom,有的话就直接返回,没有的话创建存储之后返回。因此 atomFamily 默认也不会主动销毁状态。

但 Jotai 没有坏消息!atom 不管是通过 useAtom 还是通过 store.sub() 来订阅使用 atom,都会进行对“订阅 atom 状态变更”这一行为的引用计数。这意味着,当所有使用 atom 的地方都取消订阅后,Jotai 可以发出通知,告知在这个时机下可以手动安全地销毁这个 atom。因此,可以很容易的封装一个带自动清理功能的特别版 atomFamilyWithAutoCleaner

import type { Atom } from 'jotai/vanilla'

/**
 * in milliseconds
 */
type CreatedAt = number
type ShouldRemove<Param> = (createdAt: CreatedAt, param: Param) => boolean
type Cleanup = () => void
type Callback<Param, AtomType> = (event: {
  type: 'CREATE' | 'REMOVE'
  param: Param
  atom: AtomType
}) => void
type SetAtom<Args extends unknown[], Result> = <A extends Args>(...args: A) => Result;
type OnUnmount = () => void;
type OnMount<Args extends unknown[], Result> = <S extends SetAtom<Args, Result>>(setAtom: S) => OnUnmount | void;

export interface AtomFamilyWithAutoCleaner<Param, AtomType> {
  (param: Param): AtomType
  getParams(): Iterable<Param>
  remove(param: Param): void
  setShouldRemove(shouldRemove: ShouldRemove<Param> | null): void
  /**
   * fires when a atom is created or removed
   * This API is for advanced use cases, and can change without notice.
   */
  unstable_listen(callback: Callback<Param, AtomType>): Cleanup
}

export function atomFamilyWithAutoCleaner<Param, AtomType extends Atom<unknown>>(
  initializeAtom: (param: Param) => AtomType,
  areEqual?: (a: Param, b: Param) => boolean
): AtomFamilyWithAutoCleaner<Param, AtomType>

export function atomFamilyWithAutoCleaner<Param, AtomType extends Atom<unknown>>(
  initializeAtom: (param: Param) => AtomType,
  areEqual?: (a: Param, b: Param) => boolean
) {
  let shouldRemove: ShouldRemove<Param> | null = null
  const atoms: Map<Param, [AtomType, CreatedAt]> = new Map()
  const listeners = new Set<Callback<Param, AtomType>>()
  function createAtom(param: Param) {
    let item: [AtomType, CreatedAt] | undefined
    if (areEqual === undefined) {
      item = atoms.get(param)
    } else {
      // Custom comparator, iterate over all elements
      for (const [key, value] of atoms) {
        if (areEqual(key, param)) {
          item = value
          break
        }
      }
    }

    if (item !== undefined) {
      if (shouldRemove?.(item[1], param)) {
        createAtom.remove(param)
      } else {
        return item[0]
      }
    }

    const newAtom = initializeAtom(param)
    // ===========================================================
    // ============= Begin of Auto cleaner logic =================
    // ===========================================================
    if ('onMount' in newAtom && typeof newAtom.onMount === 'function') {
      const prevOnMount = newAtom.onMount as OnMount<unknown[], unknown>
      const newOnMount: OnMount<unknown[], unknown> = (setAtom) => {
        const prevOnUnmount = prevOnMount(setAtom)
        const newOnUnmount: OnUnmount = () => {
          prevOnUnmount?.()
          createAtom.remove(param)
        }
        return newOnUnmount
      }
      newAtom.onMount = newOnMount;
    }
    // ===========================================================
    // ============= End of Auto cleaner logic ===================
    // ===========================================================
    atoms.set(param, [newAtom, Date.now()])
    notifyListeners('CREATE', param, newAtom)
    return newAtom
  }

  function notifyListeners(
    type: 'CREATE' | 'REMOVE',
    param: Param,
    atom: AtomType
  ) {
    for (const listener of listeners) {
      listener({ type, param, atom })
    }
  }

  createAtom.unstable_listen = (callback: Callback<Param, AtomType>) => {
    listeners.add(callback)
    return () => {
      listeners.delete(callback)
    }
  }

  createAtom.getParams = () => atoms.keys()

  createAtom.remove = (param: Param) => {
    if (areEqual === undefined) {
      if (!atoms.has(param)) return
      const [atom] = atoms.get(param)!
      atoms.delete(param)
      notifyListeners('REMOVE', param, atom)
    } else {
      for (const [key, [atom]] of atoms) {
        if (areEqual(key, param)) {
          atoms.delete(key)
          notifyListeners('REMOVE', key, atom)
          break
        }
      }
    }
  }

  createAtom.setShouldRemove = (fn: ShouldRemove<Param> | null) => {
    shouldRemove = fn
    if (!shouldRemove) return
    for (const [key, [atom, createdAt]] of atoms) {
      if (shouldRemove(createdAt, key)) {
        atoms.delete(key)
        notifyListeners('REMOVE', key, atom)
      }
    }
  }
  return createAtom
}

atomFamily.ts 魔改而来的 atomFamilyWithArc.ts。注意,这个代码只是为了展示实现思路,不要用于生产环境。