TypeScript 類別表達式(Class Expression)教學
簡介
在 ES6 之後,JavaScript(以及 TypeScript)正式支援 類別(class) 語法,讓我們可以用更貼近物件導向的方式撰寫程式。大部分教材都會先介紹 類別宣告(class declaration),例如 class Person { … }。然而,類別表達式(class expression) 也是一個極具彈性的寫法,它允許我們把類別當作值(value)傳遞、立即執行或動態建立。
掌握類別表達式的概念,對於以下情境非常有幫助:
- 需要在函式內部動態產生類別(例如根據參數決定繼承層級)。
- 將類別作為高階函式的參數或回傳值,實現「類別工廠」模式。
- 在模組化開發中,避免全域污染,只在需要的範圍內保留類別定義。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你熟悉 TypeScript 中的類別表達式,並提供實務上可直接套用的案例。
核心概念
1. 什麼是類別表達式?
類別表達式與函式表達式的概念相同:把類別本身視為一個值,可以賦給變數、作為參數傳遞,甚至立即執行(IIFE)。語法上分為 具名(named) 與 匿名(anonymous) 兩種形式:
// 匿名類別表達式
const MyClass = class {
greet() {
console.log('Hello');
}
};
// 具名類別表達式(名稱僅在內部作用域可見)
const AnotherClass = class NamedClass {
// 在類別內部可以透過 NamedClass 參照自己
static className = 'NamedClass';
};
重點:類別表達式的名稱(若有)只在類別本身的內部作用域可見,外部只能透過變數(如
MyClass、AnotherClass)存取。
2. 為什麼要使用類別表達式?
| 使用情境 | 好處 |
|---|---|
| 動態繼承(根據參數決定父類別) | 只在需要時才產生子類別,避免不必要的類別宣告 |
| 類別工廠(返回類別的函式) | 把類別建立的邏輯封裝,提升可重用性 |
| 立即執行的類別(IIFE) | 可以在建立同時執行一次性初始化程式碼 |
| 防止命名衝突 | 把類別限制在局部變數中,減少全域污染 |
3. 基本語法與型別推論
在 TypeScript 中,類別表達式同樣支援 屬性修飾子(public、private、protected、readonly)以及 泛型。以下範例展示如何結合這些特性:
// 泛型類別表達式,回傳一個帶有 type 屬性的類別
function createWrapper<T>(defaultValue: T) {
return class Wrapper {
private _value: T = defaultValue;
get value(): T {
return this._value;
}
set value(v: T) {
this._value = v;
}
};
}
// 使用
const NumberWrapper = createWrapper<number>(0);
const w = new NumberWrapper();
w.value = 42; // 正確,型別被推斷為 number
4. 類別表達式與 extends 的結合
下面示範 動態繼承:根據傳入的基底類別,回傳一個擴充功能的子類別。
class Animal {
constructor(public name: string) {}
speak() {
console.log(`${this.name} makes a sound.`);
}
}
// 動態產生繼承自任意基底類別的子類別
function withLogging<T extends new (...args: any[]) => {}>(Base: T) {
return class extends Base {
// 新增一個記錄建構子參數的屬性
private _log: string[] = [];
constructor(...args: any[]) {
super(...args);
this._log.push(`Created ${Base.name} with args: ${JSON.stringify(args)}`);
}
get log() {
return this._log;
}
// 覆寫 speak 方法,加入日誌
speak() {
this._log.push(`${this.constructor.name}.speak() called`);
// 呼叫父類別的原始實作
if (super.speak) super.speak();
}
};
}
// 建立帶有日誌功能的 Animal 子類別
const LoggedAnimal = withLogging(Animal);
const dog = new LoggedAnimal('Buddy');
dog.speak(); // Buddy makes a sound.
console.log(dog.log); // 查看日誌
5. 類別表達式的即時執行(IIFE)
有時候我們想在類別建立時就執行一次性的設定,例如註冊到全域容器或初始化靜態屬性。使用 IIFE 可以做到:
const Singleton = (function () {
// 私有變數,外部無法直接存取
let instance: Singleton | null = null;
// 類別表達式
return class Singleton {
private constructor(public readonly id: number) {}
static getInstance() {
if (!instance) {
instance = new Singleton(Date.now());
}
return instance;
}
greet() {
console.log(`Singleton #${this.id} greets you!`);
}
};
})();
const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
console.log(s1 === s2); // true
s1.greet();
常見陷阱與最佳實踐
| 陷阱 | 說明 | 建議的做法 |
|---|---|---|
| 名稱作用域混淆 | 具名類別表達式的名稱只在類別內部有效,外部無法存取。若誤以為外部可以使用會產生 ReferenceError。 |
使用匿名類別或將名稱存入變數,避免在外部直接引用具名類別。 |
| 過度動態 | 動態產生大量類別會影響程式的可讀性與除錯體驗。 | 僅在需要動態繼承或工廠時使用,平常仍以宣告式為主。 |
| 繼承鏈過深 | 使用多層類別表達式產生的繼承鏈,可能導致 instanceof 判斷不易理解。 |
在文件或註解中明確說明每層的目的,或使用介面(interface)取代過度繼承。 |
| 靜態屬性初始化 | 靜態屬性若在類別表達式內部使用 this,可能因為類別尚未完成宣告而出錯。 |
靜態屬性應直接賦值或使用立即執行的函式返回值。 |
| 型別推論失敗 | 在泛型類別表達式中,若未明確指定型別參數,TS 可能推斷為 any。 |
使用 as const、明確的型別參數或 extends 限制來保證型別安全。 |
最佳實踐:
- 保持簡潔:類別表達式的主要價值在於「動態」與「局部」的需求,若不需要動態性,就使用普通的類別宣告。
- 使用
readonly:對於只在建構子設定後不變的屬性,使用readonly可以提升可讀性與安全性。 - 文件化:在函式或模組的 JSDoc 中說明返回的類別結構與用途,方便其他開發者使用。
- 測試:對於透過類別表達式產生的類別,建議寫單元測試驗證其行為,特別是繼承與靜態屬性部分。
實際應用場景
1. UI 元件的動態子類別
在前端框架(如 React、Angular)中,我們常需要根據不同的配置產生特化的 UI 元件。使用類別表達式可以把「共用基底」與「客製化行為」分離:
// 基底元件
class Button {
constructor(public label: string) {}
render() {
console.log(`Button: ${this.label}`);
}
}
// 動態產生帶有樣式的子類別
function styledButton(style: 'primary' | 'secondary') {
return class extends Button {
render() {
console.log(`[${style.toUpperCase()}] Button: ${this.label}`);
}
};
}
const PrimaryButton = styledButton('primary');
const btn = new PrimaryButton('送出');
btn.render(); // [PRIMARY] Button: 送出
2. API 客戶端的版本化
當後端提供多個 API 版本時,我們可以用類別表達式根據版本號返回對應的客戶端類別:
interface ApiClient {
get<T>(url: string): Promise<T>;
}
// 版本 1
class ClientV1 implements ApiClient {
async get<T>(url: string): Promise<T> {
// ...實作
return fetch(url).then(r => r.json());
}
}
// 版本 2(加入認證頭)
class ClientV2 implements ApiClient {
constructor(private token: string) {}
async get<T>(url: string): Promise<T> {
return fetch(url, {
headers: { Authorization: `Bearer ${this.token}` },
}).then(r => r.json());
}
}
// 工廠函式
function createApiClient(version: 1 | 2, token?: string) {
return version === 1
? class extends ClientV1 {}
: class extends ClientV2 {
constructor() {
super(token!);
}
};
}
// 使用
const ApiClient = createApiClient(2, 'abc123');
const api = new ApiClient();
api.get('/users').then(console.log);
3. 依賴注入(DI)容器的註冊
在大型 Node.js 專案中,我們常使用 DI 容器管理服務。類別表達式可以在註冊時即完成設定:
type ServiceConstructor<T> = new (...args: any[]) => T;
class Container {
private map = new Map<string, any>();
register<T>(key: string, ctor: ServiceConstructor<T>) {
this.map.set(key, new ctor());
}
resolve<T>(key: string): T {
return this.map.get(key);
}
}
// 動態產生具備日誌功能的服務類別
function withLogger<T extends ServiceConstructor<any>>(Base: T) {
return class extends Base {
log(message: string) {
console.log(`[${Base.name}] ${message}`);
}
};
}
// 註冊
const container = new Container();
container.register('userService', withLogger(class {
getUser(id: number) {
return { id, name: 'Alice' };
}
}));
const userService = container.resolve<any>('userService');
userService.log('Fetching user...');
console.log(userService.getUser(1));
總結
類別表達式是 TypeScript 中一項 靈活且功能強大的語法,它讓我們可以:
- 把類別當作第一級公民,在變數、參數、回傳值之間自由傳遞。
- 根據執行時資訊動態產生子類別,解決傳統類別宣告無法因條件改變繼承的限制。
- 封裝類別建立邏輯,實作工廠模式、單例模式或依賴注入的自動註冊。
在實務開發中,適度使用類別表達式可以提升程式碼的 可組合性、可測試性,同時避免全域命名衝突。記得遵守最佳實踐:保持簡潔、加上完整註解、針對動態產生的類別撰寫測試,才能讓這項技巧真正為你的專案增值。
祝你在 TypeScript 的世界裡玩得開心,寫出更具彈性與可維護性的程式! 🚀