线上日志全靠手写——用装饰器消灭横切关注点
一段让人崩溃的代码审查
某电商团队在做代码审查时发现,几乎每个 Service 类的方法开头和结尾都重复着同样的逻辑:记录入参日志、计算执行耗时、捕获异常并上报。一个原本只有 5 行业务逻辑的方法,被日志和监控代码包裹后膨胀到 25 行。更严重的是,某位工程师在复制粘贴时遗漏了一处异常捕获,导致线上报错后无法追踪根因。
这类横跨多个模块、与核心业务无关但又不可或缺的逻辑,在软件工程中称为横切关注点(cross-cutting concerns)。装饰器(Decorator)正是 TypeScript 提供的、用于系统性解决这类问题的语法能力。
本章将从装饰器的运行机制讲起,覆盖新旧两套语法体系的全部细节,最后展示如何在真实框架中发挥装饰器的工程价值。读完之后,你将能够自己编写日志、重试、权限校验、性能监控等通用装饰器,并理解主流后端框架(如 NestJS)的装饰器驱动架构为何如此设计。
装饰器的本质
如果你写过 Java 的注解或 Python 的装饰器,会对 TypeScript 装饰器感到似曾相识。它们解决的是同一类问题:如何在不侵入原有代码的前提下,系统性地附加横切逻辑。不同于手动在每个方法中添加 console.log 或 try/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.metadata | reflect-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
不同成员之间的顺序
在一个类中,装饰器的应用遵循以下规则:
- 成员装饰器按源代码中的声明顺序依次应用(方法、访问器、属性等)
- 类装饰器最后应用
概括规则:先成员后类,成员按源码顺序。
理解这个顺序在实际开发中非常重要。例如,如果你有一个类装饰器需要读取方法装饰器设置的元数据,这是安全的——因为方法装饰器总是先于类装饰器执行。但如果你反过来期望方法装饰器读取类装饰器写入的数据,那将无法实现,因为类装饰器在所有成员装饰器之后才运行。在设计装饰器之间的协作关系时,务必牢记这个执行顺序。
context 对象与 addInitializer
所有装饰器的第二个参数 context 包含以下信息:
| 字段 | 含义 |
|---|---|
kind | 目标类型:class、method、getter、setter、field、accessor |
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:paramtypes、design:type、design: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.metadata和Symbol.metadata实现 - 装饰器不能用于独立函数,替代方案是高阶函数
在决定是否使用装饰器时,建议遵循一个原则:只有当一段逻辑需要在多个类或多个方法上重复应用时,才值得将它抽取为装饰器。对于只在一两个地方使用的逻辑,直接在方法内编写更加直观。装饰器的威力在于”一次编写、处处复用”,但过度使用会让代码的执行流变得难以追踪——调试时你需要在装饰器函数和原始方法之间来回跳转。合理使用装饰器可以显著提升代码的可维护性,但滥用则会适得其反。
购买课程解锁全部内容
告别类型错误:12 章掌握 TypeScript 工程实战
¥29.90