为何基类上的TypeScript装饰器会导致子类互相影响?
这个问题的核心是装饰器执行时机和原型链共享导致的字段污染,我来一步步拆解原因和解决办法:
为什么会出现这个问题?
首先要明确:TypeScript的属性装饰器是在类定义阶段执行的,和实例化完全无关。而且装饰器的target参数是类的原型对象,不是实例。
看你的代码执行顺序:
- 定义
Base类时,装饰器处理a字段,此时target是Base.prototype。因为Base.prototype上没有fields,所以创建数组['a']并挂载到Base.prototype.fields上。 - 定义
FirstClass类时,装饰器处理b字段,此时target是FirstClass.prototype。当访问target.fields时,因为FirstClass.prototype自身没有fields,会沿着原型链找到Base.prototype.fields,把'b'push进去——这就导致Base.prototype.fields变成了['a', 'b']! - 定义
SecondClass类时,装饰器处理c字段,同理,target是SecondClass.prototype,它也会找到Base.prototype.fields,push'c'后变成['a', 'b', 'c']。 - 当你实例化
SecondClass时,实例自身没有fields属性,就会继承被污染的Base.prototype.fields,所以拿到的是包含b的数组。
至于你观察到的target: Base {},其实这是Base.prototype的打印形式,不是实例;同理FirstClass {}是FirstClass.prototype——装饰器在类定义时就执行了,和是否实例化类完全没关系。
怎么解决?
我们需要确保每个类的原型都有独立的字段存储,不会共享父类的。这里有两种靠谱的方案:
方案1:修改装饰器,隔离原型上的数组
修改装饰器,用hasOwnProperty检查target自身是否有fields,避免沿着原型链共享数组:
const decorator = <T extends FieldTracker>(target: T, fieldName: string) => { // 只在target自身没有fields时才新建,不继承原型链的数组 if (!Object.prototype.hasOwnProperty.call(target, 'fields')) { target.fields = []; } target.fields.push(fieldName); };
但这时候子类原型的fields只有自己的字段,比如FirstClass.prototype.fields是['b'],Base.prototype.fields是['a']。如果需要实例的fields包含父类的字段,还要在Base的构造函数里合并:
abstract class Base implements FieldTracker { fields: string[]; constructor() { // 合并父类原型和当前类原型的fields const proto = Object.getPrototypeOf(this); const parentFields = Object.getPrototypeOf(proto).fields || []; const ownFields = proto.fields || []; this.fields = [...parentFields, ...ownFields]; } @decorator a: string = 'a'; }
这样实例化SecondClass时,this.fields会是['a', 'c'],完全符合预期。
方案2:用元数据存储字段(更优雅)
TypeScript支持Reflect.metadata(需要引入reflect-metadata包,或开启experimentalDecorators和emitDecoratorMetadata编译选项),用元数据存储每个类的字段,彻底避免原型链共享问题:
import 'reflect-metadata'; // 定义唯一的元数据键,避免和其他元数据冲突 const TRACKED_FIELDS = Symbol('trackedFields'); const decorator = (target: any, fieldName: string) => { // 获取当前原型已有的字段,没有就新建数组 const fields = Reflect.getMetadata(TRACKED_FIELDS, target) || []; fields.push(fieldName); // 把更新后的数组存回元数据 Reflect.defineMetadata(TRACKED_FIELDS, fields, target); }; abstract class Base implements FieldTracker { // 用getter动态合并所有继承链上的字段 get fields(): string[] { let currentProto = Object.getPrototypeOf(this); const allFields: string[] = []; // 遍历原型链,收集所有类的元数据字段 while (currentProto instanceof Base) { const protoFields = Reflect.getMetadata(TRACKED_FIELDS, currentProto) || []; // 父类的字段放在前面,保证顺序正确 allFields.unshift(...protoFields); currentProto = Object.getPrototypeOf(currentProto); } return allFields; } @decorator a: string = 'a'; } class FirstClass extends Base { @decorator b: string = 'b'; } class SecondClass extends Base { @decorator c: string = 'c'; }
这个方案更灵活,不需要修改构造函数,通过getter自动收集所有继承链上的装饰字段。测试一下:
const secondInstance = new SecondClass(); console.log(secondInstance.fields); // ['a', 'c'] 符合预期 const firstInstance = new FirstClass(); console.log(firstInstance.fields); // ['a', 'b'] 也正常工作
总结
本质问题是装饰器作用于原型,而原型链的共享特性导致数组被多个子类修改。只要确保每个类的字段存储是独立的(要么每个原型有自己的数组,要么用元数据隔离),就能解决这个问题。
内容的提问来源于stack exchange,提问作者eyelidlessness

