ES2015 到 ES2020 的变更:一次 JavaScript 语言的深层进化
把 ES2016 到 ES2020 的特性摆回各自的年份,再说清每个特性替掉了哪种老写法、以及它自带的坑。顺带纠正三个流传很广的说法:let 不是不提升、class 没换掉原型链、BigInt 修不了浮点精度。
Object.entries 是哪一年进标准的?多数人会答 ES6。它是 ES2017。Array.prototype.flat 呢?ES2019,比可选链只早一年。
年份不是冷知识。tsconfig.json 的 target 写 es2019,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.entries、String.prototype.padStart / padEnd、Object.getOwnPropertyDescriptors、SharedArrayBuffer / Atomics |
| ES2018 | 对象的 rest / spread、for await...of、正则的命名捕获组 / 反向断言 / s 标志 / Unicode 属性转义 |
Promise.prototype.finally |
| ES2019 | 可省略的 catch 绑定、JSON 超集 |
Array.prototype.flat / flatMap、Object.fromEntries、String.prototype.trimStart / trimEnd、Symbol.prototype.description、Array.prototype.sort 变成稳定排序 |
| ES2020 | 可选链 ?.、空值合并 ??、动态 import()、import.meta、export * as ns from |
BigInt、Promise.allSettled、globalThis、String.prototype.matchAll |
最容易记串的是这几条:Object.values 和 Object.entries 归 2017,不归 2015;flat 和 Object.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 initializationlet x = 1;对一个从没声明过的名字,typeof 安静地返回 "undefined";对一个 let 声明的名字,同样的 typeof 在声明之前会抛错。抛错本身就是证据:这个绑定已经在作用域里了,只是被标成「还不能读」。这段区间叫暂时性死区(TDZ)。
所以 let 换掉的不是提升,是提升之后拿到的那个东西:var 给你一个 undefined,让写错顺序的代码继续跑下去;let 给你一个会抛错的洞,让它当场停住。这个差别在循环里最值钱 —— for (let i …) 每一轮迭代都是一个新绑定,闭包抓到的是当轮的 i;var 十轮共用一个,所以老代码里到处是 IIFE。
const 锁的是绑定,不是值。const a = []; a.push(1) 完全合法,a = [] 才抛错。
class 没有换掉原型链,它换掉的是五件容易写错的事
class 编译出来仍然是函数加 prototype:typeof 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)只连了前一条,静态方法得手动抄。- 派生类的
this由super()造出来,在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 somePromise和return 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 + 1抛TypeError,BigInt 和 Number 不能混着算,必须先显式转换。(比较是放行的:1n == 1为true,1n === 1为false。)JSON.stringify(1n)抛TypeError,要序列化只能自己转成字符串。Math.*全部不接受 BigInt。
?? 和 || 只在假值上分岔,而且不许混写
|| 在左边是任意假值时取右边,0、''、NaN、false 都算。?? 只认 null 和 undefined。
const timeout1 = opts.timeout || 3000; // 传进来 0,得到 3000const timeout2 = opts.timeout ?? 3000; // 传进来 0,得到 0凡是「0 和空字符串是合法取值」的地方 —— 超时、重试次数、坐标、用户填的备注 —— 用 || 都是 bug,只是要等到有人真填 0 才暴露。
另外,?? 不能和 ||、&& 直接连写:a || b ?? c 是 SyntaxError。规范要求你用括号把顺序写清楚,因为这两者的优先级怎么定都会有一半人猜错。
?. 短路的是整条链,不只是那一个点
a?.b.c 里 a 是 null 的话,整个表达式的值是 undefined,后面的 .c 根本不会执行 —— 不需要写成 a?.b?.c。反过来,a.b?.c 保护不了 a 本身,a 是 null 照样抛。
三个容易忘的变体:obj.fn?.() 只在 fn 存在时调用(但 fn 存在却不是函数,还是照抛 TypeError);arr?.[i] 是下标形式;delete obj?.a 也是合法的。而 a?.b = 1 不合法,可选链不能出现在赋值号左边。
globalThis 是唯一一个到处都对的全局引用
浏览器里是 window,Worker 里是 self,Node 里是 global。写库的人过去得把这三个名字挨个 typeof 试一遍,还不能退回 this —— ES 模块顶层的 this 是 undefined,CommonJS 里的 this 是 module.exports,两个都不是全局对象。globalThis 把这段探测代码整个删掉。
那几样看起来像标准、其实不是的东西
装饰器(@Injectable、@Component)看着完全像语言特性,但它到今天都没有进过任何一版 ECMAScript —— TC39 的提案还停在 Stage 2.7。Angular 和 NestJS 里那些装饰器走的是 TypeScript 的 experimentalDecorators,是编译器特性,语义和现在这版提案也对不上(TypeScript:从架构分层设计到 IOC 和 AOP 里那几个日志和性能装饰器就是这条路子)。所以「装饰器是 ES 的哪一版」这个问题没有答案 —— 它一版都不属于。
顺手把几个常被算进 ES2020 的东西挪回原位:类字段和 #private、顶层 await、Array.prototype.at、Object.hasOwn 都是 ES2022;Promise.any、String.prototype.replaceAll、||= 这类逻辑赋值是 ES2021;Object.groupBy 是 ES2024。而 fetch、structuredClone、queueMicrotask 压根不在 ECMAScript 里 —— 它们由宿主环境定义,换个运行时就可能没有。