一、先說個場景
Hello~大家好,我是秋天的一陣風
寫 TypeScript 的你,provide/inject 是不是還在裸奔?
你肯定寫過這種程式碼:
ts 程式碼解讀複製程式碼// 祖先元件
provide('theme', ref('dark'))
// 後代元件
const theme = inject('theme') // theme 的型別是 any,你心裡沒點數嗎
跑起來沒問題,但你把 'theme' 打成 'thme',TypeScript 一聲不吭。等到線上樣式崩了你才發現——拼字錯誤,堪稱前端工程師的經典翻車現場。
更窒息的是,theme 推導出來的型別是 any。你想用 .value?隨便用。你想呼叫 .toUpperCase()?也隨便用。反正 TypeScript 不攔你,報不報錯全靠運氣。
這就是 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>。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>'
編譯期就攔住你,不用等到執行時炸掉。
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 的型別標註思路不是一樣嗎?
沒錯,核心思想一樣——用型別系統約束執行時行為。差別在於:
definePropsInjectionKey作用域父 → 子(元件樹)祖先 → 任意後代(跨層級)型別標註方式泛型參數單獨定義 Symbol編譯時檢查✅✅執行時驗證可選(withDefaults)無defineProps 是垂直方向的型別安全,InjectionKey 是穿透方向的型別安全。兩者不衝突,各管各的。
別把鑰匙定義在元件檔裡,否則每次 import 可能拿到不同的 Symbol 實例。統一放在一個 keys.ts 檔案裡:
css 程式碼解讀複製程式碼src/
├── keys.ts ← 所有 InjectionKey 集中管理
├── components/
│ └── ...
inject() 的回傳型別永遠包含 undefined,因為沒有人能保證上游一定 provide 了。要嘛用 ! 斷言,要嘛給預設值,要嘛老老實實做空值判斷。
provide 一個普通物件,下游拿到的是非響應式的。要保證響應式,要嘛傳 ref / reactive,要嘛傳整個 composables 的回傳值(上面 ThemeContext 的例子就是這麼做的)。
InjectionKey 解決的問題很簡單:讓 provide/inject 從「口頭約定」變成「合約約束」。
沒有它,你靠字串匹配,靠自覺,靠祈禱。 有了它,TypeScript 幫你盯著,拼錯一個字母編譯就過不了。
就這一個 Symbol 的包裝,值得你在每個用了 provide/inject 的專案裡都加上。
寫 Vue 3 + TypeScript 不用 InjectionKey,就像寫合約不蓋章——雙方口頭答應了,出事了誰也不認。