看 ES2026 和 ESNext 新特性时,我更关心它什么时候能进业务代码

JavaScript 每年都会继续演进。

到 2026 年再看新标准,最重要的不是把所有新 API 都塞进“ES2026”这个篮子里,而是先分清它们到底处在哪个阶段:哪些已经进了 ES2025,哪些属于 ES2026,哪些只是 ESNext 或后续年度值得关注的方向。

但我现在看新标准,不会只问“这个语法酷不酷”,而是先问:它什么时候能进业务代码,进了以后是不是真的让项目更好维护。

这几年踩过的坑让我对“新特性”这件事变得越来越冷静。早些年我特别爱在项目里第一时间用上刚定稿的语法,结果有一次用了可选链 ?. 配合老版本 UglifyJS,构建直接报 parse error,半夜回滚了一版本才发现是压缩工具没跟上。从那以后我看 TC39 的提案,第一反应不是“真香”,而是“这玩意儿在我这条工具链上能不能活下来”。

这篇里我会把时间线拆开说:Iterator Helpers、Set 集合运算、Promise.tryRegExp.escape 更像是 ES2025 已经落地的成果;Array.fromAsyncError.isError 属于 ES2026 这一轮可以重点看的补丁;Explicit Resource Management(using / await using)和 Temporal 则更适合先放在 ESNext / 后续年度的观察区,不要写成 ES2026 已经稳定可用。

新特性不要急着全用

看到新标准时,最容易犯的错是马上在业务代码里到处使用。

但实际项目要先考虑:

  • 浏览器是否支持
  • 构建工具是否能转译
  • 团队是否理解
  • 代码可读性是否真的变好

新语法是工具,不是炫技材料。

我一般会把新特性分成三类:

1可以关注:还在提案或生态讨论阶段,先理解方向
2可以试用:构建工具能稳定处理,适合内部工具或低风险页面
3可以推广:主流运行环境支持,团队也理解它的语义和边界

很多新能力刚出现时,只适合第一类。不要因为技术文章写得热闹,就直接放进核心业务。

我有个简单的判断习惯:先去 caniuse 和 MDN 的兼容性表查一眼,再去看自己用的 SWC / esbuild / Babel 的 release notes 里有没有这条 transform。如果一个特性需要运行时原生支持(比如 TemporalusingSymbol.dispose),但目标环境覆盖不到,那它对我来说就只能停在“可以关注”那一档。

举个真实例子。Iterator Helpers 我很想用,因为它能把链式数据处理写得很顺:

1// 想要:从一个超大日志迭代器里取前 100 条 error,惰性求值,不构建中间数组
2const firstErrors = logs
3  .values()
4  .filter(line => line.level === 'error')
5  .map(line => line.message)
6  .take(100)
7  .toArray();

想自己验证这套链式方法是否可用,可以跑一个最小例子(Iterator Helpers 已进入 Stage 4、ES2025,Node 22+ / Chrome 122+ 原生支持;老环境需 core-js polyfill):

1[1, 2, 3, 4].values().map((x) => x * 2).take(2).toArray();
2// [2, 4]

注意 .values() 先把数组变成迭代器,后面的 .map / .take 才是挂在 Iterator.prototype 上的惰性方法——直接对数组调 .take 是没有的。

这写法的好处是惰性——take(100) 满了就不再往下消费,省掉了 Array.from(logs).filter(...).slice(...) 那种先把整个数组物化再切片的浪费。但我在一个还要兼容 iOS 15 Safari 的项目里就没敢直接上,因为这些原型方法是挂在 Iterator.prototype 上的运行时能力,Babel 转译不了语法只能靠 core-js polyfill,而 polyfill 整个 Iterator helper 体积不小。最后我退回到 core-js 按需引入 + 只在内部后台用,公共 SDK 里依然手写循环。这就是“分三档”落到实处的样子。

不可变数据越来越重要

现代前端里,不可变数据已经很常见。

React 状态更新、时间旅行调试、缓存比较、数据快照,都更喜欢不可变思路。

如果标准层面继续补充不可变相关能力,前端代码就能减少一些手写复制和第三方工具依赖。

1const next = [...list, newItem];

这类写法背后的目标是:不要直接改旧数据,而是生成新数据。

我更关心的是它能不能减少“看不见的原地修改”。

在复杂中后台里,很多 bug 都来自状态被悄悄改了:

1const next = config;
2next.rules[0].enabled = false;
3setConfig(next);

这段代码表面上更新了配置,实际引用没变,React 可能不更新,缓存比较也可能失效。不可变数据相关的新能力如果能让复制和更新更清楚,就有实际价值。

这里我特别想提一下 ES2023 就进来、但到现在很多人还没用顺的几个“非破坏性数组方法”:toSortedtoReversedtoSplicedwith。它们的意义就是把原本会原地改数组的操作,变成返回新数组:

1// 旧写法:sort 会原地改 list,直接踩 React 的引用比较
2const sorted = list.sort((a, b) => a.order - b.order); // sorted === list,坑
3
4// 新写法:toSorted 返回新数组,原数组不动
5const sorted = list.toSorted((a, b) => a.order - b.order);
6
7// 改单个元素也不用展开拼接了
8const next = list.with(2, { ...list[2], enabled: false });

我在 code review 里见过太多次 arr.sort() 之后还以为原数组没变的 bug,尤其是 sort 默认按字符串排序把数字 [1, 2, 10] 排成 [1, 10, 2] 那种二连坑。toSorted 把“不可变”这件事变成默认姿势,是我现在会主动推广的特性——因为它确实降低出错概率,而不是单纯让代码短。

至于深拷贝,现在我基本用原生 structuredClone,能处理循环引用、Map、Set、Date、TypedArray,比 JSON.parse(JSON.stringify(x)) 靠谱太多(后者会丢 undefined、把 Date 变字符串、遇到 function 直接报错或丢字段):

1const snapshot = structuredClone(config); // 深拷贝快照,留作时间旅行调试

不过,不可变也不是无脑深拷贝。配置很大时,复制成本也要考虑——structuredClone 一个几 MB 的大对象在低端机上是会卡帧的。真正重要的是清楚表达“哪里变了”,做局部的浅复制,而不是每次都把整棵对象复制一遍。这也是为什么 Immer 那种“结构共享 + 写时复制”的思路一直有市场:它只复制改动路径上的节点,其余引用照旧共享。

资源管理让清理更明确

前端代码里也有很多需要清理的资源:

  • 定时器
  • 事件监听
  • WebSocket
  • 文件句柄
  • 流式连接

如果语言层面提供更清晰的资源管理能力,就能减少“创建了但忘记清理”的问题。

这对复杂页面、长连接和富交互应用很有意义。

我在项目里最常见的资源泄漏,通常不是很底层的文件句柄,而是这些:

  • 页面卸载后定时器还在跑
  • 弹窗关闭后事件监听没移除
  • 切换会话后旧 SSE 连接还在推数据
  • WebSocket 重连后旧连接没断干净
  • 大文件预览的 object URL 没释放

所以看资源管理新能力时,我会先想它能不能让“创建和清理”离得更近。

即使暂时不用新语法,也可以先养成这种结构:

1function createChatStream() {
2  const controller = new AbortController();
3
4  return {
5    signal: controller.signal,
6    dispose() {
7      controller.abort();
8    }
9  };
10}

核心思想是一样的:资源不是创建完就结束了,清理路径也要成为设计的一部分。

Explicit Resource Management 的 using 声明就是把这套思路语法化了。这里要把年份说准:它值得关注,但不要把它当作 ES2026 已经稳定落地的业务默认能力。它依赖对象实现 Symbol.dispose(同步)或 Symbol.asyncDispose(异步),变量离开作用域时自动调用清理,行为有点像 C# 的 using 或 Python 的 with

1function openChannel(url) {
2  const ws = new WebSocket(url);
3  return {
4    ws,
5    [Symbol.dispose]() {
6      ws.close();
7      console.log('channel disposed');
8    }
9  };
10}
11
12{
13  using channel = openChannel('wss://example.com');
14  channel.ws.send('hello');
15  // 块结束时自动调用 Symbol.dispose,不用记着手动 close
16}

异步版本配合 await using,对我那种“切换会话后旧 SSE 连接还在推数据”的场景特别对症:

1async function streamChat(query) {
2  await using stream = createChatStream(query);
3  for await (const chunk of stream.read()) {
4    render(chunk);
5  }
6  // 函数退出(正常或抛错)都会 await stream[Symbol.asyncDispose]()
7}
8
9function createChatStream(query) {
10  const controller = new AbortController();
11  // ...发起 fetch + ReadableStream
12  return {
13    read() { /* async generator */ },
14    async [Symbol.asyncDispose]() {
15      controller.abort();
16    }
17  };
18}

最打动我的一点是它的“异常安全”:哪怕中间 throw 了,dispose 也会被调用,等价于自动帮你写了 try/finally。过去我清理逻辑漏掉的,十有八九都是 early return 或抛异常那条没覆盖到的分支。多个 using 还会按声明的逆序释放,跟手写嵌套 finally 的顺序一致,这点设计得很克制。

配套还有一个 DisposableStack / AsyncDisposableStack,可以把一批要清理的东西集中登记,适合“一个组件挂载时建了好几样资源”的情况:

1function setupWidget(el) {
2  const stack = new DisposableStack();
3  const timer = setInterval(tick, 1000);
4  stack.defer(() => clearInterval(timer));
5
6  const onResize = () => layout(el);
7  window.addEventListener('resize', onResize);
8  stack.defer(() => window.removeEventListener('resize', onResize));
9
10  return stack; // 调用方拿到 stack,dispose 时一次性全清掉
11}

当然,这东西要真正用上,得运行时支持 Symbol.dispose / Symbol.asyncDispose,或者依赖 TypeScript 5.2+ 的 downlevel 编译。我目前的态度还是:内部工具、Node 脚本里可以小范围试,面向老浏览器的线上代码先观望,但写法结构已经可以提前对齐——把 dispose 的责任显式化,比语法本身更重要。

那些不起眼但天天能用的小补丁

比起大特性,我反而更喜欢这一波里几个“补缺口”的小东西,因为它们落地成本低、踩坑收益高。

Set 的集合运算终于进标准了。以前求两个集合的交集差集,要么转数组 filter,要么手写循环,现在直接:

1const a = new Set([1, 2, 3, 4]);
2const b = new Set([3, 4, 5]);
3
4a.intersection(b); // Set { 3, 4 }
5a.union(b);        // Set { 1, 2, 3, 4, 5 }
6a.difference(b);   // Set { 1, 2 }
7a.isSubsetOf(b);   // false

这几个方法返回的都是新 Set,原集合不动。它们属于 ES2025 已经落地的能力,不应该再算作 ES2026 新特性;老环境仍需 core-js polyfill。

做权限标签、做“选中项 vs 全量”的 diff,这几个方法能省掉一大坨可读性很差的 filter 嵌套。

Promise.try 也是 ES2025 这一批里很实用的小补丁,用来统一同步/异步函数的错误通道。过去一个回调可能同步抛错、也可能返回 rejected promise,调用方得写两套处理;现在一律包进 promise 链:

1// fn 可能同步 throw,也可能返回 Promise
2Promise.try(fn)
3  .then(handleResult)
4  .catch(handleError); // 同步抛的错也会走到这里

Error.isError 更接近 ES2026 这轮要看的补洞能力,它解决一个老问题:instanceof Error 跨 iframe / 跨 realm(比如 Node 的 vm、worker 之间传过来的对象)会失效,因为 Error 构造函数不是同一个。Error.isError(x) 是按内部标记判断的,更可靠:

1try {
2  doWork();
3} catch (e) {
4  if (Error.isError(e)) console.error(e.stack);
5  else console.error('非 Error 抛出物:', e); // 有人 throw 字符串
6}

RegExp.escape 也已经是 ES2025 的成果,它终于让“把用户输入安全地拼进正则”这件事有了官方答案,再也不用从 Stack Overflow 抄那段 replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 了:

1const keyword = userInput; // 可能含有 . * ( ) 等正则元字符
2const re = new RegExp(RegExp.escape(keyword), 'gi');
3text.replace(re, '<mark>$&</mark>');

还有 Array.fromAsync,这是 ES2026 更值得单独看的一个 API:对着异步可迭代对象一把收集成数组,配合分页接口或流式数据很顺手:

1const all = await Array.fromAsync(paginate('/api/items'));

这些东西没什么“技术含量”可炫,但恰恰是它们让日常代码少了一堆容易出错的样板。我推广新特性时,往往是从这类小补丁开始的——团队接受度高,因为它解决的是大家都被烦过的具体问题。

兼容和转译要提前查清楚

JavaScript 新特性落地时,最容易低估的是工具链。

有些语法可以通过 Babel 或 SWC 转译,有些能力依赖运行时原生支持,有些需要 polyfill,有些根本不适合 polyfill。它们的落地成本不一样。

我会先问:

  • 目标浏览器和 Node 版本是否支持
  • 构建工具能不能稳定处理
  • 是否需要 polyfill
  • polyfill 体积是否值得
  • 调试后的代码是否还容易理解
  • 团队代码规范是否允许使用

如果这些问题答不清楚,我会先把新特性限制在内部工具或低风险模块,而不是直接写进公共组件库。

这里要分清两件事:语法特性运行时 API。语法特性(比如 using 声明、装饰器)能靠 Babel/SWC/TS 把它降级成老语法,编译期就解决了;运行时 API(比如 Set.prototype.intersectionArray.fromAsyncIterator.prototype.map)只能靠 polyfill 在运行时补,转译器帮不上忙。我吃过的亏就是把这两类混为一谈——以为 @babel/preset-env 一配就万事大吉,结果 Array.fromAsync 在线上老机器上 undefined is not a function 直接白屏。

我现在配 Babel 一定把 useBuiltIns: 'usage' 加上 corejs 版本写准,让它按实际用到的 API 自动注入 polyfill:

1// babel.config.js
2module.exports = {
3  presets: [
4    ['@babel/preset-env', {
5      useBuiltIns: 'usage',
6      corejs: { version: '3.37', proposals: true }, // 只在确实要试 proposal 时打开
7      targets: { browsers: ['> 0.5%', 'last 2 versions', 'not dead'] }
8    }]
9  ]
10};

proposals: true 这行不要无脑开。已经进标准的 API,优先按目标环境和 core-js 版本确认;还在 proposal 通道里的能力,才需要把这道门打开。进生产前还要用真机或者 BrowserStack 验一遍,devtools 里跑得好不代表用户的旧设备跑得好。

学新特性时看使用场景

判断一个新特性是否值得用,可以问三个问题:

  1. 它能不能减少重复代码
  2. 它能不能降低出错概率
  3. 它能不能让意图更清楚

如果答案都是否定的,就没必要为了新而新。

Temporal 来说,它是我最期待、但也最克制不直接全量推的一个。它不该被写成 ES2026 已稳定落地的能力,但非常适合拿来说明“什么样的新 API 值得持续跟”。前端处理日期一直是重灾区:Date 的月份从 0 开始、时区永远是本地时区、new Date('2026-04-24 10:00:00') 这类不带 T 分隔符的日期时间字符串在不同浏览器里解析结果不一致(Chrome/Node 按本地时间,旧版 Safari 按 UTC 甚至直接 Invalid Date)。Temporal 把这些都重做了一遍,时区、日历、时间段都是显式的:

1// 不会再有“月份从 0 开始”这种反人类设计
2const date = Temporal.PlainDate.from('2026-04-24');
3date.add({ days: 7 }).toString(); // '2026-05-01'
4
5// 时区是显式的,不再默默用本地时区
6const meeting = Temporal.ZonedDateTime.from(
7  '2026-04-24T10:00:00[Asia/Shanghai]'
8);
9meeting.withTimeZone('America/New_York').toString();

Temporal 是个体积不小的运行时 API,polyfill(@js-temporal/polyfill)压缩后也有几十 KB。所以我现在的做法是:新项目里凡是涉及跨时区、排班、计费周期这种“日期逻辑复杂”的模块才引入它;只是格式化展示一个时间戳,老老实实用 Intl.DateTimeFormat 就够了,没必要为了用新 API 把包体撑大。判断标准还是那三条——能不能减少出错、能不能让意图更清楚、值不值这个成本。

我自己的一条经验:评估一个特性时,最好找一段现有的、被它困扰过的真实代码来做对照。把旧写法和新写法并排放一起,diff 出来如果是“更短 + 更不容易错 + 团队一眼能懂”,那就值得推;如果只是“更短但要查文档才懂”,那就先放进内部项目练手,别急着写进所有人都要维护的核心库。

还有一个问题也很重要:它会不会让团队里大多数人读起来更轻松。

新语法有时候能减少代码,但如果团队需要停下来查语义,维护成本未必下降。真正值得推广的特性,应该是用几次之后大家都觉得“这样表达更自然”,而不是只有写的人觉得高级。

ES2026 值得关注,ESNext 也值得持续看,但都不需要盲目追。

这一轮标准演进让我留意的,是三条平时容易被当成"团队纪律"来维护的东西,正在一点点变成语言自己就管的事:using 这类提案把资源清理从"记得要 close"变成块结束自动触发;toSorted / with 这类方法让不可变成了调用一个方法就有的默认结果,不用再靠 code review 盯着谁又原地改了数组;Set 运算、Array.fromAsync 这批小补丁把以前团队里手写、各自实现细节还不一样的样板,收进了标准库统一的写法。

落地节奏上,我现在的做法是先在内部脚本和后台工具里试、攒够踩坑经验,再写进团队的 ESLint 规则或脚手架模板让新写法变成默认,最后才轮到面向外部用户的核心代码——每一档都留足验证时间。ES2025 那批已经稳定的方法我现在会主动推广,ES2026 和 ESNext 这批还在观察区的,我情愿多等一个版本的兼容表更新,也不想让一个没查清 polyfill 体积的新特性在线上半夜给我打电话。