使用 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 的生命周期,在这样的误解下,很容易在初始化函数里使用 onMounted 或 onUnmounted 这样的 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");这确实能用,但是有性能隐患。原因就是前文提到的 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。注意,这个代码只是为了展示实现思路,不要用于生产环境。
Member discussion