作者 | 吴成忠(昭朗)
在本次会议中,备受关注的 Class Fields 系列提案最终进入了 Stage 4,也就意味着成为了 ECMAScript 真正的一部分。
Stage3 → Stage4
从 Stage 3 进入到 Stage 4 有以下几个门槛:
- 必须编写与所有提案内容对应的 tc39/test262 测试,用于给各大 JavaScript 引擎和 transpiler 等实现检查与标准的兼容程度,并且 test262 已经合入了提案所需要的测试用例;
- 至少要有两个实现能够兼容上述 Test 262 测试,并发布到正式版本中;
- 发起了将提案内容合入正式标准文本 tc39/ecma262 的 Pull Request,并被 ECMAScript 编辑签署同意意见。
https://github.com/tc39/test262
https://github.com/tc39/ecma262
Class Fields
提案链接:
- https://github.com/tc39/proposal-class-fields
- https://github.com/tc39/proposal-private-methods
- https://github.com/tc39/proposal-static-class-features
从 ES6 Class 开始,我们就可以在 JavaScript 中使用 class 关键字书写传统面向对象编程范式的代码了。但是在 ES6 Class 中,我们只能给这个类创建可以公共访问的实例方法和静态方法,如果希望声明类字段的话,只能在 constructor 中直接通过向 this 赋值实现:
class Pokemon {
constructor() {
this.name = 'Pikachu';
}
attack() {}
static starter() {}
}
这种方式对于工程上实践与代码阅读者来说并不直观。我们更希望有声明式的类字段,便于代码阅读者了解这个类的数据结构,不需要阅读过程代码就快速地了解这个类的全貌。
除此之外,许许多多的库作者也常常苦恼于用户会使用一些非公开字段、方法,导致后续库升级难题:难以废弃不再需要的内部方法。这导致了许多库的维护难题,即库作者们除了需要保证公开 API 的向下兼容性之外,还需要保证内部方法的向下兼容性。当然对于用户来说,这个库逼得我非得使用它的内部方法来实现我需要的功能,一定是它设计不好,怎么能算我的问题呢?
我们暂且不论库作者与用户之间的 API 协议设计的成熟度考量。目前,其实除了库内部方法之外,JavaScript 与各个 JavaScript 运行环境如浏览器、Node.js 的内置对象与方法都有大量的私有内部字段与方法,只不过他们除了能用纯 JavaScript 实现之外,还可以直接使用 JavaScript 引擎内部提供的方式,来给对象创建只能在实现内部才能访问的对象存储区域(通常被叫做 Internal Slot,书写方式为 [[SlotName]]
)。比如 ECMAScript 内置类 Map 有名为[[MapData]]
的 Internal Slot,如果我们把 Map.prototype
上的方法应用于普通对象上就会报错这些对象不是合法的 Map
对象:
Map.prototype.get.call({}, 'foo');
// 不适用于普通对象
// Uncaught TypeError: Method Map.prototype.get called on incompatible receiver #<Object>
const it = new Proxy(new Map, {});
Map.prototype.get.call(it, 'foo');
// 同样不适用于 Proxy 对象
// Uncaught TypeError: Method Map.prototype.get called on incompatible receiver [object Object]
而提案则将这个机制引入了 ECMAScript,让我们在纯 JS 中也能实现类似的机制,便于库开发者、运行时开发者实现健壮的 API 协议。
提案为 ECMAScript Class 新增了下表中所描述的特性(绿色为现有特性):
提案所包含的特性目前已经在 Chrome 74,Node 12,Safari Technology Preview 117,TypeScript 3.8,Babel 7.0+ 等等环境中使用。不过,需要注意的是,因为如 TypeScript 在提案正式进入 Stage 4 之前就已经有各自的 Class 字段实现,所以在具体细节语义上会与先行 ECMAScript 标准有所差异。
class Base {
name: string;
constructor() {
this.initProps();
}
initProps() {
this.name = 'xxx';
}
}
class Derived extends Base {
age: number;
initProps() {
super.initProps();
this.age = 10;
}
}
const d = new Derived();
console.log(d.age);
例子引用自ts 太难了 ( https://www.yuque.com/shifeng.gl/billion/zqxz5q )
在这个例子中,如果开启了 tsconfig 中的 useDefineForClassFields 选项,则会输出 undefined;没开启选项则会输出 10。原因是在 ECMAScript 标准中,一个类的字段会在基类 (例子中的 Base) 的 constructor 执行完成后再对 this 通过 Object.defineOwnProperty 的语义定义这个类自己的字段 (例子中的 Derived)。而例子中 initProps 是在基类的 constructor 中调用的,在基类的 constructor 执行完成之后,Derived 的 constructor (例子中为隐式 constructor) 会重新定义 age 字段。
不过,目前 TypeScript 团队也已经在深度参与 ECMAScript 的技术标准委员会 TC39 之中,相信后续 TypeScript 与 ECMAScript 之间的语义差异会逐渐被通过渐进的方式解决。
Stage2 → Stage3
提案从 Stage 2 进入到 Stage 3 有以下几个门槛:
- 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
- ECMAScript 编辑签署了同意意见。
Intl.LocaleInfo
提案链接:https://github.com/tc39/proposal-intl-locale-info
这个 ECMA402 国际化提案为 Intl 带来了获取用户本地化偏好的信息的 API。比如获取用户习惯的周开始日(常见的有周一和周日),周末定义,用户的书写方向等。
let zhHans = new Intl.Locale("zh-Hans")
zhHans.weekInfo
// {firstDay: 1, weekendStart: 6, weekendEnd: 7, minimalDays: 4}
zhHans.textInfo
// { direction: "ltr"}
在这个例子中,1 代表周一,7 代表周日,沿用了 ISO-8861 定义的方案,并且与 Temporal 提案 通用。
https://tc39.es/proposal-temporal/#sec-temporal-todayofweek
Stage1 → Stage2
从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。
Object.hasOwn
提案链接:https://github.com/tc39/proposal-accessible-object-hasownproperty
其实现在我们就可以通过 Object.prototype.hasOwnProperty
来使用提案所包含的特性。但是直接通过对象自身的 hasOwnProperty
来使用 obj.hasOwnProperty('foo')
是不安全的,因为这个 obj
可能覆盖了 hasOwnProperty
的定义,MDN 上也对这种使用方式进行了警告。
JavaScript 并没有保护 hasOwnProperty 这个属性名,因此,当某个对象可能自有一个占用该属性名的属性时,就需要使用外部的 hasOwnProperty 获得正确的结果...
Object.create(null).hasOwnProperty("foo")
// Uncaught TypeError: Object.create(...).hasOwnProperty is not a function
let object = {
hasOwnProperty() {
throw new Error("gotcha!")
}
}
object.hasOwnProperty("foo")
// Uncaught Error: gotcha!
所以一个正确的方式就得写成这样繁琐的方式:
let hasOwnProperty = Object.prototype.hasOwnProperty
if (hasOwnProperty.call(object, "foo")) {
console.log("has property foo")
}
而提案期望在 Object
上增加一个 hasOwn
方法,便于大部分场景使用:
let object = { foo: false }
Object.hasOwn(object, "foo") // true
let object2 = Object.create({ foo: true })
Object.hasOwn(object2, "foo") // false
let object3 = Object.create(null)
Object.hasOwn(object3, "foo") // false
因为提案本身非常简单,只有 3 行 Spec 变更,所以提案在这次会议中直接从 Stage 0 进入到了 Stage 2。
Symbol as WeakMap Keys
提案链接:https://github.com/tc39/proposal-symbols-as-weakmap-keys
目前,WeakMap 的引用键只能是 JavaScript 对象,因为他们具有唯一身份(Unique Identity)的特性,比如我们原地创建两个空对象 {} !== {}
他们是不全等的。但是 Symbol 虽然在 JavaScript 中算是原始类型(Primitive Types,JavaScript 中的原始类型有 number,string,boolean,symbol,undefined 和 null),却是具有唯一身份的值类型,即 Symbol('foo') !== Symbol('foo')
。
这个提案是从 Records & Tuples 提案的需求衍生的。Records 和 Tuples 只能包含原始类型值,他们可以被看作是原始类型值的复合结构。通过完全基于原始类型值,而不能在其中存放具有全局唯一特性的数据如 Object 等值, Records 和 Tuples 就可以实现完备的全等性,如 #[1,2,3] === #[1,2,3]
。
那如果我们还是想在 Records 和 Tuples 中引用对象时该怎么办?这时,同时具有原始类型特性与唯一身份特性的 Symbol 类型就凸显出了它的作用。这个提案就是期望通过在 WeakMaps 中使用 Symbol 来作为键来引用对象,然后通过在 Records 与 Tuples 中存储 Symbol 来间接达成在 Records 和 Tuples 中引用对象的目的。而各种 JavaScript 库也可以通过各自使用 WeakMap 来实现弱引用集合,来避免对对象使用如 Map,Array 等方式的强引用带来的可能的内存泄漏问题。如下面的例子:
class RefBookkeeper {
#references = new WeakMap();
ref(obj) {
// 简化版,可能需要通过对象获取可能存在的同一个 symbol;
const sym = Symbol();
this.#references.set(sym, obj);
return sym;
}
deref(sym) { return this.#references.get(sym); }
}
globalThis.refs = new RefBookkeeper();
// Usage
const server = #{
port: 8080,
handler: refs.ref(function handler(req) { /* ... */ }),
};
refs.deref(server.handler)({ /* ...req */ });
这个提案还有以下几个问题没有最终结论:
可以通过全局获取 Symbol(比如 WellKnown Symbol Symbol.iterator
或者通过 Symbol.for
创建的 Symbol)能不能作为 WeakMap 的键?如果允许的话,因为这些 Symbol 拥有和 JavaScript 运行环境相同的存活时间。只要这个 JavaScript 运行环境存活,这些 Symbol 都会保持存活,即意味着通过这些 Symbol 作为 WeakMap 键的对象也会一直保持存活。是否应该因为他们会一直保持存活,就不应该作为 WeakMap 的键呢?目前提案没有禁止这些 Symbol 作为 WeakMap 引用键。
是否需要在 WeakRef 和 FinalizationRegistry 中支持 symbol?这个特性的使用场景并不明确,虽然它看起来可以和这个提案提供相对一致的设计理念。目前提案的修改包含了在 WeakRef 和 FinalizationRegistry 中支持 Symbol。
Extend Timezone Options
提案链接:https://github.com/tc39/proposal-intl-extend-timezonename
这个 ECMA402 国际化提案扩展了 Intl.DateTimeFormat 中的 timeZoneName 选项,支持更多的格式化选项,让开发者可以更方便地控制日期格式化格式。
let timeZoneNames = ["short", "long", "shortOffset", "longOffset", "shortWall", "longWall"];
timeZoneNames.forEach(function(timeZoneName) {
console.log((new Date()).toLocaleTimeString("zh-Hans", {timeZoneName}));
});
// => 上午9:27:27 [PST]
// => 上午9:27:27 [太平洋标准时间]
// => 上午9:27:27 [GMT-8]
// => 上午9:27:27 [GMT-08:00]
// => 上午9:27:27 [PT]
// => 上午9:27:27 [太平洋时间]
Stage0 → Stage1
从 Stage 0 进入到 Stage 1 有以下门槛:
- 找到一个 TC39 成员作为 champion 负责这个提案的演进;
- 明确提案需要解决的问题与需求和大致的解决方案;
- 有问题、解决方案的例子;
- 对 API 形式、关键算法、语义、实现风险等有讨论、分析。
Stage 1 的提案会有可预见的比较大的改动,以下列出的例子并不代表提案最终会是例子中的语法、语义。
Change Array By Copy
提案链接:https://github.com/tc39/proposal-change-array-by-copy
Array.prototype
上有非常多十分实用的方法,如 Array.prototype.pop
、Array.prototype.sort
、Array.prototype.reverse
等等,这些方法通常都是直接就地修改当前的数组对象与其中的元素内容。如果我们需要避免修改原有的数组对象的话,通常我们可以通过 [...arr]
来快速浅拷贝一个数组对象,然后再对这个数组对象调用刚才所说的方法。
这在 Tuple 与 Record 类型正式引入 ECMAScript 之前确实没什么问题。但是如果我们需要引入 Tuple (一种内容不可变的数组),同时 Tuple 想要同样具备 Array.prototype
上的这些便捷方法的话,先有的 Array.prototype
上的就地修改的方法就不再兼容 Tuple 了,而 Tuple 也不能重用这些方法名来使用非就地修改的语义。所以这个提案就准备先为 JavaScript 引入多个就地修改方法的拷贝版,后续 Tuple 也就可以只支持这些拷贝版本的方法。
let arr = [ 3, 2, 1 ];
arr.sort();
arr; // => [ 1, 2, 3 ] 被修改了
arr = [ 3, 2, 1 ];
let sorted = arr.sorted();
sorted; // => [ 1, 2, 3 ]
arr; // => [ 3, 2, 1 ] 没有被修改
Readonly ArrayBuffer & ArrayBufferView
提案链接:https://github.com/Jack-Works/proposal-readonly-arraybuffer
在 JavaScript 中,如果我们希望在可控代码与不可控代码之间共享一个对象(或者数组),我们可以通过 Object.freeze
(或者 Object.seal/Object.preventExtension
等更精细的控制)来将对象设置为只读对象,防止不可控代码修改这个共享对象(同样适用于数组)。但是目前 JavaScript 的 ArrayBuffer 对于 Object.freeze
等操作只能限制 ArrayBuffer 等对象上的属性修改,而不能限制他们的存储区域数据被修改。
这个提案的目标就是为 ArrayBuffer 和各个 TypedArray 增加类似于 Object.freeze
的特性:
const buffer = new ArrayBuffer(4);
const view = new Int32Array(buffer);
view[0] = 42; // OK
buffer.freeze();
view[0] = 42; // TypeError
buffer.isFrozen(); // true
总结
不得不提的是,我们很了解对于 Class Fields 等提案有许多同学有非常多很有意义的见解。特别地,对于 Class Fields 提案来说,其实有许多的参与方的意见与限制需要考虑。比较遗憾的是,在阿里巴巴加入到 TC39 之时,这个提案就已经在如 Chrome 等浏览器、JavaScript 运行环境中正式发行了,也就是实际意义上地成为了 Web Reality 的一部分、成为了事实标准。而对于 TC39 这个组织来说,维护 Web Reality 、并将事实标准体现在法定标准上是它最大的意义。如果法定标准与各个浏览器的实现有或多或少的不同的话,那么法定标准就失去了约束力和它最基本的意义。未来,将广大阿里巴巴同学的意见与业务需求写入 JavaScript 标准上就是阿里巴巴在 TC39 中工作内容的一大部分。
由贺师俊牵头、阿里巴巴前端标准化小组参与组建的 JSCIG(JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论