在本次会议中,共有 9 个提案实现了 Stage 推进,其中阿里巴巴主导的 AsyncContext 提案进入到了 Stage 2。另外,有 4 个提案成功进入到 Stage 1,包括 Promise.withResolvers 以及 Class Method Param Decorators 等此前就广受关注的内置方法和语法提案。
Stage 2 → Stage 3
当一个提案进入 Stage 3 后,意味着浏览器、Node.js 等将会开始实现提案特性。在这个阶段的提案只有在发生重大问题时才会进行修改。
Explict Resource Management
提案链接:proposal-explicit-resource-management
此提案旨在为 JavaScript 中引入显式的资源管理能力,如对内存、I/O、文件描述符等资源的分配与显式释放。
在此前,JavaScript 中并不存在标准的资源管理能力,如以下的 Generator 函数:
function * g() {
const handle = acquireFileHandle();
try {
...
}
finally {
handle.release(); // 释放资源
}
}
const obj = g();
try {
const r = obj.next();
...
}
finally {
obj.return(); // 显式调用函数 g 中的 finally 代码块
}
在我们执行完毕生成器函数后,需要使用 obj.return() 来显式地调用 g 函数中的 finally 代码块,才能确保文件句柄被正确释放。
而通过此提案引入的 using 关键字,其可以被简化为如下的方式:
function * g() {
using handle = acquireFileHandle(); // 定义与代码块关联的资源
}
{
using obj = g(); // 显式声明资源
const r = obj.next();
} // 自动调用释放逻辑
除此以外,NodeJs FileHandles 上的 handle.close() 方法,WHATWG Stream Readers 上的 reader.releaseLock() 均可以使用这种方式来简化资源的管理。
实际上,这里的“显式”对应的是此前如 WeakSet 与 WeakMap 这样,会由 JS 运行时作为垃圾回收进行的“隐式”工作。显式资源管理意味着,用户可以主动声明块级作用域内依赖的资源,通过 Symbol.disposable这样的命令式或 using 这样的声明式,然后在离开作用域时就能够由运行时自动地释放这些标记的资源。
(原 Import Assertion)Import Attributes
提案链接:proposal-import-attributes
此提案最初名为 Import Assertion,尝试在导入语句中新增断言语法,由开发者将导入模块断言为指定的类型,以此来提高 JS 引擎的处理效率,以派生自 Import Assertion 提案的 JSON Modules提案为例,其语法大致如下:
import json from "./foo.json" assert { type: "json" };
import("foo.json", { assert: { type: "json" } });
随着提案的推进,此提案的包含范围从“断言”进一步扩大,成为对整个导入语句的描述。同样以对 JSON Module 的描述,现在的语法是这样的:
import json from "./foo.json" with { type: "json" };
import("foo.json", { with: { type: "json" } });
同样的,对重导出语句、动态导入语句的语法也进行了调整:
export { val } from './foo.js' with { type: "javascript" };
import("foo.json", { with: { type: "json" } })
虽然语法和范畴进行了调整,但这一提案的主旨仍然是避免模块解析只能够依赖模块路径中的文件扩展名,从而进一步提升安全性。
实际上,除了 Import Attributes 提案以外,TC39 中还存在一个尝试为导入语句附加额外信息的提案 proposal-import-reflection(原 Evaluator Attributes ,目前位于 Stage 2),其为 import 语句支持了使用 as 关键字来声明导入反射属性(元数据)的能力,如:
import x from "" as "";
不同于 Import Attributes 实际上不会影响模块的解析行为,Import Reflection 中的元数据附加会改变 import 语句的对于目标模块的执行方式,以此提案的主要驱动场景之一为例, 为 WebAssembly 模块指定额外的类型,如实例导入(WebAssembly.Instance)与模块导入(WebAssembly.Module)。
import FooModule from "./foo.wasm" as "wasm-module";
FooModule instanceof WebAssembly.Module; // true
// WASI 是适用于 WebAssembly 的模块化系统调用规范
import { WASI } from 'wasi';
const wasi = new WASI({ args, env, preopens });
// 实例化 WebAssembly 模块,并与 WASI 实现链接
const fooInstance = await WebAssembly.instantiate(FooModule, {
wasi_snapshot_preview1: wasi.wasiImport
});
// 执行
wasi.start(fooInstance);
以上示例使用了 wasm-module 作为反射信息,以改变对一个已编译完毕(但尚未链接)的 WebAssembly 模块对象的导入行为。
Stage 1 → Stage 2
当一个提案进入 Stage 2,意味着已经完成了特性细节的草稿设计,可能被用于实验性验证等早期实现。
Async Context
在 JavaScript 异步上下文追踪对于浏览器、Node.js等运行时,应用框架、应用监控程序等来说一直是一个难以攻克的难题。对于同步执行的任务,React 通过 React Context追踪任务上下文,不过在引入异步回调、Promise、async/await 时即会失效。而 Angular 则选择了通过 Zone.js 实现一定程度上的异步上下文追踪能力。
近年,Node.js 通过 AsyncLocalStorage 等尝试,提供了基础的异步任务追踪的能力。但是对于普通开发者来说,缺少标准化方案还是难以让这个特性在如浏览器、Deno 等运行时上获得支持。
这个提案提议了一个能够将任意 JavaScript 值通过逻辑连接的同步、异步操作,传播到逻辑连接的异步操作的执行上下文的存储 AsyncContext。
class AsyncContext {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap(fn: (...args: any[]) => R): (...args: any[]) => R;
// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run(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;
}
Float16Array
JavaScript 中提供了专用于存储浮点数值的 Float32Array 与 Float64Array,它们分别对应 32 位和 64 位的浮点数,即单精度和双精度浮点数。由于它们的底层是字符串,这两个数组类型在存储/处理浮点数时能获得更高的效率与更少的内存占用。
然而,在某些场景下,我们可能会希望使用半精度浮点数,即 Float16Array,虽然它的表示范围与精度都要低于单精度浮点数 Float32Array,但是能够进一步提高运行效率和减少内存占用。如在神经网络、WebGL 中,由于我们不需要高精度的计算,性能瓶颈往往在需要存储和处理大量数据上,此时使用半精度浮点数,牺牲的精度换来大幅的性能提升是非常划算的。
除了引入 Floathe6Array 以外,此提案也会对应地添加 DataView.getFloat16 与 DataView.setFloat16 等方法补充。
Iterator.range
range 是编程语言中非常常见的一个内置 API,用于快速生成一个数字类型数组,你能够指定其初始值、结束值以及其中的步长,如在 Python 中,range(1, 100, 2) 会生成 1-99 的奇数组成的数组。
这个 API 此前在 JavaScript 中是不存在的,因此此提案提出引入 Iterator.range 方法来实现类似的能力,需要注意的是它的返回结果是一个迭代器而非数组:
[...Iterator.range(1, 100, 2)]; // 1-99 的奇数
目前在 CoreJs 中已经提供了此方法的 Polyfill,参考 esnext.iterator.range。
Stage 0 → Stage 1
所有 ECMAScript 提案都需要论证所提特性的价值、解决方案可行性。当提案进入 Stage 1 意味着提案的价值与设计方案正式被 TC39 接收,并开始标准化流程。
Await Dictionary of Promises
提案链接:proposal-await-dictionary
在日常开发中,一个相当常见的场景是将一组 Promise 的返回值存储到对象属性:
const obj = {
shape: await getShape(),
color: await getColor(),
mass: await getMass(),
};
但这么写会导致异步操作实际上是串行而非并行执行。你可能会想到使用 Promise.all,但它是基于数组索引来进行赋值的,你可能一不小心就进行了错误地赋值:
// 实际上应当是 shape, color, mass
const [color, shape, mass] = await Promise.all([
getShape(),
getColor(),
getMass(),
]);
此提案提出引入 Promise.ownProperties 与 Promise.fromEntries 两个内置方法,来实现对象版本的 Promise.all:
const {
shape,
color,
mass,
} = await Promise.ownProperties({
shape: getShape(),
color: getColor(),
mass: getMass(),
});
const {
shape,
color,
mass,
} = await Promise.fromEntries(Object.entries({
shape: getShape(),
color: getColor(),
mass: getMass(),
}));
社区也有类似的实现,如 p-props的写法是这样的:
import pProps from 'p-props';
const obj = {
shape: getShape(),
color: getColor(),
mass: getMass(),
};
console.log(await pProps(obj));
Class Method Param Decorator
提案链接:proposal-class-method-parameter-decorators
目前位于 Stage 3 的新版的 ECMAScript 装饰器相比之前版本的装饰器缺少了类方法参数的装饰器支持,而这是旧版装饰器中相当重要的一个能力,它能够实现构造函数参数与方法参数中的依赖注入,或是方法参数的校验。
以 NestJs 为例,类方法参数装饰器在旧版的使用方式大致是这样的:
@Controller()
export class UserController {
constructor(@Inject() prisma: PrismaService) {}
@Get(':id')
async queryOneUser(@Param('id') id: string) { }
}
在新版的装饰器提案中,类方法参数装饰器的类型定义如下:
type ParameterDecoratorContext = {
kind: "parameter";
name: string | undefined;
index: number;
rest: boolean;
function: {
kind: "class" | "method" | "setter";
name: string | symbol | undefined;
static?: boolean;
private?: boolean;
};
metadata: object;
addInitializer(initializer: () => void): void;
}
一个比较独特的字段是 rest 属性,它能够直接标识出当前被装饰的参数是否是 rest 参数类型。
Promise.withResolvers
提案链接:proposal-promise-with-resolvers
在构造一个 Promise 时,我们可以调用其内部函数的 resolve 与 reject 方法来更改这个 Promise 的状态。而一个常见的用法是,在其内部执行其它的异步操作,并根据操作结果来决定当前 Promise 的状态:
new Promise((res, rej) => {
listener.on('end', (err, data) => {
err ? rej(err) : res(data);
})
})
但这一方式的限制是,我们必须将其它操作也放置在 Promise 的构造函数内部,也就是说,你无法在其外部去修改 Promise 的状态。
你可能会想到,将 resolve 与 reject 函数保存到外部,就像这样:
let resolve;
let reject;
const myPromise = new Promise((resolve_, reject_) => {
resolve = resolve_;
reject = reject_;
})
这种方式能很好地实现将 resolve 与 reject 方法暴露给外部控制的效果,但并不太优雅。而此提案则提出了更好的解决方式:为 Promise 新增一个静态方法 withResolvers,其调用结果会返回一个 Promise 实例,以及用于控制其的 resolve / reject 方法:
const { promise, resolve, reject } = Promise.withResolvers();
Time Zone Canonicalization
ECMAScript 中的时区名来自于 Time Zone Database,其中的数据会定期进行更新,需要开发者更新相关的时区命名。而此提案则希望在时区发生更新的情况下提供规范化的处理方式,目前其提出了包括简化时区标识符、更新 V8 与 WebKit 中过时的规范命名等措施,以及提出引入 Temporal.TimeZone.prototype.equals 方法来帮助判断两个时区标识是否指向同一个时区:
Temporal.TimeZone.from('Asia/Calcutta').equals('Asia/Kolkata');
// => true
总结
对 ECMAScript 有新想法、新需求?快来了解 TC39 是如何工作的( https://yuque.antfin-inc.com/esnext/how-we-work/fgqi7o),或者直接与 @昭朗 联系,加入 ESNext 研讨会小组(钉钉群号23188462)与我们讨论。