ES2021 到 ES2024,JavaScript 依然按部就班地"小步快跑"。这四年没有像 async/await 那样改变编程范式的大招,但积累起来相当可观:字符串的 replaceAll 让"替换全部"不再依赖正则;数组的 findLast 与一组不可变方法让数据处理更顺手;Promise.withResolvers 把"手动控制 Promise"的样板代码压缩成一行。本文按年份盘点这些特性,并给出对应的 Node.js 支持版本,方便你判断自己的环境能不能直接用。

1. ES2021:replaceAll 与逻辑赋值运算符

String.prototype.replaceAll 解决了 JavaScript 的一个老尴尬:replace 的第一个参数若是字符串,只替换第一处;想替换全部,要么写正则 /x/g,要么写循环。replaceAll 让"全部替换"成为默认行为,而且字符串参数不需要转义正则元字符,做模板变量替换、路径清洗这类工作非常顺手。对性能敏感的场景,它与全局正则的差别可以忽略,真正的价值在可读性:不用维护正则的 /g 状态,也不用担心元字符转义。需要留意:replaceAll 的正则参数必须带全局标志 g,否则会抛 TypeError,这一点与 replace 不同。同年还新增了三个逻辑赋值运算符 ||=、&&=、??=,把"先判断再赋值"压缩成一步;数字分隔符则让 1_000_000 比 1000000 好读得多。逻辑赋值运算符也有语义细节:??= 只在左侧为 null/undefined 时赋值,||= 只在左侧为假值时赋值,选择规则与 ??、|| 完全一致。

// replaceAll:替换所有匹配,不用再写正则
"2021-08-05".replaceAll("-", "/"); // "2021/08/05"

// 对比:replace 只替换第一处
"a-b-c".replace("-", "+");         // "a+b-c"
"a-b-c".replaceAll("-", "+");      // "a+b+c"

// 数字分隔符:大数字更易读
const budget = 1_000_000_000;

// 逻辑赋值运算符
let cache = null;
cache ??= compute(); // cache 为空才计算
let total = 0;
total ||= 10;        // total 为假值才赋值

2. ES2022:私有字段、.at() 与顶层 await

class 的私有成员用 # 前缀标记,#balance 只能在类内部访问,从语言层面堵住了"约定 _ 前缀"这种靠自觉的方案。数组的 .at() 支持负数索引,arr.at(-1) 优雅地拿到最后一个元素,不用再写 arr[arr.length - 1]。顶层 await 则允许 ES 模块在顶层直接使用 await,加载配置文件、初始化连接等"模块加载期就要异步"的场景,不再需要包一层 async 函数。同版本还有 Object.hasOwn(替代 Object.prototype.hasOwnProperty.call 的长串写法)、Error 的 cause 选项,以及 static 静态初始化块,用来在类定义体里书写复杂的静态字段初始化逻辑。Object.hasOwn 与 in 运算符的差别在于:in 会沿着原型链查找,hasOwn 只看自身属性,判断"对象自己有没有这个键"时用 hasOwn 更准确。顶层 await 有个限制:只能在 ES 模块中使用,CommonJS 文件里依然会报语法错误,迁移时记得把 package.json 的 type 字段改成 module。

// 私有字段:# 开头,类外无法访问
class BankAccount {
  #balance = 0;
  deposit(amount) {
    this.#balance += amount;
  }
  get balance() {
    return this.#balance;
  }
}
// acc.#balance; // SyntaxError,私有的

// .at():支持负数索引
const arr = ["a", "b", "c"];
arr.at(-1); // "c"
arr.at(0);  // "a"

// 顶层 await(ES 模块中直接可用)
const config = await fetch("/config.json").then(r => r.json());

3. ES2023:findLast 与不可变数组方法

find 和 findIndex 只能从前往后找;以前要从后往前找,得先 reverse,既低效又容易出错。findLast/findLastIndex 直接补上这个方向,在"找最近一条记录""取最后一条匹配项"这类场景非常自然。更重要的是四个不可变方法:toSorted、toReversed、toSpliced 和 with,它们返回新数组而不修改原数组,与函数式风格和 React 这类"不可变数据"框架天然契合,也避免了 sort 原地修改带来的隐蔽 bug——排序后原数组悄悄变掉,是很多诡异问题的来源。toSorted 默认按字符串顺序排序,与 sort 一样可以传入比较函数;with 适合"替换某一位"的不可变更新,和 React 里 setState 的更新模式很搭。

// findLast:从后往前找
const students = [
  { name: "A", score: 60 },
  { name: "B", score: 90 },
  { name: "C", score: 80 }
];
const lastPass = students.findLast(s => s.score >= 60);
// { name: "C", score: 80 }

// 不可变方法:返回新数组,原数组不变
const nums = [3, 1, 2];
const sorted = nums.toSorted();     // [1, 2, 3]
const reversed = nums.toReversed(); // [2, 1, 3]
const updated = nums.with(0, 99);   // [99, 1, 2]
console.log(nums);                  // [3, 1, 2],没变

// toSpliced:删除/替换元素也不动原数组
const list = ["a", "b", "c", "d"];
list.toSpliced(1, 2); // ["a", "d"],原数组不变

4. ES2024:Promise.withResolvers 与 Object.groupBy

Promise.withResolvers 一次性返回 { promise, resolve, reject } 三件套,专门服务"由外部事件决定何时完成"的 Promise:事件监听器、超时控制、游戏循环等场景,再也不用先声明两个变量、再包一层 new Promise。Object.groupBy 与 Map.groupBy 让"按字段分组"成为标准库能力,处理接口返回的列表数据非常常用。同版本还有 Array.fromAsync(把异步可迭代对象收集为数组)和 String.isWellFormed / toWellFormed(处理代理对不完整的畸形字符串)等。Promise.withResolvers 还有一个典型场景:把回调风格的旧 API 包装成 Promise,配合超时兜底,比手写 new Promise 清晰很多。Map.groupBy 与 Object.groupBy 的区别在于返回 Map,键可以是任意类型,分组键是对象或需要保留插入顺序时优先考虑它。

// 之前:手动控制 Promise 需要先声明两个变量
let resolveFn, rejectFn;
const p = new Promise((resolve, reject) => {
  resolveFn = resolve;
  rejectFn = reject;
});

// 之后:一行拿到三个工具
const { promise, resolve, reject } = Promise.withResolvers();

// 适合事件驱动场景
socket.on("data", resolve);
socket.on("close", () => reject(new Error("closed")));
await promise;

// Object.groupBy:按规则分组
const inventory = [
  { name: "苹果", type: "水果" },
  { name: "白菜", type: "蔬菜" }
];
const byType = Object.groupBy(inventory, item => item.type);
// { 水果: [苹果], 蔬菜: [白菜] }

// Array.fromAsync:异步遍历转数组
const files = await Array.fromAsync(readFiles());

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

这四年的特性支持基本可以按年份对齐:2021 年的特性需要 Node 15+,2022 年的需要 Node 16.6+(顶层 await 需要 14.8+ 的 ESM 环境),2023 年的需要 Node 20+,2024 年的需要 Node 21 或 22+。也就是说,Node 22 LTS(2024 年进入 LTS)可以覆盖本文全部特性;浏览器方面,2023 年后的 Chrome、Firefox、Safari 也基本全部支持。如果团队还停留在 Node 18,只有 2023/2024 年的部分特性不可用,可以考虑按需 polyfill 或规划升级。动手前也可以用一行 node -e 脚本检查 Array.prototype.findLast 之类的 API 是否存在,确认环境支持度。判断某个方法是否存在,用 typeof 检查最稳妥,例如 typeof arr.at === 'function'。浏览器兼容性可以对照 caniuse.com 的对应条目,对 2023 年后的设备,这些特性基本都已就绪。

特性版本Node.js 支持主要浏览器
replaceAllES202115+Chrome 85+
逻辑赋值运算符ES202115+Chrome 85+
class 私有字段ES202212+Chrome 74+
.at()ES202216.6+Chrome 92+
顶层 awaitES202214.8+(ESM)Chrome 89+
findLast / toSortedES202320+Chrome 110+
Promise.withResolversES202422+Chrome 119+
Object.groupByES202421+Chrome 117+

6. 值不值得用

这四年的特性大多属于"低风险高收益"的语法糖:replaceAll 和 .at() 日常必用,findLast 遇到就赚到,不可变数组方法值得在 React 项目里全面采用,Promise.withResolvers 能显著简化事件驱动代码。真正的取舍在于项目底线:如果必须支持 Node 18 或旧浏览器,优先使用 2021/2022 年的特性,2023/2024 年的按需 polyfill 或等升级。另外注意 Object.groupBy 返回的对象没有原型,直接用键访问没问题,但别依赖它上面的 Object 原型方法,需要时先转成普通对象或改用 Map.groupBy。总的来说,这四年的特性虽然单个体量小,却恰好覆盖了日常编码里最常遇到的痛点,属于"用上就回不去"的那一类。

💡 学习建议:把开发环境升级到 Node 22 LTS,然后打开项目里最常用的一个工具函数文件,用 replaceAll、.at()、findLast 和不可变方法各找一处可以替换的写法,感受新版语法带来的整洁度提升。