跳到主要内容
预计阅读 41 分钟

线上日志全靠手写——用装饰器消灭横切关注点

一段让人崩溃的代码审查

某电商团队在做代码审查时发现,几乎每个 Service 类的方法开头和结尾都重复着同样的逻辑:记录入参日志、计算执行耗时、捕获异常并上报。一个原本只有 5 行业务逻辑的方法,被日志和监控代码包裹后膨胀到 25 行。更严重的是,某位工程师在复制粘贴时遗漏了一处异常捕获,导致线上报错后无法追踪根因。

这类横跨多个模块、与核心业务无关但又不可或缺的逻辑,在软件工程中称为横切关注点(cross-cutting concerns)。装饰器(Decorator)正是 TypeScript 提供的、用于系统性解决这类问题的语法能力。

本章将从装饰器的运行机制讲起,覆盖新旧两套语法体系的全部细节,最后展示如何在真实框架中发挥装饰器的工程价值。读完之后,你将能够自己编写日志、重试、权限校验、性能监控等通用装饰器,并理解主流后端框架(如 NestJS)的装饰器驱动架构为何如此设计。


装饰器的本质

如果你写过 Java 的注解或 Python 的装饰器,会对 TypeScript 装饰器感到似曾相识。它们解决的是同一类问题:如何在不侵入原有代码的前提下,系统性地附加横切逻辑。不同于手动在每个方法中添加 console.logtry/catch,装饰器让你把这些逻辑抽取出来,作为一种”可插拔的增强”附着在类和方法上。

装饰器是一种应用于类及其成员的特殊函数,通过 @ 符号附加在声明前。它在类定义阶段执行(不是实例化阶段),可以观察、修改甚至替换被装饰的目标。

@frozen
class PaymentGateway {
  @trace
  charge(cents: number): boolean {
    return cents > 0;
  }
}

@frozen@trace 都是普通函数,@ 只是一种调用语法。装饰器函数的参数和返回值由其装饰的目标类型决定。

两代语法标准

TypeScript 中并存着两套装饰器实现:

维度新版(TC39 Stage 3)旧版(experimentalDecorators)
可用版本TypeScript 5.0+TypeScript 全版本
是否需要配置无需配置需开启 experimentalDecorators
函数签名(value, context)(target, key, descriptor)
参数装饰器不支持支持
元数据机制context.metadatareflect-metadata

如果你的项目基于 TypeScript 5.0 或更高版本且没有历史包袱,优先使用新版标准装饰器。如果项目依赖 NestJS、Angular 等基于旧版装饰器的框架,则需要了解旧版语法。两种语法不能在同一个项目中混用——开启 experimentalDecorators 后,新版语法会被禁用。

下文先讲新版标准语法(推荐使用),再对照旧版差异。


新版标准装饰器详解

类装饰器

类装饰器是作用范围最大的一类——它接管整个类的定义过程。类装饰器接收被装饰的类(value)和上下文对象(context),可以返回一个新类来替换原类,或者不返回值以保留原类。在实际工程中,类装饰器最常见的用途包括:将类注册到全局容器(依赖注入)、冻结类以防止意外修改、通过继承为类添加公共能力。

type ClassDecorator = (
  value: Function,
  context: {
    kind: "class";
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

场景:注册服务到全局容器

const registry = new Map<string, Function>();

function Registrable(target: Function, ctx: ClassDecoratorContext) {
  if (ctx.kind === "class") {
    registry.set(ctx.name ?? target.name, target);
  }
}

@Registrable
class NotificationService {
  send(recipient: string, content: string) {
    console.log(`To ${recipient}: ${content}`);
  }
}

console.log(registry.has("NotificationService")); // true

场景:通过继承扩展能力

function withAuditTrail<T extends new (...args: any[]) => {}>(
  Base: T,
  ctx: ClassDecoratorContext
) {
  return class extends Base {
    auditLog: string[] = [];
    recordAction(action: string) {
      this.auditLog.push(`[${new Date().toISOString()}] ${action}`);
    }
  };
}

@withAuditTrail
class InventoryManager {
  stockLevel = 100;
}

const mgr = new InventoryManager() as any;
mgr.recordAction("restock +50");
console.log(mgr.auditLog); // ['[2025-...] restock +50']

方法装饰器

方法装饰器是使用频率最高的类型,也是回到本章开头的那个问题——日志、重试、权限校验等横切逻辑——最直接的解决方案。它接收原始方法作为第一个参数,可以返回一个替代方法。返回的替代方法通常会在调用原始方法前后添加额外逻辑,形成一种”包裹”效果。

方法装饰器的完整类型签名如下:

type ClassMethodDecorator = (
  value: (this: any, ...args: any[]) => any,  // 原始方法
  context: ClassMethodDecoratorContext          // 上下文对象
) => ((this: any, ...args: any[]) => any) | void;

场景:自动重试

既然这是一门以类型安全为核心的课程,核心装饰器示例也应该展示类型安全的写法。下面先给出带完整泛型签名的版本,再给出简化版作为对照:

// 类型安全版本:通过泛型保留原始方法的参数和返回类型
function retry(maxAttempts: number) {
  return function <T extends (...args: any[]) => Promise<any>>(
    originalFn: T,
    ctx: ClassMethodDecoratorContext
  ): T {
    const fnName = String(ctx.name);
    const wrapper = async function (this: any, ...args: Parameters<T>): Promise<Awaited<ReturnType<T>>> {
      let lastError: unknown;
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await originalFn.call(this, ...args);
        } catch (err) {
          lastError = err;
          console.warn(`${fnName} attempt ${attempt} failed, retrying...`);
        }
      }
      throw lastError;
    };
    return wrapper as unknown as T;
  };
}

class ExternalApiClient {
  @retry(3)
  async fetchQuote(symbol: string): Promise<number> {
    // 模拟不稳定的外部调用
    if (Math.random() < 0.7) throw new Error("timeout");
    return 42.5;
  }
}

在实际项目中,如果装饰器数量较多且泛型签名带来的噪音影响可读性,可以简化为 any 版本——但请注意这会丢失类型检查:

// 简化版本:用 any 减少类型噪音,适合内部工具函数
function retrySimple(maxAttempts: number) {
  return function (originalFn: any, ctx: ClassMethodDecoratorContext) {
    const fnName = String(ctx.name);
    return async function (this: any, ...args: any[]) {
      let lastError: unknown;
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await originalFn.call(this, ...args);
        } catch (err) {
          lastError = err;
          console.warn(`${fnName} attempt ${attempt} failed, retrying...`);
        }
      }
      throw lastError;
    };
  };
}

场景:执行耗时统计

// 类型安全版本
function stopwatch<T extends (...args: any[]) => any>(
  originalFn: T,
  ctx: ClassMethodDecoratorContext
): T {
  const label = String(ctx.name);
  const wrapper = function (this: any, ...args: Parameters<T>): ReturnType<T> {
    const t0 = performance.now();
    const result = originalFn.call(this, ...args);
    const elapsed = (performance.now() - t0).toFixed(2);
    console.log(`[Perf] ${label}: ${elapsed}ms`);
    return result;
  };
  return wrapper as unknown as T;
}

属性装饰器

属性装饰器与方法装饰器有一个重要区别:它的第一个参数是 undefined,因为在装饰器执行时属性还没有被赋值。属性装饰器的能力是通过返回一个初始化函数来实现的——这个函数会在属性赋初始值时被调用,接收原始初始值作为参数,返回经过处理的新值。

function clamp(min: number, max: number) {
  return function (value: undefined, ctx: ClassFieldDecoratorContext) {
    return function (initialValue: number) {
      return Math.max(min, Math.min(max, initialValue));
    };
  };
}

class AudioMixer {
  @clamp(0, 100)
  masterVolume = 120; // 实际被钳制为 100
}

console.log(new AudioMixer().masterVolume); // 100
function sanitize(value: undefined, ctx: ClassFieldDecoratorContext) {
  return function (initialValue: string) {
    return initialValue.trim().toLowerCase();
  };
}

class UserProfile {
  @sanitize
  email = "  Admin@Example.COM  "; // 变为 "admin@example.com"
}

Getter / Setter 装饰器

Getter 和 Setter 装饰器的函数签名与方法装饰器结构类似,第一个参数是原始的 getter 或 setter 函数,可以返回一个替代函数。最典型的应用场景是为 getter 添加计算缓存——避免重复执行昂贵的计算逻辑。

场景:计算结果缓存(memoize getter)

function memoize(getter: any, ctx: any) {
  if (ctx.kind !== "getter") return;
  const cacheKey = Symbol(`__memo_${String(ctx.name)}`);
  return function (this: any) {
    if (!(cacheKey in this)) {
      (this as any)[cacheKey] = getter.call(this);
    }
    return (this as any)[cacheKey];
  };
}

class ReportGenerator {
  @memoize
  get summary(): string {
    console.log("Computing summary...");
    return `Report generated at ${Date.now()}`;
  }
}

const rpt = new ReportGenerator();
rpt.summary; // Computing summary... (首次计算)
rpt.summary; // 直接返回缓存,无输出

Accessor 装饰器

accessor 是 TC39 装饰器提案(Stage 3)引入的关键字,TypeScript 5.0 首次实现了对它的支持。它专门为装饰器场景设计。普通的类属性没有 getter/setter,装饰器无法拦截对它的读写操作。accessor 关键字会自动将公开属性展开为一对 getter/setter 加一个私有存储字段,从而让装饰器能够拦截属性的每一次读写:

class Channel {
  accessor subscriberCount = 0;
}
// 等价于:
// class Channel {
//   #subscriberCount = 0;
//   get subscriberCount() { return this.#subscriberCount; }
//   set subscriberCount(v) { this.#subscriberCount = v; }
// }

Accessor 装饰器可以同时拦截 get、set 和初始化:

function observable(value: any, ctx: any) {
  if (ctx.kind !== "accessor") return;
  const { get, set } = value;
  const listeners: Set<Function> = new Set();

  return {
    get() {
      return get.call(this);
    },
    set(newVal: any) {
      const oldVal = get.call(this);
      set.call(this, newVal);
      if (oldVal !== newVal) {
        listeners.forEach((fn) => fn(newVal, oldVal));
      }
    },
    init(initial: any) {
      return initial;
    },
  };
}

class ThemeSetting {
  @observable accessor mode: "light" | "dark" = "light";
}

装饰器工厂:让装饰器接受参数

直接写 @trace 时,trace 本身就是装饰器函数。如果需要传入配置参数,就要多包一层——外层函数接收参数,返回真正的装饰器函数:

function throttle(intervalMs: number) {
  return function (originalFn: any, ctx: ClassMethodDecoratorContext) {
    let lastCall = 0;
    return function (this: any, ...args: any[]) {
      const now = Date.now();
      if (now - lastCall < intervalMs) return;
      lastCall = now;
      return originalFn.call(this, ...args);
    };
  };
}

class SearchBox {
  @throttle(500)
  onInput(keyword: string) {
    console.log("Searching:", keyword);
  }
}

关键区别:@throttle(500) 有括号,是工厂模式;@trace 无括号,是直接装饰。

装饰器工厂模式在实际项目中非常普遍,因为几乎所有有实用价值的装饰器都需要某种程度的配置。比如日志装饰器需要指定日志级别,缓存装饰器需要指定过期时间,权限装饰器需要指定所需角色。工厂模式提供了这种灵活性,同时保持了装饰器语法的简洁。你也可以为工厂函数的参数设置合理的默认值,让最常见的用法不需要传参。


多个装饰器的执行顺序

叠加在同一目标上

多个装饰器从上到下依次求值(如果是工厂,执行外层函数),然后从下到上依次应用(执行装饰器函数本体)。可以理解为函数组合 f(g(x))

function alpha() {
  console.log("alpha: evaluated");
  return (_v: any, _c: any) => console.log("alpha: applied");
}

function beta() {
  console.log("beta: evaluated");
  return (_v: any, _c: any) => console.log("beta: applied");
}

class Pipeline {
  @alpha()
  @beta()
  execute() {}
}
// alpha: evaluated
// beta: evaluated
// beta: applied
// alpha: applied

不同成员之间的顺序

在一个类中,装饰器的应用遵循以下规则:

  1. 成员装饰器按源代码中的声明顺序依次应用(方法、访问器、属性等)
  2. 类装饰器最后应用

概括规则:先成员后类,成员按源码顺序

理解这个顺序在实际开发中非常重要。例如,如果你有一个类装饰器需要读取方法装饰器设置的元数据,这是安全的——因为方法装饰器总是先于类装饰器执行。但如果你反过来期望方法装饰器读取类装饰器写入的数据,那将无法实现,因为类装饰器在所有成员装饰器之后才运行。在设计装饰器之间的协作关系时,务必牢记这个执行顺序。


context 对象与 addInitializer

所有装饰器的第二个参数 context 包含以下信息:

字段含义
kind目标类型:classmethodgettersetterfieldaccessor
name目标名称
static是否静态成员
private是否私有成员
access包含 get/set 的存取方法
addInitializer注册初始化阶段回调
metadata编译期元数据存储对象

addInitializer 的典型用途是自动绑定 this,避免方法被解构后丢失上下文:

function autoBind(
  originalFn: any,
  ctx: ClassMethodDecoratorContext
) {
  const name = ctx.name;
  ctx.addInitializer(function (this: any) {
    this[name] = this[name].bind(this);
  });
}

class FormController {
  formId = "checkout-form";

  @autoBind
  onSubmit() {
    console.log(`Submitting ${this.formId}`);
  }
}

const ctrl = new FormController();
const { onSubmit } = ctrl;
onSubmit(); // "Submitting checkout-form" —— this 正确绑定

旧版实验性装饰器

旧版需要显式开启编译选项:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

旧版类装饰器

只接收一个参数——构造函数:

function immutable(ctor: Function) {
  Object.freeze(ctor);
  Object.freeze(ctor.prototype);
}

@immutable
class AppConstants {
  static VERSION = "2.1.0";
}

旧版方法装饰器

接收三个参数:原型对象、方法名、属性描述符:

function deprecated(
  proto: any,
  methodName: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.warn(`WARNING: ${methodName}() is deprecated`);
    return original.apply(this, args);
  };
}

class LegacyApi {
  @deprecated
  fetchAll() {
    return [{ id: 1 }];
  }
}

旧版属性装饰器

接收两个参数——原型和属性名,没有描述符,通过 Object.defineProperty 实现拦截:

function MaxLength(limit: number) {
  return function (proto: Object, fieldName: string) {
    const store = new WeakMap<Object, string>();
    Object.defineProperty(proto, fieldName, {
      get() { return store.get(this); },
      set(val: string) {
        if (val.length > limit) {
          throw new RangeError(`${fieldName} exceeds ${limit} characters`);
        }
        store.set(this, val);
      },
    });
  };
}

class Article {
  @MaxLength(200)
  headline!: string;
}

旧版参数装饰器

这是旧版独有的能力——装饰方法的参数,接收原型、方法名和参数位置索引:

function validate(
  proto: Object,
  methodName: string | symbol,
  paramIdx: number
) {
  console.log(`Validating param #${paramIdx} of ${String(methodName)}`);
}

class AuthService {
  authenticate(@validate ticket: string, @validate channel: string) {
    // ...
  }
}
// Validating param #1 of authenticate
// Validating param #0 of authenticate

新版标准装饰器不支持参数装饰器。TC39 委员会认为参数装饰器的使用场景有限且增加了语言复杂度,因此在标准化过程中被移除。但由于 NestJS 和 Angular 大量依赖参数装饰器实现依赖注入和参数校验,这些框架短期内仍将继续使用旧版语法。如果你正在开发新框架或新库,应当避免对参数装饰器的依赖,以便将来迁移到标准语法。


reflect-metadata 与运行时类型感知

reflect-metadata 是配合旧版装饰器使用的元数据库,它让 TypeScript 在编译时将类型信息注入到运行时:

npm install reflect-metadata
import "reflect-metadata";

function Injectable(ctor: any) {
  const deps = Reflect.getMetadata("design:paramtypes", ctor);
  console.log("Dependencies:", deps);
}

function Track(proto: any, field: string) {
  const fieldType = Reflect.getMetadata("design:type", proto, field);
  console.log(`${field} is of type:`, fieldType);
}

class DatabasePool {}

@Injectable
class OrderRepository {
  @Track
  pool!: DatabasePool;

  constructor(private db: DatabasePool) {}
}
// Dependencies: [class DatabasePool]
// pool is of type: class DatabasePool

需要开启 emitDecoratorMetadata 编译选项。design:paramtypesdesign:typedesign:returntype 这三个元数据键是 TypeScript 编译器自动注入的。这个机制的工作原理是:TypeScript 编译器在输出 JavaScript 时,会额外生成 Reflect.defineMetadata 调用,把构造函数参数类型、属性类型和方法返回类型以运行时可访问的形式保存下来。这些信息在 TypeScript 源码中只存在于类型层面(编译后应被擦除),但 emitDecoratorMetadata 选项让编译器破例保留了它们——这也是为什么它叫”实验性”功能。

新版标准装饰器通过 context.metadata 实现类似功能,无需第三方库:

function tag(key: string, val: any) {
  return (_: any, ctx: any) => {
    ctx.metadata[key] = val;
  };
}

@tag("version", 2)
class PaymentProcessor {
  @tag("endpoint", "/charge")
  processPayment() {}
}

(PaymentProcessor as any)[Symbol.metadata]; // { version: 2, endpoint: "/charge" }

装饰器在主流框架中的应用

后端框架中的路由与依赖注入

后端框架利用旧版装饰器实现声明式路由和自动依赖注入:

import { Controller, Get, Post, Body, Injectable } from "@nestjs/common";

@Injectable()
class TicketService {
  listOpen() {
    return [{ id: 1, subject: "Login issue" }];
  }
}

@Controller("tickets")
class TicketController {
  constructor(private svc: TicketService) {}

  @Get()
  getAll() {
    return this.svc.listOpen();
  }

  @Post()
  create(@Body() payload: { subject: string }) {
    return { message: `Created: ${payload.subject}` };
  }
}

前端框架中的组件声明

前端框架用装饰器定义组件的元信息:

@Component({
  selector: "app-dashboard",
  template: `<h2>{{ heading }}</h2>`,
  styles: [`h2 { font-weight: 600; }`],
})
class DashboardComponent {
  @Input() heading = "Overview";
  @Output() refresh = new EventEmitter<void>();
}

自定义实用装饰器示例

只读属性保护

function sealed(value: undefined, ctx: ClassFieldDecoratorContext) {
  ctx.addInitializer(function (this: any) {
    Object.defineProperty(this, ctx.name, { writable: false });
  });
}

class RuntimeConfig {
  @sealed buildHash = "a3f9c2e";
}

方法级权限校验

function requireRole(role: string) {
  return function (originalFn: any, ctx: ClassMethodDecoratorContext) {
    return function (this: any, ...args: any[]) {
      if (this.currentUserRole !== role) {
        throw new Error(`Access denied: requires ${role} role`);
      }
      return originalFn.call(this, ...args);
    };
  };
}

class AdminPanel {
  currentUserRole = "viewer";

  @requireRole("admin")
  deleteAllRecords() {
    console.log("Records purged");
  }
}

为什么不能装饰普通函数

装饰器只能用于类及其成员,不能用于独立函数。根本原因在于 JavaScript 的函数提升机制——函数声明会被提升到作用域顶部执行,而装饰器需要在声明处同步执行,两者的时序无法协调。

替代方案是使用高阶函数:

function withErrorBoundary<T extends (...args: any[]) => any>(fn: T): T {
  return function (this: any, ...args: any[]) {
    try {
      return fn.apply(this, args);
    } catch (err) {
      console.error(`Error in ${fn.name}:`, err);
      return undefined;
    }
  } as T;
}

const safeParse = withErrorBoundary(function parseConfig(raw: string) {
  return JSON.parse(raw);
});

本章回顾

本章从生产中频繁出现的”日志代码到处复制”问题切入,系统讲解了 TypeScript 装饰器的完整知识体系:

  • 新版标准装饰器(TS 5.0+)使用 (value, context) 签名,支持类、方法、属性、getter、setter、accessor 六种目标
  • 旧版实验性装饰器使用 (target, key, descriptor) 签名,额外支持参数装饰器
  • 装饰器工厂通过外层函数接收配置参数,返回实际的装饰器函数
  • 执行顺序:多装饰器从下到上应用;成员装饰器按源码声明顺序依次执行,类装饰器最后执行
  • 元数据:旧版依赖 reflect-metadata,新版通过 context.metadataSymbol.metadata 实现
  • 装饰器不能用于独立函数,替代方案是高阶函数

在决定是否使用装饰器时,建议遵循一个原则:只有当一段逻辑需要在多个类或多个方法上重复应用时,才值得将它抽取为装饰器。对于只在一两个地方使用的逻辑,直接在方法内编写更加直观。装饰器的威力在于”一次编写、处处复用”,但过度使用会让代码的执行流变得难以追踪——调试时你需要在装饰器函数和原始方法之间来回跳转。合理使用装饰器可以显著提升代码的可维护性,但滥用则会适得其反。

购买课程解锁全部内容

告别类型错误:12 章掌握 TypeScript 工程实战

¥29.90