本文 AI 產出,尚未審核

TypeScript 裝飾器:屬性裝飾器(Property Decorators)

簡介

在大型前端或 Node.js 專案中,隨著需求的增長,**類別(class)**的結構往往變得越來越複雜。除了繼承與介面之外,TypeScript 於 ES7 引入的 裝飾器(Decorator) 為我們提供了一種「在不改變原始程式碼的前提下」為類別、方法、屬性、參數或存取子(accessor)「加上額外行為」的強大工具。

屬性裝飾器是四種主要裝飾器(類別、方法、屬性、參數)中最直觀、最常用的一環。它可以在屬性被定義時執行自訂邏輯,例如 自動驗證、日誌、依賴注入、序列化/反序列化 等。掌握屬性裝飾器的寫法與原理,能讓你在開發中寫出更乾淨、可維護的程式碼。

本篇文章將從概念、語法、實作範例一步一步說明,並提供常見陷阱與最佳實踐,最後列出幾個實務應用情境,幫助你快速上手屬性裝飾器。


核心概念

1. 裝飾器的基本運作原理

在 TypeScript 中,裝飾器本質上是一個 函式,它會在類別宣告階段被呼叫。對於屬性裝飾器而言,函式會收到兩個參數:

參數 說明
target 宣告屬性的類別原型(prototype)或靜態屬性的建構子(constructor)。
propertyKey 屬性的名稱(字串或 Symbol)。
function MyPropertyDecorator(target: any, propertyKey: string | symbol) {
  // 這裡可以寫任何想要在屬性定義時執行的程式碼
}

注意:屬性裝飾器不會直接取得屬性的值,因為在宣告階段,屬性本身還未被實例化。若需要操作值,通常會結合 存取子(getter / setter)Object.defineProperty 來實作。


2. 基本語法與使用方式

function LogProperty(target: any, propertyKey: string) {
  console.log(`屬性 ${propertyKey} 被宣告在`, target);
}

class User {
  @LogProperty
  name: string = 'Alice';
}

執行時會在類別定義階段印出:

屬性 name 被宣告在 { constructor: [Function: User] }

3. 為屬性加上 getter / setter 的技巧

若希望在存取屬性時自動觸發行為(例如驗證或自動轉換),可以在裝飾器內使用 Object.defineProperty 重新定義屬性:

function TrimString(target: any, propertyKey: string) {
  let value: string;

  const getter = function () {
    return value;
  };

  const setter = function (newVal: string) {
    value = typeof newVal === 'string' ? newVal.trim() : newVal;
  };

  Object.defineProperty(target, propertyKey, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true,
  });
}
class Post {
  @TrimString
  title: string = '   Hello World   ';
}

const p = new Post();
console.log(p.title); // => "Hello World"

重點:此方式會在 原型上 定義存取子,所有實例共享同一套 getter / setter,適合純屬性而非每個實例獨立的情境。


4. 參數化的屬性裝飾器

有時候需要根據外部參數決定裝飾器的行為,例如設定最大長度、必填等。這時可以先寫一個「工廠函式」返回真正的裝飾器:

function MaxLength(limit: number) {
  return function (target: any, propertyKey: string) {
    let value: string;

    const getter = () => value;
    const setter = (newVal: string) => {
      if (newVal.length > limit) {
        throw new Error(`屬性 ${propertyKey} 的長度不能超過 ${limit}`);
      }
      value = newVal;
    };

    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true,
    });
  };
}
class Comment {
  @MaxLength(200)
  content: string = '';
}

5. 與 reflect-metadata 結合的高階用法

TypeScript 官方提供的 experimentalDecorators 只能取得屬性的 名稱,若想取得屬性的 型別資訊,需要配合 reflect-metadata

import 'reflect-metadata';

function Type() {
  return function (target: any, propertyKey: string) {
    const type = Reflect.getMetadata('design:type', target, propertyKey);
    console.log(`屬性 ${propertyKey} 的型別是 ${type.name}`);
  };
}

class Product {
  @Type()
  price: number = 0;
}

執行結果:

屬性 price 的型別是 Number

實務提示:在需要自動序列化、驗證或依賴注入的框架(如 class-transformerrouting-controllers)中,通常會大量使用 reflect-metadata 來取得型別資訊。


程式碼範例

下面提供 五個 常見且實用的屬性裝飾器範例,從簡單到進階,涵蓋日誌、驗證、依賴注入與序列化。

範例 1:屬性日誌裝飾器

function LogAccess(target: any, propertyKey: string) {
  const privateKey = `__${propertyKey}`;

  Object.defineProperty(target, propertyKey, {
    get() {
      console.log(`讀取屬性 ${propertyKey},值為 =>`, this[privateKey]);
      return this[privateKey];
    },
    set(value: any) {
      console.log(`設定屬性 ${propertyKey},新值 =>`, value);
      this[privateKey] = value;
    },
    enumerable: true,
    configurable: true,
  });
}

class Settings {
  @LogAccess
  theme: string = 'light';
}

const s = new Settings();
s.theme = 'dark';   // 設定屬性 theme,新值 => dark
console.log(s.theme); // 讀取屬性 theme,值為 => dark

適用情境:除錯或在開發階段追蹤屬性的變化。


範例 2:必填屬性驗證

function Required(target: any, propertyKey: string) {
  let value: any;

  const getter = () => value;
  const setter = (newVal: any) => {
    if (newVal === null || newVal === undefined) {
      throw new Error(`屬性 ${propertyKey} 為必填`);
    }
    value = newVal;
  };

  Object.defineProperty(target, propertyKey, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true,
  });
}

class Order {
  @Required
  orderId!: string; // 用非空斷言告訴編譯器我們會自行保證

  @Required
  amount!: number;
}

const o = new Order();
o.orderId = 'A001';
o.amount = 100; // 正常

// o.amount = undefined; // 會拋出 Error: 屬性 amount 為必填

適用情境:DTO(資料傳輸物件)或表單模型的必填欄位驗證。


範例 3:字串長度限制

function Length(min: number, max: number) {
  return function (target: any, propertyKey: string) {
    let value: string;

    const getter = () => value;
    const setter = (newVal: string) => {
      if (typeof newVal !== 'string') {
        throw new Error(`屬性 ${propertyKey} 必須是字串`);
      }
      if (newVal.length < min || newVal.length > max) {
        throw new Error(
          `屬性 ${propertyKey} 長度須在 ${min}-${max} 之間,現在是 ${newVal.length}`
        );
      }
      value = newVal;
    };

    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true,
    });
  };
}

class Profile {
  @Length(2, 20)
  nickname: string = '';
}

const p = new Profile();
p.nickname = 'Tom';      // OK
// p.nickname = 'A';     // Error: 長度須在 2-20 之間

範例 4:簡易依賴注入(DI)容器

// 1️⃣ 建立一個全域的服務容器
const ServiceContainer = new Map<string, any>();

function Service(name: string) {
  return function (constructor: new (...args: any[]) => any) {
    ServiceContainer.set(name, new constructor());
  };
}

// 2️⃣ 建立屬性注入裝飾器
function Inject(name: string) {
  return function (target: any, propertyKey: string) {
    Object.defineProperty(target, propertyKey, {
      get() {
        return ServiceContainer.get(name);
      },
      enumerable: true,
      configurable: true,
    });
  };
}

// 3️⃣ 定義服務與使用者
@Service('logger')
class Logger {
  log(msg: string) {
    console.log('[LOG]', msg);
  }
}

class Controller {
  @Inject('logger')
  private logger!: Logger;

  handle() {
    this.logger.log('Request received');
  }
}

new Controller().handle(); // [LOG] Request received

適用情境:小型專案或測試環境的手動 DI;在大型框架(NestJS)中,底層原理與此類似。


範例 5:自動序列化與反序列化(使用 reflect-metadata

import 'reflect-metadata';

const SERIALIZE_KEY = Symbol('serialize');

function Serialize() {
  return function (target: any, propertyKey: string) {
    const type = Reflect.getMetadata('design:type', target, propertyKey);
    const meta = Reflect.getMetadata(SERIALIZE_KEY, target) || [];
    meta.push({ key: propertyKey, type });
    Reflect.defineMetadata(SERIALIZE_KEY, meta, target);
  };
}

function toPlain(obj: any): any {
  const meta = Reflect.getMetadata(SERIALIZE_KEY, obj) || [];
  const plain: any = {};
  meta.forEach(({ key, type }) => {
    const value = obj[key];
    if (type === Date && value instanceof Date) {
      plain[key] = value.toISOString();
    } else {
      plain[key] = value;
    }
  });
  return plain;
}

class Event {
  @Serialize()
  name: string = '';

  @Serialize()
  date: Date = new Date();
}

const e = new Event();
e.name = 'Conference';
e.date = new Date('2025-01-01T09:00:00Z');

console.log(toPlain(e));
// { name: 'Conference', date: '2025-01-01T09:00:00.000Z' }

適用情境:在 API 開發中,將類別實例轉換成 JSON 時自動處理日期、枚舉或自訂型別。


常見陷阱與最佳實踐

陷阱 說明 解決方案
裝飾器執行時機不如預期 裝飾器在類別 宣告階段 執行,屬性值尚未被初始化。若在裝飾器內直接存取 this[propertyKey],往往得到 undefined 使用 Object.defineProperty 重新定義 getter / setter,或在建構子內呼叫自訂初始化函式。
使用原型(prototype)導致共享狀態 在屬性裝飾器內直接寫 target[propertyKey] = ...,所有實例會共享同一個值。 透過 Object.definePropertyinstance(即 this)層級儲存私有欄位(如 __${key}),確保每個實例獨立。
缺少 reflect-metadata 想取得型別資訊卻忘記匯入 reflect-metadata,會得到 undefined 在入口檔案(如 main.ts)最上方 import 'reflect-metadata';,並在 tsconfig.json 開啟 experimentalDecoratorsemitDecoratorMetadata
過度使用裝飾器 裝飾器過度堆疊會讓程式碼難以追蹤,尤其在大型專案中。 僅在「跨切面」需求(如驗證、日誌、DI)使用,其他邏輯仍建議寫在方法或服務內。
裝飾器與類別繼承衝突 子類別若重新定義同名屬性,父類別的裝飾器可能不會再次執行。 若需要在子類別重新應用裝飾器,請在子類別上再次使用相同的裝飾器或使用 @Override(自訂)機制。

最佳實踐

  1. 保持裝飾器單一職責:每個裝飾器只負責一件事(例如「驗證」或「日誌」),避免混合多種行為。
  2. 使用私有 Symbol 作為儲存欄位const storage = Symbol('prop'); 可避免名稱衝突。
  3. 在測試環境中禁用裝飾器:若裝飾器內有副作用,使用環境變數或條件判斷避免在單元測試時觸發。
  4. 文件化裝飾器的行為:在程式碼註解或 README 中說明裝飾器會改變哪些屬性、何時拋錯,提升可讀性。
  5. 配合 lint 規則:如 eslint-plugin-decorator 或自訂規則,確保裝飾器使用的一致性。

實際應用場景

場景 使用的屬性裝飾器 為什麼需要
表單資料驗證 @Required@Length@Pattern 前端或後端在接收 DTO 前自動檢查欄位合法性,減少冗長的手寫驗證程式。
API 日誌追蹤 @LogAccess@LogChange 監控關鍵屬性的讀寫,協助除錯或審計。
依賴注入容器 @Inject 讓類別只關心業務邏輯,容器負責提供所需服務,提升可測試性。
序列化/反序列化 @Serialize@Transform(配合 class-transformer 在與後端交換 JSON 時自動處理日期、枚舉、嵌套物件的轉換。
多語系屬性 @LocaleString(自訂) 根據使用者語系自動返回對應的文字,避免在 UI 中寫大量條件判斷。

以上場景均可在 NestJSAngularVue Class Component 等框架中看到類似的應用,學會自行實作屬性裝飾器,可讓你在這些框架之外也能靈活擴充功能。


總結

屬性裝飾器是 TypeScript 裝飾器家族中最貼近「屬性」概念的工具,透過 函式Object.definePropertyreflect-metadata,我們可以在屬性宣告時自動注入日誌、驗證、依賴注入、序列化等橫切關注點。

本文從概念說明、語法結構、五個實用範例、常見陷阱與最佳實踐,最後列出真實的應用情境,提供了一條 從入門到實務 的完整學習路徑。只要遵守「單一職責」與「避免共享狀態」的原則,屬性裝飾器將成為你在大型 TypeScript 專案中提升可維護性、可測試性與開發效率的強大武器。

快把這些範例搬進自己的程式碼庫,體驗「寫得更少、錯得更少」的開發快感吧! 🚀