From TS compiler option `useDefineForClassFields` to ES proposal `class-fields`

8 months切换至中文

翻译出现错误

[[Set]] vs [[Define]] 语义

useDefineForClassFields 是 TypeScript 3.7.0 中新增的一个编译选项(详见 PR),启用后的作用是将 class 声明中的字段语义从 [[Set]] 变更到 [[Define]]

我们考虑如下代码:

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

这是长期以来很常见的一种 TS 字段声明方式,默认情况下它的编译结果如下:

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

当启用了 useDefineForClassFields 编译选项后它的编译结果如下:

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
    });
  }
}

可以看到变化主要由如下两点:

  1. 字段声明的方式从 = 赋值的方式变更成了 Object.defineProperty
  2. 所有的字段声明都会生效,即使它没有指定默认值

默认 = 赋值的方式就是所谓的 [[Set]] 语义,因为 this.foo = 100 这个操作会隐式地调用上下文中 foosetter。相应地 Object.defineProperty 的方式即所谓的 [[Define]] 语义。

在没有 setter 相关的 class 中两种语义使用上基本没有区别,但一旦和 setter 或继承混合使用时不同的语义就会产生截然不同的效果。

考虑如下代码:

class Base {
  value: number | string;
  
  set data(value: string) {
    console.log('data changed to ' + value);
  }
  
  constructor(value: number | string) {
    this.value = value;
  }
}

class Derived extends Base {
  // 当使用 `useDefineForClassFields` 时 `value` 将在调用 `super()` 后
  // 被初始化为 `undefined`,即使你传入了正确的 `value` 值
  value: number;
  
  // 当使用 `useDefineForClassFields` 时
  // `console.log` 将不再被触发
  data = 10;
  
  constructor(value: number) {
    super(value);
  }
}

const derived = new Derived(5);

class-fields 提案的选择

对于字段声明默认赋值为 undefined 相对能获得认可,毕竟是显式地声明了一个字段并且未赋值,类似于不同层级的代码块中声明 let value: number,内层的 value 会默认重新创建一个值为 undefined 的标识符,因此 TS 中也提供了 declare field 的新语法来支持声明字段但不产生实际代码的用法。

class Derived extends Base {
  // 即使启用了 `useDefineForClassFields` 也不会覆盖初始化为 `undefined`
  declare value: number;
}

但初次接触到新的 [[Define]] 语义可能会觉得不可理喻,社区内也有很大的分歧,但实际上 TC39 最终选择了 [[Define]] 语义自然有他们的考虑。

在上面的例子中,如果是 [[Set]] 语义,datasetter 被正确触发,但 Derived 的实例上并不会拥有一个值为 10data 属性,即 derived.hasOwnProperty('data') === falsederived.data === undefined,这『可能』也是不符合预期的。

正如 TC39 总结道:

[[Set]][[Define]] 之间的选择是在比较了不同的行为预期后的设计决策:第一种预期是不管父类包含的内容,字段总是应该被创建成类的属性,而第二种预期是父类的 setter 应该被调用。经过长时间的讨论,TC39 发现保留第一种预期更重要因此决定使用 [[Define]] 语义。

作为替代,TC39 决定在仍处于 stage 2 阶段且『命途多舛』的 decorators 提案中提供一个显式使用 [[Set]] 语义的装饰器。

这在我个人看来无疑是可笑的:

  1. 首先装饰器提案已经改了又改,不知何时才能定稿,一个 stage 3 的提案依赖另一个 stage 2 的提案不合常规
  2. 长期以来 Babel/TS 的实现都是 [[Set]] 语义,虽然 [[Define]] 语义有它实际的价值,但显然从当前的迁移成本来看保留 [[Set]] 作为默认语义更合理
  3. [[Define]] 语义的实际作用是总是创建类的属性,如果依赖装饰器提案,默认 [[Set]] 显式添加类似 @define 装饰器来使用 [[Define]] 语义影响面更小

TC39 的结论可能见仁见智,无法让所有人满意,但 Chrome 已经在版本 72 中发布了基于 [[Define]] 语义的实现,而这个决定几乎不可能被重新考虑了。

TS 加速进程

class-fields 提案未正式落地之前,TS 仍为用户提供了 useDefineForClassFields 编译选项帮助用户之后可以平滑升级,但在 4.0 版本中的一个 bugfix 加速了这个进程。

首先回顾一下这个 bug:

class Base {
  get foo() {
    return 5
  }
}

class Child extends Base {
  foo = 10
}

new Child() // runtime error!

如果使用 [[Set]] 语义,Child 实例化的过程中会调用 this.foo = 10,而在基类 Basefoo 只有 getter 没有 setter,因此在运行时会抛出异常 Cannot set property foo of #<Base> which has only a getter

TS 4.0 中对这个 bug 修复的方式是『在覆盖属性访问器时一直报错』,不区分是否存在 setter,简单粗暴,这让一些仍寄希望于 useDefineForClassFields 苟延残喘的 TS 用户不得不提前开始一些针对 [[Define]] 语义的迁移工作,因为在之前的对比分析中,让 [[Set]] 语义支持者不满的地方就是设置子类字段将无法再触发父类的 setter,而 4.0 的这个特性直接禁止了 TS 中的这种写法,当我们把这种模式的代码全部修复后迁移到 [[Define]] 语义的成本和风险都将大大降低。

这对现有的 TS 项目升级无疑是一个巨大的障碍,但完成迁移后也将推动后续迁移 useDefineForClassFields 默认值,果然优秀的人一直在第五层。

Angular 项目中的几种常见迁移方式

直接复用组件 Input

@Component({})
class BaseComponent {
  protected _data: string

  @Input()
  get data() {
    return this._data
  }
  
  set data(data: string) {
    this._data = data
  }
}

// original
@Component({})
class ChildComponent extends BaseComponent {
  @Input('childData')
  data: string
}

// 使用 `inputs` 选项的方式
@Component({
  inputs: ['data: childData']
})
class ChildComponent extends BaseComponent {}

// 将 Input 别名转换成 `setter`
@Component({})
class ChildComponent extends BaseComponent {
  @Input()
  set childData(data: string) {
    this.data = data
  }
  
  // 如果子组件 `Input` 有新的默认值,需要将默认值赋值移到 `constructor` 中
  // `inputs` 的方式也一样
  constructor() {
    super();
    this.data = 1
  }
}

扩展父组件为 getter/setter

// original
@Component({})
class BaseComponent {
  @Input()
  disabled = false;
}

@Component({})
class ChildComponent extends BaseComponent {
  _disabled = false;
  
  @Input()
  get disabled() {
    return this._disabled || !this.hasEnabledItem
  }
  
  set disabled(disabled: boolean) {
    this._disabled = disabled
  }
}

// 将 `_disbaled` 移入父类并对子类可见
@Component({})
class BaseComponent {
  protected _disabled = false;

  @Input()
  get disabled() {
    return this._disabled
  }
  
  set disabled(disabled: boolean) {
    this._disabled = disabled
  }
}

@Component({})
class ChildComponent extends BaseComponent {
  @Input()
  get disabled() {
    return this._disabled || !this.hasEnabledItem
  }
}

// 使用别名的方式避免冲突,注意这种方式要求变更子组件模板中的引用名称,不推荐!
@Component({})
class ChildComponent extends BaseComponent {
  _disabled = false;
  
  @Input('disabled')
  get isDisabled() {
    return this._disabled || !this.hasEnabledItem
  }
  
  set isDisabled(disabled: boolean) {
    this._disabled = disabled
  }
}

以上几种方式是在升级 alauda-ui 的过程中总结的几种方式,可以看到升级的过程并没有想象中困难,这也是为什么 Angular 自身升级 TS 4.0 相对之前迅速了很多,这可能也侧面说明了 [[Define]] 语义可能并非真正的洪水猛兽。

总结

class-fields 提案目前依然饱受争议,但进入规范几乎已成定局,作为开发者只能积极地拥抱变化,而从 TypeScript 4.0 升级后新特性带来的修复经验来看,只要有合适的工具来帮助我们定位这些『不符合预期』的代码,修复起来也并不费劲,但是我还是想贴一下另一位对 [[Defined]] 语义不满的用户的评论

新的最佳实践可能是:

  • 如果你是一个框架/库作者:
    • 不要使用类字段,他们可能被用户的子类访问器覆盖
    • 不要使用简洁的访问器,他们可能在无意中被类字段覆盖
  • 如果你正在写一个应用
    • 看看你正在使用的框架和库的源码确定他们是否使用了类字段,而不能简单地依赖文档
    • 不要使用类字段,因为他们可能会破坏你在使用的框架/库
    • 不要使用简洁的访问器,如果您使用的框架/库变成使用类字段,它们可能会变得毫无用处

这很好地诠释了很多人对 [[Define]] 语义恐惧的原因,因为我们无法确定它是否会被终端用户覆盖掉,而 TypeScript 4.0 对这种使用方式的禁用提升了代码的可信度,或许对于纯 js 我们也可以有类似的 eslint 规则帮助我们规避非预期的覆盖行为,毕竟我们已经没有办法阻止 [[Define]] 语义的推进。


本文首发于 知乎专栏 - 1stG 全栈之路

  • 毕竟我们已经没有办法阻止 [[Define]] 语义的推进。

    有的。

    凡阿里巴巴、华为、腾讯、360的员工,可向贵公司的TC39代表请愿,要求他们阻止该提案进入Stage4。所有中国会员公司都一起反对,这个漏洞百出的提案就进不了标准。

  • @hax 如果能阻止自然是最好的!感谢你们的付出!