[en] From TS compiler option `useDefineForClassFields` to ES proposal `class-fields` [zh] 从 TS 的 `useDefineForClassFields` 选项到 ES `class-fields` 提案 #377
在没有 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;
}
class Base {
get foo() {
return 5
}
}
class Child extends Base {
foo = 10
}
new Child() // runtime error!
如果使用 [[Set]] 语义,Child 实例化的过程中会调用 this.foo = 10,而在基类 Base 中 foo 只有 getter 没有 setter,因此在运行时会抛出异常 Cannot set property foo of #<Base> which has only a getter。
[zh]
[[Set]]
vs[[Define]]
语义useDefineForClassFields
是 TypeScript 3.7.0 中新增的一个编译选项(详见 PR),启用后的作用是将class
声明中的字段语义从[[Set]]
变更到[[Define]]
。我们考虑如下代码:
这是长期以来很常见的一种 TS 字段声明方式,默认情况下它的编译结果如下:
当启用了
useDefineForClassFields
编译选项后它的编译结果如下:可以看到变化主要由如下两点:
=
赋值的方式变更成了Object.defineProperty
默认
=
赋值的方式就是所谓的[[Set]]
语义,因为this.foo = 100
这个操作会隐式地调用上下文中foo
的setter
。相应地Object.defineProperty
的方式即所谓的[[Define]]
语义。在没有
setter
相关的class
中两种语义使用上基本没有区别,但一旦和setter
或继承混合使用时不同的语义就会产生截然不同的效果。考虑如下代码:
class-fields
提案的选择对于字段声明默认赋值为
undefined
相对能获得认可,毕竟是显式地声明了一个字段并且未赋值,类似于不同层级的代码块中声明let value: number
,内层的value
会默认重新创建一个值为undefined
的标识符,因此 TS 中也提供了declare field
的新语法来支持声明字段但不产生实际代码的用法。但初次接触到新的
[[Define]]
语义可能会觉得不可理喻,社区内也有很大的分歧,但实际上 TC39 最终选择了[[Define]]
语义自然有他们的考虑。在上面的例子中,如果是
[[Set]]
语义,data
的setter
被正确触发,但Derived
的实例上并不会拥有一个值为10
的data
属性,即derived.hasOwnProperty('data') === false
且derived.data === undefined
,这『可能』也是不符合预期的。正如 TC39 总结道:
作为替代,TC39 决定在仍处于 stage 2 阶段且『命途多舛』的 decorators 提案中提供一个显式使用
[[Set]]
语义的装饰器。这在我个人看来无疑是可笑的:
[[Set]]
语义,虽然[[Define]]
语义有它实际的价值,但显然从当前的迁移成本来看保留[[Set]]
作为默认语义更合理[[Define]]
语义的实际作用是总是创建类的属性,如果依赖装饰器提案,默认[[Set]]
显式添加类似@define
装饰器来使用[[Define]]
语义影响面更小TC39 的结论可能见仁见智,无法让所有人满意,但 Chrome 已经在版本 72 中发布了基于
[[Define]]
语义的实现,而这个决定几乎不可能被重新考虑了。TS 加速进程
在
class-fields
提案未正式落地之前,TS 仍为用户提供了useDefineForClassFields
编译选项帮助用户之后可以平滑升级,但在 4.0 版本中的一个 bugfix 加速了这个进程。首先回顾一下这个 bug:
如果使用
[[Set]]
语义,Child
实例化的过程中会调用this.foo = 10
,而在基类Base
中foo
只有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
扩展父组件为
getter/setter
以上几种方式是在升级 alauda-ui 的过程中总结的几种方式,可以看到升级的过程并没有想象中困难,这也是为什么
Angular
自身升级 TS 4.0 相对之前迅速了很多,这可能也侧面说明了[[Define]]
语义可能并非真正的洪水猛兽。总结
class-fields
提案目前依然饱受争议,但进入规范几乎已成定局,作为开发者只能积极地拥抱变化,而从 TypeScript 4.0 升级后新特性带来的修复经验来看,只要有合适的工具来帮助我们定位这些『不符合预期』的代码,修复起来也并不费劲,但是我还是想贴一下另一位对[[Defined]]
语义不满的用户的评论。这很好地诠释了很多人对
[[Define]]
语义恐惧的原因,因为我们无法确定它是否会被终端用户覆盖掉,而 TypeScript 4.0 对这种使用方式的禁用提升了代码的可信度,或许对于纯 js 我们也可以有类似的eslint
规则帮助我们规避非预期的覆盖行为,毕竟我们已经没有办法阻止[[Define]]
语义的推进。本文首发于 知乎专栏 - 1stG 全栈之路