ES6 发布之后,TC39 委员会改为"每年一版"的节奏:每年 6 月定稿一个新版本,名字按年份编号,如 ES2017、ES2020。ES2016 到 ES2020 这五年,JavaScript 完成了两件大事:一是 async/await 让异步代码写起来像同步代码,基本终结了"回调地狱";二是可选链(?.)与空值合并(??)让深层属性访问不再需要层层 if 防御。本文按版本线梳理这五年的重要语法,并重点讲透这三个明星特性。

1. 先理解 TC39 流程

一个新特性要进入语言,需要经过 Stage 0(草案)到 Stage 4(完成)五个阶段,只有进入 Stage 4 才会被纳入下一个正式版本。所以"某特性在浏览器或 Node 里能用"和"它进了标准"是两回事:很多特性先在引擎里落地,后进标准。理解了这一点,你就明白为什么本文介绍的某些语法,你的老项目里可能早就用上了,你也就能看懂各种"ES2023 提案"之类的说法。TC39 的提案进度可以随时在官网查看,Stage 4 清单就是"下一版新特性"的预告。

2. ES2016:includes 与幂运算符

ES2016 只有两个小特性。Array.prototype.includes 判断数组是否包含某个值,语义比 indexOf !== -1 更直白,而且能正确识别 NaN——这是 indexOf 做不到的。幂运算符 ** 取代 Math.pow,写起来更直观,还支持小数指数与负指数。

// includes:语义化判断 + 识别 NaN
const arr = [1, 2, NaN];
arr.includes(2);   // true
arr.includes(NaN); // true,indexOf 做不到
arr.indexOf(NaN);  // -1

// 幂运算符
2 ** 10;      // 1024
2 ** 0.5;     // 1.414...,等价 Math.sqrt(2)
2 ** -1;      // 0.5

3. ES2017:async/await 登场

这是五年里最重要的一次更新。之前处理异步要靠 Promise 链式调用,流程一复杂,then 套 then 依然难读;async/await 允许你用写同步代码的方式写异步逻辑:async 函数返回一个 Promise,函数内部用 await 等待某个 Promise 完成,后续代码就像"顺序执行"一样继续。错误处理也回归朴素:一个 try/catch 就能包住整段异步逻辑。三个要点:await 只能在 async 函数(或 ES2022 之后的顶层模块)里使用;await 会暂停当前函数,但不阻塞其他任务;相互独立的多个请求应该用 Promise.all 并行,而不是逐个 await 浪费时间。

// 之前:Promise 链式调用
function load() {
  return fetch("/api/user")
    .then(r => r.json())
    .then(user => fetch(`/api/posts/${user.id}`))
    .then(r => r.json());
}

// 之后:async/await 平铺直叙
async function load() {
  try {
    const user = await (await fetch("/api/user")).json();
    const posts = await (await fetch(`/api/posts/${user.id}`)).json();
    return posts;
  } catch (err) {
    console.error("加载失败:", err);
    return [];
  }
}

另一个容易被忽略的点:async 函数一定返回 Promise。即使函数体内没有 await,它返回的也是被 Promise 包裹的值,所以 async 函数可以直接 .then() 链式调用,也可以被别的 async 函数 await;函数体内抛出的异常会自动变成 rejected 的 Promise,而不是冒泡到调用栈之外。另一个实用技巧:在 for...of 循环里配合 await,可以逐个处理有依赖关系的异步任务,这是处理"串行批量请求"的推荐写法。同版本还带上了 Object.values、Object.entries 和字符串补齐 padStart/padEnd,它们与后文的 Object.fromEntries 组合,是遍历对象的标准姿势。

const scores = { math: 90, english: 85 };
Object.values(scores);  // [90, 85]
Object.entries(scores); // [["math", 90], ["english", 85]]

"7".padStart(2, "0"); // "07"

4. ES2018 与 ES2019:对象展开与数组扁平化

ES2018 把 rest/spread 从数组推广到对象:复制对象、剔除敏感字段(如密码)都变成一行代码;Promise.finally 则保证无论成败都会执行清理逻辑。ES2019 补上了 Object.fromEntries(与 entries 互逆)、数组 flat/flatMap(扁平化)和字符串 trimStart/trimEnd。这些特性单个很小,但组合起来,项目里不少"工具函数"都可以直接删掉了。flatMap 尤其适合"先映射再拍平"一步到位的场景,比如把嵌套的分类结构展开成扁平标签列表。

// ES2018:对象 rest/spread
const { password, ...safeUser } = user;
const config = { ...defaults, ...userConfig };

// Promise.finally:无论成败都会执行
fetch("/api").finally(() => spinner.hide());

// ES2019:Object.fromEntries 与数组扁平化
const obj = Object.fromEntries([["a", 1], ["b", 2]]);
[1, [2, [3]]].flat(2); // [1, 2, 3]
["a", "b"].flatMap(x => [x, x.toUpperCase()]);
// ["a", "A", "b", "B"]

5. ES2020:可选链与空值合并

访问嵌套对象时,最烦人的是每一层都可能为 null 或 undefined。以前只能 user && user.address && user.address.city 一路防御;可选链 ?. 把"如果这一层为空,整个表达式短路返回 undefined"写进了语法。空值合并 ?? 则解决另一个经典问题:用 || 设默认值时,0、""、false 这些合法值会被误伤;?? 只在左侧是 null 或 undefined 时才取右侧默认值。两者配合,深层访问加兜底值一行搞定。需要留意的是 ?. 的短路是彻底的:fn?.(a, b) 中如果 fn 为空,参数 a、b 也不会被求值。此外,ES2020 还带来了 BigInt(任意大整数)、Promise.allSettled、globalThis(跨环境取全局对象)和动态 import()。BigInt 的写法是在整数末尾加 n:2 ** 100 会因超出安全整数范围而不精确,而 2n ** 100n 可以精确表示,适合 ID、时间戳这类大整数场景。

// 可选链 + 空值合并:一行搞定深层访问
const city = user?.address?.city ?? "未知城市";

// 与 || 的区别:?? 只看 null / undefined
const count = 0;
count || 100; // 100,0 被当成假值
count ?? 100; // 0,合法值被保留

// 方法可能不存在时
const result = obj.method?.(); // 不存在则返回 undefined

// 动态 import:按需加载模块
const utils = await import("./utils.js");

// Promise.allSettled:等所有请求结束,不因单个失败中断
const results = await Promise.allSettled([
  fetch("/api/a"),
  fetch("/api/b")
]);
results.forEach(r => console.log(r.status)); // fulfilled / rejected

与 Promise.all 不同,allSettled 不会因为某一个失败而整体拒绝,适合"批量任务允许部分失败"的场景,比如同时检查多个服务的健康状态。

6. 如何在你的环境里启用

async/await 需要 Node 8+ 或 2017 年后的浏览器;可选链与空值合并需要 Node 14+、Chrome 80+、Safari 13.1+。如果你的部署环境是 2022 年之后的 Node LTS,这些特性全部原生可用,不需要任何转译。老项目想用,给 Babel 配上 @babel/preset-env 并按目标环境配置即可,转译成本很低。另外,如果项目使用 TypeScript,tsc 从 4.0 起就完整支持这些语法,类型检查与转译一次完成,不必额外配置 Babel。

特性版本Node.js 支持主要浏览器
async/awaitES20178+Chrome 55+
对象 rest/spreadES20188.3+Chrome 60+
flat / flatMapES201911+Chrome 69+
可选链 ?.ES202014+Chrome 80+
空值合并 ??ES202014+Chrome 80+
Promise.allSettledES202012.9+Chrome 76+
BigIntES202010.4+Chrome 67+

7. 值不值得用

async/await 是必须掌握的异步范式,没有替代品;?. 与 ?? 强烈推荐,能让数据访问代码的体量直接减半,而且语义比 && 链更精确。唯一要留神的是 ?? 与 || 的差别:只有明确想"排除 null/undefined"时才用 ??,想"排除所有假值"时仍用 ||。浏览器兼容性方面,2020 年的特性到今天已是绝对主流,可以放心使用,不必再为它们保留降级写法。还有一点要记住:async/await 只是 Promise 的语法糖,Promise 本身的状态机、链式调用与错误传播依然是基本功,别因为写法变简单就跳过。最后,语法糖虽好,团队约定更重要:如果代码库统一使用 ?. 和 ??,就不要再混用旧式判空,保持一致才能发挥它们的价值。

💡 学习建议:找一段自己项目里 Promise 链式调用的代码,改写成 async/await;再找一个层层 if 判空的函数,用 ?. 和 ?? 重写。改完对比一下行数,你会立刻感受到"语法糖"的分量。