前阵子把某后台系统的状态层从 Vuex 迁到 Pinia,顺手把源码翻了一遍。说实话,Pinia状态管理原理并没有想象中那么玄:它百分之八十的工作,是在把一个用响应式 API 拼出来的对象,安全地挂在组件实例上,然后让你在任何地方都能拿到同一个引用。这篇内容想把这条链路彻底讲清楚,从 Vue 的响应式核心,到 createPinia 和 defineStore 的源码实现,再到日常开发里到底该怎么用。适合正在用 Pinia 但感觉“只会调 API”的开发者,也适合准备深入源码但不知道从哪下手的同学。
1. Pinia 到底解决了什么问题
1.1 状态管理的本质是什么
状态管理的本质,不是“多了一个全局变量”,而是把多个组件共享的数据从组件树里抽出来,放到一个统一的地方去读写。先想想没有状态管理时会遇到什么:一个用户登录信息,页面头部要用,侧边栏要用,接口请求模块也要用。传统做法要么 props 层层往下传,要么直接在模块里导出一个对象。props 传参一旦超过两层就很痛苦,组件一多,改一个字段要顺着一整条组件链去找。直接导出一个对象虽然简单,但没有任何约束,A 组件改了,B 组件不知道什么时候改的,也不知道去哪里排查。
Pinia 做的事,就是把这层约束补上。它把每个状态仓库称为 store,让 state、getters、actions 集中在一个地方。组件只能通过 store 去读写状态,所有修改动作都有迹可循。你可以把它想象成中央厨房:每个组件是档口,需要什么配料就去中央仓库领,而不是自己偷偷在后厨开一个小灶。这样做的好处是数据流变得清晰,调试时能快速定位到“是哪个 action 改了这份数据”,多人协作时也不必担心别人直接动你的全局变量。
1.2 从 Vuex 到 Pinia:少了什么,又多了什么
如果你用过 Vuex,应该对这套流程很熟:组件 dispatch action,action 里 commit mutation,mutation 里再去修改 state。双层拆分在大型项目里确实能约束数据流,但也带来了一个问题:模板代码太多。一个简单的“用户改名”操作,要在 action 和 mutation 里各写一遍逻辑,而且中途还要纠结“我这段逻辑到底该放 action 还是 mutation”。TypeScript 支持也比较费劲,getters、mutations、state 之间的类型推导经常要靠手工声明。
Pinia 直接砍掉了 mutation 这一层,action 里可以随便修改 state。这个改变不是因为偷懒,而是因为 Vue 3 的响应式系统已经足够成熟,配合开发者工具,直接修改 state 的每一步都会被记录下来,再去单独包一层 mutation 显得多余。同时 Pinia 完整拥抱了 Composition API,支持 setup 语法写 store,状态、计算属性、方法可以像写普通组件逻辑一样放在一个函数里,代码体量小了一圈。
Vuex 4 在 Vue 3 里也能用,但 Pinia 现在已经是 Vue 官方推荐的状态管理方案。不是说你必须从 Vuex 迁过去,而是新项目如果从零开始,没有特殊历史包袱的话,用 Pinia 的成本和体验都会更好。
1.3 整体架构:三个核心模块
Pinia 的源码按职责可以分成三块。第一块是createPinia,负责创建整个应用共享的 pinia 实例,同时承担安装进 Vue 应用的工作。第二块是defineStore,它接收 id 和配置,返回一个useStore函数。第三块是useStore,负责从 pinia 实例上取 store,取不到时就现场创建。
理解这套拆分很重要。Pinia 不是把所有逻辑塞在一个大类里,而是把一个 store 的生命周期拆成“定义”、“创建”、“获取”三个阶段。定义阶段只描述 store 长什么样,创建阶段真正执行状态初始化,获取阶段负责返回同一个实例。你平时写const store = useUserStore(),看上去是在“调用一个函数”,本质上是在“取出一个已经被创建过的 store”,如果没有被创建,就先创建再取出。后面看源码时,你会反复看到这条规则。
2. 响应式核心:Pinia 的地基是谁给的
2.1 ref、reactive、computed 在 Pinia 里各自的分工
Pinia 没有自己写一套响应式系统,它的所有响应式能力都来自 Vue 3 的ref、reactive、computed。在 options store 里,state 会转成reactive包裹的响应式对象,getters 会转成computed,actions 就是普通函数。在 setup store 里,你直接用这些 API 组织状态,Pinia 再负责把你的返回值变成一个可用的 store。
举个例子,用 setup 语法写一个计数 store:
export const useCounterStore = defineStore('counter', () => { const count = ref(0) const double = computed(() => count.value * 2) function increment() { count.value++ } return { count, double, increment } })这里count是一个 ref,double是一个 computed,increment是一个普通函数。Pinia 拿到这个setup函数的返回对象后,会把 ref 和 computed 包装进一个响应式 store 对象里,把函数作为 action 挂上去。你在组件里访问store.count,其实访问的是经过响应式代理后的 ref 值,所以不需要写store.count.value。这个“自动解包”的体验来自 Vue 的响应式对象特性,不是 Pinia 额外做了一层魔法。
2.2 为什么 Pinia 不去自己实现响应式
有些人会好奇,Pinia 作为一个状态管理库,为什么不搞一套自己的依赖收集系统,这样还能摆脱 Vue 单独用。这个问题的答案很简单:Pinia 的目标是做好状态管理,不是再造一个 Vue。Vue 3 的响应式已经经过了大量实战验证,Proxy 拦截、依赖收集、触发更新、effect scope,这些底层能力又稳又全,重新实现一遍既没有必要,也很难做到更优。
更深一层,Pinia 复用 Vue 响应式还能保证类型推导的一致性。你在组件里写ref,到了 store 里还是ref,在组件里用computed,到了 store 里还是computed。整个技术栈的心智模型是统一的,不需要“在组件里这样写、在 store 里那样写”。这也是为什么 setup store 用起来比 options store 更自然,因为它和组件内的写法几乎一模一样。
2.3 解构丢响应式与 storeToRefs 的源码级解释
很多新手都会踩同一个坑:在组件里把 store 解构出来,发现页面不更新了。
const { count, increment } = useCounterStore()这里count是一个普通值,Pinia 返回的 store 是 Vue 响应式对象,解构时只是把当前count的值取出来给了你,你和 store 之间的响应式连接就断了。这不是 Pinia 的 bug,而是 JavaScript 解构本身就干这事:取出值,不保留引用关系。
Pinia 为此提供了storeToRefs,它专门把 store 里的响应式字段提取成 ref,这样解构之后依然保留连接。
import { storeToRefs } from 'pinia' const { count, double } = storeToRefs(useCounterStore()) const { increment } = useCounterStore()storeToRefs的实现思路很直接:遍历 store 的所有属性,凡是isRef判断为 true 的,就用toRef包一层取出来。函数不是响应式数据,不需要这种处理,所以直接解构函数完全没问题。我现在写代码时有个习惯:所有 state 和 getters 一律通过storeToRefs解构,所有 methods 直接从 store 拿。这个习惯已经帮我避掉了不少“页面不刷新”的排查时间。
3. 源码实现拆解:createPinia 和 defineStore 做了什么
3.1 createPinia:安装、注入、Store 容器
进入源码环节。先看createPinia,它负责创建整个应用的唯一实例。
export function createPinia() { const scope = effectScope(true) const state = scope.run(() => ref({})) const pinia = { _a: null, _e: scope, _s: new Map(), _p: [], state, } return pinia }_s是 store 容器,一个 Map,key 是 store 的 id,value 是对应的响应式 store 对象。_e是 effect scope,后面所有 watch、computed 的副作用都归它管。state是一个 ref 包裹的全局对象,里面按 store id 存放每个 store 的 state。
当应用执行app.use(pinia)时,Pinia 会调用 install 方法,干三件事:把 pinia 实例标记为当前激活实例,通过app.provide把它注入到整棵组件树,同时在全局属性上挂一个$pinia。后面组件内调用useStore时,靠的是inject拿到同一个 pinia 实例。如果在组件外调用useStore,取不到注入值,就会退回去用当前激活的 pinia 实例。
这里有一句话值得记住:Pinia 实例不是类实例,就是一个普通对象,但因为这个对象通过 Vue 的 provide/inject 机制在组件树里共享,所以它在整个应用范围内保持了单例性。
3.2 defineStore:一个入口,两条生产路线
defineStore的入参有两种写法,第一种是 id 加配置对象,第二种是 id 加 setup 函数。Pinia 内部会先处理参数,判断用户给的是 options store 还是 setup store。
对于 options store,Pinia 的处理逻辑是:把state用reactive包装成响应式对象,把getters里的每一项用computed包一层,把actions里的函数直接绑定到 store 上。所以 options store 本质上是在内部帮你做了一层“组件式写法到组合式写法的转换”。
对于 setup store,Pinia 直接调用 setup 函数,拿到返回值之后再加工成 store。加工过程不是简单地把返回对象拿过来,而是要做属性分类:ref 和 computed 会作为响应式状态挂到 store 上,普通函数会作为 action 挂上去。这一步相当关键,它决定了你在 store 里写的ref在组件里能够以store.xxx形式直接访问。
3.3 useStore:缓存命中、实例获取与组件绑定
defineStore执行后返回一个useStore函数,这个函数才是组件里真正调用的东西。它的执行流程可以拆成四步。
第一步,判断当前调用环境。通过getCurrentInstance()查看是否存在组件实例。如果存在,就通过inject(piniaSymbol, null)从组件实例上取 pinia;如果不存在,说明是在普通函数或模块里调用,那就直接使用全局的 activePinia。
第二步,确保 pinia 存在。如果既没有注入,全局也没有激活实例,就会抛出“getActivePinia was called with no active Pinia”这类错误。这通常意味着在app.use(pinia)之前就调用了useStore。
第三步,检查_s缓存。如果这个 id 对应的 store 已经创建过了,直接返回缓存里的响应式对象。如果没有,就现场创建,创建完存进_s。
第四步,返回 store。
这里最容易被忽略的是“缓存机制”。因为useStore每次返回的都是同一个对象,所以你在两个组件里同时调用useCounterStore(),拿到的是同一个引用,一个组件改了count,另一个组件才能感知到。如果 Pinia 每次调用都新建一个 store,那状态管理就无从谈起了。
3.4 setup Store 的构造过程
setup store 的构造比 options store 复杂一些。核心思路是:创建一个新的 effect scope,在 scope 里执行 setup 函数,拿到返回的对象,然后把对象里的字段按类型拆分并重新组装成响应式 store。
为了帮助理解,我写一个简化版的处理流程:
function createSetupStore(id, setup, pinia) { const scope = effectScope() const setupStore = scope.run(() => setup()) const store = reactive({}) for (const key in setupStore) { const prop = setupStore[key] if (typeof prop === 'function') { store[key] = prop } else if (isRef(prop) || isComputed(prop)) { store[key] = toRef(setupStore, key) } else { store[key] = prop } } pinia._s.set(id, store) return store }实际源码里还有很多边界处理,比如对 reactive 对象要单独存到pinia.state里统一管理,比如要给 store 挂上$id、$patch、$subscribe这些内置方法。但从整体理解来看,这个简化版的骨架是准确的。
值得注意的一点是 effect scope 的作用。effectScope()会把 setup 里创建的所有 ref、computed、watch 副作用收拢到一起,后续如果要销毁整个 store,只需要调用scope.stop(),就能把里面所有副作用统一停掉。这种“作用域管理”能力是响应式系统里的一个重要武器,Pinia 用得很彻底。
4. 核心机制拆解:$patch、$subscribe、$onAction 的底层逻辑
4.1 $patch:为什么比多次赋值更快
$patch是 Pinia 提供的一个批量修改方法,接收对象或函数两种形式。
// 对象形式 store.$patch({ count: 10, name: '张三' }) // 函数形式 store.$patch((state) => { state.count = 10 state.name = '张三' })用$patch的好处主要有两个。第一是代码可读性,多个字段一起改的时候,$patch把修改意图集中在一个地方,比到处store.count = 10、store.name = '张三'清晰。第二是性能,因为 Vue 的响应式更新是异步批量调度的,多个同步修改会在同一个 tick 里合并成一次组件更新,配合$patch对象的递归合并逻辑,可以避免中间状态的多次渲染。
从源码角度看,$patch内部先判断参数是对象还是函数。对象形式会做一层递归合并,把传入的 partial state 合并到当前 state 上;函数形式则直接执行函数,把 store 的 state 传进去。整个合并过程包在 effect scope 里执行,结束后会通知所有订阅者,触发一次订阅回调。注意,不管是对象形式还是函数形式,多个字段的修改在订阅回调里通常只出现一次,不用自己手动做防抖。
4.2 $subscribe:基于 watch 的订阅器
$subscribe的底层实现是 Vue 的watch。它可以监听 store state 的变化,在每次状态变更后执行回调。
store.$subscribe((mutation, state) => { console.log('state changed:', mutation.type) }, { detached: true })回调的第一个参数里能拿到 mutation 信息,比如是不是通过$patch触发的。第二个参数是完整的 state。第二个参数 options 里有一个非常重要的配置:detached。默认情况下,$subscribe的订阅会绑定在当前调用环境上,如果是在组件里调用的,组件卸载时订阅也会被自动清除。但有些场景,比如想把状态同步到 localStorage,你可能希望它脱离组件生命周期,一直持续订阅,这时候就设置detached: true。
这个默认行为很容易被忽视。很多人把$subscribe写进 setup 函数,结果发现组件一销毁,持久化逻辑就不跑了,排查半天才发现是 detached 的问题。我的建议是:和 UI 相关的订阅,保持默认;和全局副作用相关的订阅,主动设置detached: true。
4.3 $onAction:围绕 action 的生命周期
$onAction是 Pinia 提供的一套监听 action 调用的钩子,适合做埋点、权限校验、loading 处理。用法是:
const unsubscribe = store.$onAction(({ name, args, after, onError }) => { console.log(`action ${name} called`, args) after((result) => { console.log(`action ${name} done`, result) }) onError((error) => { console.error(`action ${name} failed`, error) }) })从实现角度理解,Pinia 在创建 action 时并不会直接暴露原始函数,而是包了一层。真正执行 action 前,会先依次调用before回调,如果有回调通过after注册了成功处理,就在 action 返回后执行;如果有回调通过onError注册了错误处理,就在 action 抛错时执行。
这里有个容易踩的坑:如果 action 是异步函数,after回调是在 Promise resolve 之后才执行,是异步的,不是同步的。你如果想在 action 完成之后马上拿到结果做一些依赖后续的操作,可以把逻辑放进after回调里,而不是写在$onAction的同步部分后面。同步部分只能拿到“action 开始执行了”这个信号。
5. 动手实现一个 mini Pinia
5.1 最小核心:createPinia + defineStore
光看源码不如自己写一遍。为了让原理落地,我基于 Vue 3 的响应式 API 写了一个教学版 mini Pinia,去掉了很多边界处理,只保留核心链路。
import { reactive, effectScope, getCurrentInstance, inject, toRef, isRef } from 'vue' const piniaSymbol = Symbol('pinia') let activePinia = null export function createPinia() { const pinia = { _s: new Map(), _e: effectScope(true), install(app) { activePinia = pinia app.provide(piniaSymbol, pinia) }, } return pinia } export function defineStore(id, setup) { return function useStore() { const instance = getCurrentInstance() const pinia = instance ? inject(piniaSymbol, null) : null const targetPinia = pinia || activePinia if (!targetPinia) { throw new Error('Pinia is not installed') } if (!targetPinia._s.has(id)) { const scope = effectScope() const rawStore = scope.run(() => setup()) || {} const store = reactive({}) for (const key in rawStore) { const prop = rawStore[key] if (typeof prop === 'function') { store[key] = prop } else if (isRef(prop)) { store[key] = toRef(rawStore, key) } else { store[key] = prop } } store.$id = id targetPinia._s.set(id, store) } return targetPinia._s.get(id) } }这段代码只有几十行,却已经能支撑“一个 store 在多个组件之间共享”的核心能力。我特别讲一下为什么要把 ref 用toRef再包一层:如果不包,state 原始值直接放进 store,组件访问store.count得到的是一个 ref 对象,写起来得写.value;用toRef包过之后,配合reactive的对象属性自动解包特性,组件里就能直接访问值,这正好对应 Pinia 真实的使用体验。
5.2 给 mini 版补上 $patch 和 $subscribe
有了核心骨架之后,再补两个常用方法。$patch负责批量更新,$subscribe负责订阅变更。为了保持代码简短,这里用依赖订阅器来模拟 Vue 的 watch。
function createStore(id, setup, pinia) { const subscribers = new Set() const scope = effectScope() const rawStore = scope.run(() => setup()) || {} const store = reactive({}) for (const key in rawStore) { const prop = rawStore[key] if (typeof prop === 'function') { store[key] = prop } else if (isRef(prop)) { store[key] = toRef(rawStore, key) } else { store[key] = prop } } store.$id = id store.$patch = (partial) => { if (typeof partial === 'function') { partial(store) } else { Object.assign(store, partial) } subscribers.forEach((cb) => cb({ type: 'patch' }, store)) } store.$subscribe = (cb) => { subscribers.add(cb) return () => subscribers.delete(cb) } return store }真实 Pinia 的$patch对象形式会做递归合并,数组、嵌套对象都会按层级合并;函数形式则在 effect scope 中执行。我这里用Object.assign是为了展示主流程,实际项目里直接使用 Pinia 自身的实现就好,不必自己在业务里再造轮子。
写这一段最想传达的是:Pinia 的源码并不神秘。它就是把响应式对象包装一下,再塞进一个全局 Map,然后通过注入机制共享出去。你能基于 Vue 响应式 API 写出一个极简可用版本,就说明你已经理解了它的核心。
5.3 用 mini 版实现一个登录状态模块
拿前面的 mini Pinia 写一个登录模块试试:
import { defineStore } from './mini-pinia' import { ref, computed } from 'vue' export const useAuthStore = defineStore('auth', () => { const token = ref('') const userInfo = ref(null) const isLogin = computed(() => !!token.value) async function login(username, password) { // const { data } = await request.post('/api/login', { username, password }) const data = { token: 'mock-token', userInfo: { name: username } } token.value = data.token userInfo.value = data.userInfo } function logout() { token.value = '' userInfo.value = null } return { token, userInfo, isLogin, login, logout } })在组件里使用:
import { useAuthStore } from '@/stores/auth' import { storeToRefs } from 'pinia' const authStore = useAuthStore() const { token, userInfo, isLogin } = storeToRefs(authStore) function onLogin() { await authStore.login('admin', '123456') }这段代码和真实 Pinia 的用法完全一致,原因就是 mini 版保留了 Pinia 的核心设计。你可以把这段代码贴到项目里跑一遍,再跑一遍真实 Pinia,对比一下行为差异,很快就能理解源码里那些边界处理到底在解决什么问题。
6. 项目实战:某后台系统的 Pinia 落地清单
6.1 模块拆分与目录结构
Pinia 的 store 可以拆得很散,也可以集中管理。我的做法是:每个业务域一个独立文件,放在src/stores/modules/下面,然后在src/stores/index.ts里统一导出。目录大概长这样:
src/stores/ index.ts modules/ user.ts app.ts order.ts settings.ts每个文件内部保持单一职责。比如user.ts只管登录用户信息、权限、用户偏好;order.ts只管订单列表、当前订单详情、订单操作。不要试图建一个巨大的global.ts把所有东西塞进去,否则 Store 又会变成以前的“全局对象灾难”。
模块之间需要共享数据时,直接在 store 内部调用另一个 store 的方法是允许的。比如订单模块要显示当前操作人,可以在 order action 里调用useUserStore()拿到用户信息。这种跨 store 通信在 Pinia 里很自然,因为useStore本身就是普通函数,不一定非要在组件里调用。
6.2 组合式写法还是选项式写法
Pinia 同时支持两种写法,我的建议是优先组合式。组合式 setup store 的代码更短,类型推导更好,迁移组件逻辑也更顺手。选项式 store 的优点是比较结构化,state、getters、actions 分区清晰,老 Vuex 用户过渡起来更容易。
不要在一个项目里混用两种风格。混用会导致团队成员每次看文件时都要先辨认这是哪种写法,心智负担翻倍。如果团队刚上手 Pinia,倾向于选一种,定下来,写进规范。
另外有一条不是必须、但我强烈建议的规范:把返回对象里的函数名和业务动词对齐。login、logout、fetchOrderList、resetOrder,一眼就能看出这个 action 干什么。千万不要在 store 里写一堆setData、updateInfo这种泛化名称,命名一旦失去表意能力,store 就退化成普通对象了。
6.3 持久化、重置与副作用管理
持久化是状态管理绕不开的话题。手写方案很简单,在 store 定义后面加一个$subscribe,把关键状态同步到 localStorage:
if (import.meta.env.MODE !== 'test') { authStore.$subscribe( (mutation, state) => { localStorage.setItem('auth', JSON.stringify({ token: state.token, userInfo: state.userInfo, })) }, { detached: true } ) }注意不要直接存整个 userInfo,如果里面有敏感字段或者方法引用,会出问题。最好做一个摘取函数,只留下需要持久化的字段。回填的时候也要注意,Pinia 的 store 初始化是同步的,如果你在模块顶层同步读取 localStorage 再回填,通常在 store 创建之前就能拿到数据,不会遇到异步竞态。
关于 reset,Pinia 对 options store 提供$reset,对 setup store 不提供。因为 setup store 的初始状态就是 setup 函数里定义的,你完全可以自己实现一个 reset 方法:
export const useAuthStore = defineStore('auth', () => { const token = ref('') const userInfo = ref(null) function reset() { token.value = '' userInfo.value = null } return { token, userInfo, reset } })本质上就是把 setup 函数里创建状态的逻辑再执行一遍。不要指望内置方法能覆盖所有组合式场景,自己包一层反而更可控。
7. 常见问题与排查技巧
7.1 解构之后不响应
这个问题在上面已经讲过,但值得单独拿出来强调,因为它是出现频率最高的问题。记住一套黄金法则:状态和计算属性用storeToRefs解构,方法直接解构。不要对 store 本身做整体解构,也不要试图用Object.assign把 store 拍平,所有“把一个响应式对象拆成普通变量”的操作都会破坏响应式连接。
如果已经写了不必要的解构,排查方法也很简单:看一下页面里读取的数据是不是有一层.value或者根本不是响应式的。最快的修复方式是直接把组件模板里的count改成store.count,然后重新跑一遍。
7.2 在组件外使用 store 报错
普通函数里调用useStore是可以的,但有个前置条件:pinia 已经完成安装。如果你在模块顶层直接写:
const store = useAuthStore()而这个模块在应用启动初期就被引入,很可能在app.use(pinia)之前就执行了,这时候 activePinia 还没设置,inject也拿不到值,就会报错。
规范做法是:模块顶层不执行useStore,只在函数内部或组件 setup 中调用。工具函数可以接收 store 作为参数,也可以自己内部调用useStore,但一定要保证调用时机在应用初始化之后。
7.3 跨 store 循环依赖
A store 在 setup 阶段用了 B store,B store 在 setup 阶段又用了 A store,就会循环初始化,严重时直接栈溢出。解决办法是打破初始化时的依赖,只有在 action 执行时才去获取另一个 store。比如:
// 错误示范:模块初始化时就互相引用 const userStore = useUserStore() // 正确示范:在 action 内部按需获取 function submitOrder() { const userStore = useUserStore() // ... }把useStore调用推迟到 action 执行阶段,可以绕开初始化时的循环依赖。这个原则同样适用于模块之间的相互导入,尽量让依赖方向变单向,实在需要双向的,就靠“运行时获取”解决。
7.4 $subscribe 回调触发时机不对
$subscribe默认基于 Vue 的 watch,回调触发时机是异步的,一般在状态变更后的下一个 tick。如果在一个事件循环里连续修改多个 state,可能合并成一次回调,也可能多次触发,这取决于修改方式和 Vue 的调度策略。不要依赖回调执行的次数,只依赖回调接收到的 state 快照。
另外,$subscribe默认会在组件卸载时停止。如果你在组件里订阅,组件销毁后就不该继续执行,这是合理行为。但如果你期望一个订阅和应用同生命周期,一定要加detached: true。
7.5 异步 action 的错误处理
Pinia 不限制 action 内部是否异步,也不限制是否 catch 异常。这意味着未捕获的 Promise rejection 会静默发生。我的习惯是,所有 async action 都返回 Promise,调用方用 await 接收,然后配合$onAction的onError做兜底上报。这样既能获得统一的错误处理,也不影响具体页面自己 catch 局部异常。
比如在某个订单页里,用户点击提交后,我先在 action 内部 catch 业务错误并返回统一结构,再在组件层根据返回值决定展示什么提示。两层配合,比全堆在组件里或者全堆在 action 里都要稳。
最后再分享一点我的体会
翻完 Pinia 源码之后,我最大的感受是:它不是一个“高深”的状态管理框架,恰恰相反,它把 Vue 3 的响应式能力包装得极其克制。理解它的最好方式不是背 API,而是顺着 createPinia、defineStore、useStore 这条线,把它拆开看一遍,再自己动手写一个 mini 版,很多之前觉得模糊的点会瞬间清晰。我自己的落地约定也很简单:只允许组件和 action 里使用 store,所有业务工具函数通过参数接收 store;setup store 优先;异步 action 必须返回 Promise;跨 store 依赖一律放在 action 内部按需获取。这些约定它们每一个都能在 Pinia 的源码机制里找到依据,这才是真正把状态管理原理用到了实处。