◆

tsconfig useDefineForClassFields 属性详解

2023-02-02 01:370

useDefineForClassFields 作用

将 class 声明中的字段语义从 [[Set]] 变更到 [[Define]]

这句话听起来抽象,落到实处就是一件事:类里写的 foo = 100 这行代码,编译出来到底是"给实例赋值"还是"在实例上定义一个新属性"。TypeScript 早年实现类字段的时候,ECMAScript 的 class fields 提案还没定稿,TS 选择了赋值语义(this.foo = 100);提案最终定稿走的是 Object.defineProperty 语义。两者在大多数场景下表现一致,但一旦涉及继承、get/set 访问器、或者装饰器,行为就会岔开。

useDefineForClassFields 就是这个历史包袱的开关。设为 false 是 TS 的老行为,设为 true 则对齐标准。它的默认值跟着 target 走:target 是 ES2022 或更高(含 ESNext)时默认为 true,否则默认为 false。这也解释了为什么很多人只是把 target 从 ES2019 提到 ES2022,代码行为就莫名变了。

示例

如下代码

export class C {
  foo = 100;
  bar: string;
}

当 useDefineForClassFields 设置为 false 时,编译后结果如下

export class C {
  constructor() {
    this.foo = 100;
  }
}

这里有两个细节值得注意。一是带初始值的 foo 被搬进了构造函数,编译成一句普通赋值;二是只有类型声明、没有初始值的 bar 完全消失了,编译产物里找不到它的痕迹。也就是说在老行为下,bar: string 纯粹是给类型检查看的,运行时 'bar' in new C() 为 false。

当 useDefineForClassFields 设置为 true 时,编译后结果如下, 要注意 target 版本要在 ES2021 之前的版本

export class C {
  constructor() {
    Object.defineProperty(this, 'foo', {
      enumerable: true,
      configurable: true,
      writable: true,
      value: 100,
    });
    Object.defineProperty(this, 'bar', {
      enumerable: true,
      configurable: true,
      writable: true,
      value: void 0,
    });
  }
}

和上一份产物对比,差别一下就出来了:赋值换成了 Object.defineProperty,而且没有初始值的 bar 也被显式定义了出来,值是 undefined。这就是标准语义,声明了就一定存在,'bar' in new C() 为 true。target 低于 ES2022 时运行时没有原生 class fields,所以 TS 只能用 Object.defineProperty 来模拟这套语义。

当 target 版本在 ES2022 及其以上,编译结果如下

export class C {
  foo = 100;
  bar;
}

因为 ES2022 起 class fields 已经是原生语法,且原生行为本身就是 [[Define]],TS 不需要再做任何降级,把字段原样输出即可,产物最干净。

为什么这个开关会引发真实的 bug

语义差异最容易咬人的地方是子类字段覆盖父类的访问器。看下面这段代码:

class Base {
  private _value = 0;
  get value() {
    return this._value;
  }
  set value(v: number) {
    console.log('setter 被调用', v);
    this._value = v;
  }
}

class Derived extends Base {
  // 子类声明了同名字段,但没有给初始值
  declare value: number;
}

如果子类里写的是 value: number = 10:

  • useDefineForClassFields: false 时,编译成 this.value = 10,走的是赋值,会触发父类的 set value,_value 被正常更新,console.log 也会打印出来。
  • useDefineForClassFields: true 时,编译成 Object.defineProperty(this, 'value', ...),直接在实例上定义了一个自有属性,把原型链上父类的访问器整个遮住了。父类的 setter 一次都不会被调用,getter 也再也走不到。

这类问题的表现往往是"父类里的逻辑莫名其妙不执行了",而且很难从报错里看出来,因为它根本不报错。当子类只想声明类型、不想生成运行时字段时,正确的写法是加 declare 修饰符,像上面例子里那样,TS 就只做类型层面的声明,不产出任何代码。

同样受影响的还有类字段与装饰器、以及 constructor 参数属性的执行顺序:[[Define]] 语义下所有字段定义都发生在构造函数体开头(super() 之后),顺序更严格。

实践建议

  • 新项目直接开 true。对齐标准语义,未来升级 target、接入原生装饰器都不会有意外。
  • Vue 3 项目注意这一项。用 Vite 创建的 Vue + TS 模板里通常会显式写 "useDefineForClassFields": true,配合 ES2022 的 target。如果项目里还有 vue-class-component 这类基于装饰器的老写法,把它改成 true 之前要实测,因为装饰器依赖的正是赋值语义。
  • 老项目升级 target 时,显式写死这个字段。既然它的默认值会随 target 变动,那就别让它跟着变。在 tsconfig.json 里明确写出想要的值,把升级 target 和改变类字段语义这两件事拆开做,出问题时更容易定位:
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": false
  }
}
  • 注意它只影响 TS 的编译产物。如果项目用 esbuild、SWC、Babel 做转译(Vite 默认就是 esbuild),这些工具各自也有对应的选项,行为要和 tsconfig.json 保持一致,否则开发环境和生产构建可能表现不同。
💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEtsconfig useDefineForClassFields 属性详解加载中