编程 Vue 3 InjectionKey 编译后就是个 Symbol:类型检查通过了,注入为什么还是拿不到值

2026-11-16 00:05:36

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 同步路径上来保证。

推荐文章

程序员茄子在线接单