这个问题我第一反应和大多数人不太一样:先别急着在两个工具里挑,这份数据的"户口"在哪,决定了你能挑哪个。
去年重构一个订单后台时我也卡在这,最后一层用的是 provide/inject,但包了一层"页面上下文",理由和坑跟常见说法有点出入,记下来供你参考。
一、先分清它是"页面的"还是"应用的"
管理后台里数据大致能分三类,走的路完全不同:
- 跨路由要活的:当前登录人、权限、组织/租户、字典枚举。这类必进 Pinia,没得商量。
- 页面内的一次性数据:列表接口回来的数据、筛选条件、分页。页面一走它就该消失。
- 表单草稿、临时选中项:通常连页面级共享都不需要,就近放本地。
你说的"最外层拿到接口数据传给第四层",八成是第二类。这类数据一旦进 Pinia,你后面要额外处理一个很烦的事——残留。
我见过最多的 bug 就是:筛选条件放进了 store,用户从列表页点进详情再退回来,筛选还留着,他会以为是数据错了。所以真要走 Pinia,你得同时在路由离开时写 reset,等于给自己加了一份维护成本。而 provide/inject 天然跟着组件树走,组件卸载,数据自己就没了,这一点在"页面级状态"上反而更省心。
二、inject 有个边界容易被忽略:它只往下,不横跨
同一个 provide 会覆盖整棵子树,所以"爷爷下面两个不同分支的兄弟组件"都能拿到,这点没问题。
但要注意另一种情况:如果第四层那个组件是通用组件——比如你们项目里封装好的 BaseTable、字典选择器、审批流组件——它可能被十几个页面复用。这种组件一旦在里面写 inject,就等于绑死了"必须有外层 provide 才能跑",别的页面拿过去直接报错或空白,可复用性就废了。
判断起来很简单,问一句:这个第四层组件,是不是只服务于当前这个页面?
- 是 → inject 没问题
- 不是 → 老老实实走 props(哪怕深一点),或者走 store
三、比起裸 provide,更推荐封一个"页面上下文"
裸 provide('xxx', ref(...)) 最大的问题是来源隐式:新人打开第四层只看到 inject,不知道谁给的。用工厂函数 + TypeScript 的 InjectionKey 收口一下,会好很多:
// contexts/orderContext.ts
import { inject, provide, type InjectionKey, type Ref } from 'vue'
interface OrderContext {
filters: Readonly<Ref<OrderFilters>> // 只读,避免谁都能改
keyword: Readonly<Ref<string>>
setKeyword: (v: string) => void
reset: () => void
}
const key: InjectionKey<OrderContext> = Symbol('order-context')
export function provideOrderContext(ctx: OrderContext) {
provide(key, ctx)
}
export function useOrderContext() {
const ctx = inject(key)
if (!ctx) throw new Error('useOrderContext 必须在 OrderPage 组件树内使用')
return ctx
}
好处是 provide 和 inject 成对出现,搜一下函数名就知道数据从哪来;TS 也能直接推断类型,不用写 as。
这里有一点和常见建议不太一样:不是所有情况都适合给 inject 的第二个参数兜底默认值。像上面这种"必须有"的上下文,我宁愿它直接抛错。因为默认值会把"某条分支忘了 provide"这种低级错误藏起来,最后表现成"数据一直不更新",查起来比报错难受多了。只有在你确定这个上下文本身就是可选的时候,才给默认值。
四、Pinia 在这个场景的真实代价,不在性能,在"谁都能改"
很多人担心响应式性能,其实 props 和 inject 的开销都极小,不用纠结。Pinia 真正的成本是:
- 改的来源容易散。store 一旦建好,半年后谁都在里面写一笔,出问题得翻 action 时间线。
- 多标签页会互相覆盖。同一个页面开两个 Tab 编辑不同记录,共享单例的 store 会串。
- 页面级状态进 store 会稀释它的价值。store 最后变成杂物间。
当然它也有不可替代的好处:devtools 能清清楚楚看"什么时候、被哪个 action 改的",排查跨页面状态问题确实比 inject 舒服。
五、我现在的实际组合方式
不是二选一,是分层:
- 跨路由的(用户、权限、字典)→ Pinia
- 页面内的(列表数据、筛选、分页)→
provideOrderContext 这类页面上下文,页面卸载自动清
- 只读的部分 → 传
Readonly<Ref<T>> 或者只读的 computed,改的动作由页面自己管,通过 setKeyword 这类方法暴露
- 第四层只是纯展示 → 甚至可以让它只 inject 数据,交互动作往上抛
最后给你一个三问清单,问完基本就有答案了:
- 路由切走之后,这份数据还需要吗?需要 → Pinia。
- 第四层那个组件会被别的页面复用吗?会 → 别用 inject。
- 除了往下发,还有别的地方要改它吗?要 → 把改动动作一起放进上下文,别把裸对象扔下去。
按你描述的情况——接口数据、只在当前页面、第四层专用——先用封装好的 provide/inject 是对的,等哪天你发现详情页也要用同一份数据了,再把它迁到 Pinia,那时候也来得及,成本并不高。