Vue 基础体系 · 第 31/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Plugin 与 Provide/Inject:依赖注入、类型和作用域
在 Vue 3 中,Plugin、provide 和 inject 经常一起出现,但它们解决的是不同层次的问题:
- 依赖注入(Dependency Injection,DI):组件不直接创建依赖,而是从外部上下文取得依赖。
provide/inject:Vue 提供的依赖注入机制,负责在组件树或应用上下文中发布和查找值。- Plugin:一种应用初始化扩展机制,通常在
app.use()时注册全局能力,也可以借此调用app.provide()。 - 类型:TypeScript 只能约束编译期代码,不能在运行时保证注入值真的存在或结构正确。
- 作用域:依赖值并不是“全局变量”,它具有应用级、组件树级和最近祖先覆盖等边界。
理解这些概念的关键,是先区分“谁负责创建依赖”“谁负责保存依赖”“谁负责查找依赖”。
一、依赖注入到底解决什么问题
假设一个组件需要主题服务:
const theme = new ThemeService()
如果组件自己创建服务,会产生几个直接后果:
- 组件和具体实现强耦合;
- 同一个服务可能被多个组件重复创建;
- 测试时难以替换成 mock;
- 服务配置分散在各个组件内部;
- 服务的生命周期和组件生命周期混在一起。
依赖注入把创建和使用拆开:
// 外部创建
const theme = createThemeService()
// 外部注册
provide(themeKey, theme)
// 组件只声明依赖
const theme = inject(themeKey)
组件不再关心服务从哪里来,只关心服务是否满足约定的接口。
可以把依赖解析形式化为:
其中:
- 是依赖键,例如一个
Symbol; - 是当前组件;
- 是最近祖先组件提供的值;
- 是应用级提供的值;
- 是显式传入的默认值。
这个规则说明了两个重要事实:
inject()不是任意位置的全局查找,而是沿着当前组件的逻辑祖先链查找;- 如果多个祖先使用同一个 key,离当前组件最近的 provider 会覆盖更远的 provider。
二、provide 与 inject 的基本机制
1. 组件级 provide
组件可以在 setup() 中向后代组件提供依赖:
<!-- Parent.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { themeKey } from './theme'
const theme = ref<'light' | 'dark'>('light')
provide(themeKey, {
theme,
toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
},
})
</script>
<template>
<Child />
</template>
后代组件可以注入:
<!-- Child.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from './theme'
const themeService = inject(themeKey)
if (!themeService) {
throw new Error('Theme service is not provided')
}
</script>
<template>
<button @click="themeService.toggle()">
当前主题:{{ themeService.theme }}
</button>
</template>
Child 不需要是 Parent 的直接子组件。只要它位于 Parent 的后代组件树中,就可以获取这个依赖。
但是,兄弟组件不能直接互相注入:
Parent
├── ChildA provide(key, value)
└── ChildB inject(key)
ChildB 不会向兄弟节点查找 ChildA 提供的值。依赖查找方向是从当前组件向祖先方向,而不是横向遍历整个组件树。
2. 应用级 app.provide
也可以把依赖注册到应用上下文:
import { createApp } from 'vue'
import App from './App.vue'
import { themeKey } from './theme'
const app = createApp(App)
app.provide(themeKey, {
theme: ref('light'),
toggle() {
// ...
},
})
app.mount('#app')
应用级依赖对该应用创建的所有组件可用。它适合:
- 路由器;
- API 客户端;
- 国际化服务;
- 权限服务;
- 应用级配置;
- 插件创建的共享服务。
应用级 provider 可以被组件级 provider 覆盖。可以把它理解为应用根上下文中的默认值:
应用上下文:themeKey -> lightTheme
App
└── Layout
└── AdminArea
└── provide(themeKey, darkTheme)
└── AdminPanel -> inject(themeKey) 得到 darkTheme
这里 AdminPanel 得到的是 darkTheme,因为组件级 provider 比应用级 provider 更接近它。
三、inject 的查找时机和默认值
1. inject 必须在正确的上下文中调用
通常应在组件的 setup() 或 <script setup> 顶层调用:
const service = inject(serviceKey)
不要在组件挂载后、普通事件回调中首次调用:
onMounted(() => {
// 不应把 inject 当作任意时刻的查找函数
const service = inject(serviceKey)
})
inject() 依赖当前正在执行的组件实例上下文。普通事件回调执行时,不一定存在这个上下文。
组合式函数也可以使用 inject(),但该组合式函数必须在组件 setup() 执行期间调用:
// useTheme.ts
import { inject } from 'vue'
import { themeKey } from './theme'
export function useTheme() {
const theme = inject(themeKey)
if (!theme) {
throw new Error(
'useTheme() must be called under a component that provides theme service',
)
}
return theme
}
<script setup lang="ts">
import { useTheme } from './useTheme'
const theme = useTheme()
</script>
这个封装把“依赖不存在时怎么办”统一起来,比让每个组件自行处理 undefined 更容易维护。
2. 默认值
inject 的第二个参数可以提供默认值:
const logger = inject(loggerKey, console)
如果没有找到 provider,logger 就是 console。
如果默认值的创建成本较高,或者创建过程有副作用,可以使用第三个参数:
const service = inject(
serviceKey,
() => createFallbackService(),
true,
)
这里第三个参数 true 表示把第二个参数当作工厂函数,只有注入失败时才执行。
这三种写法的语义不同:
// 默认值本身就是服务对象
inject(serviceKey, fallbackService)
// 默认值是一个函数对象,不会自动执行
inject(callbackKey, fallbackCallback)
// 第三个参数为 true,函数才被视为默认值工厂
inject(serviceKey, () => createFallbackService(), true)
如果依赖是必需的,通常不应静默使用默认服务,而应直接抛出错误。默认值适合真正具有合理降级行为的依赖,例如可选日志器或开发环境工具。
四、响应式值不会因为注入自动产生
provide 和 inject 本身只是传递引用,不会自动把普通值变成响应式值。
1. 提供普通值:得到的是一次性的值
const count = 0
provide(countKey, count)
后续即使某处重新给局部变量赋值,也不会更新已经提供的值:
let count = 0
provide(countKey, count)
count = 1
provider 中保存的是最初传入的数字 0。数字是原始值,传递时已经复制。
2. 提供 ref:注入方共享同一个响应式引用
const count = ref(0)
provide(countKey, count)
后代组件注入后:
const count = inject(countKey)
if (!count) {
throw new Error('count is not provided')
}
count.value++
这里 provider 和 injector 访问的是同一个 ref 对象,因此修改会触发依赖更新。
在 <template> 中,Vue 会对模板表达式中的 ref 自动解包:
<template>
<span>{{ count }}</span>
</template>
但在普通 TypeScript 代码中仍然需要使用:
count.value
3. provide 不会自动解包
下面两种代码的含义不同:
const count = ref(0)
provide(countKey, count) // 注入方拿到 Ref<number>
provide(countKey, count.value) // 注入方拿到当前 number
后一种会丢失响应式引用,只提供当前快照。
五、类型安全:为什么应优先使用 InjectionKey<T>
Vue 的 provide 和 inject 支持字符串、数字或 Symbol 作为 key,但 TypeScript 需要一种方式把 key 和值的类型关联起来。
Vue 提供了 InjectionKey<T>:
import type { InjectionKey, Ref } from 'vue'
export type Theme = 'light' | 'dark'
export interface ThemeService {
readonly theme: Readonly<Ref<Theme>>
toggle(): void
}
export const themeKey: InjectionKey<ThemeService> =
Symbol('theme')
现在 provide 和 inject 会共享 ThemeService 类型:
provide(themeKey, {
theme,
toggle() {
// ...
},
})
如果缺少字段,TypeScript 会报错:
provide(themeKey, {
theme,
// 缺少 toggle()
})
注入时也可以获得类型推导:
const themeService = inject(themeKey)
// 类型为 ThemeService | undefined
undefined 仍然存在,是因为 TypeScript 不知道运行时是否真的有组件提供该依赖。InjectionKey<T> 只能保证“如果找到值,它应当是 T”,不能保证“值一定存在”。
1. 使用唯一 Symbol,避免字符串冲突
不推荐在大型项目中直接使用字符串:
provide('theme', service)
inject('theme')
字符串 key 可能被不同模块意外复用。更稳妥的做法是把 key 和类型放在同一个模块中:
// theme.ts
export const themeKey: InjectionKey<ThemeService> =
Symbol('theme')
所有 provider 和 injector 都从这个模块导入同一个 themeKey。
下面的代码即使描述文字相同,也不是同一个 key:
const keyA = Symbol('theme')
const keyB = Symbol('theme')
keyA === keyB // false
因此不能让 provider 和 injector 各自创建 Symbol('theme')。在 monorepo 或组件库场景中,还应注意同一 key 模块不能因重复打包而产生多份运行时副本,否则两份 Symbol 也无法匹配。
2. 类型断言不能提供运行时验证
下面的写法只是在欺骗 TypeScript:
const key = Symbol('service') as InjectionKey<ThemeService>
它不会检查运行时提供的对象:
provide(key, {
wrongField: true,
} as unknown as ThemeService)
如果依赖来自外部配置、网络数据或 JavaScript 包,仍然需要运行时校验。TypeScript 类型和运行时验证解决的是两个不同问题:
- TypeScript:防止源码中的已知类型错误;
- 运行时校验:防止实际输入不符合预期结构。
六、对外暴露只读状态,避免注入方任意修改
如果 provider 直接暴露可写的 ref:
const theme = ref<Theme>('light')
provide(themeKey, {
theme,
toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
},
})
任何注入方都可以绕过服务方法:
themeService.theme.value = 'dark'
这可能破坏状态修改规则。可以对外暴露只读引用:
import { readonly, ref } from 'vue'
const theme = ref<Theme>('light')
const service: ThemeService = {
theme: readonly(theme),
toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
},
}
完整的类型和实现如下:
// theme.ts
import {
readonly,
ref,
type InjectionKey,
type Plugin,
type Ref,
} from 'vue'
export type Theme = 'light' | 'dark'
export interface ThemeService {
readonly theme: Readonly<Ref<Theme>>
toggle(): void
}
export const themeKey: InjectionKey<ThemeService> =
Symbol('theme')
export function createThemePlugin(
initialTheme: Theme = 'light',
): Plugin {
return {
install(app) {
const theme = ref<Theme>(initialTheme)
const service: ThemeService = {
theme: readonly(theme),
toggle() {
theme.value =
theme.value === 'light' ? 'dark' : 'light'
},
}
app.provide(themeKey, service)
},
}
}
这里有两个重要设计点:
theme的可写引用只保留在插件内部;- 对外接口只暴露
readonly(theme)和明确的toggle()方法。
这不是 Vue 的强制要求,而是依赖服务的封装策略。对于简单配置可以直接提供对象;对于有状态、有约束的服务,隐藏内部可变状态通常更安全。
七、Plugin 是什么,以及它和 provide/inject 的关系
Vue 插件不是一种特殊的组件,也不是自动拥有全局状态的对象。插件只是一个可被 app.use() 安装的扩展。
插件可以是:
import type { App, Plugin } from 'vue'
const plugin: Plugin = {
install(app: App, options?: unknown) {
// 注册能力
},
}
也可以是一个函数:
import type { App } from 'vue'
const plugin = (app: App, options?: unknown) => {
// 注册能力
}
安装时:
const app = createApp(App)
app.use(plugin, {
/* 插件配置 */
})
app.mount('#app')
app.use(plugin, options) 会调用:
plugin.install(app, options)
如果插件本身就是函数,则直接调用这个函数。
插件常见的能力包括:
app.provide()注册应用级依赖;app.component()注册全局组件;app.directive()注册全局指令;app.config.globalProperties增加组件实例属性;- 安装路由、状态管理或其他应用级服务。
其中,插件和依赖注入的关系通常是:
createApp
│
├── app.use(plugin)
│ └── plugin.install(app)
│ └── app.provide(serviceKey, service)
│
└── mount
└── 组件 setup()
└── inject(serviceKey)
插件负责初始化和注册;inject 负责在组件中取得注册结果。插件本身不是依赖注入机制,但它是组织应用级依赖的一种常见入口。
八、一个可运行的 Vue 3 + TypeScript 示例
假设项目由现代 Vite 创建:
npm create vite@latest vue-di-demo -- --template vue-ts
cd vue-di-demo
npm install
npm run dev
将主题服务写入 src/theme.ts:
import {
readonly,
ref,
type InjectionKey,
type Plugin,
type Ref,
} from 'vue'
export type Theme = 'light' | 'dark'
export interface ThemeService {
readonly theme: Readonly<Ref<Theme>>
toggle(): void
}
export const themeKey: InjectionKey<ThemeService> =
Symbol('theme')
export function createThemePlugin(
initialTheme: Theme = 'light',
): Plugin {
return {
install(app) {
const theme = ref<Theme>(initialTheme)
const service: ThemeService = {
theme: readonly(theme),
toggle() {
theme.value =
theme.value === 'light' ? 'dark' : 'light'
},
}
app.provide(themeKey, service)
},
}
}
在入口安装插件:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createThemePlugin } from './theme'
const app = createApp(App)
app.use(createThemePlugin('dark'))
app.mount('#app')
组件中注入:
<!-- src/App.vue -->
<script setup lang="ts">
import { computed, inject } from 'vue'
import { themeKey } from './theme'
const themeService = inject(themeKey)
if (!themeService) {
throw new Error(
'Theme service is unavailable. Did you install the theme plugin?',
)
}
const currentTheme = computed(() => themeService.theme.value)
</script>
<template>
<main :class="`theme-${currentTheme}`">
<p>当前主题:{{ currentTheme }}</p>
<button type="button" @click="themeService.toggle()">
切换主题
</button>
</main>
</template>
<style>
.theme-light {
background: white;
color: black;
}
.theme-dark {
background: #222;
color: white;
}
</style>
运行后的状态变化如下:
createThemePlugin('dark')创建一个插件对象;app.use()调用插件的install();install()创建theme这个ref;- 插件把
ThemeService放入当前应用上下文; App.vue的inject(themeKey)找到该服务;currentTheme读取同一个ref;- 点击按钮调用
toggle(); theme.value变化,computed重新计算,模板更新。
如果忘记安装插件:
const app = createApp(App)
app.mount('#app')
inject(themeKey) 会返回 undefined。Vue 通常还会在开发环境输出“未找到注入值”的警告,而示例中的显式错误会进一步说明修复方向。
九、插件状态必须放在正确的作用域中
上面的插件把状态创建在 install() 内部:
install(app) {
const theme = ref(initialTheme)
app.provide(themeKey, service)
}
因此状态属于这次插件安装对应的应用。
这与模块级单例不同:
// 不同应用会共享这个模块级对象
const globalTheme = ref<Theme>('light')
export function createBadPlugin(): Plugin {
return {
install(app) {
app.provide(themeKey, {
theme: globalTheme,
toggle() {
// ...
},
})
},
}
}
模块级单例的风险包括:
- 同一页面创建多个 Vue 应用时,它们共享状态;
- 测试用例之间可能相互污染;
- SSR 中不同请求可能读取或修改同一个用户状态;
- 应用销毁后,单例仍然由模块引用保留。
更安全的结构是:
export function createThemePlugin(initial: Theme) {
return {
install(app) {
const state = createState(initial)
app.provide(themeKey, createService(state))
},
}
}
每个应用、每次插件安装都创建自己的状态。
但是还要注意插件对象的身份。Vue 会记录已经安装到某个应用的插件,避免同一个插件对象被重复安装:
const plugin = createThemePlugin('light')
app.use(plugin)
app.use(plugin) // 同一个 app 中通常不会再次安装
如果每次都调用工厂函数,就会得到不同的插件对象:
app.use(createThemePlugin('light'))
app.use(createThemePlugin('dark'))
这两个对象不是同一个插件,后一次安装可能覆盖同一个 key 对应的 provider。应用代码应明确规定插件只安装一次,避免通过多次安装产生不确定的配置和状态。
十、Provide/Inject 的作用域边界
1. 应用作用域
app.provide(apiClientKey, apiClient)
该值对当前 app 创建的组件树可见。
两个独立应用之间默认不共享应用上下文:
const appA = createApp(AppA)
const appB = createApp(AppB)
在 appA 中提供的依赖,不能被 appB 中的组件注入。
2. 组件树作用域
provide(formContextKey, formContext)
该值只对当前组件及其后代可见。它特别适合:
- 表单上下文;
- 表格上下文;
- 设计系统组件上下文;
- 局部权限或局部配置;
- 复合组件之间的内部通信。
3. 最近 provider 覆盖远处 provider
<script setup lang="ts">
provide(configKey, { mode: 'outer' })
</script>
<template>
<InnerProvider />
</template>
<script setup lang="ts">
provide(configKey, { mode: 'inner' })
</script>
<template>
<Consumer />
</template>
Consumer 读取的是 inner。这种覆盖行为可以实现嵌套上下文,但也会使依赖来源变得隐蔽,因此 key 的命名和组件边界必须清晰。
4. Teleport 不等于脱离组件依赖树
Teleport 改变的是 DOM 节点插入位置,不是组件的逻辑父子关系。被 Teleport 的组件仍然属于原来的 Vue 组件树,通常仍能访问原祖先提供的依赖。
这与把组件挂载到另一个独立 createApp() 中不同。独立应用拥有独立的 app context,不能因为 DOM 节点相邻就共享注入值。
十一、Plugin 注入和 globalProperties 不是一回事
插件还可以这样扩展组件实例:
app.config.globalProperties.$api = apiClient
在 Options API 中可以使用:
export default {
mounted() {
this.$api.get('/users')
},
}
这与:
app.provide(apiClientKey, apiClient)
有本质区别。
| 机制 | 访问方式 | 主要作用域 | 类型处理 |
|---|---|---|---|
provide/inject |
inject(apiClientKey) |
组件树或应用上下文 | 使用 InjectionKey<T> |
globalProperties |
this.$api、模板中的 $api |
应用级组件实例 | 需要模块声明合并 |
| props | 父传子 | 明确的直接父子关系 | 类型最显式 |
| 模块导出单例 | import { api } |
JavaScript 模块作用域 | 不受 Vue 组件树控制 |
如果使用 globalProperties,TypeScript 需要扩展 Vue 的组件实例类型:
// src/types/vue.d.ts
import type { ApiClient } from '../api'
declare module 'vue' {
interface ComponentCustomProperties {
$api: ApiClient
}
}
globalProperties 适合少量稳定的实例级工具,例如 $formatDate 或 $api。它的问题是依赖来源不够显式:组件只看到 $api,但不容易从代码结构中看出它在哪里注册。
provide/inject 更适合组合式 API 和可测试服务:
const api = inject(apiClientKey)
if (!api) {
throw new Error('API client is unavailable')
}
依赖键直接出现在组合式函数中,作用域和替换方式更明确。
十二、生命周期:服务何时创建,何时销毁
组件级 provider 的值通常在 provider 组件的 setup() 中创建:
const controller = createController()
provide(controllerKey, controller)
组件卸载时,如果这个服务内部有定时器、事件监听或订阅,应由 provider 负责清理:
const controller = createController()
provide(controllerKey, controller)
onUnmounted(() => {
controller.dispose()
})
否则即使组件已经卸载,定时器或事件监听仍可能持有闭包引用,造成资源泄漏。
应用级插件服务的生命周期更长,通常从 app.use() 持续到应用卸载。插件如果创建了 WebSocket、轮询器或全局事件监听,也应设计明确的 dispose():
export interface SocketService {
connect(): void
dispose(): void
}
然后由应用根组件或应用管理代码在销毁时调用。不要假设“提供给 app 的对象会自动销毁”;provide 只负责存储和查找引用,不会自动分析服务内部资源。
如果服务内部使用 Vue 的 watch() 或 effectScope(),还应保存停止函数或作用域:
const stop = watch(source, callback)
onUnmounted(() => {
stop()
})
依赖注入管理的是可访问性,不自动管理任意第三方资源的生命周期。
十三、SSR 中必须避免跨请求共享可变依赖
在服务端渲染中,通常每个请求都应创建一个新的应用:
export function createApp() {
const app = createSSRApp(App)
app.use(createThemePlugin('light'))
return { app }
}
不要把用户相关状态放在模块顶层:
// SSR 中危险:所有请求共享
const currentUser = ref<User | null>(null)
如果请求 A 修改了这个状态,请求 B 可能读取到请求 A 的数据。正确方式是把状态创建放入每次 createApp() 或插件安装流程:
export function createUserPlugin(user: User | null): Plugin {
return {
install(app) {
const currentUser = ref(user)
app.provide(userKey, {
user: readonly(currentUser),
})
},
}
}
这里的隔离条件是:
即不同请求必须拥有不同的可变状态对象。
SSR 还要求服务端初始值和客户端 hydration 时的初始值一致。若服务端提供主题为 dark,客户端启动时却通过浏览器 API 推断为 light,就可能出现服务端 HTML 与客户端初始渲染不一致的问题。注入服务本身不会自动解决 hydration 数据同步,初始状态仍需由应用显式传递。
十四、异步 setup 与依赖注入的边界
inject() 应在 setup 上下文仍然有效时同步调用:
<script setup lang="ts">
const service = inject(serviceKey)
await loadData()
// 可以继续使用已经取得的 service
service?.run()
</script>
不应在 await 之后才首次调用:
<script setup lang="ts">
await loadData()
const service = inject(serviceKey) // 不应依赖这种写法
</script>
原因不是依赖值变了,而是 inject() 需要当前组件的 setup 注入上下文。异步初始化可以先注入依赖,再等待数据。若业务需要根据异步结果选择不同服务,应先注入服务工厂或容器,再由工厂完成异步工作。
Suspense 可以协调异步组件渲染,但不会改变 provide/inject 的祖先查找规则,也不会让任意异步回调自动获得组件注入上下文。
十五、常见失败表现与诊断路径
失败一:注入结果为 undefined
const service = inject(serviceKey)
service.load()
运行时可能出现:
Cannot read properties of undefined
诊断顺序应是:
- provider 和 injector 是否导入了同一个 key;
- provider 是否位于当前组件的祖先链;
- 应用级插件是否执行了
app.use(); app.use()是否发生在mount()之前;- 是否实际创建了另一个独立 Vue 应用;
- 是否因重复依赖打包导致 key 模块存在多份运行时副本。
如果依赖是必需的,应立即转成明确错误:
function useRequiredService() {
const service = inject(serviceKey)
if (!service) {
throw new Error(
'Required service is missing. Check app.use() or provider scope.',
)
}
return service
}
失败二:值存在,但更新不触发
常见原因是提供了快照:
provide(countKey, count.value)
而不是引用:
provide(countKey, count)
也可能是提供了一个普通对象:
provide(stateKey, {
count: 0,
})
后续修改另一个对象或局部变量,并不会自动更新这个对象。需要提供 ref、reactive、computed,或者显式提供修改方法。
失败三:注入值类型正确,但运行时方法不存在
这通常来自不安全的类型断言:
provide(serviceKey, value as ThemeService)
类型断言没有改变 value 的运行时结构。应修复 provider 的真实实现,或者在外部输入边界进行运行时校验。
失败四:修改了嵌套 provider,却影响了错误的组件
如果一个子树重新 provide 同一个 key,只有它的后代会读取新值:
Root provider
├── A -> 读取 Root provider
└── B provider
└── C -> 读取 B provider
如果 A 也意外读取到了 B 的值,通常说明组件实际嵌套关系与预期不一致,或者组件被移动到了另一个 provider 的后代树中。应从 Vue Devtools 或组件模板结构检查逻辑组件树,而不是只看最终 DOM 位置。
十六、测试时如何替换注入依赖
依赖注入的一个直接价值是替换实现。
生产代码:
export interface ApiClient {
get<T>(url: string): Promise<T>
}
export const apiClientKey: InjectionKey<ApiClient> =
Symbol('api-client')
组件:
const api = inject(apiClientKey)
if (!api) {
throw new Error('API client is missing')
}
测试时可以提供 mock:
const mockApi: ApiClient = {
async get() {
return {
id: 1,
name: 'test user',
}
},
}
const app = createApp(TestHost)
app.provide(apiClientKey, mockApi)
app.mount(container)
组件不需要知道这是生产 API 客户端还是测试替身。测试替换依赖时必须使用同一个 apiClientKey 对象;重新创建一个同名 Symbol 不会生效。
对于组件级覆盖,也可以只在测试宿主组件中提供 mock,而不影响整个应用:
<script setup lang="ts">
provide(apiClientKey, mockApi)
</script>
<template>
<ComponentUnderTest />
</template>
这正是组件作用域 provider 相比模块级单例更容易隔离的地方。
十七、何时使用 props、provide/inject 或状态管理
这几种机制不能简单按“局部”和“全局”二分。
使用 props 的情况
父组件明确知道子组件需要什么,并且数据只经过一两层:
<ProfileCard :user="user" />
props 的优点是数据流显式,组件接口容易阅读和检查。
使用 provide/inject 的情况
依赖需要穿过多层组件,但并不适合暴露为每一层组件的公共 props,例如:
- 复合组件内部上下文;
- 表单字段访问表单控制器;
- 主题、国际化、API 客户端;
- 可替换的基础服务;
- 某个局部子树的配置。
使用状态管理的情况
多个不相关组件需要读写同一业务状态,或者状态需要:
- 明确的 action;
- 持久化;
- 开发工具追踪;
- 跨页面生命周期;
- 复杂的异步流程和状态转换。
provide/inject 可以承载共享状态,但它不会自动提供状态管理库的调试、持久化、模块化和约束能力。
一个实际判断标准是:如果组件需要的是“某个祖先上下文中的服务”,使用 provide/inject 很自然;如果应用需要“可被全局追踪的业务状态模型”,专门的状态管理方案通常更合适。
十八、最终模型:插件初始化,provider 保存,inject 查找
可以把完整关系压缩为下面的模型:
flowchart TD
A[createApp] --> B[app.use(plugin)]
B --> C[plugin.install(app)]
C --> D[创建服务与响应式状态]
D --> E[app.provide(key, service)]
E --> F[组件 setup]
F --> G[inject(key)]
G --> H{最近祖先或应用上下文是否存在}
H -- 是 --> I[返回同一个服务引用]
H -- 否 --> J[默认值或 undefined]
I --> K[调用服务或读取响应式状态]
K --> L[状态变化触发组件更新]
其中最容易混淆的边界是:
- Plugin 决定应用启动时注册什么;
provide决定依赖放入哪个组件或应用作用域;inject按当前组件的逻辑祖先链查找;InjectionKey<T>让 provider 和 injector 在编译期共享类型;ref、reactive、computed决定注入值是否具有响应式更新;- 服务内部的清理逻辑 决定资源是否能在组件或应用结束时正确释放。
因此,推荐的基础结构通常是:
// 1. 集中定义 key 和接口
export const serviceKey: InjectionKey<Service> = Symbol()
// 2. 在插件或 provider 中创建实例
const service = createService()
// 3. 注册到明确作用域
app.provide(serviceKey, service)
// 4. 在组合式函数中统一注入和错误处理
export function useService() {
const service = inject(serviceKey)
if (!service) {
throw new Error('Service is not available')
}
return service
}
这套结构没有把依赖伪装成不可追踪的全局变量,同时保留了应用级共享、组件级覆盖、TypeScript 类型推导和测试替换能力。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 自定义指令:生命周期、DOM 行为、清理和组件替代边界
- 下一篇:Vue Transition 与 TransitionGroup:进入离开、列表动画和性能
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论