一、先说个场景
Hello~大家好,我是秋天的一阵风
写 TypeScript 的你,provide/inject 是不是还在裸奔?
你肯定写过这种代码:
ts
// 祖先组件
provide('theme', ref('dark'))
// 后代组件
const theme = inject('theme') // theme 的类型是 any,你心里没点数吗
跑起来没问题,但你把 'theme' 打成 'thme',TypeScript 一声不吭。等到线上样式崩了你才发现------拼写错误,堪称前端工程师的经典翻车现场。
更窒息的是,theme 推导出来的类型是 any。你想用 .value?随便用。你想调 .toUpperCase()?也随便用。反正 TypeScript 不拦你,报不报错全靠运气。
这就是 InjectionKey 要解决的问题。
二、InjectionKey 到底是啥
一句话:给 provide/inject 加类型标注的钥匙。
它的类型定义长这样(源码在这):
ts
interface InjectionConstraint<T> {}
export type InjectionKey<T> = symbol & InjectionConstraint<T>
拆开来看:
InjectionConstraint<T>是一个空接口,里面啥都没有。InjectionKey<T>是symbol和这个空接口的交叉类型。
一个空接口有什么用?它在运行时什么都不干,编译成 JS 之后直接消失。但 TypeScript 编译器会"记住"这个泛型参数 T。
ts
const key: InjectionKey<string> = Symbol('test')
console.log(typeof key) // 'symbol',它就是个普通 Symbol
运行时的 key 就是一个干干净净的 Symbol,没有魔法。
那它图什么?图的是类型层面的信息传递。
TypeScript 编译器看到你写了 InjectionKey<Ref<string>>,它就"记住"了:这个 Symbol 对应的值应该是 Ref<string>。等你用 inject(key) 的时候,编译器根据这个记忆自动推导返回类型。
ts
const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
// TypeScript 编译器的内心独白:
// "themeKey 是 InjectionKey<Ref<string>>,
// 所以 inject(themeKey) 的返回值应该是 Ref<string> | undefined"
这其实是 TypeScript 的一个经典技巧,叫品牌类型(branded type)。用来给同一种底层类型打上不同的"品牌":
ts
type UserId = string & { __brand: 'userId' }
type OrderId = string & { __brand: 'orderId' }
const uid: UserId = '123' as UserId
const oid: OrderId = '456' as OrderId
// 虽然运行时都是 string,但 TypeScript 认为它们是不同类型
// 你不能把 UserId 赋值给 OrderId,编译报错
InjectionKey 用的是同一招:把不同的泛型参数 T 当作不同的"品牌",这样每个 InjectionKey 的类型信息就不会串。
打个比方:想象你有一把钥匙,钥匙上贴了个小纸条写着"开卧室门"。钥匙本身是金属做的,纸条不影响开锁功能。但你一看纸条就知道这把钥匙对应哪扇门。symbol 就是钥匙本身,InjectionConstraint<T> 就是那张纸条------纸条不参与开锁,但它帮你快速找到正确的钥匙。
总结就是:InjectionConstraint<T> 运行时是空气,编译时是 TypeScript 推导类型的依据。
三、怎么用
第一步:定义一把钥匙
ts
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
注意两个细节:
- 用的是
Symbol('theme'),不是'theme'字符串。Symbol 天然唯一,不存在命名冲突。 - 类型标注
InjectionKey<Ref<string>>,意思是:谁用这把钥匙 inject,拿到的一定是Ref<string>。
第二步:provide 的时候用它
ts
// 祖先组件
import { provide, ref } from 'vue'
import { themeKey } from './keys'
const theme = ref('dark')
provide(themeKey, theme)
如果你 provide 的值和钥匙的类型对不上,TypeScript 直接报错:
ts
provide(themeKey, 42) // ❌ 类型 'number' 不能赋值给类型 'Ref<string>'
编译期就拦住你,不用等到运行时炸。
第三步:inject 的时候也用它
ts
// 后代组件
import { inject } from 'vue'
import { themeKey } from './keys'
const theme = inject(themeKey) // 类型自动推导为 Ref<string> | undefined
看见没?不用手动写类型了。inject(themeKey) 自动知道返回的是 Ref<string>(或者 undefined,因为可能没人 provide)。
如果你想断言它一定存在:
ts
const theme = inject(themeKey)! // Ref<string>
// 或者给个默认值
const theme = inject(themeKey, ref('light')) // Ref<string>
四、实战:全局状态管理
光说理论不过瘾,来个实际场景。假设你在做一个多主题切换的项目:
ts
// keys.ts
import type { InjectionKey, Ref, ComputedRef } from 'vue'
interface ThemeContext {
current: Ref<string>
toggle: () => void
isDark: ComputedRef<boolean>
}
export const themeKey: InjectionKey<ThemeContext> = Symbol('theme')
ts
// ThemeProvider.vue
<script setup lang="ts">
import { ref, computed, provide } from 'vue'
import { themeKey } from './keys'
const current = ref('dark')
const toggle = () => {
current.value = current.value === 'dark' ? 'light' : 'dark'
}
const isDark = computed(() => current.value === 'dark')
provide(themeKey, { current, toggle, isDark })
</script>
<template>
<slot />
</template>
ts
// 任意后代组件
<script setup lang="ts">
import { inject } from 'vue'
import { themeKey } from './keys'
const theme = inject(themeKey)!
// 全部有类型提示,一个字母都不会错
theme.current.value // string
theme.toggle() // void
theme.isDark.value // boolean
</script>
五、和 defineProps 的对比
你可能会问:这和 defineProps 的类型标注思路不是一样的吗?
没错,核心思想一样------用类型系统约束运行时行为。区别在于:
| defineProps | InjectionKey | |
|---|---|---|
| 作用域 | 父 → 子(组件树) | 祖先 → 任意后代(跨层级) |
| 类型标注方式 | 泛型参数 | 单独定义 Symbol |
| 编译时检查 | ✅ | ✅ |
| 运行时验证 | 可选(withDefaults) | 无 |
defineProps 是垂直方向的类型安全,InjectionKey 是穿透方向的类型安全。两者不冲突,各管各的。
六、几个容易踩的坑
坑一:钥匙放哪
别把钥匙定义在组件文件里,否则每次 import 可能拿到不同的 Symbol 实例。统一放在一个 keys.ts 文件里:
css
src/
├── keys.ts ← 所有 InjectionKey 集中管理
├── components/
│ └── ...
坑二:别忘了 undefined
inject() 的返回类型永远包含 undefined,因为没人能保证上游一定 provide 了。要么用 ! 断言,要么给默认值,要么老老实实做空值判断。
坑三:响应式丢失
provide 一个普通对象,下游拿到的是非响应式的。要保证响应式,要么传 ref/reactive,要么传整个 composables 的返回值(上面 ThemeContext 的例子就是这么干的)。
总结
InjectionKey 解决的问题很简单:让 provide/inject 从"口头约定"变成"合同约束"。
没有它,你靠字符串匹配,靠自觉,靠祈祷。 有了它,TypeScript 帮你盯着,拼错一个字母编译就过不了。
就这一个 Symbol 的包装,值得你在每个用了 provide/inject 的项目里都加上。
写 Vue 3 + TypeScript 不用 InjectionKey,就像写合同不盖章------双方口头答应了,出事了谁也不认。