ECMAScript 双月报告:TC39 2021年4月会议提案进度汇总

作者 | 吴成忠(昭朗)

在本次会议中,备受关注的 Class Fields 系列提案最终进入了 Stage 4,也就意味着成为了 ECMAScript 真正的一部分。

Stage3 → Stage4

从 Stage 3 进入到 Stage 4 有以下几个门槛:

  1. 必须编写与所有提案内容对应的 tc39/test262 测试,用于给各大 JavaScript 引擎和 transpiler 等实现检查与标准的兼容程度,并且 test262 已经合入了提案所需要的测试用例;
  2. 至少要有两个实现能够兼容上述 Test 262 测试,并发布到正式版本中;
  3. 发起了将提案内容合入正式标准文本 tc39/ecma262 的 Pull Request,并被 ECMAScript 编辑签署同意意见。

https://github.com/tc39/test262

https://github.com/tc39/ecma262

Class Fields

提案链接:

从 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 新增了下表中所描述的特性(绿色为现有特性):

ECMAScript 双月报告:TC39 2021年4月会议提案进度汇总

提案所包含的特性目前已经在 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 有以下几个门槛:

  1. 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
  2. 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 有以下门槛:

  1. 找到一个 TC39 成员作为 champion 负责这个提案的演进;
  2. 明确提案需要解决的问题与需求和大致的解决方案;
  3. 有问题、解决方案的例子;
  4. 对 API 形式、关键算法、语义、实现风险等有讨论、分析。

Stage 1 的提案会有可预见的比较大的改动,以下列出的例子并不代表提案最终会是例子中的语法、语义。

Change Array By Copy

提案链接:https://github.com/tc39/proposal-change-array-by-copy

Array.prototype 上有非常多十分实用的方法,如 Array.prototype.popArray.prototype.sortArray.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 的问题,非常欢迎有兴趣的同学参与讨论

https://github.com/JSCIG/es-discuss/discussions

ECMAScript 双月报告:TC39 2021年4月会议提案进度汇总

上一篇:ORACLE 10升级到10.2.0.5 Patch Set遇到的内核参数检测失败问题


下一篇:博科300交换机不中断(non-disruptive)固件升级