Vue 3 InjectionKey 编译后就是个 Symbol:类型检查通过了,注入为什么还是拿不到值
InjectionKey 只在 TypeScript 的类型检查阶段存在。经过编译后,它不会留下任何运行时代码。Symbol() as InjectionKey 里的 as 不会被编译成任何运行时校验;等到真正执行 provide / inject 时,系统能比较的只有 Symbol 的对象身份,并不存在 Theme 或 string 这类类型信息。
这条边界决定了后面几类失效:类型检查能通过,运行时却取不到值,或者取到的值是同一类型但来自另一份数据。
InjectionKey 编译后不剩任何东西
runtime-core/src/apiInject.ts 里它的定义是:
export interface InjectionConstraint {}
export type InjectionKey = symbol & InjectionConstraint
这是个 type 别名,既不是 class,也不是带运行时标记的对象;编译产物里没有任何对应代码。因此:
const key = Symbol() as InjectionKey
key 在 JS 里就是一个普通 Symbol(),其描述字符串(如果写了)只用于调试输出。
provide 真正执行的代码只有三件事:取得当前实例、创建原型链、把值挂到 key 上:
if (currentInstance) {
let provides = currentInstance.provides
const parentProvides = currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key as string] = value // TS doesn't allow symbol as index type
}
inject 则用 in 做查找:
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.`)
}
in 比较的是 key 本身,String(key) 只用于生成警告文案。整条链路上没有任何地方读取泛型参数。
判据:把 .ts 编译成 .js,看 key 还剩什么。如果只剩一个 Symbol(),那么任何类型层面的约束都不会在运行时生效。
判据一:key 是否是同一个 Symbol 实例
Symbol('theme') 每次调用都会生成新的身份,描述字符串相同不代表相等。
// a.ts
export const ThemeKey = Symbol('theme') as InjectionKey
// b.ts
export const ThemeKey = Symbol('theme') as InjectionKey
两个文件各自导出自己的 key,类型都叫 InjectionKey,provide 和 inject 都能通过编译;运行时 'theme' in provides 为 false,消费者拿到 undefined,dev 环境下打印 injection "Symbol(theme)" not found.。
反例的变体是字符串 key:
provide('theme', theme)
const theme = inject('theme') // string | undefined
字符串 key 拼错不会产生编译错误,inject 的泛型只是对返回值的断言。上面的 b.ts 如果写成 inject('them'),同样只能靠运行时警告发现。
判据:同一个 key 是否只有一个导出点。跨包、跨 monorepo 子项目、跨微前端边界时,尤其要确认是同一个模块实例,而不是两处各写了一遍 Symbol()。
如果确实需要跨模块共享同一身份,Symbol.for('theme') 会走全局注册表,两次调用返回同一个 Symbol,in 能命中。代价是它变成了全局命名空间,撞名会静默共享。
判据二:as 之后,谁在运行时检查值
Symbol() as InjectionKey 是断言,不是校验。provide 的泛型约束只在 TypeScript 这一侧成立:
const ThemeKey = Symbol() as InjectionKey
provide(ThemeKey, { color: 'dark' } as any) // 编译通过
绕过类型检查的路径不少:as any、.js 文件里调用、第三方包导出、单元测试里的 mock。运行时没有任何检查,消费者拿到的是同样的对象,类型却写着 Theme。错误会推迟到使用属性时出现,例如访问 theme.colors.primary 时读到 undefined。
判据:注入值的使用点离 provide 有多远。跨模块、跨包时,类型只是单方面的声明,需要在消费侧做一次运行时检查。把检查集中在 composable 里,同时完成错误信息、类型收窄:
export function useTheme(): Theme {
const theme = inject(ThemeKey)
if (!theme) throw new Error('useTheme() called without a ThemeKey provider in the component tree')
return theme
}
injectStrict 是同一思路的泛化版本:
export const injectStrict = (key: InjectionKey, defaultValue?: T | (() => T), treatDefaultAsFactory?: false): T => {
const result = inject(key, defaultValue, treatDefaultAsFactory)
if (!result) throw new Error('injection not found')
return result
}
注意这里的 if (!result) 会把 0、''、false 一并当成缺失,key 对应的值可能是 falsy 时应当改成 result === undefined 判断。
判据三:undefined 是真实状态,默认值会把它藏起来
官方类型是 inject(key): T | undefined,这个 undefined 是准确的:消费者可能被渲染在不含 provider 的子树里,比如单测直接 mount、Storybook 单独预览、路由切换时组件挂到了 provider 之外。
默认值会把 undefined 从类型里去掉,同时也去掉了「组件在预期的 provider 树之外」这条信号。源码里,arguments.length > 1 时直接返回默认值,连 dev 警告都不打印:
const theme = inject(ThemeKey, lightTheme) // Theme,看起来永远不会缺
结果整棵子树都在用默认主题,provider 挂了也没人发现。
判据:默认值是否是一个「合法但错误」的值。默认空数组、默认空对象、默认浅色主题都属于这类;它们会让缺失变得不可观测。这种情况下应该不传默认值,改用 useTheme 抛错。只有默认值本身就是正确语义时(例如可选的 feature flag),才适合使用默认值。
判据四:provide 的调用点是否在 setup 的同步路径上
源码里的警告条件是:
if (__DEV__) {
if (!currentInstance || currentInstance.isMounted) {
warn(`provide() can only be used inside setup().`)
}
}
组件挂载后再调用 provide,会有 dev 警告,但后面的 if (currentInstance) 仍然成立,值会被写入这个实例的 provides。问题在于此时子组件早已完成 inject,新写入的值不会回流。
反例:
setup() {
onMounted(async () => {
const config = await fetchConfig()
provide(ConfigKey, config) // 太晚了
})
}
子组件在挂载阶段 inject(ConfigKey) 拿到的是 undefined。正确做法是把异步结果放进 ref 或 shallowRef,同步 provide 这个 ref,异步完成后改 .value。
判据:provide 调用是否在 setup 顶层同步执行,且发生在任何 await 之前。如果值来自异步请求,提供 ref 而非最终值。
判据五:就近覆盖是静默的
provides 通过 Object.create(parentProvides) 形成原型链,in 会沿着链找到最近的那个 key。父链上多个组件对同一个 key 提供了值时,离得近的生效,远的被遮蔽。
类型系统对此毫无感知:中间层再 provide(ThemeKey, anotherTheme),深层的 inject(ThemeKey) 仍然是 Theme,编译通过,运行时悄悄换了数据源。
反例是布局组件为了「临时调一下」在中间层补了一个 provide,结果所有后代组件都切换到了这份数据,包括本应读取根级配置的组件。
判据:注入值是否依赖组件在树中的位置。如果同一个 key 在多层都可能被提供,消费侧要么显式用默认值表达「允许被覆盖」,要么在中间层改用另一个 key。
判据六:泛型写 Ref 还是 number
provide 一个 ref 时,key 的泛型必须是 Ref:
export const CounterKey: InjectionKey> = Symbol('counter')
provide(CounterKey, ref(0))
const count = inject(CounterKey) // Ref | undefined
写成 InjectionKey 后,provide(CounterKey, ref(0)) 在类型层面就会报错;如果通过 as any 绕过,运行时消费者拿到的是 Ref 对象,.value 可以访问,直接当数字用则出错。反过来,InjectionKey 配 provide(CounterKey, 0) 在类型上正确,但值不会更新;要让下游跟随变化,必须传 ref 或 getter。
判据:注入方是否需要感知后续变化。需要则 key 的类型与 provide 的实参都必须是 Ref,两侧一起改。
把 key 收在一处,把检查放在消费侧
InjectionKey 的运行时身份只有 Symbol,所以工程上要做的是把它当符号管理,而不是当类型管理:
// inject-keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface UserInfo { id: number; name: string }
export const InjectKeyUser: InjectionKey = Symbol()
export const CounterKey: InjectionKey> = Symbol('counter')
所有组件从这个文件导入,不在业务文件里 Symbol()。Vuex 的用法也是同一套:
export const key: InjectionKey> = Symbol()
app.use(store, key)
const store = useStore(key) // store.state.count 类型为 number
app.use(store, key) 内部就是把 store 通过 app 级别的 provide 挂上去,useStore(key) 再从 _context.provides 里取。
消费侧统一用一个会抛错的入口,把「key 存在但值缺失」和「值类型不对」两类运行时问题挡在使用点之前:
export function useUser(): UserInfo {
const user = inject(InjectKeyUser)
if (!user) throw new Error('useUser() called outside of a UserInfo provider')
return user
}
类型检查覆盖的是「同一个 key 的 provide 和 inject 是否声明了同一形状」,覆盖不到「运行时是否真的提供了」「提供的是不是同一份」「提供时机对不对」。这三点只能靠 key 单例、消费侧运行时检查、以及把 provide 限制在 setup 同步路径上来保证。