2562 字
约 8 分钟
15
S14-X03 · Pinia:Vue 3 状态管理的边界与模块拆分

难点不是"会不会写 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 类型 自动推导 自动

两条结论:

  1. $reset() 只在 options 风格存在,setup 里调会提示 only available on option stores,必须自己实现重置。
  2. 新项目选 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 水合钩子 版本迁移、字段校验

三条判断:

  1. 默认全量持久化危险:新增的每个字段都会进 localStorage,包括 loading、错误消息、大对象;改一次 store 就清一次用户缓存。永远显式 pick。
  2. 旧版 paths 已被 pick 取代(按 pinia-plugin-persistedstate 4.x 核对);升级后写 paths 会被静默忽略,这种失效最难发现。
  3. 持久化不是缓存:只保证刷新后还在,不管过期与一致性。要过期就存 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. 自测

  1. 三条判定里哪一条最容易被忽略,忽略了会出什么事?
  2. setup 风格为什么没有 $reset(),要重置怎么写?
  3. storeToRefs 为什么不处理 actions?
  4. 两个 store 互相在 action 里调对方,运行时怎样、打包后可能怎样?
  5. SSR 下 createPinia() 放模块顶层,具体串了哪一步的数据?
  6. 同页两个编辑器实例,Pinia 与 provide/inject 差在哪?
S14-X03 · Pinia:Vue 3 状态管理的边界与模块拆分
https://www.clxhxhhr.top/posts/5713/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。