_
2025年10月25日 10:31:04 AM
6768 字
15 分钟

TL;DR

使用 Class Fields 定义的类字段会作为新的属性绑定到类的实例上,即使是派生的类。

class A {
  field;
}
Object.defineProperty(A.prototype, 'field', {
  get() { return 'get field'; },
  set(v) { console.log(`set field: ${v}`); },
  enumerable: true,
});
class B extends A {}

const instance = new B();
console.log(instance.hasOwnProperty('field')); // true
console.log(Object.getOwnPropertyDescriptor(instance, 'field') === Object.getOwnPropertyDescriptor(A.prototype, 'field')); // false
console.log(instance.field); // undefined
delete instance.field;
console.log(instance.field); // get field
JAVASCRIPT

问题处理过程

背景

项目中和其他模块做联调,他们用 experimental decorators 实现了一个 @Trace 装饰器,用于代理对象上某个字段的访问和修改,大致实现如下:

const Trace = (target: object, key: string) => {
  const innerKey = `__inner_${key}`;
  target[innerKey] = target[key];
  Object.defineProperty(target, key, {
    get() { return this[innerKey]; },
    set(v) {
      console.log(`set value to: ${v}`);
      // other effects
      this[innerKey] = v;
    },
    enumerable: true,
  });
}
[trace.docorator.ts]
TYPESCRIPT

我侧使用这里提供的 @Trace 装饰器调试时,发现属性的改变根本没有走到上面的 set 方法中,代码实现长这样:

class Pos {
  @Trace x: number;
  @Trace y: number;

  constructor({x, y}: {x: number; y: number}) {
    // 这里预期是要打印 set value to XXX,但实际上没有
    this.x = x;
    this.y = y;
  }
}

export default { Pos };
[pos.ts]
TYPESCRIPT

测试用的代码:

const instance = new Pos({
  x: 640,
  y: 320,
});
instance.x = 1024; // 这里预期是要打印 set value to 1024,但实际上也没有
[pos.spec.ts]
TYPESCRIPT

但是对方说他们已经充分验证过,且已经有部分功能用上了这个能力,没有发现问题,最后两个领域一起拉在线会议分析。

怀疑点1:某个流程中改变了类 Pos 的原型

项目中实际的实现代码比刚才展示的复杂得多,且 Pos 还被其他类装饰器装饰,最先想到的是 Pos 在某个流程中原型被改变了。

验证起来很简单,在实例上观察是否存在 __inner_x__inner_y 这两属性即可验证原型是否被改变:

const instance = new Pos({
  x: 640,
  y: 320,
});
console.log('__inner_x' in instance); // true
console.log('__inner_y' in instance); // true
TYPESCRIPT

发现 Pos 实例上能拿到 @Trace 装饰后添加的属性,原型链未改变。

怀疑点2:原型上的属性被重定义

之后想到 Pos 原型上的属性在某个流程被重新定义,例如:

class Pos {
  @Trace x: number;
  // xxx
}

Object.defineProperty(Pos.prototype, 'x', {
  // xxx
});
TYPESCRIPT

但是 @Trace 装饰器实现中未将属性定义为 configurable: true ,其不能被二次定义[1],也排除了可能性。

怀疑点3:实例上有同名的自有属性

最后想到可能实例上有同名的自由属性,导致 instance.x 走不到原型上的 getter 和 setter。

const instance = new Pos({
  x: 640,
  y: 320,
});

console.log(instance.hasOwnProperty('x')); // true
console.log(Object.getOwnPropertyDescriptor(instance, 'x')); // {value: undefiend, writable: true, enumerable: true, configurable: true}
TYPESCRIPT

由此可见实例上的 x 已经被重新定义,而非原型上的属性了,那么为什么会有这种情况呢?

原因

上面虽然找到了实现上的原因,但是并没有找到问题根因,也不能解释为什么对方自己验证是可用的,经过一番定位,最终我们将问题锁定到 typescript 编译产物上。

下面是项目中 typescript 编译选项配置文件 tsconfig.json 的一个简化版本:

{
  "files": ["test.ts"],
  "compilerOptions": {
    "module": "esnext",
    "target": "esnext",
    "noEmitOnError": true,
    "experimentalDecorators": true
  }
}
[tsconfig.json]
JSON

当我们在项目根目录下运行 tsc 编译命令时,会生成 pos.js 这个编译后的文件,由于我们指定了 target: "esnext" ,typescript 的编译产物会尽可能地使用 EcmaScript 的新特性。

问题场景我们的编译产物如下:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
class Pos {
    x;
    y;
    constructor({ x, y }) {
        this.x = x;
        this.y = y;
    }
}
__decorate([
    Trace
], Pos.prototype, "x", void 0);
__decorate([
    Trace
], Pos.prototype, "y", void 0);
export default { Pos };
[pos.js with target:"esnext"]
JAVASCRIPT

当换用将 tsconfig.json 中的 target 配置替换为 es2021 及以下版本时运行 tsc 编译时,生成的文件代码有一些差别:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
class Pos {
    constructor({ x, y }) {
        this.x = x;
        this.y = y;
    }
}
__decorate([
    Trace
], Pos.prototype, "x", void 0);
__decorate([
    Trace
], Pos.prototype, "y", void 0);
export default { Pos };
[pos.js with target:"es2021"]
JAVASCRIPT

可以明显看到,使用 esnext 编译的代码中包含 x、y 的类字段定义,而 es2021 及以下版本没有。由于对方验证时用的较早的 es6,而我侧调试时用的最新的 esnext,正是这个差异导致了问题发生。

EcmaScript Class Fields (类字段)

有关类字段声明 [[set]] 和 [[define]] 语义的争论由来已久[2],但最终各个编译/转译工具都拥抱了 EcmaScript 标准的 class-fields 提案。

简单来讲,现有标准下类中声明的公有实例字段存在于类的每个已创建实例中。通过声明公共字段,可以确保该字段始终存在,而且类的定义也更加自文档化(self-documenting)[3]

引用 mdn 上的例子:

const PREFIX = "prefix";

class ClassWithField {
  field;
  fieldWithInitializer = "实例字段";
  [`${PREFIX}Field`] = "带前缀字段";
}

const instance = new ClassWithField();
console.log(Object.hasOwn(instance, "field")); // true
console.log(instance.field); // undefined
console.log(instance.fieldWithInitializer); // "实例字段"
console.log(instance.prefixField); // "带前缀字段"
JAVASCRIPT

类中定义的字段相当于用 Object.defineProperty(this, 'xxx', { // xxx }) 在实例上创建了这个属性。之后访问这个字段时,首先拿到的是实例上定义的自有属性。

从这点可以看出 typescript 的 experimentalDecorators 与新的 EcmaScript 标准并不适合,类属性装饰器没有影响类实例属性的能力,难以达到开发者的预期效果。从属性装饰器的实现不难看出:

declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
const SomeLegacyPropertyDecorator: PropertyDecorator = (target: object, key: string) => {
  // 传入参数是类的 prototype 和属性名
  // 如何在这里影响类实例的属性?🤔
}
TYPESCRIPT

如果要实现上文中 @Trace 装饰器想要的效果,我们可能得搭配上一个新的类装饰器,并在构造器中主动删除掉指定名称的装饰器。但是原始类中构造器中对属性的赋值仍然无法被追踪,因为派生类的 super() 方法必须在构造器的顶部。

const TraceableClass = <T extends new (...args: any[]) => any>(target: T) => {
  return class extends target {
    constructor(...args: any[]) {
      super(...args);
      delete this['x'];
      delete this['y'];
    }
  };
};

const Trace = (target: object, key: string) => {
  const innerKey = `__inner_${key}`;
  target[innerKey] = target[key];
  Object.defineProperty(target, key, {
    get() {
      return this[innerKey];
    },
    set(v) {
      console.log(`set value to: ${v}`);
      // other effects
      this[innerKey] = v;
    },
    enumerable: true,
  });
};

@TraceableClass
class Pos {
  @Trace x: number;
  @Trace y: number;

  constructor({ x, y }: { x: number; y: number }) {
    // 这里仍然不会打印 set value to XXX 😢
    this.x = x;
    this.y = y;
  }
}

const instance = new Pos({
  x: 0,
  y: 0,
});
instance.x = 1; // 这里会打印 set value to 1 🎉
TYPESCRIPT

结语

在使用 typescript 开发的时候,需要注意 Class Fields 特性在不同构建 target 下带来的编译产物差异,周期性关注某个语言 Changelog 和特性说明可以防止踩不少坑。

是时候拥抱 EcmaScript 标准的装饰器了!

对于开发者来说,全部使用最新语言标准开发不一定是好事,但坚持在过时的技术和实现上一定不是好事。

参考


ES Class Fields 的小坑

作者Nepsyn
发布于2025年10月25日
许可协议