本文 AI 產出,尚未審核

TypeScript 實務開發與架構應用 – DI(Dependency Injection)設計


簡介

在大型前端或 Node.js 專案中,模組之間的耦合度往往直接影響維護成本與測試效率。傳統上,我們會在類別內部直接 new 其他物件,這樣的寫法雖然簡單,但會讓 依賴關係隱藏,導致:

  • 單元測試困難:無法輕易替換成 mock。
  • 重構成本高:改變某個依賴的實作方式,需要在多處手動調整。
  • 可讀性下降:新加入的開發者不易快速了解類別需要哪些外部資源。

Dependency Injection(依賴注入,簡稱 DI) 是解耦的核心模式之一。透過把「需要的物件」在外部「注入」進來,我們可以:

  1. 明確宣告依賴,讓程式結構一目了然。
  2. 簡化測試,只要提供相應的 stub/mock 即可。
  3. 提升彈性,同一個介面可以有多種實作,隨時切換。

在 TypeScript 中,由於擁有型別系統與裝飾器(Decorator)支援,實作 DI 不僅安全,而且寫起來相當優雅。接下來,我們會一步步說明 DI 的核心概念,並透過實務範例展示如何在 TypeScript 專案中落地。


核心概念

1. 依賴與注入的基本概念

  • 依賴(Dependency):一個類別或函式在執行時需要的外部資源,例如資料庫連線、API 客戶端、日誌服務等。
  • 注入(Injection):將依賴的實例 由外部提供 給目標類別,而不是在類別內部自行建立。

DI 常見的 注入方式 包括:

注入方式 說明 典型使用情境
建構子注入(Constructor Injection) 透過建構子參數注入依賴 大多數情況的首選,保證類別在被建立時即完整。
屬性注入(Property Injection) 直接給予類別屬性值 柔性需求、循環依賴時較常使用。
方法注入(Method Injection) 呼叫方法時傳入依賴 僅在特定操作需要臨時依賴時使用。

下面先以 建構子注入 為例,展示最簡單的 DI 實作。

2. 手動實作 DI 容器

在沒有任何套件輔助時,我們可以自行寫一個極簡的 Service Container,負責註冊與解析相依。

// di-container.ts
type Constructor<T = any> = new (...args: any[]) => T;

interface Provider<T = any> {
  useClass?: Constructor<T>;
  useValue?: T;
}

class Container {
  private providers = new Map<string, Provider>();

  /** 註冊類別或值 */
  register<T>(token: string, provider: Provider<T>) {
    this.providers.set(token, provider);
  }

  /** 解析相依,回傳實例 */
  resolve<T>(token: string): T {
    const provider = this.providers.get(token);
    if (!provider) {
      throw new Error(`No provider found for ${token}`);
    }

    // useValue 直接回傳
    if (provider.useValue !== undefined) {
      return provider.useValue as T;
    }

    // useClass 需要建立實例,並遞迴解決建構子參數
    if (provider.useClass) {
      const target = provider.useClass;
      const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', target) || [];
      const deps = paramTypes.map((type) => this.resolve(type.name));
      return new target(...deps);
    }

    throw new Error(`Provider for ${token} is invalid`);
  }
}

重點:此容器利用 reflect-metadata 取得建構子參數的型別(需要在 tsconfig.json 開啟 emitDecoratorMetadata),實作了最基本的 遞迴解析。在實務中,我們通常會直接使用成熟的 DI 框架(如 tsyringeinversify),但了解底層原理有助於排除問題。

3. 使用裝飾器標記可注入的類別

TypeScript 的 裝飾器 能讓我們在類別上貼上「這是一個可被容器管理」的標記。

// decorators.ts
import 'reflect-metadata';
import { Container } from './di-container';

const GLOBAL_CONTAINER = new Container();

/** @Injectable 標記類別可被容器解析 */
export function Injectable(): ClassDecorator {
  return (target) => {
    const token = target.name;
    GLOBAL_CONTAINER.register(token, { useClass: target as any });
  };
}

/** @Inject 用於建構子參數 */
export function Inject(token?: string): ParameterDecorator {
  return (target, _propertyKey, parameterIndex) => {
    const types = Reflect.getMetadata('design:paramtypes', target) || [];
    const depToken = token ?? types[parameterIndex].name;
    // 這裡不直接注入,而是留給容器在 resolve 時使用
    // 可在未來擴充為屬性注入等
  };
}

/** 取得全域容器 */
export const container = GLOBAL_CONTAINER;

說明@Injectable 會在類別被載入時自動註冊至容器;@Inject 目前僅保留 token 訊息,實際解析仍由容器完成。這樣的寫法與 Angular 的 DI 風格相似,讓開發者只需要關注「我需要什麼」而不是「怎麼取得」。

4. 範例一:簡易的日誌服務

// logger.service.ts
import { Injectable } from './decorators';

export interface ILogger {
  log(message: string): void;
  error(message: string): void;
}

@Injectable()
export class ConsoleLogger implements ILogger {
  log(message: string) {
    console.log(`[Info] ${message}`);
  }
  error(message: string) {
    console.error(`[Error] ${message}`);
  }
}
// user.service.ts
import { Injectable, Inject } from './decorators';
import { ILogger } from './logger.service';

@Injectable()
export class UserService {
  constructor(@Inject() private logger: ILogger) {}

  getUser(id: number) {
    this.logger.log(`取得使用者資料,ID=${id}`);
    // 假設此處呼叫 API...
    return { id, name: 'Alice' };
  }
}
// main.ts
import { container } from './decorators';
import { UserService } from './user.service';
import { ConsoleLogger } from './logger.service';

// 直接從容器解析
const userService = container.resolve<UserService>('UserService');
console.log(userService.getUser(1));

執行結果

[Info] 取得使用者資料,ID=1
{ id: 1, name: 'Alice' }

重點UserService 完全不需要知道 ConsoleLogger 的具體實作,只依賴 ILogger 介面。若日後想換成 RemoteLogger,只需要在容器註冊時改變對應即可,UserService 仍保持不變。

5. 範例二:使用 tsyringe 框架的屬性注入

tsyringe 是由 Microsoft 推出的輕量 DI 套件,支援 屬性注入,在某些情況下比建構子注入更直觀。

npm install tsyringe reflect-metadata
// tsyringe-example.ts
import 'reflect-metadata';
import { container, injectable, inject, singleton } from 'tsyringe';

interface IConfig {
  apiUrl: string;
}

@singleton()
@injectable()
class ConfigService implements IConfig {
  apiUrl = 'https://api.example.com';
}

@injectable()
class ApiService {
  // 屬性注入
  @inject('IConfig') private config!: IConfig;

  fetch(endpoint: string) {
    const url = `${this.config.apiUrl}/${endpoint}`;
    // 這裡簡化為 console.log
    console.log(`GET ${url}`);
  }
}

// 註冊介面對應的 token
container.register<IConfig>('IConfig', { useClass: ConfigService });

const api = container.resolve(ApiService);
api.fetch('users');

執行結果

GET https://api.example.com/users

說明@singleton() 確保 ConfigService 只會被實例化一次,屬性注入則讓 ApiService 的建構子保持乾淨,適合 大量依賴同一設定 的情況。

6. 範例三:在 NestJS 中的 DI(實務應用)

NestJS 是目前最流行的 Node.js 框架之一,DI 被設計為核心概念。以下示範一個簡易的 UserModule

// app.module.ts
import { Module } from '@nestjs/common';
import { UserModule } from './user/user.module';

@Module({
  imports: [UserModule],
})
export class AppModule {}
// user/user.service.ts
import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class UserService {
  private readonly logger = new Logger(UserService.name);

  getProfile(id: number) {
    this.logger.log(`Fetching profile for ${id}`);
    return { id, name: 'Bob' };
  }
}
// user/user.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get(':id')
  get(@Param('id') id: string) {
    return this.userService.getProfile(+id);
  }
}
// user/user.module.ts
import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserController } from './user.controller';

@Module({
  providers: [UserService],
  controllers: [UserController],
})
export class UserModule {}

關鍵:在 NestJS 中,只要把類別加上 @Injectable()(或 @Controller)並在模組中列出,框架會自動處理建構子注入。開發者只需要關注 業務邏輯,不必手動建立或管理實例。


常見陷阱與最佳實踐

陷阱 為何會發生 解法 / 最佳實踐
循環依賴(A 依賴 B,B 又依賴 A) DI 容器在解析時會無限遞迴 使用 屬性注入Factory Provider,把其中一方改為延遲取得(lazyInject)。
過度注入(一個類別注入太多服務) 破壞單一職責原則,讓測試與維護變困難 重新檢視類別職責,將功能拆分成子服務或 Facade
忘記註冊 Provider 容器找不到相對應的 token,拋出錯誤 建立 集中註冊檔(如 providers.ts),確保所有服務都有對應的註冊。
硬編碼 token 使用字串作為 token 時拼寫錯誤不易發現 使用 Symbolclass 作 token,並在 IDE 中利用自動補全。
缺乏型別安全 直接使用 any 失去 TypeScript 的優勢 盡量以 介面 + 泛型 定義 Provider,讓 DI 容器保證型別正確性。
在測試時忘記替換實作 測試仍使用真實服務,造成副作用 使用 測試專用的 Container,在 beforeEach 中重新註冊 mock。

最佳實踐

  1. 以介面為依賴:永遠依賴抽象(interface)而非具體類別。
  2. 單例 vs 暫時:對於「全域唯一」的資源(設定、連線池)使用 singleton;對於需要多例的情況使用 transient
  3. 模組化註冊:每個功能模組自行管理自己的 Provider,最後在根容器匯入。
  4. 使用裝飾器:裝飾器讓 DI 設定與類別定義緊密結合,減少外部配置文件。
  5. 明確測試策略:在測試套件中建立獨立的容器,僅注入必要的 mock,確保測試的純粹性。

實際應用場景

場景 為什麼適合使用 DI 具體做法
REST API 服務層 多個控制器共用同一個 Service,且 Service 需要資料庫、快取、日誌等多個資源 透過建構子注入 UserRepositoryCacheServiceLogger,在測試時替換成 InMemoryRepositoryFakeCache
微服務間的 HTTP 客戶端 不同微服務需要不同的 Base URL、認證方式,且可能會在不同環境切換 設計 HttpClient 介面,使用 @Inject('API_ENDPOINT') 注入環境變數,容器根據 process.env.NODE_ENV 註冊不同實作。
插件化系統 允許第三方模組在執行時注入自訂的策略或處理器 在核心容器中提供 registerPlugin(token, provider),插件在載入時自行註冊,主程式只透過介面呼叫。
命令列工具 (CLI) 每個指令可能需要不同的服務,且需要在執行前解析參數 使用 tsyringecontainer.resolve 於指令入口動態取得相依,確保指令本身保持輕量。
前端框架 (Angular / NestJS) 前端元件、服務、管道等都需要共享狀態或 API 客戶端 Angular 本身即使用 DI,開發者只要在 providers 陣列中註冊即可,與本文的手動容器概念相同。

總結

Dependency Injection 在 TypeScript 生態系中扮演 解耦、可測、可擴展 的關鍵角色。透過:

  • 建構子、屬性或方法注入 的不同策略,
  • 容器(Container) 的註冊與解析機制,
  • 裝飾器(Decorator)interface 的結合,

我們可以在 純 Node.js、NestJS、Angular、甚至自建框架 中,以一致且安全的方式管理相依。

在實務開發時,請遵守以下要點:

  1. 以抽象(介面)作為依賴入口,避免硬耦合。
  2. 合理規劃 Scope(singleton / transient),提升效能與資源利用。
  3. 避免循環依賴,必要時使用延遲注入或工廠模式。
  4. 在測試環境中使用獨立容器,確保每個測試案例的隔離。
  5. 利用成熟的 DI 框架(如 tsyringeinversify、NestJS 內建容器)減少自製錯誤。

掌握 DI 的概念與實作後,你的 TypeScript 專案將更具 可維護性可測試性,同時也能在需求變更時快速調整,真正達到「寫一次、跑無限」的開發體驗。祝你在未來的專案中玩得開心,寫出乾淨、彈性的程式碼!