TypeScript 實務開發與架構應用 – DI(Dependency Injection)設計
簡介
在大型前端或 Node.js 專案中,模組之間的耦合度往往直接影響維護成本與測試效率。傳統上,我們會在類別內部直接 new 其他物件,這樣的寫法雖然簡單,但會讓 依賴關係隱藏,導致:
- 單元測試困難:無法輕易替換成 mock。
- 重構成本高:改變某個依賴的實作方式,需要在多處手動調整。
- 可讀性下降:新加入的開發者不易快速了解類別需要哪些外部資源。
Dependency Injection(依賴注入,簡稱 DI) 是解耦的核心模式之一。透過把「需要的物件」在外部「注入」進來,我們可以:
- 明確宣告依賴,讓程式結構一目了然。
- 簡化測試,只要提供相應的 stub/mock 即可。
- 提升彈性,同一個介面可以有多種實作,隨時切換。
在 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 框架(如tsyringe、inversify),但了解底層原理有助於排除問題。
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 時拼寫錯誤不易發現 | 使用 Symbol 或 class 作 token,並在 IDE 中利用自動補全。 |
| 缺乏型別安全 | 直接使用 any 失去 TypeScript 的優勢 |
盡量以 介面 + 泛型 定義 Provider,讓 DI 容器保證型別正確性。 |
| 在測試時忘記替換實作 | 測試仍使用真實服務,造成副作用 | 使用 測試專用的 Container,在 beforeEach 中重新註冊 mock。 |
最佳實踐:
- 以介面為依賴:永遠依賴抽象(interface)而非具體類別。
- 單例 vs 暫時:對於「全域唯一」的資源(設定、連線池)使用 singleton;對於需要多例的情況使用 transient。
- 模組化註冊:每個功能模組自行管理自己的 Provider,最後在根容器匯入。
- 使用裝飾器:裝飾器讓 DI 設定與類別定義緊密結合,減少外部配置文件。
- 明確測試策略:在測試套件中建立獨立的容器,僅注入必要的 mock,確保測試的純粹性。
實際應用場景
| 場景 | 為什麼適合使用 DI | 具體做法 |
|---|---|---|
| REST API 服務層 | 多個控制器共用同一個 Service,且 Service 需要資料庫、快取、日誌等多個資源 | 透過建構子注入 UserRepository、CacheService、Logger,在測試時替換成 InMemoryRepository、FakeCache。 |
| 微服務間的 HTTP 客戶端 | 不同微服務需要不同的 Base URL、認證方式,且可能會在不同環境切換 | 設計 HttpClient 介面,使用 @Inject('API_ENDPOINT') 注入環境變數,容器根據 process.env.NODE_ENV 註冊不同實作。 |
| 插件化系統 | 允許第三方模組在執行時注入自訂的策略或處理器 | 在核心容器中提供 registerPlugin(token, provider),插件在載入時自行註冊,主程式只透過介面呼叫。 |
| 命令列工具 (CLI) | 每個指令可能需要不同的服務,且需要在執行前解析參數 | 使用 tsyringe 的 container.resolve 於指令入口動態取得相依,確保指令本身保持輕量。 |
| 前端框架 (Angular / NestJS) | 前端元件、服務、管道等都需要共享狀態或 API 客戶端 | Angular 本身即使用 DI,開發者只要在 providers 陣列中註冊即可,與本文的手動容器概念相同。 |
總結
Dependency Injection 在 TypeScript 生態系中扮演 解耦、可測、可擴展 的關鍵角色。透過:
- 建構子、屬性或方法注入 的不同策略,
- 容器(Container) 的註冊與解析機制,
- 裝飾器(Decorator) 與 interface 的結合,
我們可以在 純 Node.js、NestJS、Angular、甚至自建框架 中,以一致且安全的方式管理相依。
在實務開發時,請遵守以下要點:
- 以抽象(介面)作為依賴入口,避免硬耦合。
- 合理規劃 Scope(singleton / transient),提升效能與資源利用。
- 避免循環依賴,必要時使用延遲注入或工廠模式。
- 在測試環境中使用獨立容器,確保每個測試案例的隔離。
- 利用成熟的 DI 框架(如
tsyringe、inversify、NestJS 內建容器)減少自製錯誤。
掌握 DI 的概念與實作後,你的 TypeScript 專案將更具 可維護性、可測試性,同時也能在需求變更時快速調整,真正達到「寫一次、跑無限」的開發體驗。祝你在未來的專案中玩得開心,寫出乾淨、彈性的程式碼!