ECMAScript 双月报告:Async Context 提案成功进入到 Stage 1
在本次会议中,Change Array By Copy 提案、 Intl.NumberFormat V3 提案、Symbol as WeakMap Keys 提案成功进入到 Stage 4,分别耗时 22 个、 34 个、以及 32 个月。另外,由阿里巴巴提出的 Async Context 提案也在本次会议中成功进入到 Stage 1。
Stage 3 → Stage 4
当一个提案进入到 Stage 4 时,意味着提案已经可以在多个浏览器、Node.js 上试用,并且这些运行时都已经完成语言合规测试。同时,这个提案将会被吸纳到下一个年度发布的 ECMAScript 版本中,如 ECMAScript 2023 等。
Change Array By Copy
提案链接:proposal-change-array-by-copy[1]
JavaScript 中的数组操作方法中有一部分是会改变原数组的,如 sort、reverse、splice 等,在需要保持原数组不变时,我们往往需要先复制一份原数组。这一提案引入了一系列秉持 Change Array by Copy 理念的方法,它们的功能完全对应于会改变原数组的版本,惟一的区别是,这些方法调用将产生一个新的数组:
Array.prototype.toReversed,对应于 Array.prototype.reverse Array.prototype.toSorted,对应于 Array.prototype.sort Array.prototype.toSpliced,对应于 Array.prototype.splice Array.prototype.with,对应于 Array.prototype.copyWithin
同时,TypedArray 以及其子类的原型上也将新增上面除 toSpliced 以外的三个方法。
这些方法实际上最早来自于 Record and Tuple[2] 这个目前处于 Stage2 的提案,其为 JavaScript 引入了不可变的对象(Record)与数组(也称元组,Tuple)结构。在 Tuple 的原型上就不存在数组那样将会修改自身的方法(push,pop等),而是上面列举的 toReversed 这一类方法,你可以查看 Tuple Prototype[3] 了解更多 Tuple 上存在的方法。
为了使数组也能享受一部分“不可变”的特性,同时在未来能更容易处理数组和元组的兼容性,这些方法被抽出成为了一个独立的提案,即 Change Array by Copy 。
这四个方法目前已经拥有对应的 Polyfill 支持,参考 CoreJS[4] 或 ES Shims[5]。
Intl.NumberFormat V3
提案链接:proposal-intl-numberformat-v3[6]
Intl.NumberFormat V3 提案为 Intl.NumberFormat 新增了一系列方法,用于简化数字在国际化中的显示内容,如格式化数字的范围、四舍五入、字符串分割等:
const nf = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 0,
});
nf.formatRange(3, 5); // "CNY 3–5"
Symbol as WeakMap Keys
提案链接:proposal-symbols-as-weakmap-keys[7]
这一提案支持了在 WeakMap 中使用 Symbol 类型作为键,而此前 WeakMap 中只允许对象类型作为键。这一特性实际上是为了允许在 Records 与 Tuples 数据类型中引用对象。
Records 与 Tuples 提案为 JavaScript 引入了两个新的数据类型,它们的特性是基于值比较来判断相等性,如对于两个 Tuple 的比较中, #[1, 2,3] === #[1, 2, 3]
是成立的,因为内部的成员值完全一致。然而,这一基于值比较的特性导致了无法在 Record 与 Tuple 中使用基于引用地址比较的对象类型。而如果我们能够在 WeakMap 中使用 Symbol 类型作为键,就可以在 Record 与 Tuple 中使用 Symbol 存放引用,间接地实现对象类型值的存储。
对于 Map 与 WeakMap 的差异,我们知道 Map 类型是通过两个数组来分别存储键和键值的,这两个数组对于其中对象类型键/键值的引用始终存在,从而导致即使已经不存在其它的引用也无法回收处理。因此,WeakMap 持有的引用为弱引用,在对象类型不存在其它引用时,能正确地执行能垃圾回收。
正是因为弱引用的要求,WeakMap 的键是无法枚举的,且需要是唯一的值。对象类型很好地满足了这个要求,两个完全一样的对象类型实际上也拥有着不同的引用。你肯定会想到 Symbol 也具有这种“唯一”的特性,这也是为何此提案想要允许 Symbol 作为 WeakMap 的键。
同时,Symbol 也能够起到比对象类型更好的标识作用:
const weakMap = new WeakMap();
const key = Symbol('ref for data');
const data = { };
weakMap.set(key, data);
在 ECMAScript 中,Symbol 也有多种类型:
Unique Symbol,比如我们通过 Symbol(description)
创建的 Symbol 就是全局唯一的值;Well-known Symbol,比如 Symbol.iterator
,是预知的、在语言特性中广泛使用的 Symbol 值;Registered Symbol,比如我们通过 Symbol.for(description)
注册的 Symbol,同样也是全局唯一的值,但是每次获取的都是同一个 Symbol 值。
在提案的方案中,Unique Symbol 与 Well-known Symbol 都是可以作为 WeakMap Key 的,但是 Registered Symbol 不能作为 WeakMap Key。这是因为 Registered Symbol 实际上是无法观测到垃圾回收的,而不能观测到垃圾回收的值类型作为 WeakMap Key 没有实际意义。而 Well-known Symbol 虽然也是实际意义上无法被垃圾回收,但是这些 Symbol 是一个确定的列表,无法动态添加删除,所以也被允许作为 WeakMap Key。
细心的同学们可能已经发现了,目前 ECMAScript 中我们还没有办法判断一个 Symbol 的类型,这对于我们实际使用这个提案的特性时会造成“有些 Symbol 能作为 key 而有些不能”的问题。为了解决这个问题,同样在这次进入 Stage 2 的提案 Symbol Predicates 提供了解决方案。
Stage 2 → Stage 3
当一个提案进入 Stage 3 后,意味着浏览器、Node.js 等将会开始实现提案特性。在这个阶段的提案只有在发生重大问题时才会进行修改。
ArrayBuffer transfer
提案链接:proposal-arraybuffer-transfer[8]
这一提案属于 proposal-resizablearraybuffer[9] 提案的衍生,其引入了 ArrayBuffer.prototype.transfer 方法,来支持对 ArrayBuffer 的所有权转移能力。
在 JavaScript 中,可转移对象指的是拥有可在不同上下文间转移的资源的对象,在转移资源后,原始上下文中的对象将不再指向资源,只有新的上下文持有资源的所有权。这一能力通常用于确保在同一时刻只有一个线程能够访问资源。更常见的一个例子是在 Web Worker 场景下,将可转让对象(比如一个 ArrayBuffer)在主线程与工作线程之间传递,传递方仍然持有原始 ArrayBuffer 对象,但其 byteLength
为0,同时无法再对其进行写入。这一过程无需经过任何拷贝操作,也就意味着在数据量较大时能够有明显的性能提升。
此前我们并不能直接将一个 ArrayBuffer 的资源所有权转移到另一个 ArrayBuffer 对象,并以此来避免原始的缓冲区输入被篡改,而只能使用 slice
方法来复制一个 ArrayBuffer 对象,如以下这个例子:
function validateAndWriteSafeButSlow(arrayBuffer) {
// 复制一份,避免缓冲区被篡改
const copy = arrayBuffer.slice();
await validate(copy);
await fs.writeFile("data.bin", copy);
}
const data = new Uint8Array([0x01, 0x02, 0x03]);
validateAndWrite(data.buffer);
setTimeout(() => {
// 篡改数据
data[0] = data[1] = data[2] = 0x00;
}, 50);
这种方式需要将原本的 ArrayBuffer 中的每个字节进行复制,然后开辟新的缓冲区存放,在数据量较大将导致性能问题。而现在,我们可以使用 transfer
方法来直接转移其所有权,使得其无法被篡改:
function validateAndWriteSafeAndFast(arrayBuffer) {
// 转移所有权,并直接移动而非复制数据
const owned = arrayBuffer.transfer();
assert(arrayBuffer.detached);
await validate(owned);
await fs.writeFile("data.bin", owned);
}
这里的 arrayBuffer.detached
属性也来自与此提案,用于作为一种清晰且权威的方式,来检查一个 ArrayBuffer 对象是否已从缓冲区分离。
Stage 1 → Stage 2
当一个提案进入 Stage 2,意味着已经完成了特性细节的草稿设计,可能被用于实验性验证等早期实现。
Intl era and monthCode
提案链接:proposal-intl-era-monthcode[10]
这一提案属于 ECMAScript 402 中的 Intl 提案,与我们更熟悉的 Temporal 提案不同的是,Temporal 仅对 ISO8601 时间格式与 UTC 时区下的行为做了明确定义,对 ISO8601 以外的时间格式和 UTC 以外的时区,只提供了最基本的定义。而 Intl.era 提案旨在对这些规范细节进行进一步的完善。
这一提案之所以没有被作为 Temporal 提案的一部分,原因在于 Temporal 是 ECMA262 规范(即 ECMAScript)的一部分,其需要在所有支持 ECMAScript 的环境中运行并保持一致性,而 Intl 提案所属的 ECMAScript 402 作为 ECMAScript 的国际化标准,其在运行时可能会受到限制,如仅保留少数语言支持,此提案的行为也可能受到影响。
Symbol predicates
提案链接:proposal-symbol-predicates[11]
此提案为 Symbol 顶级对象引入了两个新的方法:Symbol.isRegistered
与 Symbol.isWellKnown
,它们分别用于判断一个 Symbol 值是否已被注册,以及是否是 ECMA262 & ECMA402 规范中内置的 Symbol 类型(如 Symbol.iterator
、Symbol.toPrimitive
等)。
这个提案主要是为了解决在上面的 Symbol as WeakMap Key 提案中,仅有 Unique Symbol(直接通过 Symbol()
创建的 Symbol 值) 与 Well-known Symbol(内置 Symbol) 可以作为 WeakMap 结构 key 的问题。通过此提案提供的 Symbol.isRegistered
方法,我们能够避免错误地使用了 Registered Symbol
作为 WeakMap Key 。
你也可以使用这两个方法来判断一个 Symbol 类型是否是独一无二的:
const isUniqueSymbol = sym => typeof sym === "symbol" && !(Symbol.isRegistered(sym) || Symbol.isWellKnown(sym));
isUniqueSymbol(Symbol()); // true 一个新的 Symbol 类型
isUniqueSymbol(Symbol.for("foo")); // false Symbol.for 方法会将此 Symbol 注册到全局
isUniqueSymbol(Symbol.asyncIterator); // false 内置 Symbol 类型
isUniqueSymbol({}); // false 非 Symbol 类型
目前这两个方法还没有正式的 polyfill,但你可以通过 is-registered-symbol[12] 与 is-well-known-symbol[13] 来提前尝试。
Stage 0 → Stage 1
所有 ECMAScript 提案都需要论证所提特性的价值、解决方案可行性。当提案进入 Stage 1 意味着提案的价值与设计方案正式被 TC39 接收,并开始标准化流程。
Async Context
提案链接:proposal-async-context[14]
在 JavaScript 异步上下文追踪对于浏览器、Node.js等运行时,应用框架、应用监控程序等来说一直是一个难以攻克的难题。对于同步执行的任务,React 通过 React Context[15] 追踪任务上下文,不过在引入异步回调、Promise、async/await 时即会失效。而 Angular 则选择了通过 Zone.js 实现一定程度上的异步上下文追踪能力。
近年,Node.js 通过 AsyncLocalStorage 等尝试,提供了基础的异步任务追踪的能力。但是对于普通开发者来说,缺少标准化方案还是难以让这个特性在如浏览器、Deno 等运行时上获得支持。
这个提案提议了一个能够将任意 JavaScript 值通过逻辑连接的同步、异步操作,传播到逻辑连接的异步操作的执行上下文的存储 AsyncContext
。
class AsyncContext<T> {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;
// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run<R>(value: T, fn: () => R): R;
// 获取当前 AsyncContext 实例的值。
get(): T;
}
通过 AsyncContext
即可实现在异步逻辑调用链上获得类似于 ReactContext 等同步调用上下文访问的能力:
// Framework listener
doc.addEventListener('click', () => {
timer.run(Date.now(), async () => {
// User code
const f = await fetch(dataUrl);
// 不需要额外传递时间戳参数
patch(dom, await f.json());
});
});
// Some framework code
const timer = new AsyncContext();
function patch(dom, data) {
// 异步任务链中间节点不需要关心额外的参数传递
doLotsOfWork(dom, data, update);
}
function update(dom, html) {
// 通过 AsyncContext 获取异步任务链的开始时间
log(Date.now() - timer.get());
dom.innerHTML = html;
}
如果你想了解更多 Async Context 的介绍,请参考 ECMAScript Async Context 提案介绍[16] 。
防止原型链污染 Symbol.proto
提案链接:proposal-symbol-proto[17]
JavaScript 中的原型污染是一个具有极大威胁的漏洞,它能够被用于覆盖或注入 JavaScript 中对象原型的属性。此前 Lodash 中就出现过原型污染漏洞 CVE-2019-10744,其中的 defaultsDeep 方法能够被用于篡改 Object.prototype 属性:
import { defaultsDeep } from "lodash";
defaultsDeep({}, JSON.parse('{"__proto__": {"polluted": true}}'));
{}.polluted; // true
现在社区中并没有一种很好地应对原型污染的方式,如 Object.seal
、Object.freeze
这样的方法会使得许多依赖向原型添加方法的 polyfill 实现无法工作。而此提案引入了「安全模式」以及「专用于动态更改原型的 symbol 类型」两种互相搭配的方式,来“堵住”原型污染这个漏洞。
首先,在安全模式下,不允许使用字符串属性名来动态访问原型,即删除 __proto__
与 prototype
(或 constructor
)属性,只允许代码中通过 getPrototypeOf
显式获取原型引用。安全模式能够防止如 defaultsDeep 或者 deepMerge 这样的函数无意间修改了原型属性,又不会导致 polyfill 这样需要修改原型的代码无法工作。
其次,此提案还希望引入了内置的 Symbol 类型,用于在安全模式下修改原型。目前提案中提出了 Symbol.proto
与 Symbol.constructor
两个备选方案:
使用 Symbol.proto
能够在安全模式下访问 prototype 上的属性使用 Symbol.constructor
能够在安全模式下访问构造函数(类似于Object.getConstructorOf
)
由于目前处于 Stage 1 阶段,此提案在后续的演进中可能会再次发生较大的变化。
总结
由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:https://github.com/JSCIG/es-discuss/discussions。
参考资料
proposal-change-array-by-copy: https://github.com/tc39/proposal-change-array-by-copy
[2]Record and Tuple: https://github.com/tc39/proposal-record-tuple
[3]Tuple Prototype: https://github.com/tc39/proposal-record-tuple/blob/main/NS-Proto-Appendix.md#tuple-prototype
[4]CoreJS: https://github.com/zloirock/core-js#change-array-by-copy
[5]ES Shims: https://github.com/es-shims
[6]proposal-intl-numberformat-v3: https://github.com/tc39/proposal-intl-numberformat-v3
[7]proposal-symbols-as-weakmap-keys: https://github.com/tc39/proposal-symbols-as-weakmap-keys
[8]proposal-arraybuffer-transfer: https://github.com/tc39/proposal-arraybuffer-transfer
[9]proposal-resizablearraybuffer: https://github.com/tc39/proposal-resizablearraybuffer
[10]proposal-intl-era-monthcode: https://github.com/tc39/proposal-intl-era-monthcode
[11]proposal-symbol-predicates: https://github.com/tc39/proposal-symbol-predicates
[12]is-registered-symbol: https://github.com/inspect-js/is-registered-symbol
[13]is-well-known-symbol: https://github.com/inspect-js/is-well-known-symbol
[14]proposal-async-context: https://github.com/tc39/proposal-async-context
[15]React Context: https://reactjs.org/docs/context.html
[16]ECMAScript Async Context 提案介绍: https://aliyuque.antfin.com/esnext/blog/ypqoah7s7m7ikb9s
[17]proposal-symbol-proto: https://github.com/tc39/proposal-symbol-proto