Vue 基础体系 · 第 31/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Plugin 与 Provide/Inject:依赖注入、类型和作用域

在 Vue 3 中,Pluginprovideinject 经常一起出现,但它们解决的是不同层次的问题:

  • 依赖注入(Dependency Injection,DI):组件不直接创建依赖,而是从外部上下文取得依赖。
  • provide / inject:Vue 提供的依赖注入机制,负责在组件树或应用上下文中发布和查找值。
  • Plugin:一种应用初始化扩展机制,通常在 app.use() 时注册全局能力,也可以借此调用 app.provide()
  • 类型:TypeScript 只能约束编译期代码,不能在运行时保证注入值真的存在或结构正确。
  • 作用域:依赖值并不是“全局变量”,它具有应用级、组件树级和最近祖先覆盖等边界。

理解这些概念的关键,是先区分“谁负责创建依赖”“谁负责保存依赖”“谁负责查找依赖”。


一、依赖注入到底解决什么问题

假设一个组件需要主题服务:

const theme = new ThemeService()

如果组件自己创建服务,会产生几个直接后果:

  1. 组件和具体实现强耦合;
  2. 同一个服务可能被多个组件重复创建;
  3. 测试时难以替换成 mock;
  4. 服务配置分散在各个组件内部;
  5. 服务的生命周期和组件生命周期混在一起。

依赖注入把创建和使用拆开:

// 外部创建
const theme = createThemeService()

// 外部注册
provide(themeKey, theme)

// 组件只声明依赖
const theme = inject(themeKey)

组件不再关心服务从哪里来,只关心服务是否满足约定的接口。

可以把依赖解析形式化为:

resolve(k,c)={vi,从组件 c 向上查找,找到距离最近的 key 为 k 的 providervapp,组件树中没有找到,但应用上下文提供了 kd,没有 provider,且调用 inject 时提供了默认值undefined,以上都不存在resolve(k, c) = \begin{cases} v_i, & \text{从组件 } c \text{ 向上查找,找到距离最近的 key 为 } k \text{ 的 provider}\\ v_{app}, & \text{组件树中没有找到,但应用上下文提供了 } k\\ d, & \text{没有 provider,且调用 inject 时提供了默认值}\\ undefined, & \text{以上都不存在} \end{cases}

其中:

  • kk 是依赖键,例如一个 Symbol
  • cc 是当前组件;
  • viv_i 是最近祖先组件提供的值;
  • vappv_{app} 是应用级提供的值;
  • dd 是显式传入的默认值。

这个规则说明了两个重要事实:

  1. inject() 不是任意位置的全局查找,而是沿着当前组件的逻辑祖先链查找;
  2. 如果多个祖先使用同一个 key,离当前组件最近的 provider 会覆盖更远的 provider。

二、provideinject 的基本机制

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)

如果依赖是必需的,通常不应静默使用默认服务,而应直接抛出错误。默认值适合真正具有合理降级行为的依赖,例如可选日志器或开发环境工具。


四、响应式值不会因为注入自动产生

provideinject 本身只是传递引用,不会自动把普通值变成响应式值。

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 的 provideinject 支持字符串、数字或 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')

现在 provideinject 会共享 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)
    },
  }
}

这里有两个重要设计点:

  1. theme 的可写引用只保留在插件内部;
  2. 对外接口只暴露 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>

运行后的状态变化如下:

  1. createThemePlugin('dark') 创建一个插件对象;
  2. app.use() 调用插件的 install()
  3. install() 创建 theme 这个 ref
  4. 插件把 ThemeService 放入当前应用上下文;
  5. App.vueinject(themeKey) 找到该服务;
  6. currentTheme 读取同一个 ref
  7. 点击按钮调用 toggle()
  8. 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),
      })
    },
  }
}

这里的隔离条件是:

state(requesti)state(requestj)当 ijstate(request_i) \neq state(request_j) \quad \text{当 } i \neq j

即不同请求必须拥有不同的可变状态对象。

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

诊断顺序应是:

  1. provider 和 injector 是否导入了同一个 key;
  2. provider 是否位于当前组件的祖先链;
  3. 应用级插件是否执行了 app.use()
  4. app.use() 是否发生在 mount() 之前;
  5. 是否实际创建了另一个独立 Vue 应用;
  6. 是否因重复依赖打包导致 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,
})

后续修改另一个对象或局部变量,并不会自动更新这个对象。需要提供 refreactivecomputed,或者显式提供修改方法。

失败三:注入值类型正确,但运行时方法不存在

这通常来自不安全的类型断言:

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 在编译期共享类型;
  • refreactivecomputed 决定注入值是否具有响应式更新;
  • 服务内部的清理逻辑 决定资源是否能在组件或应用结束时正确释放。

因此,推荐的基础结构通常是:

// 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。