-
-
Notifications
You must be signed in to change notification settings - Fork 2
Description
[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
});
}
}
可以看到变化主要由如下两点:
- 字段声明的方式从
=
赋值的方式变更成了Object.defineProperty
- 所有的字段声明都会生效,即使它没有指定默认值
默认 =
赋值的方式就是所谓的 [[Set]]
语义,因为 this.foo = 100
这个操作会隐式地调用上下文中 foo
的 setter
。相应地 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]]
语义,data
的 setter
被正确触发,但 Derived
的实例上并不会拥有一个值为 10
的 data
属性,即 derived.hasOwnProperty('data') === false
且 derived.data === undefined
,这『可能』也是不符合预期的。
正如 TC39 总结道:
在
[[Set]]
和[[Define]]
之间的选择是在比较了不同的行为预期后的设计决策:第一种预期是不管父类包含的内容,字段总是应该被创建成类的属性,而第二种预期是父类的setter
应该被调用。经过长时间的讨论,TC39 发现保留第一种预期更重要因此决定使用[[Define]]
语义。
作为替代,TC39 决定在仍处于 stage 2 阶段且『命途多舛』的 decorators 提案中提供一个显式使用 [[Set]]
语义的装饰器。
这在我个人看来无疑是可笑的:
- 首先装饰器提案已经改了又改,不知何时才能定稿,一个 stage 3 的提案依赖另一个 stage 2 的提案不合常规
- 长期以来 Babel/TS 的实现都是
[[Set]]
语义,虽然[[Define]]
语义有它实际的价值,但显然从当前的迁移成本来看保留[[Set]]
作为默认语义更合理 [[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
,而在基类 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
@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 全栈之路