Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[en] From TS compiler option useDefineForClassFields to ES proposal class-fields [zh] 从 TS 的 useDefineForClassFields 选项到 ES class-fields 提案 #377

Open
JounQin opened this issue Sep 24, 2020 · 2 comments
Labels

Comments

@JounQin
Copy link
Owner

JounQin commented Sep 24, 2020

[zh]

[[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 全栈之路

@hax
Copy link

hax commented Jan 20, 2021

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

有的。

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

@JounQin JounQin added the notes label Jan 22, 2021
@JounQin JounQin changed the title [zh] 从 TS 的 useDefineForClassFields 选项到 ES class-fields 提案 [en] From TS compiler option useDefineForClassFields to ES proposal class-fields [en] From TS compiler option useDefineForClassFields to ES proposal class-fields [zh] 从 TS 的 useDefineForClassFields 选项到 ES class-fields 提案 Jan 22, 2021
@JounQin
Copy link
Owner Author

JounQin commented Jan 22, 2021

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants