来源: Alibaba F2E公众号
作者:吴成忠(昭朗)
今年因为疫情原因,TC39 的会议频率大为提升,而每一次的会议内容也分散了许多。但是关于提案的讨论热度并不会因为疫情降低,比如这次会议中备受关注的 Realms 提案经过了大改:Realms 在新的提案方案里 Realm 之间无法直接交换除了原始 JavaScript 值(number, string, bigint, symbol 等)和 Callable 以外的 JavaScript 值。其中,Realm 中透出的函数会以一个 Callable 的 Wrapper 对象返回,这个 Wrapper 对象上不能访问到原函数对象的属性,只能用来调用这个对应的函数(参数也只能是原始 JavaScript 值)。当然,基于目前的 API 我们可以通过建立对象桥接来共享 JSON 对象数据,如 https://github.com/caridy/irealm 。不过这方法不能支持交换 ArrayBuffer/TypedArray 对象。另外提案明确增加了 Host Globals Hook 编辑意见,Host 比如浏览器可以向 Realms 的 globalThis 中注入 Host 特有的 API 对象,比如 TextEncoder 等等,这导致了不同的 Host 如 Node.js 和浏览器上的 Realm 可能会有不同的 Global API,让 Realm 不再是纯净的 JavaScript 执行环境。遗憾的是,Realms 暂时没能取得进入 Stage 3 的共识。
TC39 2021年5月会议提案进度汇总, From:阿里巴巴前端标准化组
Stage 3 -> Stage 4
从 Stage 3 进入到 Stage 4 有以下几个门槛:
- 必须编写与所有提案内容对应的 tc39/test262 测试,用于给各大 JavaScript 引擎和 transpiler 等实现检查与标准的兼容程度,并且 test262 已经合入了提案所需要的测试用例;
- 至少要有两个实现能够兼容上述 Test 262 测试,并发布到正式版本中;
- 发起了将提案内容合入正式标准文本 tc39/ecma262 的 Pull Request,并被 ECMAScript 编辑签署同意意见。
RegExp Match Indices
提案链接: https://github.com/tc39/proposal-regexp-match-indices
当前,ECMAScript 中的 RegExp.prototype.exec
方法的返回值已经提供了对于匹配的捕获组(Capture Group)文本与对应的捕获组在正则表达式中的索引。但是,有些场景下我们不仅仅只是希望匹配文本,更需要获得被匹配的文本在输出文本中的起始位置与结束位置,比如我们常用的 VSCode 等开发环境提供语法高亮就需要这些信息。因此,提案期望向 RegExp.prototype.exec
返回的数组对象上,新增 indices
属性用来描述这些位置信息。
// 创建一个启用了索引功能的正则表达式对象
const re1 = /a+(?<Z>z)?/d;
// 索引值是相对于输入字符串,而不是被匹配的字符串
const s1 = "xaaaz";
const m1 = re1.exec(s1);
m1.indices[0][0] === 1;
m1.indices[0][1] === 5;
s1.slice(...m1.indices[0]) === "aaaz";
m1.indices[1][0] === 4;
m1.indices[1][1] === 5;
s1.slice(...m1.indices[1]) === "z";
m1.indices.groups["Z"][0] === 4;
m1.indices.groups["Z"][1] === 5;
s1.slice(...m1.indices.groups["Z"]) === "z";
// 捕获组没有命中时值会是 undefined
const m2 = re1.exec("xaaay");
m2.indices[1] === undefined;
m2.indices.groups["Z"] === undefined;
我们已经可以在 Chrome Canary 91,Firefox Nightly 88 等环境使用这个特性。
Top-level await
提案链接: https://github.com/tc39/proposal-top-level-await
从 ECMAScript 引入 Promise 与 Async Function 开始,await
操作符可以说是贯穿了现代 JavaScript 项目的大大小小操作。但是遗憾的是,await
操作符必须在 Async Function 中使用,这也意味着我们不能在一个脚本、模块的顶层逻辑中使用 await
:
await foo(); // ❌ 不可以,不在 Async Function 内
async function bar() {
await foo(); // ✅ 可以
}
而很多时候,我们会希望在模块被导入的时候立刻执行一些代码逻辑,此时如果我们想要调用一些异步方法,就必须通过如 IIAFE(立刻调用的 Async Function 表达式):
import { setTimeout } from 'timers/promises';
let value;
export { value };
// 就地创建一个 Async Function 并立即执行
(async () => {
await setTimeout(1000);
value = 'foobar';
throw new Error('boom'); // 没人处理这个异常!
})();
但是这样写很麻烦,问题更大的是导入这个模块的地方无法捕获这个异步操作中抛出的错误,并且导入的地方也无法知晓这个 IIAFE 什么时候执行结束。
此时,更为健壮的做法可能是将这个 IIAFE 的返回 Promise 值作为模块导出,让使用的地方通过 Promise 操作来保证异常处理、逻辑顺序执行。
// a.mjs
import { setTimeout } from 'timers/promises';
let value;
export { value };
// 就地创建一个 Async Function 并立即执行
export default (async () => {
await setTimeout(1000);
value = 'foobar';
})();
// b.mjs
import promise, { value } from "./a.mjs";
export function outputValue() { return value; } // ❌ 不安全,所有使用相关值的地方都需要修改函数返回值签名
promise.then(() => {
// ✅ 可以
console.log(outputValue());
});
但是这方法显而易见的是所有使用这个模块的地方都需要被传染成 async/await 的方式,并且所有使用这个模块导出值的地方都需要正确的等待 IIAFE 的 Promise 完成。
Top-level await 提案即是期望替代以上几个策略的方案,通过 Top-level await 我们可以在模块内屏蔽更多细节,而使用的地方也可以更加简单:
import { setTimeout } from 'timers/promises';
let value;
export { value };
// 直接 await 异步操作
await setTimeout(1000);
value = 'foobar';
// b.mjs
import { value } from "./a.mjs";
export function outputValue() { return value; } // ✅ 可以
// ✅ 可以
console.log(outputValue());
提案已经可以在 Chrome 89,Node.js 14.8.0 中使用。值得注意的是只有 ECMAScript Module 中可以使用 Top-level await,也就是说我们不能在如 Node.js 的 CommonJS 模块、HTML中非type="module"
的 <script
> 标签等中使用 Top-level await。
另外值得注意的是,虽然功能上 Top-level await 看起来像是 IIAFE 的替代品,但是他们的含义并不完全相同。在 Top-level await 中,依赖模块中使用的 Top-level await 会阻塞 import
这个依赖的模块代码的执行。而在 IIAFE 中,await 并不会阻塞当前模块的执行。这即是我们期望的 Top-level await 的优点,却非常容易造成如浏览器页面白屏、卡顿等问题,这是使用 Top-level await 时非常需要注意的问题。
Stage 2 -> Stage 3
提案从 Stage 2 进入到 Stage 3 有以下几个门槛:
- 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
- ECMAScript 编辑签署了同意意见。
Accessible Object.prototype.hasOwnProperty
提案链接: 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
Resizable and growable ArrayBuffers
提案链接: https://github.com/tc39/proposal-resizablearraybuffer
这个提案可以给 Streaming、WebAssembly 等场景提供一个更加方便、高效的内存扩展方式。目前调整一个 ArrayBuffer 的大小需要复制内容,但是因为复制非常慢,而且可能导致内存空间碎片化,实际实践中限制非常多。
提案给 ArrayBuffer 与 SharedArrayBuffer 的构造函数分别增加了一个参数,可以设置 ArrayBuffer 与 SharedArrayBuffer 最大可以增长的大小 maximumByteLength
。
设置了 maximumByteLength
的 ArrayBuffer 是一个内部存储区域可以拆卸的 ArrayBuffer。提案设计上希望这些 ArrayBuffer 可以原地调整大小,但是对于 JavaScript 代码来说,实际 JavaScript 引擎有没有对这个大小调整是否是原地的是无法观测到的(如在 JavaScript 中判断内部存储区域实际内存地址是否改变等)。
let rab = new ArrayBuffer(1024, { maximumByteLength: 1024 ** 2 });
assert(rab.byteLength === 1024);
assert(rab.maximumByteLength === 1024 ** 2);
assert(rab.resizable);
rab.resize(rab.byteLength * 2);
assert(rab.byteLength === 1024 * 2);
// Transfer the first 1024 bytes.
let ab = rab.transfer(1024);
// rab is now detached
assert(rab.byteLength === 0);
assert(rab.maximumByteLength === 0);
// The contents are moved to ab.
assert(!ab.resizable);
assert(ab.byteLength === 1024);
SharedArrayBuffer 是可以在多个执行环境中共享的 ArrayBuffer,但是考虑到多个执行环境的同步问题,所以在提案中 SharedArrayBuffer 增加了 grow(newByteLength) 的方法,无法缩小 SharedArrayBuffer 的大小。
Intl.DisplayNames V2
提案链接: https://github.com/tc39/intl-displaynames-v2
很多时候,我们不只是需要将一些动态值显示为用户能够阅读的语言文本,还需要将如历法名字、货币名、语言名、日期单位名等等信息也显示为用户所熟悉的文本。
目前,Intl.DisplayNames API
已经提供了基本的地名、语言名、书写系统名、货币名等各种名字的国际化显示文本支持。而这个提案则对 Intl.DisplayNames
的国际化数据进行了扩充,增加了日期时间单位名(“日”、“月”、“年”等)、历法名(公历等)、语言方言标准化名字(如通俗的 “简体中文” 和标准写法 “中文(简体)” 等)。
let dn = new Intl.DisplayNames("zh-Hans", {type: "calendar"})
dn.of("chinese") // "农历"
dn = Intl.DisplayNames("zh-Hans", {type: "dateTimeField"})
dn.of("month") // "月"
dn = Intl.DisplayNames("zh-Hans", languageDisplay: "dialect"})
dn.of("zh-Hans") // "简体中文"
dn = Intl.DisplayNames("zh-Hans", languageDisplay: "standard"})
dn.of("zh-Hans") // "中文(简体)"
Intl Extend TimeZoneName Option
提案链接: 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 [太平洋时间]
Stage 1 -> Stage 2
从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。
RegExp set notation
提案链接: https://github.com/tc39/proposal-regexp-set-notation
Spec 链接: https://docs.google.com/document/d/1Tbv3hfX9CxQtzH9r-JdxJsQZhmmDsidRUKKxg345JV0/edit
许多正则表达式引擎都支持预设的字符集(通常都是 Unicode 的各种字符集),避免开发者需要在正则表达式中硬编码字符集。同时提案也包含了字符集的交集、差集操作,便于自由组合多个字符集。
// 差集
[A--B]
// 交集
[A&&B]
// 嵌套字符集
[A--[0-9]]
比如下面这个正则表达式可以匹配所有非 ASCII 数字,然后我们就可以将这些非 ASCII 数字转换成 ASCII 数字:
[\p{Decimal_Number}--[0-9]]
或者匹配所有非 ASCII 的 Emoji:
[\p{Emoji}--\p{ASCII}]
结语
由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:esdiscuss。
https://github.com/JSCIG/es-discuss/discussions