TC39 9月会议提案进度报告

本次会议上,我们主持了 Error Cause 提案进入了 Stage 1,作为阿里巴巴集团内 JavaScript 错误处理模式的一个总结,将这个行为写入标准,便于更多的开发者工具如 Alinode、阿里云 ARMS 等改进异常分析的体验。

Stage 3 → Stage 4

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

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

Intl.DisplayNames

这个提案为 JavaScript 的 Intl 带来了获取常见名词的国际化方案,如地名、语言、书写系统、货币等名词:

// 获取简体中文的显示用名词
let regionNames = new Intl.DisplayNames(
    ['zh-Hans'], {type: 'region'});
regionNames.of('US'); // => "美国"
regionNames.of('419'); // => "拉丁美洲"
regionNames.of('MM'); // => "缅甸"

作为一个 Stage 4 提案,它所涵盖的特性已经在 Chrome m81 和 Node.js 14 中发行并已经可以无需 flag 使用。
除了这个提案所涵盖特性之外,这次会议还有 Intl.DisplayNames 的增强提案进入了 Stage 1,详情见后文介绍。

Stage 2 → Stage 3

提案从 Stage 2 进入到 Stage 3 有以下几个门槛:

  1. 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
  2. ECMAScript 编辑签署了同意意见。

.item() for indexables

提案链接:tc39/proposal-item-method
Spec 文本链接:item()
这个提案在先前的 TC39 7月会议#iterator.items() 中进入了 Stage 2,为 JavaScript Array/TypedArray 带来了反向索引的能力 arr.item(-1)
而本次会议中,提案新增了 String.prototype.item() 的定义(索引单元语义与 "foo"[idx] 相同,如 '????‍❤️‍????'.item(3) === "❤" ),以作为在 JavaScript 中可索引类型的一致性支持。
WHATWG HTML 标准中对于很多 HTML List 类型都有 .item() 的定义,但是这些定义都只支持以 0 为基底的索引、不支持反向索引。在这个提案进入 Stage 3 之后,相信 HTML 标准也会在近期跟进相关定义。

Import Assertions

提案链接:tc39/proposal-import-assertions
Spec 文本链接:import assertions
这个提案先前在 TC39 6月会议#Module attributes 进入 Stage 2 后,调整了提案的方案并修改名字为 Import Assertions 而不是 Module Attributes 来表明提案更多关注在断言上。
曾经有一个版本 JSON Module 的提案希望通过语句 import json from "./data.json" 导入一个 JSON 文件。不过这个版本的提案因为使用了 MIME 类型来作为自动选择解析器,存在安全问题 (如虽然后缀名是 .json 但是指定了 MIME 类型为 JavaScript 导致可以执行任意代码)而被否决。而这个提案就是为 import 语句增加了断言的能力,让开发者自行决定所应该使用的解析器,是 JSON 文件就应该只使用 JSON 解析器,否则就抛出错误。
提案目前的使用方案给 import 语句增加了 assert 从句,import 内置函数增加了 assert 选项:

import json from "./foo.json" assert { type: "json" };
import("foo.json", { assert: { type: "json" } });

另外值得注意的是这个提案只定义了 assert 的语义,并没有定义 JSON Module 相关的语义。JSON Module 的具体语义在另一个Stage 2 提案中定义。

Stage 1 → Stage 2

从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。

Class static initialization block

提案链接:tc39/proposal-class-static-block
Spec 文本链接:class static initialization blocks
这个提案提议的 Class Static 初始化块会在类被执行、初始化时被执行。Java 等语言中也有类似的静态初始化代码块的能力,Static Initialization Blocks
提案中定义的初始化代码块可以获得 Class 内的作用域,如同 class 的方法一样,也意味着可以访问类的 #字段。通过这个定义,我们就可以实现 JavaScript 中的 Friend 类了。

let A, B;
{
  let friendA;

  A = class A {
    #x;

    static {
        friendA = {
          getX(obj) { return obj.#x },
          setX(obj, value) { obj.#x = value }
        };
    }
  };

  B = class B {
    constructor(a) {
      const x = friendA.getX(a); // ok
      friendA.setX(a, value); // ok
    }
  };
}

export { A, B };

ResizableArrayBuffer and GrowableSharedArrayBuffer

提案链接:tc39/proposal-resizablearraybuffer
Spec 文本链接:ResizableArrayBuffer and GrowableSharedArrayBuffer
这个提案主要是期望给 WebAssembly 的场景提供更加方面的内存扩展方式。目前调整一个 ArrayBuffer 的大小需要复制内容,但是复制非常慢,而且可能导致内存空间碎片化。
提案提出了两种新的 ArrayBuffer 类型:ResizableArrayBuffer 和 GrowableSharedArrayBuffer。
ResizableArrayBuffer 是一个内部存储区域可以拆卸的 ArrayBuffer。设计上希望 ResizableArrayBuffer 可以原地调整大小,但是提案没有对调整大小是否能够被观测做要求(改变地址等)。同样,提案不对调整大小的方案有做定义。

let rab = new ResizableArrayBuffer(1024, 1024 ** 2);
assert(rab.byteLength === 1024);
assert(rab.maximumByteLength === 1024 ** 2);

rab.resize(rab.byteLength * 2);
assert(rab.byteLength === 1024 * 2);

GrowableSharedArrayBuffer 是可以在多个执行环境*享的 ArrayBuffer,但是考虑到多个执行环境的同步,所以 GrowableSharedArrayBuffer 是一个只能增长而不能缩减大小的设计。

Intl.Enumeration

提案链接:tc39/proposal-intl-enumeration
Spec 文本链接:Intl Enumeration API Specification
这个提案是在 TC39 6月会议#Intl Enumeration API 中进入 Stage 1 的。提案提供了一些方法来枚举当前 JavaScript 运行环境所支持的国际化选项,比如历法、货币、计数体系、时区、单位等等。

// 获取支持的历法
Intl.getSupportedCalendars()
// ['buddhist', 'chinese', 'coptic', 'dangi', 'ethioaa', 'ethiopic', 
//  'gregory', 'hebrew', 'indian', 'islamic', 'islamic-umalqura',
//  'islamic-tbla', 'islamic-civil', 'islamic-rgsa', 'japanese', 
//  'persian', 'roc', 'islamicc'];

Stage 0 → Stage 1

从 Stage 0 进入到 Stage 1 有以下门槛:

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

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

Error Cause

提案链接:tc39/proposal-error-cause
错误处理是现代语言中非常基本的一部分,而解决错误需要充足的上下文信息来帮助开发者理解为什么发生了错误、该如何解决。JavaScript 中目前常见的错误处理手段有如:

  • 就地创建一个新的 Error 实例并连接上捕获的错误的 message:throw new Error('Foobar: ' + err.message);
  • 就地创建一个新的 Error 实例并将捕获的 err 作为一个属性附加上去:
const wrapErr = new Error('Download raw resource failed');
wrapErr.cause = err;
throw wrapErr;
  • 定义一个新的 Error 子类来使用:
class CustomError extends Error {
  constructor(msg, cause) {
    super(msg);
    this.cause = cause;
  }
}
throw new CustomError('Foobar', err);

但是以上这些方式或多或少有些问题,丢失了捕获的 Error 的属性、需要写比较多的代码自定义等等。这其中的 cause 字段也不会被如 Chrome DevTools 等开发者工具所使用来 Pretty Print(只是作为一个普通的字段)。
提案对 Error Constructor 新增了一个可选的参数 cause,可以接受任意 JavaScript 值(JavaScript 可以 throw 任意值),并会把这个值赋值到 cause 属性上。也因此定义,Chrome DevTools 等开发者工具也可以默认打印 cause 属性中的值了。

try {
  return await fetch('//unintelligible-url-a')
      .catch(err => {
      throw new Error('Download raw resource failed', err)
    })
} catch (err) {
  console.log(err)
  console.log('Caused by', err.cause)
  // Error: Upload job result failed
  // Caused by TypeError: Failed to fetch
}

Double-Ended Iterator & Destructuring
提案链接:tc39/proposal-deiter
通常对于迭代器来说我们都会从迭代器的开头开始迭代,但是在部分场景中,我们希望能直接从迭代器的末尾开始反向迭代。提案提议了一个双向的迭代器,并且支持解构语法,帮助我们实现如 let [first, ...rest, last] = iterable 的简洁代码。对于迭代器如何从尾部开始迭代的部分该如何设计,在本次 TC39 会议上没有一个取得所有人一致意见的结论。

let a = [1, 2, 3, 4, 5, 6]
let deiter = a.values() // 假设返回了一个双向迭代器
deiter.next() // {value: 1}
deiter.next() // {value: 2}
deiter.next('back') // {value: 6}

Modulus & Additional Integer Math

提案链接:integer-and-modulus-math-proposal
目前 JavaScript 中的 Math 中的算术操作都是基于浮点数的,并且 % 运算符只是取余操作。如 -21 模 4 得 3 因为 -21 + 4 x 6 等于 3,但是 -21 除以 4 得商 -5 和余数 -1。
提案为 JavaScript 带来了以下几个 Math 方法:

  • Math.mod(x, y) – IEEE 754 模运算;
  • Math.idiv(x, y) – Int32 除法;
  • Math.imod(x, y) – Int32 模运算;
  • Math.idivmod(x, y) – Int32 除法与模运算的结合,返回 [除法结果, 模结果];
  • Math.imuldiv(x, y, z) – Int32 乘并除以一个 64位的中间产物 - (x * y) / z;
  • Math.irem(x, y) – Int32 取余;

Standardized Debug

提案链接:tc39/proposal-standardized-debug
很多时候,我们只是希望打印一个值的中间结果,但是目前我们可能需要相对较多的代码来达成目的,并且有些时候计算这些中间结果可能比较耗费资源:

function doSomething(a) {
  let b = (a + costlyOperation()) * 2;
  // ...
}

function doSomething(a) {
  console.log(a + costlyOperation());  // ❌ 重复的 costlyOperation()
  let b = (a + costlyOperation()) * 2;
  // ...
}

function doSomething(a) {
  let tmp = a + costlyOperation();  // ❌ 需要重构,增加一个变量
  console.log(tmp);
  let b = tmp * 2;
  // ...
}

// Promise 案例
foo()
  .then((bar) => bar.baz());
  
foo()
  .then((bar) => {    // ❌ 为了打印一个变量需要将箭头函数体改成代码块
    console.log(bar);
    return bar.baz();
  });
  
foo()
  .then((bar) => {    // ❌ 需要增加一个 tick
    console.log(bar);
    return bar;
  })
  .then((bar) => bar.baz());

提案提议增加一个 debug 的语法来打印

function doSomething(a) {
  let b = dbg!(a + costlyOperation()) * 2;
  // ...
}

不过目前 TC39 会议上增加新语法并没有取得共识,后续更可能是扩展现有的 debugger 语句如 debugger.log(v)

String Dedent

提案链接:tc39/proposal-string-dedent
目前 JavaScript 中的多行模版字符串并不会自动将共同的行首空格删除,所以我们可能写出如下代码:

class Foo {
  methodA() {
    const foo = `First Line
Second Line
Third Line`;
    return foo;
  }
}

看起来很怪异,而提案期望增加一个方法来自动剔除共同的行首空格,我们就可以写成:

class Foo {
  methodA() {
    const foo = String.dedent(`
      First Line
      Second Line
      Third Line`);
    return foo;
  }
}

对于如何定义空格或者如何定义共同的行首空格,可以查阅相关的 issue,毕竟 JavaScript 中不仅 \x20(Space)是空格,\x09 (Tab)也是,还有 \x0B\x0C\xA0\uFEFF 等等也都是。

Intl Locale

提案链接:tc39/proposal-intl-locale-info
很多地区惯例的每周第一天是星期一还是星期天都不太相同,如果要开发者完全了解这些信息几乎是一个不可能的任务。这个提案就是为了解决这些问题而设计的,通过这个提案,我们可以获取以下信息来改善我们应用的国际化体验:

+ get Intl.Locale.prototype.firstDayOfWeek
+ get Intl.Locale.prototype.minimalDaysInFirstWeek
+ get Intl.Locale.prototype.weekendStart
+ get Intl.Locale.prototype.weekendEnd
+ get Intl.Locale.prototype.direction
+ get Intl.Locale.prototype.measurementSystem
+ get Intl.Locale.prototype.defaultHourCycle
+ get Intl.Locale.prototype.defaultCalendar
+ get Intl.Locale.prototype.commonCalendars

Intl.DisplayNames v2

提案链接:tc39/intl-displaynames-v2
很多 Intl API 都提供了各种值的国际化能力,如

let dtf = new Intl.DateTimeFormat("en", {month: "long"})
dtf.format(new Date("2020-01-01")) // January

但是目前还没有 Intl API 可以直接获取“一月”这个名词的国际化格式。
同样在本次会议进入 Stage 4 的 Intl.DisplayNames 只定义了 Intl.DisplayNames API 和基本的地名、语言、书写系统、货币的支持。而这个 Stage 1 提案则对这个国际化数据进行了扩充,如星期(星期一等)、月份(一月等)、单位(米等)、时区(北京时间等)、日历(公历等)、计数系统(第一等)。

let dn = new Intl.DisplayNames("zh-Hans", {type: "month", style: "long"})
dn.of(1) // "1月"

// 科普特历
let dn = new Intl.DisplayNames("en", {type: "month", style: "long",
                                      calendar: "coptic"})
dn.of(1) // "Tout"

总结

语言的标准化不是一个一蹴而就的过程,在 TC39 的会议上我们也目睹了许多对开发者来说非常有帮助的提案因为设计无法取得共识而被阻挡进入 Stage 1、2,亦或者如 Decorator 提案被不断增加设计限制而反反复复大幅修改设计。ECMAScript 双月刊并没有将这些提案记录下来,不过我们还是在积极参与这些提案。这个过程需要更多使用 JavaScript 的同学的积极参与,提出自己的需求,参与到讨论中:https://github.com/JSCIG/es-discuss/issues


TC39 9月会议提案进度报告
关注「Alibaba F2E」
把握阿里巴巴前端新动向

上一篇:圣司:我的前端成长之路,内观自在,外观世音,追寻内心平静


下一篇:关于数据智能浪潮对前端技术发展影响的一些思考