provide / inject:Vue 的跨层级依赖注入

问题场景:Props Drilling

前面学过 Props 向下、Emit 向上。Props 的问题是层级多了会穿很多层

App
 └─ Layout(不关心 user,但要转发)
     └─ Header(不关心 user,但要转发)
         └─ Nav(不关心 user,但要转发)
             └─ UserMenu(终于用到了 user)

AppUserMenu 要穿 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>

关键点

Java 对照provide ≈ 往 Spring 容器里注册一个 Bean,inject@Autowired。区别是 Vue 的"容器"是组件树,作用域是这棵子树,不是全局。


2. key:字符串还是 Symbol?

字符串最简单,但有两个问题:

推荐用 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(Pinia 是业务状态,不适合塞到库里);业务应用几乎全用 Piniaprovide/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

  1. 新建 src/injectionKeys.ts,声明 themeKey: InjectionKey<{ theme: Ref<'light'|'dark'>; toggleTheme: () => void }>
  2. DefaultLayout.vueprovide(themeKey, ...)
  3. 新建 src/composables/useTheme.ts,内部 inject(themeKey) 并校验必须存在
  4. 在随便哪个页面/组件里 const { theme, toggleTheme } = useTheme() 调用,加一个按钮切换
  5. 跑起来感受:不用沿路径传 theme,任意层级直接拿到

做完这步再学 Pinia 的时候,你会自然发现:"哦,Pinia 就是给 整个应用做 provide/inject,并且带 DevTools"。


延伸阅读