在TypeScript项目中写业务逻辑时,日志打印和异常捕获是最典型的横切关注点。同一个项目里,有人喜欢在每个方法开头打印一行入参,有人在catch里console.error一下就完事,风格五花八门,而且大量的样板代码淹没了真正的业务逻辑。装饰器提供了一种优雅的解决思路:把日志和异常处理封装成可复用的注解,贴在方法上即可生效,业务代码保持干净清爽。本文将从头到尾讲清楚这套方案的原理与实现。

一、装饰器的底层原理:它到底改写了什么
要理解装饰器方案,先得明白装饰器在运行时的行为。装饰器本质上是一个高阶函数,接收被修饰的目标(类、方法、属性或参数),返回一个“加工后”的目标。对于方法装饰器而言,它拿到的是方法所属类的原型、方法名以及该方法的属性描述符,这意味着我们可以在不修改原方法代码的前提下,用一个新的函数把原方法包一层。
这个“包一层”的能力正是切面编程的核心。我们可以在包装函数里先记录入参、开始时间,再调用原方法拿到返回值,最后打印耗时和出参;如果原方法抛出异常,就在catch块里统一处理后选择重新抛出或吞掉。整个流程对业务代码完全透明,调用方感知不到任何变化。
需要注意的是,TypeScript 5.0之后官方支持了标准的Stage 3装饰器语法,与早期的experimentalDecorators行为有差异。本文以目前使用最广泛的实验性装饰器为准(NestJS、TypeORM等主流框架均基于此),在tsconfig.json中需要开启如下配置:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2017"
}
}其中emitDecoratorMetadata会额外把类型信息写入元数据,配合reflect-metadata库使用,可以让我们在运行时拿到参数类型、设计返回类型等信息,对日志的格式化非常有用。
二、实现统一日志打印装饰器
日志装饰器的目标是:方法被调用时自动打印类名、方法名、入参、返回值和执行耗时。这些信息足够排查绝大多数问题,而且不需要开发者手写一行日志代码。
实现时有个关键细节:TypeScript编译后方法会挂到原型上,描述符中的value就是原方法本体。我们要做的就是替换这个value为一个异步或同步的包装函数。由于现代业务大量使用async方法,包装函数最好统一按异步处理,用await调用原方法,这样同步和异步函数都能正确计时。
import 'reflect-metadata';
// 可选参数:自定义日志器与标签
export function Log(option?: { label?: string }) {
return function (
target: Object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
const className = target.constructor.name;
descriptor.value = async function (...args: any[]) {
const start = Date.now();
const label = option?.label ?? `${className}.${propertyKey}`;
console.log(`[${label}] 调用,入参:`, JSON.stringify(args));
const result = await original.apply(this, args);
const cost = Date.now() - start;
console.log(`[${label}] 完成,耗时 ${cost}ms,返回:`, JSON.stringify(result));
return result;
};
return descriptor;
};
}使用方式非常简洁,直接贴在方法上即可:
class OrderService {
@Log({ label: '订单服务' })
async createOrder(userId: number, sku: string) {
// 纯业务逻辑,没有任何日志代码
await new Promise(r => setTimeout(r, 120));
return { orderId: 'A1001', userId, sku };
}
}有一点要提醒:JSON.stringify序列化入参时,如果参数包含循环引用会直接抛错,所以生产环境建议做一层保护,比如try catch包裹序列化,或者在参数是复杂对象时只打印其构造函数名和关键字段。另外,入参中可能包含密码、token等敏感信息,最好支持一个脱敏列表,把指定位置的参数替换为星号后再输出。
三、实现统一异常捕获装饰器
异常捕获装饰器的思路类似,但它关注的是错误路径。包装函数用try catch包裹原方法调用,捕获到异常后记录完整的错误堆栈和调用上下文,然后根据配置决定是抛出一个统一的业务异常对象,还是返回一个默认值保证流程不中断。
export function Catch(option?: {
rethrow?: boolean; // 是否重新抛出,默认true
fallback?: any; // 不抛出时返回的默认值
}) {
return function (
target: Object,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
const className = target.constructor.name;
descriptor.value = async function (...args: any[]) {
try {
return await original.apply(this, args);
} catch (err: any) {
console.error(
`[${className}.${propertyKey}] 异常:`,
err instanceof Error ? err.stack : err,
'\n调用上下文 入参:',
JSON.stringify(args)
);
if (option?.rethrow === false) {
return option?.fallback;
}
// 包装成统一错误对象再抛出,方便上层全局处理
throw new Error(`[${className}.${propertyKey}] 执行失败: ${err.message}`);
}
};
return descriptor;
};
}这里有一个容易被忽视的坑:异步方法抛出的异常是Promise拒绝,如果包装函数不加await直接调用原方法,catch块将永远捕获不到异常,错误会变成未处理的Promise rejection。所以即不确定原方法是否为异步,也建议统一按await处理,同步函数的返回值经过await不会有任何变化。
另一个实践建议是对错误做分类。可以定义一套错误码枚举,把数据库错误、网络超时、参数校验失败分别映射到不同的日志级别和提示文案,catch块里根据err.name或自定义的err.code判断类别,这样日志输出对排查问题更有针对性,也方便接入告警系统。
四、组合使用与生产环境注意事项
日志装饰器和异常装饰器可以叠加使用,TypeScript支持多个装饰器依次修饰同一个方法,执行顺序是从上到下应用、从下到上执行:
class PaymentService {
@Catch({ rethrow: false, fallback: { success: false } })
@Log({ label: '支付服务' })
async pay(orderId: string) {
const res = await fetch(`https://api.ipipp.com/pay/${orderId}`);
if (!res.ok) throw new Error(`支付接口返回 ${res.status}`);
return res.json();
}
}这种叠加方式下,外层的Catch能捕获到内层Log包装函数抛出的一切异常,两层切面互不干扰,职责清晰。
落地到生产环境还有几点需要权衡。首先是性能,装饰器包装会带来一层函数调用开销,对绝大多数业务来说可以忽略,但在每秒调用量十万级以上的热点路径上要谨慎。其次是日志量,全量打印入参出参在高并发下会产生海量日志,建议给Log装饰器加采样开关或按环境区分级别,开发环境打印全量,生产环境只打印慢请求和异常请求。最后是可测试性,装饰器依赖target.constructor.name,代码经过压缩混淆后类名可能变成单字母,最好在装饰器参数里显式传入业务标签,避免日志失去定位能力。
总结一下,装饰器方案的价值在于把重复的横切逻辑收敛到一处,新增方法时只需一个注解就能获得完整的日志和异常处理能力,排查问题时信息齐全,代码评审时也不再纠结日志风格。如果你的项目还在满天飞地手写console和try catch,不妨从核心Service类开始逐步迁移,收益会非常明显。
TypeScript装饰器日志打印异常捕获修改时间:2026-09-10 08:34:50