边界:何时用、何时不用
Provide/Inject 与跨层通信 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
provide/inject 用起来太顺手,很容易变成「什么数据都往里塞」的黑板。这一节建立判断标准:什么时候该用 props、什么时候 provide/inject、什么时候必须上 Pinia,并掌握「注入契约」的写法与可测试性设计。
问题场景
一个抽屉组件里放了表单,表单里放了日期选择器。日期选择器要读抽屉的尺寸做定位,抽屉要读页面的滚动锁状态。有人用 provide 把「页面状态」全塞进去,结果三个月后没人说得出这些数据从哪来——组件脱离这个页面就报错,测试时得手动搭一整棵祖先树。
核心解释
三种通信方式的定位:
| 方式 | 方向 | 适用 |
|---|---|---|
| props / emits | 父子 | 明确的父子关系,数据流可追踪 |
| provide / inject | 祖先 → 任意后代 | 跨多层、主题/配置/插槽上下文这类「环境型」依赖 |
| Pinia | 任意组件间 | 应用级状态、需要集中管理/调试/持久化 |
判断口诀:能 props 就 props;环境型依赖用 inject;业务状态用 Pinia。provide/inject 的本质是「依赖注入」,它解决的是「深层组件需要上层环境」,而不是「两个无关组件要共享数据」。后者用 Pinia,因为集中管理、可调试、可持久化。
注入契约要显式:无论用字符串还是 Symbol,注入方都应该在「没有提供者」时有一个明确行为——要么有默认值,要么抛出清晰的错误,而不是让后续代码炸在 undefined.xxx 上:
// 健壮的注入封装,把「找不到」变成可读错误
import { inject, type InjectionKey, type Ref } from "vue";
export const drawerKey: InjectionKey<{ width: Ref<number> }> = Symbol("drawer");
export function useDrawer() {
const ctx = inject(drawerKey);
if (!ctx) {
throw new Error("useDrawer 必须在 <Drawer> 内部使用(缺少 drawerKey 提供者)");
}
return ctx;
}
这段封装有两个好处:错误信息直接指出问题;组件里调用 useDrawer() 比每次 inject(key) 更简洁,也方便将来改成从其他来源取。
可测试性:依赖注入让测试更容易——测试时可以自己 provide 一个假上下文,不必渲染整棵祖先树:
import { mount } from "@vue/test-utils";
import { themeKey } from "../keys";
import MenuItem from "../components/MenuItem.vue";
const wrapper = mount(MenuItem, {
global: {
provide: {
[themeKey as unknown as string]: { theme: ref("dark"), toggleTheme: vi.fn() },
},
},
});
什么时候不该用:只在两层之间传一个值(props 更直观);数据需要被多个平级页面共享(Pinia);数据需要持久化或跨路由保持(Pinia + localStorage)。
示例拆解
一个「表单上下文」重构案例:原本每个输入组件都从 props 拿 disabled 和 size,改成 provide/inject 后,输入组件无需中间层转发:
<!-- FormProvider.vue -->
<script setup>
import { provide, reactive } from "vue";
const ctx = reactive({ disabled: false, size: "md" });
provide("formCtx", ctx);
</script>
<template>
<form><slot /></form>
</template>
<!-- TextInput.vue -->
<script setup>
import { inject, computed } from "vue";
const ctx = inject("formCtx", reactive({ disabled: false, size: "md" }));
const classes = computed(() => [`size-${ctx.size}`, { disabled: ctx.disabled }]);
</script>
<template>
<input :disabled="ctx.disabled" :class="classes" />
</template>
这里 ctx 是 reactive 对象,任意层级的输入组件都能读到 disabled 与 size,表单整体切换禁用态时全部输入自动跟随。如果某个输入想覆盖,就用 props 显式传入并优先——这就是「环境 + 局部覆盖」的组合模式。
动手任务
- 把
formCtx改成带默认值的 inject,验证TextInput脱离FormProvider单独渲染不报错。 - 给
formCtx加一个registerField方法(收集所有字段名),在FormProvider里实现「提交时打印所有字段」,体会「后代向祖先注册」的反向用法。 - 列出你项目里三个用 props 逐层传的值,判断哪些该改成 provide/inject,哪些其实该进 Pinia。
常见错误
- 用 provide/inject 存业务列表数据:多个页面要读写同一份数据时,inject 的「就近提供者」语义会导致同一组件在不同位置读到不同数据,难以排查。业务状态用 Pinia。
- 注入方假设提供者一定存在:组件复用到别处就崩。必须给默认值或抛明确错误。
- 在 provide 的值里混用「可变引用 + 替换」:替换 provide 的对象引用不会通知注入方。用 reactive/ref 的字段变化,或提供 setter 方法。
- 用 inject 代替 props 做父子通信:父子关系明确时 props 更直观、类型更严格、数据流可见。
检查与复盘
- 能说出 props / provide-inject / Pinia 三者的适用边界
- 能写出带「找不到提供者」错误提示的注入封装
- 能解释测试时如何用 provide 注入假上下文
参考资料
- Vue 官方文档「依赖注入」:provide/inject 的完整规则
- Vue 官方文档「状态管理」:何时引入 Pinia
读完这一课了?
标记完成后,工作台会安排接下来的学习。