ECMAScript 双月报告:阿里巴巴提案 Error Cause 进入 Stage3
因为疫情的影响,TC39 的会议从线下线上结合的会议模式转变成了完全线上的会议。而对于参会人员分布地区多样的线上会议来说,时区恐怕是最大的一个问题之一了。从今年开始 TC39 将会缩减每一次会议的时长,而增加会议的举办频率,期望缓解因时差带来的困境。所以这次会议的议程相对以往而言少了许多(有进展的提案也少了很多)。
这次会议上,阿里巴巴代表负责的提案 Error Cause 也进入了 Stage 3,将开始在 JavaScript 引擎中开始实现,浏览器、Node.js 实验性发行。
提案从 Stage 2 进入到 Stage 3 有以下几个门槛:
撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
ECMAScript 编辑签署了同意意见。
Error Cause
提案链接:https://github.com/tc39/proposal-error-cause
这个提案为 Error Constructor 新增了一个可选的参数 options
,其中可以设置 cause
并且接受任意 JavaScript 值(JavaScript 可以 throw 任意值,如 undefined
或者字符串),把这个值赋值到新创建的 error.cause
上。
错误原因的特性在许多其他语言中都有类似的设计,如 C# Exception Cause,Java Exception Cause,Python raise exception from cause。同样的,在庞大的 JavaScript 生态中也已经有了非常广泛的使用,如 verror 每周有上千万的下载量,@netflix/nerror 每周有数十万的下载量。
不过,即使在 JavaScript 第三方库中有再多的使用,Chrome DevTools 等开发者工具也难以依赖这些第三方库中定义的、不是语言定义中存在的属性。有了这个提案之后,这些开发者工具也可以默认打印 cause
属性中的值了,可以为异常处理带来更良好的体验。
try {
return await fetch('//unintelligible-url-a') // 抛出一个 low level 错误
.catch(err => {
throw new Error('Download raw resource failed', { cause: err }) // 将 low level 错误包装成一个 high level、易懂的错误
})
} catch (err) {
console.log(err)
console.log('Caused by', err.cause)
// Error: Download raw resource failed
// Caused by TypeError: Failed to fetch
}
Temporal
提案链接:https://github.com/tc39/proposal-temporal
众所周知,JavaScript 最初的 API 设计非常地仓促而缺少众多的场景考虑。JavaScript 长期一直被诟病的设计问题中,Date
恐怕是其中最恶名昭彰的 API 之一。时间处理 API 作为现代语言中最基础的部分之一,几乎所有编程语言都或多或少有相关的机制帮助开发者处理日期与时间数据。然而,日期与时间作为人类文化的重要组成部分,不同的文化关于日期传统与偏好都有大大小小的差异。在这个背景之下,日期与时间处理的抽象 API 并不是一件简单的事情。JavaScript 在起步时通过 “仿 Java” API 设计快速获得了充实,当然也包括了还处于“婴儿时期”的 java.Util.Date
。因为设计考虑欠佳,Java 1.1 在 1997 年就废弃了 java.Util.Date
几乎所有方法,然而在 20 年后,我们还是得继续在 JavaScript 中使用这些 API。
说了这么多,目前 JavaScript 中 Date
到底有什么问题呢:
不支持除了用户当前本地的时区与 UTC 之前的时区;
Date 的字符串解析非常不可靠,导致其几乎不能使用;
Date 对象是可修改、变动的;
针对夏令时的行为几乎不可预测;
计算 API 非常不实用;
不支持公历(Gregorian)之外的历法;
Temporal 提案就是为了解决 ECMAScript 中 Date
带来的长期的痛点,为 ECMAScript 引入了 Temporal
全局命名空间(类似于 Math
),并在这个命名空间中设计了许多现代的日期、时间 API。
Temporal 通过以下设计原则解决上述 Date
的问题:
提供了易用的日期、时间计算 API;
提供原生的时区支持 API,并且包括夏令时的支持;
所有的 Temporal 时间、日期对象值不可变;
严格的日期字符串解析器;
除了公历之外,也支持了如农历、伊斯兰历等等历法
考虑了非常多设计因素的日期与时间是如此的复杂,为了能够提供易于理解、易于使用的日期、时间 API,Temporal 提供了多种不同的类型:
依托上图中所抽象的类型,Temporal 才能提供
可以正确地表述只有部分值的日期与时间,如 PlainYearMonth,PlainMonthDay;
避免容易导致错误的自动填 0 行为;
根据当前的类型进行恰当的计算;
所有 Temporal 中的类型都有其对应的可以用于存储或者传输的字符串表示。为了在 ISO8601/RFC3339 中表示时区信息,Temporal 提案对 ISO8601/RFC3339 进行了扩展。目前这些扩展也正在工业标准化的流程之中了,为了避免后续标准流程中的变动,浏览器与运行时实现可能只会在实验性 flag 的情况下才会启用这些扩展。
下面我们来看看 Temporal 和 Date API 的常见用例的对比。
日期值计算
在 Date 中,并没有提供使用的日期计算 API,我们必须自行读取值,计算后再重新设置回去,这在闰年等等场景下会导致错误计算。如果指定的参数超出了合理范围,像 setMonth
会导致我们期望的月份计算不符合预期(1月 + 1 => 3月)。
const now = new Date('2020-02-29T12:00:00.000Z');
now.setFullYear(now.getFullYear() + 1);
now;
// ❌ 实际值: 2021-03-01T12:00:00.000Z
// ⭕️ 期望值: 2021-02-28T12:00:00.000Z
const now = new Date('2020-01-30T12:00:00.000Z');
now.setMonth(now.getMonth() + 1);
now;
// ❌ 实际值: 2020-03-01T12:00:00.000Z
// ⭕️ 期望值: 2020-02-29T12:00:00.000Z
而 Temporal 则原生提供了日期计算 API:
const now = Temporal.PlainDateTime.from('2020-02-29T12:00:00.000Z');
const after = now.add({ years: 1 });
now;
// ✅ 不变量: 2020-02-29T12:00:00.000Z
after;
// ✅ 期望值: 2021-02-28T12:00:00.000Z
const now = Temporal.PlainDateTime.from('2020-01-30T12:00:00.000Z');
const after = now.add({ months: 1 });
now;
// ✅ 不变量: 2020-02-29T12:00:00.000Z
after;
// ✅ 期望值: 2020-02-29T12:00:00.000Z
时间字符串解析
new Date
表达式或者 Date.parse
可以解析输入的字符串并构造 Date
对象,但是当这个字符串是一个变量、可能是用户输入的情况时,解析并不会在异常时抛出错误、而是隐式地尝试去修正错误:
const input = '2019-02-29T12:00:00.000Z';
new Date(input);
// ❌ 实际值: 2019-03-01T12:00:00.000Z
// ⭕️ 期望值: invalid
Date.parse(input);
// ❌ 实际值: 1551441600000
// ⭕️ 期望值: invalid
Temporal 的解析不会自作主张地尝试去订正错误,而是将错误正确地抛给开发者来界定如何处理:
Temporal.PlainDateTime.from('2019-02-29T12:00:00.000Z');
// ✅ 期望状态: RangeError
闰年计算
在各种历法中,都有闰年相关的定义,如公历通常是每4年一次(除了 1700、1800 等可模 100 而不能模 400 的年份),更有历法是闰年增加一个月即闰月(如农历)。这导致了闰年的计算并不是一个直白、简单的事情,而是要根据不同的历法来计算:
const isLeapYear = year => year % 4 === 0 ? true : false;
isLeapYear(1900);
// ❌ 实际值: true
// ⭕️ 期望值: false
在 Temporal 中,Calendar
为我们提供了闰年的计算:
const calendar = Temporsal.Calendar.from('iso8601');
const isLeapYear = year =>
calendar.inLeapYear(Temporal.PlainYearMonth.from({ year, month: 2 });
isLeapYear(1900);
// ✅ 期望值: false
从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。
Array find from last
提案链接:https://github.com/tc39/proposal-array-find-from-last
这个提案引入了 Array.prototype.findLast
与 Array.prototype.findLastIndex
。从 Array.prototype.lastIndexOf
和 Array.prototype.find
可以衍生得出这两个新的 API 语义是与 Array.prototype.find
类似的,不过是从 Array 的尾部开始遍历寻找符合其第一个 callback 参数所期望的元素。
const array = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }];
// find
array.findLast(n => n.value % 2 === 1); // => { value: 3 }
// findIndex
array.findLastIndex(n => n.value % 2 === 1); // => 2
array.findLastIndex(n => n.value === 42); // => -1
从 Stage 0 进入到 Stage 1 有以下门槛:
找到一个 TC39 成员作为 champion 负责这个提案的演进;
明确提案需要解决的问题与需求和大致的解决方案;
有问题、解决方案的例子;
对 API 形式、关键算法、语义、实现风险等有讨论、分析。
Stage 1 的提案会有可预见的比较大的改动,以下列出的例子并不代表提案最终会是例子中的语法、语义。
Module Fragments
提案链接:https://github.com/littledan/proposal-module-fragments
虽然 ECMAScript Modules 已经在浏览器、Node.js 中落地多年了,但是现实是残酷的,JavaScript 开发者们通常还是需要通过如 webpack, rollup,Parcel, esbuild 等等打包工具来将包含了了大量琐碎 JavaScript 模块的工程打包成多个大 JavaScript 文件 -- 毕竟浏览器等等场景无法高性能地处理大量小 JavaScript 模块的加载,打个包总是可以给我们带来非常显著的性能提升 🤷♀️ 。但是在打包工具将大量 JavaScript 文件打包后,JavaScript 引擎不再能够感知到模块的边界、无法将模块拆分并行解析。
提案希望能够在 ECMAScript Module 中引入单个 JavaScript 模块文件可以包含多个模块的机制。这样,即使我们继续使用各种打包工具,也可以因为清晰的模块的边界而获得性能上的提升,而打包工具也可以更加直白、简单。
// filename: app.js
module "#count" {
let i \= 0;
export function count() {
i++;
return i;
}
}
module "#uppercase" {
export function uppercase(string) {
return string.toUpperCase();
}
}
import { count } from "#count";
import { uppercase } from "#uppercase";
console.log(count()); // 1
console.log(uppercase("daniel")); // "DANIEL"
第一眼看这个提案,持续跟踪 TC39 进展的同学们可能会发现上一次会议中有一个神似的提案 Module Blocks 刚进入 Stage 2。那么这个提案有什么不同呢?
// Module Blocks
let moduleBlock = module {
export let y = 1;
};
let moduleExports = await import(moduleBlock);
assert(moduleExports.y === 1);
Module Blocks 是一个不同的概念:这个模块可以通过 import()
或者 new Worker()
来使用,但是不能通过静态的 import
语句(如 import { foo } from 'bar'
)使用。这是因为这个模块没有显示声明的名字,无法通过字符串在引擎的模块映射中找到,而 Module Blocks 的对象本身即是一个模块映射中的键,只能通过这个对象来导入这个模块。
所以总的来说,Module Blocks 在一定程度上可以非常灵活,并且可以出现在如 if 语句、循环语句等等中(反正也不需要开发者起名字,当然要物尽其用)。以下是目前两个提案设计中,Module Blocks 可以用,而 Module Fragments 不能用的场景:
eval
中;传统脚本模式中;
嵌套的 Module Blocks 中;
嵌套在函数体中;
通过 ModuleBlock 构造器动态构建;
这些特性当然给 Module Blocks 带来了使用场景的多样性,但是 Module Fragments 同样也有其独特性:这些模块是有名字的,可以通过字符串表示,可以用在静态 ECMAScript 模块中。
值得一提的是,近期 Web 落地的 import maps 也有相当类似的功能,但是两者是不冲突的。Import maps 甚至可以与 Module Fragments 结合使用,毕竟 Module Fragments 可以将多个模块合并在一个 Fetch 请求中,让大量模块的加载更加快速。
由贺师俊牵头、阿里巴巴前端标准化小组参与组建的 JSCIG(JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:esdiscuss(https://github.com/JSCIG/es-discuss/discussions)。
关注「Alibaba F2E」微信公众号(左)微信视频号(右)
把握阿里巴巴前端新动向