创建日期: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 store:
state/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. 最佳实践速查
- 一个文件一个 store,id 唯一;
- state 只放“共享且会变”,派生丢 getters,别在组件里重复算;
- 改 state 用直接赋值或 action,批量用
$patch函数形式; - setup store 里不想暴露的量放 closure,不进 return;
- 持久化/全局能力用插件,别在每个 store 各写一遍;
- 组件销毁相关订阅记得
stop()或detached; - SSR 每个请求新建 pinia;模块顶层不要
useStore(); - 类型白嫖:别手动给 store 形状写一堆 interface,除非确实复杂。
动手
- 给 auth store 接上真实请求并把
data.persist换成持久化插件,刷新页面验证登录态还在; - 写个 vitest:两个用例共享一个 action 但 beforeEach 隔离 store;
- 给项目加 HMR 三行,验证改 store 不丢 state。
自测
- 你项目的“每页都要的全局 loading”该放 store 还是组件?
- getter 适合“每次全新计算”的逻辑吗?为什么?
- SSR 为什么要每请求新建 pinia?模块顶层 useStore 有什么问题?
- 测试组件时该 mock 什么、不该 mock 什么?
acceptHMRUpdate是干嘛的?开发/生产都要吗?
下一篇:原理——Pinia 内部是怎么把一个 setup 函数变成一个 store 的。