这次 TC39 会议中,阿里巴巴提出的 Error Cause 提案成功进入 Stage 4 ,也是我国首个进入 Stage 4 的 TC39 提案。这个提案将在 ECMAScript 2022 中正式发布。
Stage 3 → Stage 4
从 Stage 3 进入到 Stage 4 有以下几个门槛:
- 必须编写与所有提案内容对应的 tc39/test262 测试,用于给各大 JavaScript 引擎和 transpiler 等实现检查与标准的兼容程度,并且 test262 已经合入了提案所需要的测试用例;
- 至少要有两个实现能够兼容上述 Test 262 测试,并发布到正式版本中;
- 发起了将提案内容合入正式标准文本 tc39/ecma262 的 Pull Request,并被 ECMAScript 编辑签署同意意见。
Intl.Segmenter
提案链接:https://github.com/tc39/proposal-intl-segmenter
很多自然语言脚本(中文,英文,法文等等)都有词分割与句分割。Unicode UAX 29 定义了文本元素的分割算法,可以在文本中找出不同文本元素的分界线(包括如中文,韩文,日文,泰文等基于词典分割的东亚语言)。这对于实现更加可靠的输入法、文本编辑器、文本处理都有非常大的帮助。
将 Unicode UAX 29 中定义的文本元素、词句分割算法在浏览器、JavaScript 中原生实现后,相比于开发者们引入自己的实现方案来说,可以节省非常多的带宽与内存(不用再额外下载 CJK 词典了)。
Chrome 曾试图提供类似能力的 API:Intl.v8BreakIterator,但其 API 设计显然不是那么符合直觉,参考提案仓库中 #60 的讨论。因此这个提案尝试为 JavaScript 引入更符合现代 JavaScript 风格的 API。
// 创建一个专用于特定语言的分词器,如这里是中文 let segmenter = new Intl.Segmenter("zh-CN", {granularity: "word"}); // 使用此分词器处理输入 let input = "我不是,我没有,你别瞎说。"; let segments = segmenter.segment(input); for (let {segment, index, isWordLike} of segments) { console.log("segment at code units [%d, %d): «%s»%s", index, index + segment.length, segment, isWordLike ? " (word-like)" : "" ); }
其切分结果包含三个属性:
segment
:此拆分单元的长度;index
:此拆分单元的起始位置;isWordLike
:当创建分词器时,如果设置粒度(granularity)精确为 "word" 级别,且此拆分单元近似于当前语言的单个单词("word-like"),则返回true
;input
:原始输入值;
如上面的代码打印结果会是:
// segment at code units [0, 3): «我不是» (word-like) // segment at code units [3, 4): «,» // segment at code units [4, 5): «我» (word-like) // segment at code units [5, 7): «没有» (word-like) // segment at code units [7, 8): «,» // segment at code units [8, 9): «你» (word-like) // segment at code units [9, 10): «别» (word-like) // segment at code units [10, 12): «瞎» (word-like) // segment at code units [11, 12): «说» (word-like) // segment at code units [12, 13): «。»
此提案还包含一个 containing
方法来进一步的简化使用,你可以直接判断输入字符串的某一位置是否是 work-like
的:
// ┃0 1┃2 3┃4 ┃5 ┃6 7 8 9┃10┃11 // ┃我不┃做人┃了┃,┃J O J O┃!┃ let input = "我不做人了,JOJO!"; let segmenter = new Intl.Segmenter("zh-CN", {granularity: "word"}); let segments = segmenter.segment(input); let current = undefined; current = segments.containing(0) // → { index: 0, segment: "我不", isWordLike: true } current = segments.containing(1) // → { index: 0, segment: "我不", isWordLike: true } current = segments.containing(5) // → { index: 5, segment: ",", isWordLike: false }
你可能会想,这一方法看起来应该放在 String 上更加合适。但作为一个本地语言强相关的 API,就如目前 TC39 存在着的许多其他与本地化相关的 API(如 Intl.NumberFormat、Intl.DurationFormat),它们都被放置在 Intl
命名空间下。另外,Intl.Segmenter.prototype.segment
方法返回的也将会是一个特殊的 SegmentIterator
对象。
这个 API 已经可以在 Chrome 88 中使用。
Error Cause
提案链接:https://github.com/tc39/proposal-error-cause
Error Cause这一提案由淘系前端团队的 @昭朗负责推进,在 TC39 2020年9月会议 中成功从 Stage 0 进入到 Stage 1 ,时隔一年后成功进入了 Stage 4 ,这也是中国首个进入 Stage 4 的 TC39 提案。实际上,早在本次会议以前,于 8 月 31 日 发布的 Chrome 93 版本就已支持使用 Error Cause。
这一提案为 JavaScript 中的 Error 构造函数新增了一个属性 cause
,我们可以通过这个属性为抛出的错误附加错误原因,来清晰的跨越多个调用栈传递错误上下文信息。在这一提案出现前,NPM社区已经出现了类似功能的包,如 verror 、@netflix/nerror ,它们都有着极为庞大的下载量,以此可以知晓开发者们对体验良好的异常处理是如何渴求了。然而,这些第三方库毕竟不是语言内置特性,因此并不能被 Chrome DevTools 这一类开发者工具所消费。
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 }
Stage 2 → Stage 3
从 Stage 2 进入到 Stage 3 有以下几个门槛:1. 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;2. ECMAScript 编辑签署了同意意见。
Intl.DurationFormat
提案链接:https://github.com/tc39/proposal-intl-duration-format
这一提案引入了统一的国际化相关的时间间隔格式化 API(实际上 Intl
命名空间下的 API 绝大多数都是国际化相关的 API)。在现在的 JavaScript 日期处理中,这通常需要使用 dayjs 或 moment 这一类工具库才能便捷的完成。而且这些第三方库通常都会携带相当大的一个数据集用来支持各种语言的格式化。而通过 Intl.DurationFormat
,我们就可以直接利用浏览器自带的 ICU 数据集进行国际化时间间隔展示,如:
new Intl.DurationFormat("zh-CN", { style: "long" }).format({ hours: 1, minutes: 46, seconds: 40, }); // => "1小时46分40秒"
对于格式化结果,你可以通过 style
控制,不同的 style 对应的结果如下:
long
: 例如1 hour and 50 minutes
short
: 例如1hr, 50min
narrow
: 例如1h 50m
digitial
: 例如1:50:00
Stage 1 → Stage 2
从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。
Array Grouping
提案链接:https://github.com/tc39/proposal-array-grouping
在业务开发中,我们可能经常会需要将一个数组中的元素根据某些特征来进行分类。这个提案引入了一个新的数组方法 Array.prototype.groupBy
,来实现语言内置的数组分组(类似于 Lodash 中的 groupBy 方法),最终获取到每一个组别的结果:
const array = [1, 2, 3, 4, 5]; // 遍历数组成员来决定它应该被划分到哪个分组 array.groupBy(i => { return i % 2 === 0 ? 'even': 'odd'; }); // 返回一个对象,对象的键作为分类特征即为 groupBy 预测函数的返回值 // => { odd: [1, 3, 5], even: [2, 4] }
Destructuring Private Fields
提案链接:https://github.com/tc39/proposal-destructuring-private
在 ECMAScript 2015 中,我们能够通过如 const { foo } = obj;
的写法解构对象与数组。但是在引入了 #字段后,因为 #字段
名作为一个整体不再是一个合法的变量名,所以不能在解构语法中使用。为了解决这个问题,这一提案提供解构类私有字段 #字段
的能力,如:
class Foo { #x = 1; constructor() { console.log(this.#x); // => 1 const { #x: x } = this; console.log(x); // => 1 } }
值得注意的是,这个提案并不会改变 #字段
不能作为变量名的现实,所以我们在解构中必需为这个解构变量提供一个新的名字。
Stage 0 → Stage 1
从 Stage 0 进入到 Stage 1 有以下门槛:
- 找到一个 TC39 成员作为 champion 负责这个提案的演进;
- 明确提案需要解决的问题与需求和大致的解决方案;
- 有问题、解决方案的例子;
- 对 API 形式、关键算法、语义、实现风险等有讨论、分析。
Stage 1 的提案会有可预见的比较大的改动,以下列出的例子并不代表提案最终会是例子中的语法、语义。
String.cooked
提案链接:https://github.com/tc39/proposal-string-cooked
这一提案引入了新的 String
内置对象上的静态方法。它类似于 String.raw
方法,但与其不同点在于 String.raw
方法不会对其中的值做反转义,而 String.cooked
会,如:
String.raw`Hi \u{597D}\u{5BB6}\u{4F19}\u{FF0C}\u{4F60}\u{4E5F}\u{6765}\u{4E86}` // Hi \u{597D}\u{5BB6}\u{4F19}\u{FF0C}\u{4F60}\u{4E5F}\u{6765}\u{4E86} String.cooked`Hi \u{597D}\u{5BB6}\u{4F19}\u{FF0C}\u{4F60}\u{4E5F}\u{6765}\u{4E86}` // Hi 好家伙,你也来了
除此之外,有些时候我们可能希望自定义模版字符串标签函数,但是又不想自行拼接字符串。这时因为String.raw
本身的特性,就常常被用于简单拼装模版字符串。但是开发者在这个过程中可能会忘了 String.raw
不会反转义字符串中的值:
function example(strings, ...values) { return String.raw(strings, ...values.map(value => { return value.toUpperCase() })) } example`\u{61}${"b"}\u{63}` // "\u{61}B\u{63}"
而作为替代,String.cooked
就可以在这种场景处理简单的模版字符串的值插补:
function example(strings, ...values) { return String.cooked(strings, ...values.map(value => { return String(value).toUpperCase() })) } example`\u{61}${"b"}\u{63}` // "aBc"
RegExp Modifiers
提案链接:https://github.com/tc39-transfer/proposal-regexp-modifiers
目前,RegExp 支持多种执行模式,包含 i
(大小写通配),m
(多行匹配),s
(单行匹配),还有后文中即将引入的 x
(增强模式),等等。但是这些模式只能对整个正则表达式启用,而无法对于正则表达式中的某一个部分单独启用,这给编写正则表达式带来了一定的限制。另外,如果我们在如 JSON 等配置文件中书写正则表达式的话也无法在表达式中指定执行模式,如 "/foo/m"
。
这个 RegExp 修饰符提案允许我们通过额外修饰符来控制在正则表达式中部分位置生效的 flag(如 i
/g
/m
等)。这个特性在如 JSON 配置文件这种无法执行 JavaScript 代码的环境下书写正则表达式时同样可以生效。
其语法示例如下:
(?imsx-imsx)
:从当前位置开始到表达式结尾启用 flag,并在遇到下一个相同修饰符时 flag 被覆盖。如(?-i)A(?i)B(?-i)C
将匹配ABC
与AbC
(注释:(?-i)
代表在作用域内取消大小写通配,(?i)
代表在作用域内启用大小写通配)。(?imsx-imsx:…)
:在子表达式作用域内启用或禁用 flag。如(?-i:A(?i:B)C)
匹配ABC
与AbC
,但是不能匹配aBC
或者ABc
。
// 开关模式 const re1 = /^(?i)[a-z](?-i)[a-z]$/; re1.test("ab"); // true re1.test("Ab"); // true re1.test("aB"); // false // 只在 (?:) 子表达式内生效 const re2 = /^(?i:[a-z](?-i:[a-z]))$/; re2.test("ab"); // true re2.test("Ab"); // true re2.test("aB"); // false
RegExp \R Escape
提案链接:https://github.com/tc39-transfer/proposal-regexp-r-escape
在实际生产中,我们经常可以看到多种换行模式,如 CRLF,LF 等等。如果我们需要完备地匹配所有换行符,那么我们可能需要在正则表达式的 u
(Unicode)模式下 (?>\r\n?|[\x0A-\x0C\x85\u{2028}\u{2029}])
这么一长串正则表达式来匹配。
这一提案新增了 \R
标志来匹配所有行结尾字符,如 CRLF,LF 等等。这个新 \R
标志 同样需要在 u
模式下启用,如果不是在 Unicode 模式下则只是匹配一个 R
字符。
// 在忽略多种换行风格的情况下切割文件内容 const lines = fs.readFileSync("file.txt", "utf8").split(/\R/ug);
RegExp Extended mode and comments
提案链接:https://github.com/tc39-transfer/proposal-regexp-x-mode
<划掉>我们知道,正则表达式是只用来写而不是用来读的代码。</划掉>
众所周知,正则表达式因为使用了大量的符号与惯用组合而导致代码十分难读、难以理解,而且我们也无法使用注释这种惯用方式来增加可维护性。为了让正则表达式更容易维护,这个提案期望为正则表达式引入注释来在一定程度上缓解这个问题。
为了保持兼容性,提案通过新增一个正则表达式扩展模式,来增加表达式内注释的新语法。注释写法如行内注释 (?#comment)
以及行注释 (# comment
)。扩展模式需要通过新的 flag x
(意味增强 Extended)启用。另外,增强模式下允许在正则表达式中任意使用无含义的空格,如:
// 在表达式中新增空格! const re = /(foo) (bar) (baz)/x; re.test("foobarbaz"); // true // 使用表达式行内注释 const re = /foo(?#comment)bar/; re.test("foobar"); // true // 使用行注释 const re = new RegExp(String.raw` # match ASCII alpha-numerics [a-zA-Z0-9] `, "x");
RegExp Buffer Boundaries
提案链接:https://github.com/tc39-transfer/proposal-regexp-buffer-boundaries
目前,在正则表达式中 ^
与 $
对于内容开头与结尾的匹配会受到 m
(多行匹配模式)flag 的影响:
const pattern = String.raw`^foo$`; const re1 = new RegExp(pattern, "u"); re1.test("foo"); // true re1.test("foo\nbar"); // false const re2 = new RegExp(pattern, "um"); re1.test("foo"); // true re1.test("foo\nbar"); // true
而提案期望引入缓冲边界(Buffer Boundaries)标志,在正则表达式中匹配原始输入的开头或结尾。这一行为类似 ^
或者 $
(但缓冲边界不会被是否使用了 m
flag 影响 )。
\A
:匹配输入的开头;\z
:匹配输入的结尾;\Z
:零宽断言,由缓冲边界的换行符(可选)组成,等价于(?=\R?\z)
。
const pattern = String.raw`\Afoo\z`; const re1 = new RegExp(pattern, "u"); re1.test("foo"); // true re1.test("foo\nbar"); // false // 启用多行匹配 const re2 = new RegExp(pattern, "um"); re1.test("foo"); // true re1.test("foo\nbar"); // false // 结尾 buffer boundary const re = /end\Z/u; re.test("end"); // true re.test("end\n"); // true (可选的换行符) re.test("end\n\n"); // false (只能有一个结尾换行符)
缓冲边界因为使用了前文中 \R
的语义,同样需要启用 u
(Unicode)模式才能生效。
Evaluator Attributes
提案链接:https://github.com/tc39-transfer/proposal-import-reflection
Import Assertion 提案(https://github.com/tc39/proposal-import-assertions)为 ECMAScript 中的导入语句新增了特殊的断言语法,来断言模块为指定的类型,从而使得引擎能够更加准确和快速的处理模块导入,如派生自此提案的 JSON Modules 提案(https://github.com/tc39/proposal-json-modules),其语法大致如下:
import json from "./foo.json" assert { type: "json" }; import("foo.json", { assert: { type: "json" } });
对于 Import Assertion,不同的断言并不会影响其解析结果。当然,如果你断言一个导入为 JSON 导入,而其实际上并不是,那么在解析过程中将抛出错误。同样都是对 import 语句的增强语法,Evaluator Attributes 提案呢?
这一提案为 import 语句新增了使用 as 关键字在 import 语句的 module specifier 后声明的求值器属性(Evaluator Attributes),以此来改变 import 语句的对于目标模块的执行方式:
import x from "<module-specifier>" as "<evaluator-attribute>";
这一提案的提出主要是为了支持 WebAssembly 的额外模块类型,如实例导入(WebAssembly.Instance
)与模块导入(WebAssembly.Module
),如以下示例就使用了 wasm-module
作为求值器属性(这也是这个提案的主要驱动用例),以改变对一个已编译完毕(但尚未链接)的 WebAssembly 模块对象的导入行为。
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);
这个提案引入的新属性与 Import Assertion 的区别是:对于同一模块,如果被指定了不同的求值器属性,那么将会被视作不同的实例,并且在第二次加载时重新执行。也即是说,使用求值器属性的模块导入,其缓存策略与普通的 ESM 模块是不同的,一个模块不再只拥有一个副本。简单地说,使用了求值器属性的导入,其缓存策略除了基于模块资源实际的引用,还将包含求值器属性。
这一语法也能够与 Import Assertion,export 语句,以及 Dynamic Import 一同协作:
import x from "<specifier>" assert {} as "<evaluator-attribute>"; export { default as x } from "<specifier>" as "<evaluator-attribute>"; const x = await import("<specifier>", { as: "<evaluator-attribute>" });
Bind this
提案链接:https://github.com/tc39/proposal-bind-this
此前,TC39 已经有多个提案致力于解决日常编码中对于一个对象的方法拓展的工效问题,如 Pipeline Operator (Stage 2,https://github.com/tc39/proposal-pipeline-operator) ,Extensions(Stage 1,https://github.com/tc39/proposal-extensions)和 Bind Operator(Stage 0,https://github.com/tc39/proposal-bind-operator)。其中 Bind Operator 的主要 Champion Group 成员已经长期没有推动提案的演进;Extensions 提案的设计目标相对于解决方法绑定 this 值来说更为宏大。而这次这个 Bind this 提案即是对于 Bind Operator 提案进行裁剪,期望以更小的问题范畴来推进提案。
提案相比于他的先例提案来说更为小巧,它只是期望于提升我们日常 fn.call(obj, ...args)
的体验 obj::fn(...args)
。提案内容可以简单地总结为以下几个例子:
// 为集合类型拓展 toSet 的方法定义 const toSet = function () { return new Set(this) } // 给 DOM 对象拓展 allDivs 属性访问器 const allDivsAccessor = { get() { return this.querySelectorAll('div') } } const flatMap = Array.prototype.flatMap const sizeDescriptor = Object.getOwnPropertyDescriptor(Set.prototype, 'size') let classCount = document ::(allDivsAccessor.get)() ::flatMap(element => element.classList) ::toSet() ::(sizeDescriptor.get)()
我们可以发现,这个提案期望解决的问题与 Stage 1 提案 Extensions 几乎重叠。对于目前的提案设计来说,与 Extensions 提案有以下不同之处:
没有额外的特殊变量命名空间
在 Extensions 提案中,我们需要先将对象方法提取到独立的命名空间中,才能使用 ::
语法将这个方法用于拓展其他对象:
const ::has = Set.prototype.has; s::has(1);
上面这个例子中,我们提取的 ::has
方法是生成在一个独立的命名空间中的(普通的变量不能包含 :
符号),这个 ::has
也只能用于方法拓展而不能挪作他用(至少提案目前是没有对这方面有明确的设计文档)。这个设计对于提案期望解决日常开发中命名重叠的问题可能有一定的帮助,不过给提案设计增加了非常大的复杂度,也给开发者使用上设置了一个门槛。
而对于这个 Bind this 提案来说,提案期望开发者继续沿用日常开发中已经非常熟悉的避免命名冲突的惯例:
const $has = Set.prototype.has; s::$has(1); // 另一种我们熟悉的命名空间解决方案 const $ = { has: Set.prototype.has, }; const s = new Set([0, 1, 2]); s::($.has)(1);
也就是说提案本身并不引入任何复杂的设计,其中 $has
其实就是一个普通的变量,他的值是一个普通的函数对象,没有任何新奇设计。提案的核心在于我们可以将一个函数通过 ::
操作符将他的 this
绑定至 ::
操作符前的引用,并通过 ()
调用这个绑定 this 后的函数。
对于属性访问器来说没有特殊的处理,属性访问器中的 getter 与 setter 即是普通函数
在 Extensions 提案中, ::
操作符对于属性访问器进行了进一步的理解:
const ::size = Object.getOwnPropertyDescriptor( Set.prototype, 'size'); // 其中 ::size 代表的是一个对象,这个对象可能有 get 与 set 两个方法 // 调用 ::size 上的 get 方法 s::size;
这也意味着我们可以将任意存在 get 与 set 方法的对象用于 ::
操作符获得类似属性访问的特性。
不过对于 Bind this 提案来说,属性访问器其实就是一个普通对象上存在 get 与 set 两个函数,除此之外与普通函数并无差异:
const { get: getSize } = Object.getOwnPropertyDescriptor( Set.prototype, 'size'); // 调用 size getter 方法. s::getSize();
提案期望 ::
操作符不去做过多的解释,只是一个对于函数对象的 this 绑定操作。
没有 const ::{} from ...
类 import 语句语法
Extensions 提案中引入了一种类似于 import 语句的语法帮助开发者从构造器上提取方法:
const ::{ has, size, } from Set; // 因为 Set 是一个构造器,自动从 Set.prototype 上提取方法与属性描述器 s::has(1); s::size;
因为 JavaScript 的原型链设计,不管是 ES5 中我们手动通过操作 prototype 构造面向对象范式,还是在 ES6 之后通过 class
关键字定义类与继承关系,大部分的方法都是定义在 prototype 上的。而这个语法可以帮助开发者便捷地从 Constructor.prototype
上提取方法,而不必自行从如 Set.prototype
上明确提取,可以少键入几个字符。
而 Bind this 提案不期望改变目前开发者已经熟悉的工作流,只是期望解决 fn.call(obj)
这一个笨拙的写法的问题:
const { has: $has, } = Set.prototype; const { get: $getSize } = Object.getOwnPropertyDescriptor( Set.prototype, 'size'); s::$has(1); s::$getSize();
可以发现,这个设计中不存在任何魔法,所有我们熟知的 JavaScript 概念都需要开发者自行去明确解决。这在一定程度上可以降低学习这门语言的门槛,当然副作用也就是需要开发者继续输入一大段字符了。
没有多态的 ... :: ... : ...
三元操作符
在 Extensions 提案中,... :: ... : ...
是一个特殊的多态三元操作符,它会根据中间的操作元的类型作出不同的行为:
import * as _ from 'lodash'; [0, 1, 2]::_:map(it => it); // _ 不是一个构造器,所以将 this 作为第一个参数传入函数 // _.map([0, 1, 2], it => it); [0, 1, 2]::Array:map(it => it); // Array 是一个构造器,所以将 this 作为 this 绑定传入函数 // Array.prototype.map.call([0, 1, 2], it => it);
这在一定程度上确实可以帮助开发者解决现存社区包的兼容问题。不过对于 lodash 这种写法的函数来说,其实这也是 Stage 2 的 Pipeline Operator 提案所期望解决的问题,而 Bind this 提案并不会去解决:
import * as _ from 'lodash'; [0, 1, 2] |> _.take(^, 2); // 相当于 // _.take([0, 1, 2], 2).
而对于第二个 Array:map
例子,Bind this 提案也不提供额外的魔法,提案本身内容十分简单、明了:
[0, 1, 2]::(Array.prototype.map)(it => it); // 相当于 // Array.prototype.map.call([0, 1, 2], it => it);
通过上面几个例子我们可以发现,Bind this 提案所期望解决的问题与 Extensions 提案还是有非常大的重叠的。因此虽然两个提案目前都已经是 Stage 1,不过按照 TC39 的规则最终这两个提案中只会有一个提案能够进入 Stage 2。
结语
由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:https://github.com/JSCIG/es-discuss/discussions。