跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本篇偏工程约定,建立在前三篇已验证的 API 之上。部分“建议”是社区共识,非 Pinia 强制;涉及 API 存在的说法以 pinia 4.0.3 为准。

Pinia 进阶与工程实战:组织、类型、SSR、测试、HMR

一句话:会了 API,还差“怎么摆才不乱”。这一篇讲大项目里 store 怎么组织、TypeScript 怎么白嫖推导、SSR/测试/HMR 各有什么坑,最后给一个 token 场景把前面全部串起来。

1. 目录与命名

src/
└─ stores/
├─ index.js # 汇总再导出,组件只 import 这一处
├─ user.js # 一个 store 一个文件
├─ cart.js
└─ ui.js

约定:文件 useXxxStore.js → 导出 useXxxStore;store id 与文件同名(defineStore('cart', …))。好处:搜索靠名字、devtools 里显示的也是名字

// stores/user.js
export const useUserStore = defineStore('user', () => {});
// stores/index.js
export * from './user';
export * from './cart';

2. 放什么进 store(别把 store 当垃圾桶)

一张决策表帮你划边界:

东西放哪理由
跨组件共享、会变store.state共享才需要全局
从 state 算出来getters(别在组件里重复算)一份逻辑、自动缓存
改 state 的操作store.actions让“怎么改”可测试、可审计
只属于一个组件的局部 UI组件本地 ref别全局化
每次都要重新算的“函数”store 外部工具函数或 action 内getter 是带缓存的,不适合“每次全新计算”

thin vs thick store:有人倾向“只存数据、逻辑全放组件”,有人倾向“连业务请求都封装进 action”。折中且好测的常见做法:请求放 action(这样“调 action → 改 state”可以脱离组件被测试),但不要在 action 里塞 UI 专属逻辑。

3. TypeScript:几乎不用手写类型

  • options storestate/getters/actions 全用函数 + 类型注解,Pinia 自动推导 store 形状;
  • setup store:返回值的类型就是 store 类型,computed/ref 的类型直接带上;
  • storeToRefs 保留类型,模板/组件里解构出来都是精确类型。

想要更稳,给复杂 state 显式声明接口,getters 与 actions 的 this 就会得到完整提示:

interface UserState { name: string; avatar: string; }
export const useUserStore = defineStore('user', {
state: (): UserState => ({ name: '', avatar: '' }),
getters: {
displayName: (s) => s.name || '匿名', // 自动推导 string
},
actions: {
set(p: Partial<UserState>) { Object.assign(this.$state, p); },
},
});

两个易错点:getters 里别把 state 解构成局部变量const { name } = state 会失去响应式,类型上也没暴露问题);要给 store 挂自定义属性(插件加的)需扩展 PiniaCustomProperties(见插件篇 §4)。

4. Options API 用户:map 全家桶还在

v4 仍导出 mapState / mapStores / mapGetters / mapActions / mapWritableState<script setup> 时代不常用,但维护旧代码/写 Options 组件时能救急:

import { mapStores, mapState, mapActions } from 'pinia';
export default {
computed: {
...mapStores(useCartStore), // 注入 this.cartStore
...mapState(useCartStore, ['count', 'total']),
},
methods: { ...mapActions(useCartStore, ['add', 'submit']) },
};

5. SSR:每次请求一个干净的 pinia

核心坑:store 是单例。Node 服务端多个请求并发时,如果模块顶层只建一个 pinia/store,请求 A 的登录态会串到请求 B。正确做法是每个请求新建一份

// 服务端每次请求(伪代码)
const pinia = createPinia();
app.use(pinia); // 等价于把这一份装进本次请求的 app
const user = useUserStore(); // 用“当前请求”的这份
  • 顶层(模块作用域)别在导入时 useStore()——那时还没有 active pinia / 会被所有请求共享;
  • setup store 与 SSR 的水合:服务端渲染出的 state 序列化后给客户端,客户端需 pinia.state.value = JSON.parse(piniaState) 回填。setup store 里「每次都会重算的 derived」不需要持久化(它们会自动从 state 重算);若 setup store 的某些 ref 服务端已算好且想保留,v4 有 skipHydrate(ref) / shouldHydrate(ref) 标记,一般不用碰。

6. 测试:三行起手

store 是纯逻辑,最好测。单元测 store

import { beforeEach, describe, expect, it } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useCartStore } from '@/stores/cart';

describe('cart', () => {
beforeEach(() => { setActivePinia(createPinia()); }); // 每个用例一份干净 store

it('满 100 自动减 10', async () => {
const cart = useCartStore();
cart.add('a', 60); cart.add('b', 60);
expect(await cart.submit()).toBe(110); // 120 - 10
});
});

测试组件里用的 store:渲染时往 app 装一个真 pinia,再往里面预置 store(useStore().$patch({…}) 或直接在 defineStore 前 override),避免依赖真实网络。

别在测试里 mock pinia 本身;mock 你的 action 里的 fetch/api 层即可。

7. HMR:开发时换文件不丢状态

Vite dev 下热更会重建 store 定义导致旧 state 丢失,Pinia 提供 acceptHMRUpdate

// stores/cart.js 文件底部
if (import.meta.hot) {
import.meta.hot.accept(import.meta.url, () => {
import.meta.hot.invalidate();
acceptHMRUpdate(useCartStore, import.meta.hot);
});
}

8. 综合场景:登录态 store(串起全部)

// stores/auth.js —— options + 持久化插件 + 无组件使用 三者合一
import { defineStore } from 'pinia';

export const useAuthStore = defineStore('auth', {
// 下面这行是给 [插件篇](/docs/code/pinia/解构订阅插件) 的持久化插件看的“暗号”
data: { persist: ['token'] },
state: () => ({ token: '', user: null }),
getters: {
isLoggedIn: (s) => !!s.token,
displayName: (s) => s.user?.name ?? '游客',
},
actions: {
async login(username, password) {
// const { data } = await api.post('/login', { username, password }); // 真实请求
const data = { token: 'jwt-…', user: { name: username } }; // 示意
this.token = data.token;
this.user = data.user;
},
logout() {
this.token = '';
this.user = null;
},
},
});

路由守卫里用(无组件环境也能用——app.use(pinia) 后 active pinia 已就绪):

// router/index.js
router.beforeEach((to) => {
const auth = useAuthStore(); // 这里没有组件,照样能用
if (to.meta.requiresAuth && !auth.isLoggedIn) return '/login';
});

跑起来之后的完整链路:login action 改 state → 持久化插件 $subscribe 落 localStorage → 刷新页面插件水合回 token → 守卫用 isLoggedIn 放行——前面每一篇的 API 都在这一个小场景里。

9. 最佳实践速查

  1. 一个文件一个 store,id 唯一;
  2. state 只放“共享且会变”,派生丢 getters,别在组件里重复算;
  3. 改 state 用直接赋值或 action,批量用 $patch 函数形式;
  4. setup store 里不想暴露的量放 closure,不进 return;
  5. 持久化/全局能力用插件,别在每个 store 各写一遍;
  6. 组件销毁相关订阅记得 stop()detached
  7. SSR 每个请求新建 pinia;模块顶层不要 useStore()
  8. 类型白嫖:别手动给 store 形状写一堆 interface,除非确实复杂。

动手

  1. 给 auth store 接上真实请求并把 data.persist 换成持久化插件,刷新页面验证登录态还在;
  2. 写个 vitest:两个用例共享一个 action 但 beforeEach 隔离 store;
  3. 给项目加 HMR 三行,验证改 store 不丢 state。

自测

  1. 你项目的“每页都要的全局 loading”该放 store 还是组件?
  2. getter 适合“每次全新计算”的逻辑吗?为什么?
  3. SSR 为什么要每请求新建 pinia?模块顶层 useStore 有什么问题?
  4. 测试组件时该 mock 什么、不该 mock 什么?
  5. acceptHMRUpdate 是干嘛的?开发/生产都要吗?

下一篇:原理——Pinia 内部是怎么把一个 setup 函数变成一个 store 的。