Dark Dwarf Blog background

TypeScript 装饰器

TypeScript 装饰器

1. 基本概念

a.a. 装饰器模式

装饰器模式是一种结构型设计模式,它允许我们在运行时动态地向一个对象添加新的行为和职责,同时又不改变其结构。

这种模式的核心优势在于它遵循了“开闭原则”:对扩展开放,对修改关闭。我们可以在不修改现有代码的情况下,为其增添新功能。

b.b. JavaScript/TypeScript 中的装饰器

在 TypeScript 和 JavaScript (ES2022+) 中,装饰器是这一设计模式的语法糖。它是一种特殊的函数,以 @expression 的形式附加到类、方法、属性、访问器或参数上。expression 必须是一个求值后为函数的表达式。

@sealed
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

// `sealed` 就是一个装饰器函数
function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

2. 装饰器的两种标准

TypeScript 的装饰器经历了两个主要版本:

  1. 传统/实验性装饰器: 这是 TypeScript 早期的实现,在许多现有项目(如早期版本的 Angular, NestJS)中仍广泛使用。它功能强大但与最终的 JavaScript 标准存在差异。
  2. ES2022 标准装饰器: 这是被 TC39 委员会采纳并成为 JavaScript 官方标准的新版本。

要在 tsconfig.json 中使用传统装饰器,必须启用 experimentalDecorators 选项。现代装饰器在 targetES2022 或更高版本时默认可用。

{
  "compilerOptions": {
    "target": "ES2022",
    "experimentalDecorators": true // 启用传统装饰器
  }
}

3. 方法装饰器

a.a. 基本概念

方法装饰器是最常用的一种,通常用于实现面向切面编程 (AOP)。

AOP (Aspect-Oriented Programming) 允许我们将那些横跨多个模块的“横切关注点”——如日志记录、性能监控、权限校验——分离出来,形成独立的“切面”,然后通过装饰器等方式嵌入到业务逻辑中,从而保持业务代码的纯净。

b.b. 现代装饰器

现代方法装饰器接收两个参数:originalMethod (被装饰的原始方法) 和 context (一个包含方法元信息的上下文对象)。它必须返回一个新的函数来替换原始方法。

例如,如果我们想对一个方法添加鉴权逻辑:只有在 admin 时才执行该函数:

// AdminOnly.ts
export function AdminOnly<T extends (...args: any[]) => any>(
  originalMethod: T,
  context: {
    kind: "method" | string;
    name: string | symbol;
    static?: boolean;
    private?: boolean;
    addInitializer?: (initializer: () => void) => void;
  },
) {
  // 仅在 method 上使用
  if (context.kind !== "method") {
    throw new Error("AdminOnly decorator can only be applied to methods");
  }

  // 返回新的方法实现以替换原始方法
  return function (
    this: any,
    ...args: Parameters<T>
  ): ReturnType<T> | undefined {
    const currentUser = { role: "admin" }; // 模拟当前用户
    console.log("正在进行权限检查...");

    if (currentUser.role === "admin") {
      console.log("权限检查通过!");
      // 用原来的上下文和参数调用原方法
      return originalMethod.apply(this, args);
    } else {
      console.error("Access Denied: Admins only.");
      return undefined;
    }
  } as T;
}

c.c. 传统装饰器 (Experimental)

传统方法装饰器接收三个参数:target (对于实例方法是类的原型,对于静态方法是类的构造函数)、propertyKey (方法名) 和 descriptor (属性描述符)。通过直接修改 descriptor.value 来包装原始方法。

function AdminOnly<T extends (...args: any[]) => any>(
  target: any,
  propertyKey: string,
  descriptor: TypedPropertyDescriptor<T>,
) {
  const originalMethod = descriptor.value!;
  const wrapped: T = function (
    this: any,
    ...args: Parameters<T>
  ): ReturnType<T> | undefined {
    const currentUser = { role: "admin" };
    if (currentUser.role === "admin") {
      return originalMethod.apply(this, args) as ReturnType<T>;
    } else {
      console.error("Access Denied: Admins only.");
      return undefined;
    }
  } as T;
  descriptor.value = wrapped;
  return descriptor;
}

4. 装饰器工厂

为了让装饰器更灵活,我们可以使用装饰器工厂。它就是一个返回装饰器函数的函数,允许我们向装饰器传递参数。

// 这是一个装饰器工厂
function Role(role: string) {
  // 它返回一个传统的方法装饰器
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      // 模拟检查用户角色
      const currentUser = { roles: ["user", "editor"] };
      if (currentUser.roles.includes(role)) {
        return originalMethod.apply(this, args);
      } else {
        console.error(`Access Denied: Requires role '${role}'.`);
      }
    };
  };
}

class ArticleEditor {
  @Role("editor")
  editArticle(content: string) {
    console.log("Article edited.");
  }

  @Role("admin")
  publishArticle() {
    console.log("Article published.");
  }
}

const editor = new ArticleEditor();
editor.editArticle("new content"); // "Article edited."
editor.publishArticle(); // "Access Denied: Requires role 'admin'."

5. 类装饰器

类装饰器应用于类的构造函数,可用于观察、修改甚至替换整个类定义。

a.a. 现代装饰器 (TS 5.0+)

接收 originalClasscontext。可以返回一个新的类来继承并扩展原始类。

function AddVersion(originalClass: any, context: ClassDecoratorContext) {
  // 返回一个继承自原始类的新类
  return class extends originalClass {
    version = "1.0.0"; // 添加新属性
    constructor(...args: any[]) {
      super(...args); // 调用父类构造函数
      console.log(`${String(context.name)} class instantiated.`);
    }
  };
}

@AddVersion
class MyComponent {
  name = "Component";
}

const comp = new MyComponent() as any;
// 输出: MyComponent class instantiated.
console.log(comp.version); // "1.0.0"

b.b. 传统装饰器

只接收一个参数:类的构造函数。通常用于修改类的原型或添加静态属性。

function Frozen(constructor: Function) {
  Object.freeze(constructor);
  Object.freeze(constructor.prototype);
  console.log(`${constructor.name} is now frozen.`);
}

@Frozen
class Config {
  static setting = "A";
}

// Config.static.setting = "B"; // 运行时会报错,因为类已被冻结

6. 装饰器与元数据

元数据是“关于数据的数据”。装饰器最强大的应用之一就是读写元数据,这为依赖注入、ORM 等高级框架提供了基础。

ES 提案中包含了一个标准的元数据 API,通过 context.metadata 对象和 Symbol.metadata 访问。

function meta(key: string, value: any) {
  return function (target: any, context: { metadata: any }) {
    context.metadata[key] = value;
  };
}

@meta("apiVersion", "2.0")
class ApiClient {
  @meta("endpoint", "/users")
  getUsers() {}
}

// 访问类和方法上的元数据
const metadata = (ApiClient as any)[Symbol.metadata];
console.log(metadata.apiVersion); // '2.0'
console.log(metadata.endpoint); // '/users'

在传统装饰器中,没有内置的元数据 API,因此社区广泛使用 reflect-metadata 这个库。我们需要先安装它并在项目入口处导入一次。它提供 Reflect.defineMetadataReflect.getMetadata 等方法来附加和读取元数据:

import "reflect-metadata";

// 定义元数据的 key
const requiredMetadataKey = Symbol("required");

function Required(target: any, propertyKey: string) {
  // 为类的属性定义元数据
  Reflect.defineMetadata(requiredMetadataKey, true, target, propertyKey);
}

class User {
  @Required
  name: string;
  age?: number;
}

function validate(obj: any) {
  const user = new obj();
  for (let key in user) {
    const isRequired = Reflect.getMetadata(requiredMetadataKey, user, key);
    if (isRequired) {
      console.log(`Property '${key}' is required.`);
    }
  }
}

validate(User); // "Property 'name' is required."