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')
小练习:给 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"。