三层真不算深,但筛选项这种"父层持有、子层读写"的状态,确实是最适合抽出来的一类。先说结论:先 provide/inject 封一个 composable,真要跨路由或者要持久化再换 Pinia,因为换了以后调用方一行都不用改。
先说那个"响应式容易丢"的说法,这多半是从 Vue2 时代传下来的。Vue3 里只要你 provide 的是 ref/reactive/computed,天然就是响应式的,坑在别的地方:
provide('filters', reactive({...}))没问题,但子组件里const { status } = inject('filters')就解构丢了——reactive 解构出来的是普通值。想省事就toRefs,或者干脆别解构,用filters.status。- 直接 provide 一个普通对象、普通字符串,那确实不响应,得包成 ref。
- 用 Symbol 当 key,别用字符串,不然重名了很难查,TS 也能有类型提示。
我的习惯是把 provide 和 inject 都收进一个文件,组件里只调 useFilters():
// useFilters.js
const KEY = Symbol('filters')
export function provideFilters() {
const filters = ref({ status: '', keyword: '' })
provide(KEY, {
filters: readonly(filters), // 只读,防止子组件偷偷改
setFilter: (k, v) => (filters.value[k] = v),
reset: () => (filters.value = { status: '', keyword: '' }),
})
}
export function useFilters() {
const ctx = inject(KEY)
if (!ctx) throw new Error('useFilters 必须在 provideFilters 的子树内使用')
return ctx
}
关键点是状态只读、改动走方法。很多人 provide 出去一个 reactive 直接让子组件随便改,短期爽,时间长了就变成隐式双向绑定,谁改的完全查不到,比 props 还难维护。
provide/inject 真正需要注意的就三条:组件一旦被挪到 provider 外面就报错(上面那个 throw 能帮你早点发现);它跟组件树生命周期绑死,路由切走了状态就没了,想缓存得自己想办法;还有就是它只对"后代"生效,兄弟组件之间传不了。
Pinia 的坑主要是另一个方向:
- 它是单例。同一个组件在一页里复用多次,状态就串味了,这种情况只能靠 provide/inject 做实例级隔离。
- 组件卸载了 store 还在,得手动 reset,不然下次进来还带着上次的筛选条件,这 bug 挺常见。
- 什么都往里塞,最后 store 变成大泥球,改一个字段不知道谁在响应。
所以判断标准挺简单:这个筛选条件只服务于当前这块组件树,路由一走就该清空 → provide/inject。它要被别的路由、侧边栏、导出功能读到,或者要同步到 URL、localStorage、要 devtools 看历史 → Pinia。
还有个容易被忽略的第三选项:把筛选条件同步到 route.query,表格直接读 route.query,改筛选就是 router.replace。好处是刷新不丢、能分享链接、浏览器后退能用,代价是频繁输入要防抖。做列表页的话这个其实最省心。
顺便说,这三层 props + emit 冒泡如果能忍,说明层级还浅;真到了"改一次崩一次"的程度,就是该抽 state 的信号了。先用 composable 包起来,将来迁 Pinia 只改内部实现,调用方感知不到,这是最稳的走法。