Vue 3 设计与实现
第 14 章 依赖注入与插件系统
第 14 章 依赖注入与插件系统
本章要点
- provide/inject 的本质:基于原型链的依赖传递机制
- 依赖注入的解析过程:从当前组件沿 parent 链向上查找
- InjectionKey 的类型安全设计:Symbol + 泛型的巧妙结合
- app.provide 的全局注入:应用级别的依赖如何注入到每一个组件
- 插件系统的设计哲学:app.use() 如何组织第三方扩展
- 插件的安装机制:install 函数与重复安装检测
- 依赖注入在大型应用中的架构价值:替代 props drilling 的优雅方案
- 依赖注入的 6 种反模式与 4 种高级模式
依赖注入是 Vue 中最容易被低估的特性之一。很多开发者只在”跨层级传递数据”时才想到 provide/inject,却忽略了它在架构层面的深远意义——它是 Vue 插件系统的基石,是组合式函数共享状态的关键通道,也是 Pinia、Vue Router 等官方库与组件树交互的核心机制。
在前面的章节中,我们深入了解了组件系统的实例化过程和生命周期。本章将揭开组件间数据流动的另一条路径——不是自上而下的 props,也不是自下而上的 emit,而是”穿越”组件层级的依赖注入。
为什么要为 DI 写整整一章?
很多入门书把 provide/inject 当成一个小技巧讲完——三五页、一个代码示例、结束。这种处理会让读者错过一个重要观察:Vue 3 的组合式 API 能成立,本质上依赖于 DI。useRouter()、useRoute()、useStore()、useI18n()——你在组件里随手调用的每一个 useXxx,几乎没有一个不是 inject 的薄包装。如果你把 provide/inject 从 Vue 里拔出来,整个组合式 API 生态就会塌掉一半:Router 不知道怎么在组件里取 route,Pinia 不知道怎么在组件里拿到 pinia 实例,所有需要”跨层级共享”的 composable 都要退回到 React Context 式的 <Provider> 嵌套或者全局单例。所以这一章的目标不是教你”怎么用”——而是教你为什么 Vue 的生态必须长成今天这个样子。
本章也会几次回到第 10 章(组件系统:currentInstance 和 parent 链)、第 12 章(生命周期:onUnmounted 何时触发)的内容——这些不是”额外知识”,而是 DI 能工作的前提。你在读到 currentInstance.parent.provides 这类代码时,如果还对 currentInstance 的维护机制陌生,请不要犹豫翻回第 10 章先重新熟悉一下——这本书各章之间的关联是刻意设计的,回头读一节比硬啃一节有用得多。
14.1 provide/inject 的基本模型
14.1.1 从问题出发
考虑一个典型的场景:一个主题系统需要将主题配置从根组件传递到任意深度的子组件。
// 使用 props drilling —— 痛苦的方式
// App → Layout → Sidebar → Menu → MenuItem → Icon
// 每一层都要声明和传递 theme prop
这种方式的痛苦不在于工作量——而在于它强迫本来不关心主题的中间层组件也要参与主题传递。Layout、Sidebar 这些组件本应专注于布局,却被迫背负起主题参数。这违反了”单一职责原则”。
更糟糕的是变更成本传染:如果有一天你想给 theme 加一个 fontFamily 字段,这条传递链上的每一个组件都要修改类型声明、修改 props 列表、修改 template 中的转发。业务同学管这叫”一改改一片”——五个组件只有 MenuItem 真的用了新字段,其余四个都是纯粹的搬运工。在一个几十个组件的页面里,这种”搬运工组件”会把 PR diff 撑得面目全非,也会让代码评审失焦——reviewer 看着一页页只增加了 theme.fontFamily 转发的 diff,很难注意到真正的业务改动藏在哪一行里。这是 Vue 团队在设计 DI 时的第一动机:不是为了”实现一个新功能”,而是为了”把搬运工组件从代码库里删掉”。
provide/inject 提供了更优雅的方案:
// 祖先组件
const app = {
setup() {
const theme = reactive({ mode: 'dark', primary: '#42b883' })
provide('theme', theme)
}
}
// 任意深度的后代组件
const DeepChild = {
setup() {
const theme = inject('theme')
// 直接使用,无需中间组件传递
}
}
看起来像是”魔法”——数据怎么就”穿越”了中间的组件层级?答案藏在 JavaScript 最基础的机制中。
14.1.2 三种数据流方式的对比
Vue 组件间的数据流有三条路径,各有各的语义:
| 方式 | 方向 | 耦合度 | 适用场景 |
|---|---|---|---|
| Props | 父 → 子(单向) | 高(显式契约) | 明确的父子数据交换 |
| Emit | 子 → 父(单向) | 高(显式契约) | 子向父通知事件 |
| Provide/Inject | 祖 → 孙(跨层) | 低(隐式发现) | 主题、服务、插件注入 |
Provide/Inject 的”低耦合”是双刃剑——它让跨层传递变得容易,但也让组件之间的依赖关系不再从 <template> 里一眼看出。这是架构选择题:显式代价 vs 隐式便利。
14.1.3 原型链:依赖注入的底层引擎
Vue 的 provide/inject 底层使用了原型链继承。每个组件实例都有一个 provides 对象,子组件的 provides 的原型指向父组件的 provides:
graph TD
A["App provides<br/>{theme: {...}}"] --> B["Layout provides<br/>Object.create(parent)"]
B --> C["Sidebar provides<br/>Object.create(parent)"]
C --> D["MenuItem provides<br/>Object.create(parent)"]
style A fill:#42b883,color:#fff
style D fill:#35495e,color:#fff
当 MenuItem 调用 inject('theme') 时,JavaScript 引擎沿原型链向上查找,天然地实现了”跨层级查找”。这个设计的精妙之处在于:
- 查找是 O(1) 到 O(n) 的:n 是组件嵌套深度,但实际上 JavaScript 引擎对原型链查找做了高度优化
- 中间组件零开销:不需要声明 props,不需要转发数据
- 同名覆盖:中间组件可以 provide 同名 key,自然地”遮蔽”祖先的值——就像作用域链中的变量遮蔽
“用 JavaScript 语言自带的原型链机制实现业务层的继承查找”——这是一种零成本抽象的典型:不需要重新实现”向上搜索链”这种数据结构,直接复用 JavaScript 引擎已经高度优化过的原型查找。
从”搭台子”到”借台子”的设计智慧
我想花一点笔墨把这件事讲透——因为这是一种非常高级的工程审美,很多人即使日常在用 Vue 也从未注意到。Vue 团队在设计 DI 时有两条路可选:
- 路线 A:自建查找链。给每个组件挂一个普通的
Map,在 inject 时写一个while (current) { if (current.provides.has(key)) return current.provides.get(key); current = current.parent; }的循环向上找。 - 路线 B:借用原型链。直接让子组件的
provides以父组件的provides为原型,写provides[key]就走原型链自然向上。
路线 A 是大多数初学者本能想到的方案,工程上也没错——Angular、NestJS 的 IoC 容器大体是这个思路。但 Vue 选了路线 B,收益是什么?
- 代码更短:inject 实现只需要一个
in+[]操作,没有循环,没有显式的.parent。 - 查找更快:V8、SpiderMonkey 对”原型链上的属性访问”有一套成熟的 Inline Cache 机制(见 V8 官方博客 Fast properties in V8),命中缓存后基本等于单次内存读取;而一个手写的
while循环每次迭代都是一次函数调用边界,引擎很难跨边界优化。 - 语义更统一:原型链遮蔽(中间层 provide 同名 key 覆盖祖先值)是 JavaScript 语言自带的、程序员熟悉的行为,不需要额外学新规则。
这种”不自己搭台子,借语言的台子”的设计哲学,在 Vue 源码里会反复出现——第 4、5 章的响应式借了 Proxy、第 10 章的组件实例借了闭包、本章的 DI 借了原型链。如果你在读其他框架源码时能时刻问自己”这一部分如果换成借用语言机制,可以简化多少”,你的代码审美会上一个台阶。
14.2 provide 的实现
14.2.1 源码解析
// packages/runtime-core/src/apiInject.ts
export function provide<T, K = InjectionKey<T> | string | number>(
key: K,
value: K extends InjectionKey<infer V> ? V : T
): void {
if (!currentInstance) {
if (__DEV__) {
warn(`provide() can only be used inside setup().`)
}
} else {
let provides = currentInstance.provides
// 关键:检查当前组件是否已经"分叉"了 provides 对象
const parentProvides =
currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
// 第一次在当前组件调用 provide 时
// 创建一个以父组件 provides 为原型的新对象
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key as string] = value
}
}
这段代码的核心逻辑只有几行,但设计极为精巧:
延迟分叉(Lazy Fork)策略:组件实例初始化时,provides 直接指向父组件的 provides(共享引用)。只有当组件第一次调用 provide 时,才通过 Object.create() 创建新对象。这意味着:
- 不调用 provide 的组件:零内存开销(共享父对象引用)
- 调用 provide 的组件:只创建一个浅层对象,自身提供的值存在自身对象上,祖先的值通过原型链访问
这个设计的收益在大型应用里会非常明显。想象一个有 2000 个组件实例的 SaaS 后台页面,其中真正 provide 依赖的通常只有几十个——根组件、路由守卫、各个业务模块的 provider 组件。如果不做延迟分叉,哪怕只是为了”保留覆盖的可能性”,也要为每个组件都 Object.create() 一次——2000 次额外对象分配,在初次渲染时会被 V8 的 young generation GC 立刻回收,看起来”没什么”,但实际上贡献了可测量的 GC 压力。Vue 团队选择只在真正需要分叉时才分叉,这是典型的”按需付费”设计。
parentProvides === provides 这行看似朴素的判断,本质上是一个幂等守卫——它让同一次 setup 里连续 provide 多次只分叉一次:第一次 provide 会分叉并写入,第二次、第三次 provide 看到的 provides 已经不是 parentProvides 了,就直接往已有的分叉对象上写。没有这行判断,就会出现”第二次 provide 反而覆盖了第一次” 的 bug——因为每次都重新 Object.create(parentProvides),第一次写入的值会留在被抛弃的旧对象上。这种”一个等号判断撑起整个语义”的代码最值得反复品——读源码的乐趣就在这里。
sequenceDiagram
participant C as 组件实例
participant P as 父组件 provides
Note over C: 初始化时
C->>P: provides = parent.provides(共享引用)
Note over C: 第一次 provide('key', val)
C->>C: provides === parentProvides?
C->>C: provides = Object.create(parentProvides)
C->>C: provides['key'] = val
Note over C: 后续 provide 调用
C->>C: 直接设置 provides['key'] = val
14.2.2 类型安全的 InjectionKey
Vue 3 提供了 InjectionKey 类型,让 provide/inject 在 TypeScript 中获得完整的类型推导:
// 定义类型安全的 key
import { InjectionKey } from 'vue'
interface UserService {
currentUser: Ref<User | null>
login(credentials: Credentials): Promise<void>
logout(): void
}
// Symbol 保证全局唯一,泛型参数携带类型信息
export const UserServiceKey: InjectionKey<UserService> = Symbol('UserService')
// provide 时,值必须匹配类型
provide(UserServiceKey, {
currentUser: ref(null),
login: async (cred) => { /* ... */ },
logout: () => { /* ... */ }
})
// inject 时,自动推导类型为 UserService | undefined
const userService = inject(UserServiceKey)
// userService?.currentUser.value ✓ 类型安全
InjectionKey 的定义极其简洁:
export interface InjectionKey<T> extends Symbol {}
它本质上就是一个带泛型标记的 Symbol。类型信息只存在于编译时,运行时没有任何开销。这是 TypeScript “零成本抽象”的典型应用。
这个 interface InjectionKey<T> extends Symbol {} 是怎么做到既是 Symbol 又携带 T 的?——很多读者第一次看到这行声明会楞一下。秘密在于 TypeScript 的 phantom type(幽灵类型)模式:泛型参数 T 其实没有出现在 interface 的任何成员里,它完全是一个”类型槽”,作用只是让编译器在看到 InjectionKey<UserService> 时能在类型上记住”这个 Symbol 后面会对应一个 UserService 值”。运行时 Symbol('UserService') 就是一个再普通不过的 Symbol,无所谓 T。等到 provide(UserServiceKey, x) 的重载签名写成 K extends InjectionKey<infer V> ? V : T 时,编译器就能从这个幽灵 T 里把 V 推出来。这是 TypeScript 社区的经典模式,Rust 的 PhantomData、Haskell 的 phantom type 都是同一个思想——用类型维度的信息指导编译期检查,运行时一毛钱成本都不花。
14.2.3 string vs Symbol:哪个更好?
很多人习惯用字符串做 key:
provide('theme', theme) // 字符串 key
inject('theme')
这在小项目里没问题,但在大项目会带来两个隐患:
- 命名冲突:两个不相关的插件都用
'logger'作为 key,就会互相覆盖 - 失去类型推导:字符串 key 的 inject 返回
unknown,要手动类型断言
对比:
// ❌ 字符串 key
const theme = inject('theme') as Theme // 手动断言
// ✅ InjectionKey(Symbol)
const theme = inject(ThemeKey) // 自动推导为 Theme | undefined
生产建议:凡是会被多个组件共享、需要类型提示的 DI,一律用 InjectionKey;临时性、调试性的 DI 才用字符串。
14.3 inject 的实现
14.3.1 源码解析
export function inject<T>(key: InjectionKey<T> | string): T | undefined
export function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue: T | (() => T),
treatDefaultAsFactory?: boolean
): T
export function inject(
key: InjectionKey<any> | string,
defaultValue?: unknown,
treatDefaultAsFactory = false
) {
// 支持在 setup() 和函数式组件中使用
const instance = currentInstance || currentRenderingInstance
if (instance || currentApp) {
// 确定从哪里开始查找
const provides = currentApp
? currentApp._context.provides
: instance!.parent == null
? instance!.vnode.appContext && instance!.vnode.appContext.provides
: instance!.parent.provides
if (provides && (key as string | symbol) in provides) {
return provides[key as string]
} else if (arguments.length > 1) {
// 有默认值
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance && instance.proxy)
: defaultValue
} else if (__DEV__) {
warn(`injection "${String(key)}" not found.`)
}
}
}
几个值得注意的设计决策:
14.3.2 为什么从父组件开始查找?
const provides = instance!.parent == null
? instance!.vnode.appContext && instance!.vnode.appContext.provides
: instance!.parent.provides
inject 从 parent.provides 开始查找,而不是从自身的 provides。这意味着组件不能 inject 自己 provide 的值。这是故意的设计——避免循环依赖。
想清楚这一点需要一点时间。设想一个组件同时 provide 又 inject 同一个 key 会发生什么?如果允许从自身 provides 开始找,那它会拿到自己刚 provide 的值——但这几乎总是错的:用户的本意是”我是这个值的消费者**,上层应该有一个 provider**“,而不是”我既是生产者又是消费者”。Vue 把这种自引用编译期就堵死,避免了一类非常难查的 bug(“我明明覆盖了全局 theme,怎么组件内还是那个新值?”)。这是 API 设计里的一条隐形规则:有歧义的用法不如直接不允许。
这也呼应了第 10 章讲的”组件是单向树”的观点——DI 的查找方向只能从子到祖,不能反过来。树的单向性是 Vue 很多优化(排序更新、边界错误捕获、卸载清理)共同依赖的不变量,DI 也必须遵守。
14.3.3 根组件的特殊处理
根组件没有 parent,所以 inject 查找的是 appContext.provides——也就是通过 app.provide() 注册的全局依赖。
14.3.4 工厂函数默认值
// 每次 inject 都创建新实例
const service = inject(ServiceKey, () => new ExpensiveService(), true)
第三个参数 treatDefaultAsFactory 允许传入工厂函数作为默认值。这在默认值创建开销较大时特别有用——只有在真正需要默认值时才执行工厂函数。
14.3.5 in 操作符与原型链
注意查找时使用的是 in 操作符:
if (provides && (key as string | symbol) in provides) {
return provides[key as string]
}
in 操作符会沿原型链查找,所以即使当前 provides 对象上没有这个 key,只要祖先链上某个 provides 有,就能找到。而直接用方括号访问 provides[key] 取值时,同样会沿原型链取到正确的值。
14.4 app.provide:全局依赖注入
14.4.1 应用级 provide
const app = createApp(App)
// 全局 provide —— 所有组件都能 inject
app.provide('globalConfig', { apiBase: '/api/v1' })
app.provide(RouterKey, router)
app.provide(StoreKey, store)
应用级 provide 的实现在 createAppAPI 中:
// packages/runtime-core/src/apiCreateApp.ts
provide(key, value) {
if (__DEV__ && (key as string | symbol) in context.provides) {
warn(`App already provides property with key "${String(key)}".`)
}
context.provides[key as string | symbol] = value
return app
}
context.provides 就是应用上下文的 provides 对象。根组件初始化时,它的 provides 会以这个对象为起点:
// 组件实例初始化
instance.provides = parent
? parent.provides
: Object.create(appContext.provides)
所以全局 provide 的值自然地成为了整棵组件树原型链的”最顶层”。
14.4.2 全局 provide 与根组件 provide 的关系
graph TD
A["appContext.provides<br/>{router, store, config}"] --> B["Root provides<br/>Object.create(appContext)"]
B --> C["Child provides"]
C --> D["GrandChild provides"]
style A fill:#e74c3c,color:#fff
style B fill:#42b883,color:#fff
根组件通过 Object.create(appContext.provides) 继承全局依赖,子组件再通过同样的机制继承根组件的依赖。层层嵌套,形成完整的依赖链。
14.5 依赖注入的响应性
14.5.1 响应式值的传递
provide/inject 传递的是值的引用,不会自动创建响应式包装:
// 祖先组件
setup() {
const count = ref(0)
// ✅ 传递 ref —— 后代组件拿到的是同一个 ref
provide('count', count)
// ❌ 传递 .value —— 后代只拿到初始值,不会响应更新
provide('count', count.value) // 传递了数字 0
}
// 后代组件
setup() {
const count = inject('count') // Ref<number>
// count.value 会随祖先的修改而变化
}
14.5.2 readonly 保护
为了防止后代组件意外修改祖先的状态,推荐使用 readonly 包装:
// 祖先组件
setup() {
const state = reactive({ count: 0 })
provide('state', readonly(state))
provide('increment', () => state.count++)
// 后代只能读取 state,只能通过提供的方法修改
}
这构成了一个类似 Flux 的单向数据流:状态只读,修改必须通过显式的函数调用。这个模式在 Pinia 等状态管理库中被广泛使用。
为什么 readonly 这么重要?——在大型团队里,一个模块的作者几乎控制不了谁会来 inject。每新增一个消费组件,就多一个”可能手贱直接改上游 state”的地方。一旦发生这种事,问题极难定位:你在 provider 里加断点看 state 怎么被改的,完全看不到任何 mutation 源头(因为是后代组件里直接 state.count = 99 改的)。readonly 把这种可能性从运行时连根拔起——任何 state.xxx = y 都会在开发模式下打出 warning、在类型层面给出编译错误。这是”纪律由工具保证而不是靠团队约定”的经典例子。
14.5.3 “state + actions” 模式
一个完整的、可维护的 DI 模式是 state + actions 分离:
// 在祖先层定义
function provideCounter() {
const state = reactive({ count: 0, max: 100 })
const actions = {
increment: () => state.count < state.max && state.count++,
decrement: () => state.count > 0 && state.count--,
reset: () => state.count = 0,
}
provide(CounterStateKey, readonly(state))
provide(CounterActionsKey, actions)
}
// 在后代层消费
function useCounter() {
const state = inject(CounterStateKey)!
const actions = inject(CounterActionsKey)!
return { state, ...actions }
}
好处:
- 消费者不能绕过 actions 改 state(readonly 保护)
- 所有状态修改点集中在 actions 里,便于调试
- 单元测试可以独立 mock state 或 actions
这个”state + actions 分离”模式是 Pinia setup store 风格的原型。第 15 章讲 Pinia 时你会看到,Pinia 做的事本质上就是把这个手写模式标准化 + 加上 devtools 集成 + 自动状态持久化——核心思想和这几行 provideCounter 完全一致。如果你能独立写出本节这段代码并解释清楚每一行的意图,你在阅读 Pinia 源码时会非常从容——这不是”又一个新 API”,而是”一个已经理解透的模式被库作者替你整理了一遍”。
14.6 插件系统
14.6.1 app.use() 的实现
Vue 的插件系统通过 app.use() 实现,其源码简洁而完整:
// packages/runtime-core/src/apiCreateApp.ts
use(plugin: Plugin, ...options: any[]) {
if (installedPlugins.has(plugin)) {
__DEV__ && warn(`Plugin has already been applied to target app.`)
} else if (plugin && isFunction(plugin.install)) {
installedPlugins.add(plugin)
plugin.install(app, ...options)
} else if (isFunction(plugin)) {
installedPlugins.add(plugin)
plugin(app, ...options)
} else if (__DEV__) {
warn(
`A plugin must either be a function or an object with an "install" function.`
)
}
return app
}
核心逻辑:
- 重复安装检测:用
Set记录已安装的插件,防止重复安装 - 两种插件形式:对象(有
install方法)或函数(直接作为 install) - 链式调用:返回
app支持app.use(A).use(B).use(C)
为什么 app.use 能这么短?
看到这段代码,很多从 Angular / NestJS 切过来的同学第一反应是”这就够了?“——毕竟在 Angular 里,一个 module 的注册要涉及 providers、imports、declarations、exports 四类声明,经过一整套 Reflect Metadata 的反射解析。Vue 只写了二十行 if / else if 就把插件系统搭起来——为什么?
答案藏在 Vue 的设计取舍里。Vue 的 app 不是 IoC 容器,而是一个裸对象——上面直接挂着 component、directive、provide、config.globalProperties 等扩展点。插件要做什么?就是拿到这个 app 对象,往上面挂东西。没有依赖图、没有生命周期作用域、没有延迟实例化——所以整个 use 只需要做三件事:查重、允许两种语法、支持链式。
这又是一次**“借 JavaScript 的台子”的体现:JavaScript 对象本身就是一个动态可扩展的数据结构,往上面挂属性是语言原生支持的,不需要额外的”声明周期框架”来维护”什么阶段可以注入什么”。这种极简主义让 Vue 插件的学习曲线**比 Angular 平缓得多——一个后端同学接触 Vue 插件,通常十分钟就能写出第一个 install 函数。而 Angular 的 NgModule 概念,至少要一两天才能熟悉。
14.6.2 插件的类型定义
export type Plugin<Options extends any[] = any[]> =
| (PluginInstallFunction<Options> & {
install?: PluginInstallFunction<Options>
})
| {
install: PluginInstallFunction<Options>
}
type PluginInstallFunction<Options extends any[]> = Options extends [infer E, ...infer Rest]
? (app: App, option: E, ...rest: Rest) => any
: (app: App) => any
TypeScript 的条件类型确保了 app.use(plugin, option) 中 option 的类型与插件声明的选项类型一致。
14.6.3 插件能做什么
一个插件通过 app 参数可以访问 Vue 应用的所有扩展点:
const MyPlugin: Plugin = {
install(app, options) {
// 1. 注册全局组件
app.component('MyButton', MyButton)
// 2. 注册全局指令
app.directive('focus', {
mounted(el) { el.focus() }
})
// 3. 全局依赖注入
app.provide(MyPluginKey, createPluginInstance(options))
// 4. 全局属性(Options API 中通过 this 访问)
app.config.globalProperties.$myMethod = () => { /* ... */ }
// 5. 全局 mixin(不推荐,但仍支持)
app.mixin({ /* ... */ })
// 6. 自定义选项合并策略
app.config.optionMergeStrategies.customOption = (parent, child) => {
return child ?? parent
}
}
}
graph LR
A[app.use] --> B[plugin.install]
B --> C[app.component]
B --> D[app.directive]
B --> E[app.provide]
B --> F[app.config.globalProperties]
B --> G[app.mixin]
B --> H[app.config.optionMergeStrategies]
style A fill:#42b883,color:#fff
style B fill:#35495e,color:#fff
14.7 插件与依赖注入的协作模式
14.7.1 典型模式:创建→提供→使用
几乎所有 Vue 生态的重要库都遵循相同的模式:
// 第一步:创建实例
const router = createRouter({ /* ... */ })
const pinia = createPinia()
// 第二步:通过插件安装,内部使用 app.provide
app.use(router) // router.install → app.provide(routerKey, router)
app.use(pinia) // pinia.install → app.provide(piniaKey, pinia)
// 第三步:在组件中通过组合式函数 inject
const router = useRouter() // 内部调用 inject(routerKey)
const store = useCounterStore() // 内部调用 inject(piniaKey)
这个”创建→注入→使用”三段式是 Vue 生态的标准模式。useXxx 组合式函数的内部几乎都是 inject 的封装:
这个三段式的价值在于”分层明确”。创建(create)发生在应用启动阶段,是运行时实例化;注入(use)发生在 createApp().use() 链上,是组件树的根节点挂载依赖;使用(useXxx)发生在 setup 里,是消费者拿值。每一步都有清晰的时空位置,也有专门的 API,新人很难搞混。对比 React Context 的使用——Context 的 Provider 是一个渲染期组件,意味着”创建”和”挂载”耦合在 JSX 里;而 Vue 的三段式把三件事拆成三个互不干扰的阶段,组件代码里只剩一行 const router = useRouter(),阅读负担最轻。这种”把复杂度推到边界,让核心路径尽量干净”的取舍,是 Vue 3 整个生态体验的底色。
// Vue Router 的 useRouter
export function useRouter(): Router {
return inject(routerKey) as Router
}
// Pinia 的内部实现(简化)
export function useStore(id: string) {
const pinia = inject(piniaSymbol)!
if (!pinia._s.has(id)) {
// 创建 store ...
}
return pinia._s.get(id)!
}
14.7.2 设计一个完整的插件
以一个国际化插件为例,展示完整的插件设计:
// types.ts
import type { InjectionKey, Ref } from 'vue'
interface I18n {
locale: Ref<string>
t: (key: string, params?: Record<string, string>) => string
setLocale: (locale: string) => Promise<void>
}
export const I18nKey: InjectionKey<I18n> = Symbol('i18n')
// plugin.ts
import { ref, Plugin } from 'vue'
import { I18nKey, type I18n } from './types'
interface I18nOptions {
defaultLocale: string
messages: Record<string, Record<string, string>>
loadLocale?: (locale: string) => Promise<Record<string, string>>
}
export function createI18n(options: I18nOptions): I18n & { install: Plugin['install'] } {
const locale = ref(options.defaultLocale)
const messages = reactive(new Map(Object.entries(options.messages)))
function t(key: string, params?: Record<string, string>): string {
const msg = messages.get(locale.value)?.[key] ?? key
if (!params) return msg
return msg.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`)
}
async function setLocale(newLocale: string) {
if (!messages.has(newLocale) && options.loadLocale) {
const loaded = await options.loadLocale(newLocale)
messages.set(newLocale, loaded)
}
locale.value = newLocale
}
const i18n: I18n = { locale, t, setLocale }
return {
...i18n,
install(app) {
// 依赖注入:组合式 API 通过 inject 使用
app.provide(I18nKey, i18n)
// 全局属性:Options API 通过 this.$t 使用
app.config.globalProperties.$t = t
app.config.globalProperties.$i18n = i18n
}
}
}
// 组合式函数
export function useI18n(): I18n {
const i18n = inject(I18nKey)
if (!i18n) {
throw new Error('useI18n() requires i18n plugin to be installed')
}
return i18n
}
使用时:
// main.ts
const i18n = createI18n({
defaultLocale: 'zh-CN',
messages: {
'zh-CN': { greeting: '你好,{name}!' },
'en': { greeting: 'Hello, {name}!' }
},
loadLocale: (locale) => import(`./locales/${locale}.json`)
})
app.use(i18n)
// 组件中
setup() {
const { t, locale, setLocale } = useI18n()
return { t, locale, setLocale }
}
这里有一个双轨暴露的细节:同时通过 app.provide 暴露给 Composition API(通过 useI18n),通过 app.config.globalProperties 暴露给 Options API(通过 this.$t)。这让插件对两种 API 风格都友好。
双轨暴露在 Vue 3 官方库里几乎成了标配——Pinia 的 useStore / this.$pinia、Vue Router 的 useRouter / this.$router、Vue I18n 的 useI18n / this.$t,全都是这个模式。这背后有一个很现实的考虑:Vue 3 的升级不是单向推动所有人从 Options API 切到 Composition API,而是两条路线长期并存。一个好插件要承担的责任是”让两边都能用得舒服”,而不是逼着某一边迁移。Vue 核心团队在 3.x 的每个小版本里都会反复强调这个兼容承诺——这也是 Vue 在”框架升级”这件事上比很多同行做得更稳健的地方。
14.8 与其他框架 DI 的对比
Vue 的依赖注入看起来和其他框架的 DI 有相似之处,但实现和语义差别很大。对比一下这张表背后的故事——你会发现三大框架在 DI 上的分歧,本质是三种世界观的分歧。
| 框架 | 机制 | 类型安全 | 生命周期 |
|---|---|---|---|
| Vue | 原型链查找(provide/inject) | InjectionKey 泛型(编译时) | 组件卸载时失效 |
| Angular | IoC 容器(@Injectable) | Token + Type 全安全(运行时) | 模块/组件/全局三层 |
| React Context | Context.Provider + useContext | Context<T> 泛型 | Provider 卸载时失效 |
| NestJS | 装饰器 + IoC 容器 | 完全运行时类型检查 | 请求作用域/全局 |
Vue 的独特优势:
- 零成本:InjectionKey 运行时是普通 Symbol,没有容器、没有反射
- 自然作用域:组件树天然就是依赖树,不需要额外的 Module/Provider 概念
- 响应式集成:inject 的是响应式对象,修改自动触发依赖组件重渲染
Vue 的短板:
- 无法自动注入:每个 inject 都要手动调用,不像 Angular 的构造函数自动注入
- 无生命周期作用域:所有 DI 跟随组件生命周期,不能定义”请求作用域”这种
- 循环依赖不可靠:原型链是线性的,不像 IoC 容器能检测环
如果你熟悉 React,Vue 的 provide/inject ≈ React 的 Context API,但 Vue 的实现更”底层”——React 是一个 Provider 组件渲染树,Vue 是原型链继承,性能上 Vue 更优(没有额外组件)。
这三者的哲学差异值得再展开一点
- Angular 把 DI 当作”架构核心”。模块、作用域、token、provider、useClass/useValue/useFactory——整套 IoC 容器的语义都搬进了框架。好处是大型企业应用能受益于完整的 DI 建模(测试 mock、懒加载、多实例),代价是学习曲线陡、运行时多一层反射/容器。
- React 在很长一段时间里没有官方 DI。Context 是 2018 年加进来的”凑合方案”,性能上有”any context consumer re-renders on any provider change”的老毛病,生态只好靠 Redux / Zustand / Jotai 这些第三方库来补。Context 的 API 设计更像是 “能用就行”——JSX 里的
<Provider>需要包住整个子树、useContext必须在函数组件里、值变化触发整个 consumer 树重渲染。你会发现 React 自己在 19 的useContext/use(Context)上反复推敲,就是在弥补早期设计的遗憾。 - Vue 取了中间路线。不做 IoC 容器(避免学习负担),但给足了”跨层共享 + 响应式集成”的最小可用能力。因为 Vue 的响应式系统是组件之外独立成立的(第 4 章讲过),所以 inject 出来的 reactive 对象变化会自然触发订阅者重渲染——不会像 React Context 那样导致”整个 consumer 子树同步重渲染”的问题。
理解了这三种哲学的差异,你选框架时就不会停留在”React 更火 / Vue 更简单”的表层判断——你会知道:如果你的项目需要严格的依赖建模和分层测试,Angular 的 DI 值得那条学习曲线;如果你想要最小心智负担又保留响应式带来的性能优势,Vue 的 provide/inject 是目前市面上最优雅的折中。
14.9 依赖注入的 6 种反模式
DI 的能力越大、滥用的空间也越大——这一节专门收集我在真实项目里见过的各种”本意不坏但效果糟糕”的用法。反模式之所以叫反模式,不是因为它们无法工作(都能工作),而是因为它们让后续维护者接手时理解成本急剧上升。好代码的金标准从来不是”我能写出”,而是”三个月后的我、或者一个陌生同事能在 30 分钟内读懂”。以下这些反模式全都败在这一点上。
在真实项目中,我见过的 DI 反模式:
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 用 DI 替代 props(小范围也用) | 失去组件契约清晰度 | 3 层以内的传递用 props |
Key 用短字符串('u'、's') | 冲突、不可读 | InjectionKey + 描述性 Symbol 名字 |
| provide 可变的非响应对象 | 子组件拿到快照不会更新 | 一律用 ref/reactive 或函数 |
| inject 忘记处理 undefined | 运行时 crash | 提供默认值或显式 throw |
| 在普通函数里 inject | 脱离 setup 上下文,返回 undefined | 要么改造成组合式函数,要么 hasInjectionContext 检查 |
| 多个插件共用 key | 覆盖 + 无警告 | Symbol 保证唯一性 |
这张反模式表里,第 1 条”小范围也用 DI 替代 props”是最隐蔽的陷阱:它在短期内看似让代码更”干净”(少写了 props 声明),但远期代价很大——半年后新来的同事读这份代码时,面对一个组件的 template 完全看不出”这个值从哪里来”,必须翻遍整棵组件树找 provide 点。Props 虽然写起来啰嗦,但它是”组件契约”的一部分——读代码不需要跳转。这个权衡是每个架构师都会反复遇到的:隐式 vs 显式、方便 vs 可读、当下 vs 未来。我的经验法则是:“3 层以内的传递坚持用 props,超过 3 层且被 5 个以上组件消费才考虑 DI”——否则就是用一个重武器解决一个小问题。
14.10 多应用实例与依赖隔离
14.10.1 createApp 的隔离性
Vue 3 支持同一页面上运行多个 Vue 应用实例,每个实例有独立的依赖上下文:
const app1 = createApp(App1)
const app2 = createApp(App2)
app1.provide('config', { theme: 'dark' })
app2.provide('config', { theme: 'light' })
app1.mount('#app1')
app2.mount('#app2')
// 两个应用的组件 inject('config') 得到不同的值
这种隔离性来自每个 app 都有独立的 AppContext:
export function createAppContext(): AppContext {
return {
app: null as any,
config: { /* ... */ },
mixins: [],
components: {},
directives: {},
provides: Object.create(null), // 每个 app 独立的 provides
// ...
}
}
Object.create(null) 创建的纯净对象作为 provides 链的起点,确保不同 app 实例之间完全隔离。这对微前端架构至关重要。
用 Object.create(null) 而不是 {} 有一个小但重要的差别:{} 的原型是 Object.prototype,上面挂着 toString、hasOwnProperty 等方法;如果某个用户恰好 provide 了一个叫 'toString' 的 key,就会和原型上的方法发生语义混淆(in 检查会为 true 但拿到的是方法而不是用户提供的值)。Object.create(null) 的原型是 null——完全裸的 key-value 容器,不会和 Object 原型上的任何内建属性冲突。这种”在可能出现 key 冲突的场景下一律用无原型对象”的小习惯,在 Vue、React、Node.js 标准库里到处可见,值得写进你自己的代码规范里。
14.10.2 微前端下的跨应用共享
微前端场景下需要在多个 Vue 应用间共享状态(如用户信息)。有三种方案:
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| 全局变量 | window.__SHARED__ = {...} | 简单 | 失去响应式 |
| 共享模块 | 一个 ESM 模块导出单例 | 响应式保留 | 模块联邦配置 |
| Runtime Provider | 在共享的 runtime 里 createApp | 架构清晰 | 框架级改造 |
一般推荐”共享模块”方案——用 module federation / import map 让多个 app 引用同一份 Pinia 实例,通过它共享状态。
14.11 依赖注入的高级模式
到这里你已经掌握了 DI 的核心用法——provide / inject / InjectionKey / app.provide / 插件。但在真实大型项目里,仅靠这些基础组合还不够。本节讲四种从真实项目里提炼出来的高级模式,每一种都解决了一个基础用法搞不定的问题。你在阅读 Pinia、Vue Router、VueUse 等库的源码时会反复见到这些模式——它们是 Vue 3 生态的”方言”。
14.11.1 条件注入与可选依赖
// 带默认值的注入 —— 组件可以独立工作,也可以集成到更大的系统中
function useTheme() {
const injected = inject(ThemeKey, null)
if (injected) {
// 集成模式:使用上层提供的主题
return injected
}
// 独立模式:使用本地状态
const localTheme = reactive({
mode: 'light' as 'light' | 'dark',
toggle() { this.mode = this.mode === 'light' ? 'dark' : 'light' }
})
return localTheme
}
14.11.2 分层注入
// 应用层
app.provide(LoggerKey, new Logger({ level: 'warn' }))
// 模块层 —— 覆盖应用层的 logger
const AdminModule = {
setup() {
provide(LoggerKey, new Logger({ level: 'debug', prefix: '[Admin]' }))
// Admin 模块下的所有组件使用 debug 级别日志
}
}
// 组件层
const AdminPanel = {
setup() {
const logger = inject(LoggerKey)!
// 拿到的是 Admin 模块的 logger(debug 级别)
// 而不是应用层的 logger(warn 级别)
}
}
这正是原型链遮蔽的天然效果——近者优先。
“近者优先”是 DI 分层设计里最好用的一条规则。想象一下如果不是这个语义,而是”远者优先”——你会发现想做模块级覆盖几乎不可能,只能反过来在所有消费者那里做 if/else 判断。原型链的”子覆盖父”语义天然符合我们做软件设计时的直觉——越靠近业务场景的约束越具体,应该优先适用;越全局的配置越通用,只在局部没有覆盖时生效。这也是 CSS 层叠、JavaScript 作用域链、Git 配置(local 覆盖 global 覆盖 system)遵循的同一个原则——Vue 的 DI 继承了这条”层层覆盖”的 Unix 老传统。
14.11.3 hasInjectionContext:安全检测
Vue 3.3 引入了 hasInjectionContext() 工具函数:
import { hasInjectionContext, inject } from 'vue'
function useFeature() {
if (hasInjectionContext()) {
// 在 Vue 组件的 setup 中调用,可以安全地使用 inject
const config = inject(ConfigKey)
return createFeature(config)
}
// 在 Vue 组件外部调用(如纯工具函数)
return createFeature(defaultConfig)
}
其实现非常简单:
export function hasInjectionContext(): boolean {
return !!(currentInstance || currentRenderingInstance || currentApp)
}
但这个小函数解决了组合式函数的一个大问题:让同一个函数既可以在 Vue 组件内使用(享受依赖注入),也可以在组件外使用(使用默认配置)。
14.11.4 作用域销毁:配合 effectScope
DI 本身不负责销毁,但可以配合 effectScope 实现作用域化的副作用管理:
const ServiceKey: InjectionKey<EventService> = Symbol('EventService')
function provideService() {
const scope = effectScope()
const service = scope.run(() => {
const ws = new WebSocket('/events')
onUnmounted(() => ws.close())
return new EventService(ws)
})!
provide(ServiceKey, service)
onUnmounted(() => scope.stop()) // 组件卸载时清理所有相关 effect
}
14.12 性能考量
前面把 DI 的语义讲得非常细——现在把注意力转向性能,因为这类”底层到不可见”的机制一旦有性能问题,排查起来会特别痛苦。好在 Vue 的 DI 设计把性能陷阱都提前规避了,这一节我们把能规避的和规避不了的分别说清楚,让你在自己设计跨层通信方案时知道哪些坑可以提前绕开。
14.12.1 原型链深度
理论上,组件嵌套越深,inject 的查找路径越长。但实际上:
- JavaScript 引擎优化:V8 等引擎对原型链查找有内联缓存(Inline Cache)优化,热路径上的查找接近 O(1)
- 查找发生在 setup 阶段:组件的
setup()只执行一次,inject 的结果会被缓存在局部变量中 - 不影响更新性能:inject 返回的是引用(ref/reactive),后续的响应式更新走的是响应系统的路径,不再需要原型链查找
14.12.2 内存开销
每个调用了 provide 的组件:一个浅层对象(Object.create(parent))
没有调用 provide 的组件:零开销(共享父对象引用)
在一个 1000 个组件的页面中,如果只有 10 个组件调用了 provide,那么只会创建 10 个额外的对象。这个开销可以忽略不计。
14.12.3 DI 不会成为热路径上的瓶颈
我见过一些同学在压力测试里怀疑”inject 会不会是大列表渲染的性能瓶颈”——答案是几乎不会。原因有三:第一,列表项通常只 inject 少数几个全局依赖(theme、i18n、router),不会沿链找很深;第二,inject 只在 setup 执行时调用一次,之后每次重渲染都是对已经是局部变量的 theme、t 的直接引用,不再触发任何原型链查找;第三,即使最坏情况——1000 个组件每个都做 3 次 inject——也只是 3000 次原型链访问,在现代 JS 引擎上总耗时在亚毫秒级。真正的大列表性能瓶颈几乎永远在 patch 阶段(第 11 章已经详细讨论过),而不是 DI 阶段。如果你的 profile 指向了 inject,那基本可以肯定是别的问题伪装成了 inject 的耗时——值得翻回第 18 章的性能分析部分重新看看火焰图怎么读。
14.13 小结
本章深入解析了 Vue 的依赖注入与插件系统,核心要点:
| 机制 | 关键实现 | 设计理念 |
|---|---|---|
| provide | Object.create(parent.provides) | 延迟分叉,零成本继承 |
| inject | in 操作符 + 原型链查找 | 复用语言原生机制 |
| InjectionKey | Symbol + 泛型 | 零成本类型安全 |
| app.provide | appContext.provides | 全局依赖的起点 |
| app.use | installedPlugins Set | 幂等安装,链式调用 |
| 插件模式 | create → use → inject | Vue 生态的标准三段式 |
依赖注入看似简单,但它是 Vue 整个生态系统的”神经网络”——Pinia 通过它传递 store,Router 通过它传递路由实例,所有组合式函数通过它共享跨组件状态。在接下来的两章中,我们将看到 Pinia 和 Vue Router 如何在这套机制之上构建复杂而强大的功能。
一句话记忆:
Provide 是”把东西放上货架”,Inject 是”找最近的货架”——Vue 把货架组织成组件树的原型链,让找东西免费、放东西便宜。
这一章留给你的工程直觉
如果 10 年之后你还记得本章的一件事,我希望是这个:当一个底层语言机制能被直接复用时,不要自己再造一个。Vue 用原型链做 DI、用 Proxy 做响应式、用微任务做 nextTick——每一次都在避免重新发明轮子。这不是”偷懒”,而是一种工程克制:知道什么值得自己做、什么应该交给 JavaScript 引擎做、什么应该交给浏览器做。
初学者常常把”代码多 = 工程正式”当成好事,写一个功能要铺很多层抽象;成熟工程师恰恰相反——他们会反复问”这里真的需要抽象吗”,能砍掉的层尽量砍。Vue 3 源码之所以值得读,很大一部分原因是它在每个关键决策点都选了”更克制”的路。读源码的过程,其实也是在训练这种克制的工程审美。
延伸阅读
- Vue 3 源码
packages/runtime-core/src/apiInject.ts:整份文件只有不到 100 行,强烈推荐和本章一起通读——你会发现前面讲的所有细节都落在非常少的几行代码里。 - Vue 3 源码
packages/runtime-core/src/apiCreateApp.ts:重点看createAppAPI的返回值部分——use、component、directive、provide、mixin全在这个对象里,阅读这段代码能一次性建立对app对象结构的全局认知。 - V8 博客 Fast properties in V8(v8.dev/blog/fast-properties):如果你想彻底搞懂”为什么借原型链会比 while 循环快”,这篇是必读——它把 V8 对属性访问的 Inline Cache、Hidden Class、Transition Tree 讲得非常透彻。
- Angular 官方文档 Hierarchical Injectors:对比 Vue DI 最好的材料——你会看到同一个”跨组件共享依赖”问题,用 IoC 容器的思路可以演化成多么精巧(但也复杂)的系统。
- React RFC use(Context):React 团队对 Context API 反思后给出的新提案,里面对 Context 早期设计缺陷的承认值得一读,有助于你理解 Vue DI 在哪些方面”恰好没踩那些坑”。
下一章我们会看 Pinia——它是 DI + 响应式 + 组合式 API 三条线索汇合处开出的一朵花:用 setup 函数定义 store,用 app.use(pinia) 注入应用,用 useStore() 在组件里消费。如果你完整消化了本章,Pinia 源码在你眼里会变成”三段式的自然应用”而不是”又一个状态管理库”。
思考题
-
如果在同一个组件的 setup 中先 provide 再 inject 同一个 key,能拿到自己提供的值吗?为什么?请从源码角度解释。
-
为什么 Vue 选择原型链而不是手动的 while 循环向上查找?两种方案在性能和语义上有什么差异?
-
设计一个支持”作用域销毁”的依赖注入方案:当提供依赖的组件卸载时,自动清理相关资源。你会如何利用现有的 provide/inject 和生命周期钩子来实现?
-
在微前端场景下,多个 Vue 应用实例共享同一页面。如果需要在不同应用间共享某些依赖(如用户认证状态),你会如何设计?直接使用 provide/inject 可行吗?