难点不是"会不会写 store",而是"哪些数据配得上 store"。放少了,props 透传五层没人愿意改;放多了,store 变成全局变量袋,谁也不敢删。Pinia 的 API 太轻,正好掩盖了这个决定。
这篇拆五步:判定边界 → 选写法 → 保住响应性 → 跨模块与持久化 → 拆文件,末尾补 SSR 与坑。
1. 判定:三条线决定数据归属
从上往下问,命中即止:
| 判定 | 问题 | 命中 → 归属 |
|---|---|---|
| ① 共享面 | ≥2 个互不相关的组件树要读它吗 | 是 → store 候选 |
| ② 生命周期 | 要比组件/页面活得久(跨路由保留)吗 | 是 → store 候选 |
| ③ 可序列化 | 能 JSON 序列化,不含函数、DOM、类实例吗 | 否 → 留在组件 |
三条全中才该进 store。栽在 ③ 的典型:DOM 引用、IntersectionObserver 实例、上传中的 File。这类东西进 store,SSR 序列化与时间旅行都会炸。
过了判定还要追问一句:这是服务端状态缓存,还是客户端状态?
| 数据 | 例子 | 该放哪 |
|---|---|---|
| 服务端状态缓存 | 列表、详情、字典、分页 | 请求层缓存(按 key),store 只留跨页必需项 |
| 客户端状态 | 登录态、主题、侧栏折叠 | store |
| 组件内部 | 输入草稿、hover | ref 局部 |
| 派生 | 过滤后列表、合计 | computed 或 getter |
把整份列表搬进 store,等于手写缓存失效与竞态,而请求层已经做完了。
2. 两种写法:options 与 setup
// options 风格:贴近 Vuex 迁移心智
export const useUserStore = defineStore('user', {
state: (): { token: string | null } => ({ token: null }),
getters: { isLogin: (s) => s.token !== null },
actions: {
async login(u: string, p: string) {
const r = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ u, p }) })
this.token = (await r.json()).token
},
logout() { this.$reset() },
},
})
// setup 风格:与组合式 API 同构
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(null)
const isLogin = computed(() => token.value !== null)
async function login(u: string, p: string) {
const r = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ u, p }) })
token.value = (await r.json()).token
}
function logout() { token.value = null } // 内置 $reset 不可用
return { token, isLogin, login, logout }
})
| 概念 | options | setup | 组合式对应 |
|---|---|---|---|
| 状态 | state: () => ({}) |
ref() |
ref |
| 派生 | getters: {x: s => ...} |
computed() |
computed |
| 方法 | actions: {fn(){this.x}} |
普通函数 | 函数 |
| 重置 | $reset() 内置 |
自己写 | — |
| 类型 | 需手写 state 类型 |
自动推导 | 自动 |
两条结论:
$reset()只在 options 风格存在,setup 里调会提示 only available on option stores,必须自己实现重置。- 新项目选 setup 风格——省掉
state: (): UserState =>这类样板,还能直接用watch、onScopeDispose。
3. 响应性:storeToRefs 保住解构
store 实例是 reactive 对象,解构出来的是求值后的普通值——这是响应式的基本规则,不是 Pinia 的 bug。
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { token } = userStore // ❌ 快照,登录后模板不更新
const { token: tk } = storeToRefs(userStore) // ✅ ref
const { login } = userStore // ✅ 方法不必包 ref
| 写法 | 得到 | 响应式 | 何时用 |
|---|---|---|---|
storeToRefs(store) |
state/getter 的 ref | 是 | 要解构 |
store.xxx |
求值结果 | 在模板/computed 内是 | 单点访问 |
storeToRefs 只处理 state 与 getters,actions 是普通函数。它返回的 ref 是内部 ref 的代理,不能提到模块顶层长期持有,否则 SSR 跨请求会串。
4. 跨 store 与 $patch
// 在 action / getter / setup 函数体内部取,不要在模块顶层取
export const useCartStore = defineStore('cart', () => {
const items = ref<{ sku: string; qty: number }[]>([])
function add(sku: string, qty = 1) {
const user = useUserStore() // ✅ 此时 Pinia 已激活
if (!user.isLogin) throw new Error('请先登录')
const hit = items.value.find((i) => i.sku === sku)
hit ? (hit.qty += qty) : items.value.push({ sku, qty })
}
return { items, add }
})
Pinia 靠 activePinia 定位实例,模块顶层执行时它还是 undefined。
循环依赖:A 的 action 用 B、B 的 action 用 A,运行时不报错,打包后可能变成 undefined is not a function。解法不是调 import 顺序,而是把共享数据下沉到第三个 store,或改由视图层编排:
改由视图层编排:const user = useUserStore(); const cart = useCartStore(),在 checkout() 里先 ensureSession() 再 submit(),两个 store 互不认识。
const cart = useCartStore()
cart.items = []; cart.coupon = null // 逐字段:每次触发一次订阅
cart.$patch({ items: [], coupon: null }) // 对象式:一次事务,一次订阅
cart.$patch((s) => { s.items = s.items.filter(i => i.qty > 0) }) // 函数式:可做数组操作
| 方式 | 订阅次数 | 适用 |
|---|---|---|
| 逐字段赋值 | 每字段一次 | 字段少 |
$patch(object) |
一次 | 批量重置,浅合并 |
$patch(fn) |
一次 | 数组操作、要读旧值 |
$reset() |
一次 | 仅 options 风格 |
在 $subscribe 里做持久化或埋点时,逐字段赋值会留下多条中间态。
5. 持久化:pinia-plugin-persistedstate
// main.ts
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// stores/user.ts
export const useUserStore = defineStore('user', () => { /* ... */ }, {
persist: { key: 'app:user', storage: localStorage, pick: ['token', 'sidebarCollapsed'] },
})
| 配置项 | 作用 | 建议 |
|---|---|---|
key |
存储键名 | 加前缀,防同域冲突 |
storage |
localStorage / sessionStorage / 自定义 | token 用前者,草稿用后者 |
pick |
白名单路径 | 必填,默认全量存 |
serializer |
读写序列化 | 存 Date/Map 时替换 |
beforeHydrate / afterHydrate |
水合钩子 | 版本迁移、字段校验 |
三条判断:
- 默认全量持久化危险:新增的每个字段都会进 localStorage,包括 loading、错误消息、大对象;改一次 store 就清一次用户缓存。永远显式
pick。 - 旧版
paths已被pick取代(按 pinia-plugin-persistedstate 4.x 核对);升级后写paths会被静默忽略,这种失效最难发现。 - 持久化不是缓存:只保证刷新后还在,不管过期与一致性。要过期就存
expiresAt自判,或在afterHydrate校验。
6. SSR:store 实例必须按请求隔离
唯一不可妥协的规则:createPinia() 每次请求新建。
// entry-server.ts
export async function render(url: string) {
const app = createSSRApp(App)
const pinia = createPinia() // ✅ 每请求一份
app.use(pinia)
const router = createRouter(url)
app.use(router)
await router.isReady()
const html = await renderToString(app)
return { html, state: JSON.stringify(pinia.state.value) }
}
| 写法 | 结果 |
|---|---|
createPinia() 模块顶层 |
所有请求共用 state,A 看到 B 的数据 |
每请求 createPinia() |
正确隔离 |
useXStore() 模块顶层 |
activePinia 为 undefined |
defineStore() 模块顶层 |
正确,这是工厂函数 |
客户端顺序:先 app.use(pinia),再灌 pinia.state.value,最后 mount。反了会按空状态初始化一遍,水合闪一下。
7. 拆分粒度与 provide/inject
| 拆法 | 后果 |
|---|---|
| 按页面(homeStore/detailStore) | 改版即废,同一实体在多个 store 重复 |
| 按领域(user/cart/catalog) | 稳定,页面增删不影响 |
| 按技术层(apiStore/uiStore) | 变垃圾桶 |
判断标准一句话:store 名能不能脱离路由说通。"cart" 能,"checkoutPage" 不能。一个领域一个文件,超 300 行再考虑拆;但先问"这两块数据是否总一起变",总一起变的拆开只会加同步成本。
| 维度 | Pinia store | provide/inject |
|---|---|---|
| 作用域 | 全局,每 pinia 一份 | 组件子树 |
| 多实例共存 | 要给 defineStore 加 id 工厂 | 天然支持 |
| 跨路由存活 | 是 | 否 |
| devtools | 有时间旅行 | 无 |
| 适用 | 登录态、主题、全局配置 | 编辑器、播放器、向导 |
一句话:全局一份用 Pinia,一树一份用 provide/inject。同页开两个编辑器还用 store,就得给 store 加 id 参数——那是在对抗工具。
8. 排错:六个现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 解构后模板不更新 | 从 reactive 解构丢响应性 | storeToRefs |
$reset is not a function |
setup 风格无内置重置 | 自己实现或 $patch 整体覆盖 |
| 刷新后字段是旧的 | 全量持久化残留旧结构 | 显式 pick + 版本键迁移 |
no active Pinia |
在 app.use(pinia) 前或模块顶层取 store |
移进函数体 |
| SSR 两个用户看到同一份数据 | createPinia() 写成模块单例 |
每请求新建 |
Cannot access 'useXStore' before initialization |
两 store 循环 import | 抽第三 store / 视图层编排 |
两条易忽略:store 里存 DOM 引用(SSR 无 document 直接崩,devtools 撞循环引用);watch(store.$state, ...) 是深监听,要具体字段就 watch(() => store.x, ...)。
9. 边界:本就不该用 Pinia
| 场景 | 更合适 | 理由 |
|---|---|---|
| 一层父子传值 | props + emit | store 引入全局耦合 |
| 服务端数据缓存 | 请求层缓存 + staleTime | 失效/竞态/重试已内置 |
| 每实例独立上下文 | provide/inject | 不必造 id 工厂 |
| 纯派生计算 | computed | getter 也是 computed |
| 跨标签页同步 | BroadcastChannel | 持久化插件不广播 |
| 表单草稿 | 组件内 ref / useLocalStorage | 草稿不必进时间线 |
Pinia 的定位是跨组件树、跨路由、可序列化的客户端状态容器。越界使用,每多用一分都在还债。
10. 自测
- 三条判定里哪一条最容易被忽略,忽略了会出什么事?
- setup 风格为什么没有
$reset(),要重置怎么写? storeToRefs为什么不处理 actions?- 两个 store 互相在 action 里调对方,运行时怎样、打包后可能怎样?
- SSR 下
createPinia()放模块顶层,具体串了哪一步的数据? - 同页两个编辑器实例,Pinia 与
provide/inject差在哪?