博客 / 詳情

返回

Vue開發三年,我才發現依賴注入的TypeScript正確打開方式

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

你是不是也遇到過這樣的場景?

在Vue項目裏,為了跨組件傳遞數據,你用provideinject寫了一套祖孫通信邏輯。代碼跑起來沒問題,但TypeScript編輯器總給你畫紅線,要麼是“類型any警告”,要麼就是“屬性不存在”的錯誤提示。

你看着一片飄紅的代碼區,心裏想着:“功能能用就行,類型標註太麻煩了。”於是,你默默地加上了// @ts-ignore,或者乾脆把注入的值斷言成any。項目在跑,但心裏總覺得不踏實,像是在代碼裏埋下了一個個“類型地雷”。

別擔心,這幾乎是每個Vue + TypeScript開發者都會經歷的階段。今天這篇文章,就是來幫你徹底拆掉這些地雷的。

我會帶你從最基礎的any警告開始,一步步升級到類型安全、重構友好的最佳實踐。讀完這篇文章,你不僅能解決眼下的類型報錯,更能建立一套完整的、類型安全的Vue依賴注入體系。無論你是維護大型中後台系統,還是開發獨立的組件庫,這套方法都能讓你的代碼更可靠、協作更順暢。

為什麼你的Provide/Inject總在報類型錯誤?

讓我們先看一個非常典型的“反面教材”。相信不少朋友都寫過,或者見過下面這樣的代碼:

// 祖輩組件 - Grandparent.vue
<script setup lang="ts">
import { provide } from 'vue'

// 提供一些配置和方法
const appConfig = {
  theme: 'dark',
  apiBaseUrl: 'https://api.example.com'
}

const updateTheme = (newTheme: string) => {
  console.log(`切換主題到:${newTheme}`)
}

// 簡單粗暴的provide
provide('appConfig', appConfig)
provide('updateTheme', updateTheme)
</script>
然後在子孫組件裏這樣注入:
// 子孫組件 - Child.vue  
<script setup lang="ts">
import { inject } from 'vue'

// 問題來了:類型是什麼?編輯器不知道!
const config = inject('appConfig')
const updateFn = inject('updateTheme')

// 當你嘗試使用的時候
const switchTheme = () => {
  // 這裏TypeScript會抱怨:updateFn可能是undefined
  // 而且config也是any類型,沒有任何類型提示
  updateFn('light')  // ❌ 對象可能為"undefined"
  console.log(config.apiBaseUrl) // ❌ config是any,但能運行
}
</script>

看出來問題在哪了嗎?

  1. 字符串鍵名容易寫錯'appConfig''appconfig'大小寫不同,但TypeScript不會幫你檢查這個拼寫錯誤
  2. 注入值的類型完全丟失inject返回的類型默認是any或者unknown,你辛辛苦苦定義的類型信息在這裏斷掉了
  3. 缺乏安全性:如果上游沒有提供對應的值,inject會返回undefined,但TypeScript無法確定這種情況

這就是為什麼我們需要給Provide/Inject加上“類型安全帶”。

從基礎到進階:四種類型標註方案

方案一:使用泛型參數(基礎版)

這是最直接的方式,直接在inject調用時指定期望的類型。

// 子孫組件
<script setup lang="ts">
import { inject } from 'vue'

// 使用泛型告訴TypeScript:我期望得到這個類型
const config = inject<{ theme: string; apiBaseUrl: string }>('appConfig')
const updateFn = inject<(theme: string) => void>('updateTheme')

// 現在有類型提示了!
const switchTheme = () => {
  if (config && updateFn) {
    updateFn('light')  // ✅ 正確識別為函數
    console.log(config.apiBaseUrl) // ✅ 知道apiBaseUrl是string
  }
}
</script>

這種方法像是給TypeScript遞了一張“期望清單”:“我希望拿到一個長這樣的對象”。但缺點也很明顯:

  • 類型定義是重複的(祖輩組件定義一次,每個注入的子孫組件都要寫一次)
  • 鍵名還是字符串,容易拼寫錯誤
  • 每次都要手動做空值檢查

方案二:定義統一的注入鍵(進階版)

我們可以定義專門的常量來管理所有的注入鍵,就像管理路由名稱一樣。

// 首先,在一個單獨的文件裏定義所有注入鍵
// src/constants/injection-keys.ts
export const InjectionKeys = {
  APP_CONFIG: Symbol('app-config'),        // 使用Symbol確保唯一性
  UPDATE_THEME: Symbol('update-theme'),
  USER_INFO: Symbol('user-info')
} as const  // as const 讓TypeScript知道這是字面量類型

然後在祖輩組件中使用:

// Grandparent.vue
<script setup lang="ts">
import { provide } from 'vue'
import { InjectionKeys } from '@/constants/injection-keys'

interface AppConfig {
  theme: 'light' | 'dark'
  apiBaseUrl: string
}

const appConfig: AppConfig = {
  theme: 'dark',
  apiBaseUrl: 'https://api.example.com'
}

const updateTheme = (newTheme: AppConfig['theme']) => {
  console.log(`切換主題到:${newTheme}`)
}

// 使用Symbol作為鍵
provide(InjectionKeys.APP_CONFIG, appConfig)
provide(InjectionKeys.UPDATE_THEME, updateTheme)
</script>
在子孫組件中注入:
// Child.vue
<script setup lang="ts">
import { inject } from 'vue'
import { InjectionKeys } from '@/constants/injection-keys'

// 類型安全地注入
const config = inject(InjectionKeys.APP_CONFIG)
const updateFn = inject(InjectionKeys.UPDATE_THEME)

// TypeScript現在知道config的類型是AppConfig | undefined
const switchTheme = () => {
  if (config && updateFn) {
    updateFn('light')  // ✅ 正確:'light'在主題範圍內
    // updateFn('blue') // ❌ 錯誤:'blue'不是有效主題
  }
}
</script>

這個方法解決了鍵名拼寫錯誤的問題,但類型定義仍然分散在各處。而且,如果你修改了AppConfig接口,需要在多個地方更新類型引用。

方案三:類型安全的注入工具函數(專業版)

這是我在大型項目中推薦的做法。我們創建一組工具函數,讓Provide/Inject變得像調用API一樣類型安全。

// src/utils/injection-utils.ts
import { InjectionKey, provide, inject } from 'vue'

// 定義一個創建注入鍵的工具函數
export function createInjectionKey<T>(key: string): InjectionKey<T> {
  return Symbol(key) as InjectionKey<T>
}

// 再定義一個類型安全的provide函數
export function safeProvide<T>(key: InjectionKey<T>, value: T) {
  provide(key, value)
}

// 以及類型安全的inject函數
export function safeInject<T>(key: InjectionKey<T>): T
export function safeInject<T>(key: InjectionKey<T>, defaultValue: T): T
export function safeInject<T>(key: InjectionKey<T>, defaultValue?: T): T {
  const injected = inject(key, defaultValue)
  
  if (injected === undefined) {
    throw new Error(`注入鍵 ${key.toString()} 沒有被提供`)
  }
  
  return injected
}
如何使用這套工具?
// 首先,在一個集中位置定義所有注入類型和鍵
// src/types/injection.types.ts
import { createInjectionKey } from '@/utils/injection-utils'

export interface AppConfig {
  theme: 'light' | 'dark'
  apiBaseUrl: string
}

export interface UserInfo {
  id: number
  name: string
  avatar: string
}

// 創建類型安全的注入鍵
export const APP_CONFIG_KEY = createInjectionKey<AppConfig>('app-config')
export const USER_INFO_KEY = createInjectionKey<UserInfo>('user-info')
export const UPDATE_THEME_KEY = createInjectionKey<(theme: AppConfig['theme']) => void>('update-theme')

在祖輩組件中提供值:

// Grandparent.vue
<script setup lang="ts">
import { safeProvide } from '@/utils/injection-utils'
import { APP_CONFIG_KEY, USER_INFO_KEY, UPDATE_THEME_KEY, type AppConfig } from '@/types/injection.types'

const appConfig: AppConfig = {
  theme: 'dark',
  apiBaseUrl: 'https://api.example.com'
}

const userInfo = {
  id: 1,
  name: '張三',
  avatar: 'https://example.com/avatar.jpg'
}

const updateTheme = (newTheme: AppConfig['theme']) => {
  console.log(`切換主題到:${newTheme}`)
}

// 現在provide是類型安全的
safeProvide(APP_CONFIG_KEY, appConfig)
safeProvide(USER_INFO_KEY, userInfo)  // ✅ 自動檢查userInfo是否符合UserInfo接口
safeProvide(UPDATE_THEME_KEY, updateTheme)
</script>

在子孫組件中注入:

// Child.vue
<script setup lang="ts">
import { safeInject } from '@/utils/injection-utils'
import { APP_CONFIG_KEY, UPDATE_THEME_KEY } from '@/types/injection.types'

// 看!這裏沒有泛型參數,但類型完全正確
const config = safeInject(APP_CONFIG_KEY)
const updateFn = safeInject(UPDATE_THEME_KEY)

// 直接使用,不需要空值檢查
const switchTheme = () => {
  updateFn('light')  // ✅ 完全類型安全,且不會undefined
  console.log(`當前API地址:${config.apiBaseUrl}`)
}
</script>

這種方案的優點是:

  1. 類型推導自動完成:不需要手動寫泛型
  2. 編譯時檢查:如果你提供的值類型不對,TypeScript會在safeProvide那行就報錯
  3. 運行時安全:如果注入鍵沒有被提供,會拋出清晰的錯誤信息
  4. 重構友好:修改接口定義時,所有使用的地方都會自動更新

方案四:組合式API風格(現代最佳實踐)

Vue 3的組合式API讓我們的代碼可以更好地組織和複用。對於依賴注入,我們可以創建專門的useXxx函數。

// src/composables/useAppConfig.ts
import { safeProvide, safeInject } from '@/utils/injection-utils'
import { APP_CONFIG_KEY, UPDATE_THEME_KEY, type AppConfig } from '@/types/injection.types'

// 提供者邏輯封裝
export function useProvideAppConfig(config: AppConfig, updateThemeFn: (theme: AppConfig['theme']) => void) {
  safeProvide(APP_CONFIG_KEY, config)
  safeProvide(UPDATE_THEME_KEY, updateThemeFn)
  
  // 返回一些可能需要的方法
  return {
    // 這裏可以添加一些基於config的衍生邏輯
    getThemeColor() {
      return config.theme === 'dark' ? '#1a1a1a' : '#ffffff'
    }
  }
}

// 消費者邏輯封裝
export function useAppConfig() {
  const config = safeInject(APP_CONFIG_KEY)
  const updateTheme = safeInject(UPDATE_THEME_KEY)
  
  // 計算屬性:自動響應式
  const isDarkTheme = computed(() => config.theme === 'dark')
  
  // 方法:封裝業務邏輯
  const toggleTheme = () => {
    const newTheme = config.theme === 'dark' ? 'light' : 'dark'
    updateTheme(newTheme)
  }
  
  return {
    config,
    updateTheme,
    isDarkTheme,
    toggleTheme
  }
}

在祖輩組件中使用:

// Grandparent.vue
<script setup lang="ts">
import { useProvideAppConfig } from '@/composables/useAppConfig'

const appConfig = {
  theme: 'dark' as const,
  apiBaseUrl: 'https://api.example.com'
}

const updateTheme = (newTheme: 'light' | 'dark') => {
  console.log(`切換主題到:${newTheme}`)
}

// 一行代碼完成所有provide
const { getThemeColor } = useProvideAppConfig(appConfig, updateTheme)
</script>

在子孫組件中使用:

// Child.vue
<script setup lang="ts">
import { useAppConfig } from '@/composables/useAppConfig'

// 像使用Vue內置的useRoute、useRouter一樣
const { config, isDarkTheme, toggleTheme } = useAppConfig()

// 直接使用,所有類型都已正確推斷
const handleClick = () => {
  toggleTheme()
  console.log(`當前主題:${config.theme}`)
}
</script>

這種方式的強大之處在於:

  1. 邏輯高度複用:注入邏輯被封裝起來,可以在多個組件中複用
  2. 開箱即用:使用者不需要關心注入的實現細節
  3. 類型完美推斷:所有返回的值都有正確的類型
  4. 易於測試:可以單獨測試useAppConfig的邏輯

實戰:在組件庫中應用類型安全注入

假設你正在開發一個UI組件庫,需要提供主題配置、國際化、尺寸配置等全局設置。依賴注入是完美的解決方案。

// 組件庫的核心注入類型定義
// ui-library/src/injection/types.ts
export interface Theme {
  primaryColor: string
  backgroundColor: string
  textColor: string
  borderRadius: string
}

export interface Locale {
  language: string
  messages: Record<string, string>
}

export interface Size {
  small: string
  medium: string  
  large: string
}

export interface LibraryConfig {
  theme: Theme
  locale: Locale
  size: Size
  zIndex: {
    modal: number
    popover: number
    tooltip: number
  }
}

// 創建注入鍵
export const LIBRARY_CONFIG_KEY = createInjectionKey<LibraryConfig>('library-config')

// 組件庫的provide函數
export function provideLibraryConfig(config: Partial<LibraryConfig>) {
  const defaultConfig: LibraryConfig = {
    theme: {
      primaryColor: '#1890ff',
      backgroundColor: '#ffffff',
      textColor: '#333333',
      borderRadius: '4px'
    },
    locale: {
      language: 'zh-CN',
      messages: {}
    },
    size: {
      small: '24px',
      medium: '32px',
      large: '40px'
    },
    zIndex: {
      modal: 1000,
      popover: 500,
      tooltip: 300
    }
  }
  
  const mergedConfig = { ...defaultConfig, ...config }
  safeProvide(LIBRARY_CONFIG_KEY, mergedConfig)
  
  return mergedConfig
}

// 組件庫的inject函數  
export function useLibraryConfig() {
  const config = safeInject(LIBRARY_CONFIG_KEY)
  
  return {
    config,
    // 一些便捷的getter
    theme: computed(() => config.theme),
    size: computed(() => config.size),
    locale: computed(() => config.locale),
    
    // 主題相關的方法
    setPrimaryColor(color: string) {
      // 這裏可以實現主題切換邏輯
      config.theme.primaryColor = color
    }
  }
}

在應用中使用你的組件庫:

// App.vue - 應用入口
<script setup lang="ts">
import { provideLibraryConfig } from 'your-ui-library'

// 配置你的組件庫
provideLibraryConfig({
  theme: {
    primaryColor: '#ff6b6b',  // 自定義主題色
    borderRadius: '8px'       // 更大的圓角
  },
  locale: {
    language: 'en-US',
    messages: {
      'button.confirm': 'Confirm',
      'button.cancel': 'Cancel'
    }
  }
})
</script>
在組件庫的按鈕組件中使用:
// ui-library/src/components/Button/Button.vue
<script setup lang="ts">
import { useLibraryConfig } from '../../injection'

const { theme, size } = useLibraryConfig()

// 使用注入的配置
const buttonStyle = computed(() => ({
  backgroundColor: theme.value.primaryColor,
  borderRadius: theme.value.borderRadius,
  height: size.value.medium
}))
</script>

<template>
  <button :style="buttonStyle" class="library-button">
    <slot></slot>
  </button>
</template>

這樣,你的組件庫就擁有了完全類型安全的配置系統。使用者可以享受完整的TypeScript支持,包括智能提示、類型檢查和自動補全。

避坑指南:常見問題與解決方案

在實踐過程中,你可能會遇到一些特殊情況。這裏我總結了幾種常見問題的解法。

問題一:注入值可能是異步獲取的

有時候,我們需要注入的值是通過API異步獲取的。這時候直接注入Promise不是一個好主意,因為每個注入的組件都需要處理Promise。

更好的做法是使用響應式狀態:

// 祖輩組件
<script setup lang="ts">
import { ref, provide } from 'vue'
import { USER_INFO_KEY } from '@/types/injection.types'

// 使用ref來管理異步狀態
const userInfo = ref<{ id: number; name: string } | null>(null)

// 異步獲取數據
fetchUserInfo().then(data => {
  userInfo.value = data
})

// 直接注入ref,子孫組件可以響應式地訪問
provide(USER_INFO_KEY, userInfo)
</script>

// 子孫組件
<script setup lang="ts">
import { inject } from 'vue'
import { USER_INFO_KEY } from '@/types/injection.types'

const userInfoRef = inject(USER_INFO_KEY)

// 使用計算屬性來安全訪問
const userName = computed(() => userInfoRef?.value?.name ?? '加載中...')
</script>

問題二:需要注入多個同類型的值

如果需要在同一個應用中注入多個同類型的對象(比如多個數據源),可以使用工廠函數模式:

// 創建帶標識符的注入鍵
export function createDataSourceKey(id: string) {
  return createInjectionKey<DataSource>(`data-source-${id}`)
}

// 在祖輩組件中
provide(createDataSourceKey('user'), userDataSource)
provide(createDataSourceKey('product'), productDataSource)

// 在子孫組件中
const userSource = safeInject(createDataSourceKey('user'))
const productSource = safeInject(createDataSourceKey('product'))

問題三:類型循環依賴問題

在大型項目中,可能會遇到類型之間的循環依賴。這時可以使用TypeScript的interface前向聲明:

// types/moduleA.ts
import type { ModuleB } from './moduleB'

export interface ModuleA {
  name: string
  b: ModuleB  // 引用ModuleB類型
}

// types/moduleB.ts  
import type { ModuleA } from './moduleA'

export interface ModuleB {
  id: number
  a?: ModuleA  // 可選,避免強制循環
}

或者在注入鍵中使用泛型:

export function createModuleKey<T>() {
  return createInjectionKey<T>('module')
}

// 使用時各自指定具體類型
provide(createModuleKey<ModuleA>(), moduleAInstance)

結語:擁抱類型安全的Vue開發

回顧我們今天的旅程,我們從最開始的any類型警告,一步步升級到了類型安全、工程化的依賴注入方案。

讓我為你總結一下關鍵要點:

  1. 永遠不要忽略類型:那些// @ts-ignore註釋就像是代碼中的定時炸彈,總有一天會爆炸

  2. 選擇合適的方案

    • 小項目:方案一或方案二就足夠
    • 中大型項目:強烈推薦方案三或方案四
    • 組件庫開發:方案四的組合式API模式是最佳選擇
  3. 建立代碼規範:在團隊中統一依賴注入的寫法,會讓協作順暢很多

  4. 利用工具函數:花點時間封裝safeProvidesafeInject這樣的工具函數,長期來看會節省大量時間

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文檔,大家一起討論學習,一起進步。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.