ES2015 到 ES2020 的变更:一次 JavaScript 语言的深层进化

把 ES2016 到 ES2020 的特性摆回各自的年份,再说清每个特性替掉了哪种老写法、以及它自带的坑。顺带纠正三个流传很广的说法:let 不是不提升、class 没换掉原型链、BigInt 修不了浮点精度。

预计
9 分钟

Object.entries 是哪一年进标准的?多数人会答 ES6。它是 ES2017。Array.prototype.flat 呢?ES2019,比可选链只早一年。

年份不是冷知识。tsconfig.jsontargetes2019,TypeScript 会把 a?.b 拆成一串带 void 0 判断的表达式再输出;写 es2020 就原样留着 —— 从 TypeScript 3.8 起,可选链、空值合并、动态 import()export * as ns 这四样都跟着 es2020 这个 target 一起放行。Babel 的 preset-env 按 browserslist 做的也是同一件事。年份记错一年,产物里多出来的不是一个语法糖,是一整段降级代码。

先把 ES2016 到 ES2020 摆回各自的年份

下面这张表按 TC39 的 finished-proposals 排的,每条提案后面标着它进入的版本年份:

版本 语法 标准库
ES2016 幂运算符 ** Array.prototype.includes
ES2017 async / await、参数列表尾逗号 Object.values / Object.entriesString.prototype.padStart / padEndObject.getOwnPropertyDescriptorsSharedArrayBuffer / Atomics
ES2018 对象的 rest / spread、for await...of、正则的命名捕获组 / 反向断言 / s 标志 / Unicode 属性转义 Promise.prototype.finally
ES2019 可省略的 catch 绑定、JSON 超集 Array.prototype.flat / flatMapObject.fromEntriesString.prototype.trimStart / trimEndSymbol.prototype.descriptionArray.prototype.sort 变成稳定排序
ES2020 可选链 ?.、空值合并 ??、动态 import()import.metaexport * as ns from BigIntPromise.allSettledglobalThisString.prototype.matchAll

最容易记串的是这几条:Object.valuesObject.entries 归 2017,不归 2015;flatObject.fromEntries 归 2019,不归 2020;includes 归 2016,比 flat 早三年;async / await 归 2017,而消费异步序列的 for await...of 要到 2018 才有。

let 和 const 一样会提升,只是提升到了死区

「let/const 解决了变量提升」这句话流传极广,但它经不起一行代码:

粘进浏览器控制台跑一遍
console.log(typeof notDeclared); // "undefined"
console.log(typeof x); // ReferenceError: Cannot access 'x' before initialization
let x = 1;

对一个从没声明过的名字,typeof 安静地返回 "undefined";对一个 let 声明的名字,同样的 typeof 在声明之前会抛错。抛错本身就是证据:这个绑定已经在作用域里了,只是被标成「还不能读」。这段区间叫暂时性死区(TDZ)。

所以 let 换掉的不是提升,是提升之后拿到的那个东西:var 给你一个 undefined,让写错顺序的代码继续跑下去;let 给你一个会抛错的洞,让它当场停住。这个差别在循环里最值钱 —— for (let i …) 每一轮迭代都是一个新绑定,闭包抓到的是当轮的 ivar 十轮共用一个,所以老代码里到处是 IIFE。

const 锁的是绑定,不是值。const a = []; a.push(1) 完全合法,a = [] 才抛错。

class 没有换掉原型链,它换掉的是五件容易写错的事

class 编译出来仍然是函数加 prototypetypeof Foo === 'function'Foo.prototype.method 照样取得到。它不是一套新的继承模型,也没有让引擎多出什么静态分析的余地。真正的差别在这几条上,每一条都对应 ES5 时代一个反复写错的地方:

  • 原型上的方法不可枚举。 Object.keys(Foo.prototype) 是空数组,for...in 扫不到。手写的 Foo.prototype.m = … 是可枚举的,早年得靠 Object.defineProperty 一个个改。
  • 类体永远是严格模式,不管外面有没有 'use strict'
  • 不用 new 调用直接抛 TypeError 构造函数被当普通函数误调,在 ES5 里会静默地往全局对象上挂属性。
  • extends 连的是两条链。 Sub.prototype.__proto__ === Base.prototype,同时 Sub.__proto__ === Base —— 静态方法也跟着继承。ES5 常写的 Foo.prototype = Object.create(Base.prototype) 只连了前一条,静态方法得手动抄。
  • 派生类的 thissuper() 造出来,在 super() 返回之前读 this 会抛错。就是这一条让 class X extends Array 真的能用 —— 实例拿到的是引擎造的 exotic array 对象,length 会自动跟着走;ES5 的 Array.call(this) 拿不到这个行为。

顺手把版本摆正:写在类体里的字段(count = 0)和私有字段 #,是 ES2022,不是 ES2015。它们在 TypeScript 和 Babel 里能写了很多年,那是编译器的事,不是当年的标准。

模块的静态结构才是 tree shaking 的前提

import / export 只能写在顶层,不能塞进 if,路径必须是字符串字面量、不能拼接。这几条限制经常被当成不便,它们恰恰是打包器能在不运行代码的前提下算出「谁用了谁」的原因 —— Rollup 的 tree shaking 就建在这上面(Rollup 打包原理)。CommonJS 的 require() 是一次普通的函数调用,参数可以是变量,静态分析只能猜。

ES2020 的动态 import() 是给这套静态结构开的口子:它返回 Promise,接受运行时算出来的路径,代价是这一支从静态依赖图里掉了出去,打包器只能把它切成单独的 chunk(Webpack 5 打包原理详解)。它长得像函数,但不是 —— 不能 const f = import 存下来,也没有 import.length

异步这条线,每一版补一个洞

ES2015 的 Promise 只是起点,后面四版每一版都在补它剩下的缺口:

  • ES2017 的 async / await 有一条常被忽略的差别:return somePromisereturn await somePromise 在正常路径上等价,但在 try 块里不等价 —— 前者把 Promise 原样交出去,rejection 落到调用方,本函数的 catch 接不住;后者会先在本函数里等出结果,catch 才有机会拦。
  • ES2018 的 for await...of 和异步生成器。 用来消费「一段一段到达」的东西:Response.body 的流、Node 的 readable stream。普通 for...of 遍历一个 Promise 数组,拿到的是一串 Promise 本身;for await 会挨个等出来。
  • ES2018 的 Promise.prototype.finally 它是透传的:回调不接收任何参数,返回值也被忽略,原来的值或错误原样往下走。唯一能改变结果的情况,是回调自己抛错或者返回一个 rejected 的 Promise。想在这里改返回值的,改不动。
  • ES2020 的 Promise.allSettled Promise.all 一个失败就整体 reject,已经成功的那些结果全拿不到。allSettled 永不 reject,返回的数组每项是 { status: 'fulfilled', value }{ status: 'rejected', reason }。「全部跑完再看谁挂了」的批处理场景要的是它。

ES2016 到 ES2019 的标准库,每一条都替掉了一种老写法

这几版没有语言层面的大改动,值钱的地方在于:它们让一批「人人都会写、但每次都要重写一遍」的小片段消失了。

新写法 版本 它替掉的老写法
arr.includes(x) ES2016 arr.indexOf(x) !== -1 —— 而且 [NaN].indexOf(NaN)-1[NaN].includes(NaN)true
Object.entries(o) ES2017 Object.keys(o).map(k => [k, o[k]])
s.padStart(2, '0') ES2017 ('0' + s).slice(-2)
{ ...a, ...b } ES2018 Object.assign({}, a, b) —— 两者不等价,见下
arr.flat() ES2019 [].concat(...arr)
Object.fromEntries(pairs) ES2019 手写 reduce 往空对象上塞 key
try {} catch {} ES2019 catch (e) 里那个根本用不到的 e

其中三条藏着坑:

对象 spread 和 Object.assign 不能互换。 spread 是定义属性,Object.assign赋值 —— 目标对象上如果有同名的 setter,Object.assign 会触发它,spread 会直接把它覆盖掉。两者都只拷一层,嵌套对象仍然共享引用。

flat() 默认只拍一层。 深层嵌套要写 flat(Infinity),这是最常见的一次踩空。

Object.fromEntries 是和 Object.entries 配对的。 这一对凑齐之后,「过滤对象的键」才有了一句话的写法:Object.fromEntries(Object.entries(o).filter(([k]) => …))。ES2017 只给了去程,回程等了两年。

还有一条容易被忽略:Array.prototype.sort稳定性是 ES2019 才写进规范的。在那之前,「值相等的元素保持原有顺序」不是标准保证的行为,只是某些引擎在某些长度下恰好做到了。依赖它排多级表格的代码,在旧引擎上会莫名其妙地乱序。

正则的那批更新全在 ES2018:命名捕获组 (?<year>\d{4}) 让你用 m.groups.year 取值而不是数第几个括号;反向断言 (?<=\$)\d+s 标志让 . 也能匹配换行;\p{Script=Han} 这类 Unicode 属性转义(要配 u 标志)。但把所有匹配一次取出来的 String.prototype.matchAll 晚了两年,是 ES2020 的,而且必须配 g 标志,不配直接抛 TypeError

ES2020 的四个新符号,两个自带陷阱

BigInt 不是拿来修浮点精度的

BigInt 是任意精度的整数0.1 + 0.2 !== 0.3 那个问题它一点忙都帮不上 —— 那是 IEEE 754 双精度浮点的事,而 BigInt 里根本没有小数。

它解决的是另一个问题:超过 Number.MAX_SAFE_INTEGER(2 的 53 次方减 1)之后,整数不再精确。以太坊的 wei 金额、数据库的 64 位自增主键、纳秒级时间戳,都在这个范围之外。

三个会立刻撞上的限制:

  • 1n + 1TypeError,BigInt 和 Number 不能混着算,必须先显式转换。(比较是放行的:1n == 1true1n === 1false。)
  • JSON.stringify(1n)TypeError,要序列化只能自己转成字符串。
  • Math.* 全部不接受 BigInt。

??|| 只在假值上分岔,而且不许混写

|| 在左边是任意假值时取右边,0''NaNfalse 都算。?? 只认 nullundefined

配置项默认值:这两行结果不同
const timeout1 = opts.timeout || 3000; // 传进来 0,得到 3000
const timeout2 = opts.timeout ?? 3000; // 传进来 0,得到 0

凡是「0 和空字符串是合法取值」的地方 —— 超时、重试次数、坐标、用户填的备注 —— 用 || 都是 bug,只是要等到有人真填 0 才暴露。

另外,?? 不能和 ||&& 直接连写:a || b ?? cSyntaxError。规范要求你用括号把顺序写清楚,因为这两者的优先级怎么定都会有一半人猜错。

?. 短路的是整条链,不只是那一个点

a?.b.canull 的话,整个表达式的值是 undefined,后面的 .c 根本不会执行 —— 不需要写成 a?.b?.c。反过来,a.b?.c 保护不了 a 本身,anull 照样抛。

三个容易忘的变体:obj.fn?.() 只在 fn 存在时调用(但 fn 存在却不是函数,还是照抛 TypeError);arr?.[i] 是下标形式;delete obj?.a 也是合法的。而 a?.b = 1 不合法,可选链不能出现在赋值号左边。

globalThis 是唯一一个到处都对的全局引用

浏览器里是 window,Worker 里是 self,Node 里是 global。写库的人过去得把这三个名字挨个 typeof 试一遍,还不能退回 this —— ES 模块顶层的 thisundefined,CommonJS 里的 thismodule.exports,两个都不是全局对象。globalThis 把这段探测代码整个删掉。

那几样看起来像标准、其实不是的东西

装饰器(@Injectable@Component)看着完全像语言特性,但它到今天都没有进过任何一版 ECMAScript —— TC39 的提案还停在 Stage 2.7。Angular 和 NestJS 里那些装饰器走的是 TypeScript 的 experimentalDecorators,是编译器特性,语义和现在这版提案也对不上(TypeScript:从架构分层设计到 IOC 和 AOP 里那几个日志和性能装饰器就是这条路子)。所以「装饰器是 ES 的哪一版」这个问题没有答案 —— 它一版都不属于。

顺手把几个常被算进 ES2020 的东西挪回原位:类字段和 #private、顶层 awaitArray.prototype.atObject.hasOwn 都是 ES2022;Promise.anyString.prototype.replaceAll||= 这类逻辑赋值是 ES2021;Object.groupBy 是 ES2024。而 fetchstructuredClonequeueMicrotask 压根不在 ECMAScript 里 —— 它们由宿主环境定义,换个运行时就可能没有。