provide / inject:Vue 的跨层级依赖注入
问题场景:Props Drilling
前面学过 Props 向下、Emit 向上。Props 的问题是层级多了会穿很多层:
App
└─ Layout(不关心 user,但要转发)
└─ Header(不关心 user,但要转发)
└─ Nav(不关心 user,但要转发)
└─ UserMenu(终于用到了 user)
从 App 到 UserMenu 要穿 4 层 props。中间 3 层明明用不到,却必须写 :user="user"。这种现象叫 Props Drilling(属性钻透)。
Java 对照:这就像"Controller → Service → Repository 每一层都要传同一个 UserContext"。Spring 用 @Autowired 让任意组件直接拿到单例——不用沿调用链传。
Vue 的 provide / inject 就是这个能力。
1. 最简用法
<!-- 祖先组件 App.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const user = ref({ id: 1, name: '张三' })
// 提供给所有后代
provide('user', user)
</script>
<!-- 任意后代组件 UserMenu.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
// 拿到祖先提供的 user
const user = inject<Ref<{ id: number; name: string }>>('user')
console.log(user?.value.name) // '张三'
</script>
<template>
<span>{{ user?.value.name }}</span>
</template>
关键点:
provide(key, value)在祖先任何层级调用一次inject(key)在任意深度的后代取出来- 中间组件完全不用参与——不用转发、不用声明
Java 对照:provide ≈ 往 Spring 容器里注册一个 Bean,inject ≈ @Autowired。区别是 Vue 的"容器"是组件树,作用域是这棵子树,不是全局。
2. key:字符串还是 Symbol?
字符串最简单,但有两个问题:
- 拼写错:
inject('usr')不会报错,返回undefined - 重名:两个独立功能都用
'user'会互相覆盖
推荐用 Symbol 做 key:
// src/injectionKeys.ts
import type { InjectionKey, Ref } from 'vue'
import type { User } from '@/types/user'
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
<!-- 祖先 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { userKey } from '@/injectionKeys'
const user = ref({ id: 1, name: '张三' })
provide(userKey, user)
</script>
<!-- 后代 -->
<script setup lang="ts">
import { inject } from 'vue'
import { userKey } from '@/injectionKeys'
const user = inject(userKey) // 类型自动推断为 Ref<User> | undefined
</script>
InjectionKey<T> 是 Vue 提供的类型工具:给 key 打上"我能取出什么类型"的标签。inject(userKey) 自动推断类型,写错 key 编译直接报错。
Java 对照:像 Spring 的 ApplicationContext.getBean(Class<T>)——按类型取,不按字符串。
3. 默认值 + 断言"一定有"
inject 的返回值默认是 T | undefined,每次都写 user?.value 很烦。
方案 1:给默认值
const user = inject(userKey, ref({ id: 0, name: '游客' }))
// 现在 user 的类型是 Ref<User>,不用处理 undefined
方案 2:断言必须提供(推荐)
const user = inject(userKey)!
if (!user) throw new Error('userKey 必须由祖先 provide')
或者封装成 composable:
// src/composables/useUser.ts
import { inject } from 'vue'
import { userKey } from '@/injectionKeys'
export function useUser() {
const user = inject(userKey)
if (!user) {
throw new Error('useUser() 必须在 provide(userKey) 的后代里调用')
}
return user
}
这是 Vue 社区的标准惯用法。Element Plus 内部大量这样写:组件之间靠 provide/inject 通信,外面用 composable 包一层。
4. 响应式:provide 什么,inject 就是什么
// ✅ provide 一个 ref —— 后代拿到的是同一个 ref,能响应更新
const theme = ref('light')
provide('theme', theme)
// ❌ provide 一个值 —— 后代拿到的是快照,祖先改了后代不知道
provide('theme', theme.value)
规则:想要响应式,就 provide ref / reactive / computed 本身。别 .value 了再 provide。
后代能改祖先的状态吗?
技术上能(user.value = ... 直接改)。但不推荐——和 props 单向数据流的原则一致,"谁提供谁负责"。正确做法是把修改函数一起 provide:
<!-- 祖先 -->
<script setup>
const user = ref({ id: 1, name: '张三' })
const updateUser = (patch: Partial<User>) => {
user.value = { ...user.value, ...patch }
}
provide('user', { user, updateUser })
</script>
<!-- 后代 -->
<script setup>
const { user, updateUser } = inject('user')!
updateUser({ name: '李四' })
</script>
Java 对照:像 Spring 的 Service——外部通过方法改状态,不直接操作字段。
5. provide / inject vs Pinia
两者都能"跨层级共享状态"。怎么选?
| 维度 | provide / inject | Pinia |
|---|---|---|
| 作用域 | 组件子树 | 全局 |
| 复杂度 | 轻量 | 重一些(有 store、actions、getters) |
| DevTools | 不显示 | 专属面板,时间旅行调试 |
| SSR | 要小心处理 | 官方有方案 |
| 典型场景 | 局部共享(表单上下文、主题) | 全局状态(用户、购物车) |
选择原则:
- 某个组件树内共享 →
provide/inject- 例:
<ElForm>把校验规则 provide 给内部所有<ElFormItem> - 例:布局组件把"暗色模式" provide 给所有子组件
- 例:
- 整个应用共享 → Pinia
- 例:当前登录用户、购物车、全局配置
再补一条:组件库作者几乎全用 provide/inject(Pinia 是业务状态,不适合塞到库里);业务应用几乎全用 Pinia(provide/inject 不好追踪、难调试)。
6. 项目里用在哪
in4vue 目前还没用到 provide/inject。以后会用到的地方:
场景 1:主题切换
src/layouts/DefaultLayout.vue 持有当前主题,provide 给所有页面。单个页面要切主题时,调用 updateTheme。
// DefaultLayout.vue
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
const toggleTheme = () => (theme.value = theme.value === 'light' ? 'dark' : 'light')
provide(themeKey, { theme, toggleTheme })
场景 2:笔记详情页的 MarkdownRenderer 上下文
详情页会把"当前笔记元信息"provide 给 MarkdownRenderer,后者里面的"代码块"子组件(复制按钮、行号)也能直接拿到。不用沿 MarkdownRenderer → CodeBlock → CopyButton 层层传。
场景 3:AI 助手的会话上下文
后续接入 AI 问答时,AiChatPanel 会 provide "当前会话 ID + 历史消息 ref" 给内部所有子组件(消息列表、输入框、设置)。这种组件内部强耦合的场景用 provide/inject 比 Pinia 更贴切——会话状态是组件的实例状态,不是全局状态。
7. 新手常见误区
误区 1:把所有状态都塞到 provide 里
"Props 传太烦,全改成 provide" → 过度了。Props 虽然冗长,但数据流向非常清晰。一旦滥用 provide,组件之间的依赖关系变得不可见,debug 时不知道"这个 inject 到底来自哪里"。
原则:3 层以内用 Props,超过 3 层且中间层不关心才考虑 provide。
误区 2:provide 非响应式的值
// ❌ 后代拿到的是数字 0,祖先改 count.value 后代没反应
provide('count', count.value)
// ✅
provide('count', count)
误区 3:忘了 key 的类型信息
字符串 key + TypeScript 拿到 unknown,每次都要断言。用 InjectionKey<T> 一次写对,到处受用。
速查表
// 提供
import { provide } from 'vue'
provide(key, value)
// 注入
import { inject } from 'vue'
const value = inject(key) // T | undefined
const value = inject(key, defaultValue) // T
const value = inject(key)! // T(断言必须存在)
// Symbol key + 类型推断(推荐)
import type { InjectionKey } from 'vue'
export const xxxKey: InjectionKey<T> = Symbol('xxx')
8. 原理深挖:provides 沿组件树原型链向上找
每个组件实例上挂了一个 provides 对象。简化流程:
// 创建组件实例时
function createComponentInstance(vnode, parent) {
const instance = {
parent,
// ★ 子组件的 provides 默认 inherit 父组件的
provides: parent ? parent.provides : Object.create(null),
...
}
return instance
}
// provide(key, value) 实现
function provide(key, value) {
const instance = currentInstance
if (!instance) return
let provides = instance.provides
// ★ 关键:第一次 provide 时切换到一个新对象,原型指向父的 provides
if (parent && parent.provides === provides) {
provides = instance.provides = Object.create(parent.provides)
}
provides[key] = value
}
// inject(key) 实现
function inject(key, defaultValue) {
const instance = currentInstance
// ★ 直接从父的 provides 上找——原型链查找会自动一路向上
const provides = instance.parent.provides
if (key in provides) return provides[key]
return defaultValue
}
关键设计:
- provides 是个对象,靠原型链向上委托——子组件 provide 一个新 key 不会影响父组件,因为修改的是子自己的 provides 副本(原型指向父)
- inject 不主动遍历父链——用
in操作符 + JS 原生的原型链查找一步到位 - 沿组件树,不是 DOM 树——provides 的 parent 引用是组件实例的逻辑父级,所以slot 内容能 inject 到父组件 provide 的值(slot 内容属于父,详见 05-slot 笔记)
性能:inject(key) 是 O(原型链长度) = O(组件树深度),但原型链查找是 V8 高度优化的——在合理深度下成本可忽略。
9. 原理深挖:响应式怎么传过去的
// 祖先
const count = ref(0)
provide('count', count)
// 后代
const count = inject('count') // 拿到的是同一个 RefImpl 引用
console.log(count.value) // 0
// 祖先里改
count.value = 5
// 后代里读 count.value → 触发 RefImpl.get → 触发 track(如果在 effect 里)
// 后代的 render effect 已经在初次 inject 时收到了 count 的依赖
// 改 count.value → triggerRefValue → 后代 render 重跑
关键洞察:provide / inject 本身没有响应式机制——它只是个 key-value 对象传递。响应式来自于"传过去的值是 ref"。
反例:
// ❌ 传值快照
provide('count', count.value) // 后代拿到 0,祖先怎么改也没用
// ❌ 传 reactive 对象然后解构
const state = reactive({ count: 0 })
provide('state', state)
const { count } = inject('state') // 解构丢响应式
所以 provide / inject 的响应式能力完全继承自所传的对象——这也是为什么 ref / reactive / computed 都能 provide。
10. 原理深挖:为什么 inject 必须在 setup 同步路径里调
和 onMounted 一样的限制:
function inject(key, defaultValue) {
const instance = currentInstance // ★ 依赖 currentInstance
if (instance) {
const provides = instance.parent.provides
if (key in provides) return provides[key]
}
// ...
}
所以:
<script setup>
const user = inject('user') // ✅
if (cond) inject('user') // ✅(同步分支也行)
onMounted(() => inject('user')) // ❌ 此时 currentInstance 是 null
await fetch(...)
inject('user') // ❌ await 后丢上下文
</script>
判断准则:所有 Vue 的"上下文敏感 API"——onMounted / onUnmounted / inject / provide / getCurrentInstance / useSlots / useAttrs——都受这条规则约束。理由都是同一个:currentInstance 只在 setup 同步路径里有效。
11. 原理深挖:app.provide 是个全局兜底
createApp 也有 provide:
const app = createApp(App)
app.provide('apiBase', '/api/v1')
app.mount('#app')
机制:app 创建时有个 appContext.provides——所有组件实例的 provides 链顶端是这个 appContext.provides:
appContext.provides
└─ App 组件 provides(继承)
└─ Layout provides(继承)
└─ Page provides(继承)
└─ ...
用途:
- 全局服务:API base URL、上报埋点函数、I18n 实例
- 插件:Vue Router / Pinia 内部就是 app.provide(routerKey, router),组件里 useRouter() 就是 inject(routerKey)
- 测试 mock:测试时 createApp 后注入 mock 服务
为什么不直接用模块级别变量——多个 app 实例(SSR 场景每请求一个 app)时,app.provide 隔离;模块顶层 import 共享。
Java 对照:appContext.provides 像 Spring 的 ApplicationContext 根容器;组件实例的 provides 像各种子容器(WebApplicationContext)。容器有继承关系,子容器查不到时向父查。
12. 原理深挖:Vue Router 和 Pinia 都是怎么用 provide/inject 的
useRouter() 不是魔法,本质就是 inject:
// vue-router 内部
export const routerKey = Symbol('router')
export const routeLocationKey = Symbol('route location')
// install 时
router.install = function (app) {
app.provide(routerKey, router)
app.provide(routeLocationKey, currentRoute)
// ...
}
// useRouter 实现
export function useRouter() {
return inject(routerKey)
}
export function useRoute() {
return inject(routeLocationKey)
}
Pinia 同理:
// pinia 内部
export const piniaSymbol = Symbol('pinia')
pinia.install = function (app) {
app.provide(piniaSymbol, pinia)
}
export function defineStore(id, setup) {
return function useStore() {
const pinia = inject(piniaSymbol) // 拿到全局 pinia
if (!pinia._s.has(id)) {
// 注册 store
registerStore(pinia, id, setup)
}
return pinia._s.get(id)
}
}
所以——所有 Vue 生态的 "use*" 函数(除了 useRef / useComputed 这种基础 API),底层基本都是 inject。
这就是 Vue 的 DI 模式:app.provide 注册全局单例,组件层 provide 注册局部单例,inject 按 key 取出。比 Spring 的反射 + ClassPathScanning 简单得多,但功能基本对应。
13. 原理深挖:app.runWithContext = 在组件外用 inject
如果你想在路由守卫、全局错误处理这种"组件外"的地方用 inject:
// ❌ 不行:currentInstance 是 null
router.beforeEach(() => {
const auth = inject(authKey) // undefined
})
// ✅ 用 app.runWithContext
router.beforeEach(() => {
app.runWithContext(() => {
const auth = inject(authKey) // 拿到 app.provide 的值
if (!auth.isLoggedIn) ...
})
})
runWithContext 把 currentApp 临时挂上,让 inject 能在没有组件实例时也走 app 级 provides。这是 Vue 3.3+ 加的能力,专门为"框架 plugin 场景"设计。
Java 对照:类似 Spring 的 ApplicationContext.getBean——拿到容器引用就能在任何地方查 Bean,不受调用栈限制。
14. 原理深挖:组件库为什么偏爱 provide/inject
第 5 节说"组件库作者几乎全用 provide/inject"。深层原因:
Element Plus 的 ElForm + ElFormItem 内部协作:
<el-form :rules="rules" :model="form">
<el-form-item prop="username">
<el-input v-model="form.username" />
</el-form-item>
</el-form>
ElForm 内部:
// el-form
const formContext = {
rules,
model,
validate: () => { ... },
registerField: (field) => { ... }
}
provide(formContextKey, formContext)
ElFormItem 内部:
const formCtx = inject(formContextKey)
onMounted(() => formCtx?.registerField(this))
// 自动从父 form 拿到 rules 里 prop=username 对应的规则
为什么不用 Pinia:
- 多实例:一个页面可能有 3 个 ElForm,每个独立——Pinia store 是单例
- 组件库不能强制依赖 Pinia——用户项目可能没装 Pinia
- 生命周期一致:form 销毁时 ctx 也跟着销毁,靠的就是 effectScope 自动清理
- 天然作用域:表单项只能找最近的祖先表单——provide 的子树查找正好是这个语义
判断准则:
- "我在容器里,自动找到容器" → provide / inject(容器组件场景)
- "我要全局唯一的状态" → Pinia
- "我要传给一个固定的子组件" → props
小练习:给 in4vue 做一个"暗色模式"的 provide
- 新建
src/injectionKeys.ts,声明themeKey: InjectionKey<{ theme: Ref<'light'|'dark'>; toggleTheme: () => void }> - 在
DefaultLayout.vue里provide(themeKey, ...) - 新建
src/composables/useTheme.ts,内部inject(themeKey)并校验必须存在 - 在随便哪个页面/组件里
const { theme, toggleTheme } = useTheme()调用,加一个按钮切换 - 跑起来感受:不用沿路径传 theme,任意层级直接拿到
做完这步再学 Pinia 的时候,你会自然发现:"哦,Pinia 就是给 整个应用做 provide/inject,并且带 DevTools"。