ECMAScript 2026 深度实战:JS 第 17 版 8 大新特性全拆解,从 Math.sumPrecise 到 JSON.rawJSON(附完整代码)
2026 年 6 月 30 日,ECMA International 正式批准了 ECMAScript 2026——JavaScript 语言规范的第 17 个版本。和过去几年一样,这一版没有惊天动地的语法大改,而是把一批"我们早就该有、却一直靠手搓或第三方库补"的能力,正式塞进了语言内核。
本文不堆砌特性清单,而是站在工程实战视角,把 8 个核心特性的原理、坑点、适用场景逐一拆开,配上可复制运行的代码,让你读完就能用、用上就回不去。
一、背景:为什么 ES2026 值得你停下手里的活
很多团队对 JS 新特性的态度是"等 Babel 转译、等 caniuse 变绿"。但过去三四年 TC39 的节奏明显变了:特性更务实、更聚焦"补齐标准库短板"而非"再造语法糖"。ES2024 的 Promise.withResolvers、Object.groupBy;ES2025 的 Iterator 辅助方法、RegExp.escape、Set 操作方法;到了 ES2026,方向更清晰——让 JS 内置对象能直接干掉一批"我们每周都在手写的小工具函数"。
ES2026 的核心价值可以总结成一句话:把高频、易错、但一直缺乏标准实现的"胶水代码",收编进语言本身。这意味着:
- 少引一个 lodash / 手写工具库;
- 少踩一个跨 Realm、精度、编码的坑;
- 库作者不用再为"同一件事 N 种实现"维护兼容层。
下面这张表先给你一个全局视角,后面逐条深挖。
| 特性 | 解决的老痛点 | 一句话定位 |
|---|---|---|
Math.sumPrecise | 浮点数求和精度丢失 | 精确求和,告别 0.1+0.2 !== 0.3 |
Iterator.concat | 多个迭代器无法原生拼接 | 迭代器世界的 concat |
Array.fromAsync | 异步数据源要手写 for await 收集 | 异步版 Array.from |
Error.isError | 跨 iframe/worker 的 instanceof 失灵 | 跨 Realm 稳健判定错误 |
Map/WeakMap.getOrInsert / getOrInsertComputed | 缓存模式又臭又长的 has/set | 缓存、计数一行闭环 |
Uint8Array 的 toBase64/fromBase64/toHex/fromHex | 二进制转码要 Buffer/atob 分平台 | 浏览器与 Node 统一 |
JSON.parse reviver 第三参数 | 解析时拿不到原始文本 | reviver 能读到"原始书写" |
JSON.rawJSON / JSON.isRawJSON | 大整数/日期被强制转成 Number | 把"原始值"原样塞进 JSON |
二、Math.sumPrecise:浮点求和精度噩梦终结者
2.1 先复习那个经典翻车现场
JavaScript 的 Number 是 IEEE-754 双精度浮点,大数和小数相加时精度会被吃掉。看这个例子:
// 经典翻车:大数配小数
const naive = 1e20 + 3 + 1 - 1e20;
console.log(naive); // 0 ❌ 我们期望的是 4
// 再来一个更贴近业务的:金额累计
const amounts = [0.1, 0.2, 0.3, 1e15, -1e15, 0.1];
let sum = 0;
for (const a of amounts) sum += a;
console.log(sum); // 0.1 ❌ 正确答案应该是 0.7
第二个例子尤其致命:金融对账里,一笔 0.1 + 0.2 + 0.3 的零钱,叠加一笔 1e15 的转账再冲账,结果你凭空少了 0.6。过去要么上 decimal.js,要么用 Kahan 补偿求和手搓算法——都是有成本的。
2.2 ES2026 的答案
// ES2026:精确求和
const precise = Math.sumPrecise([1e20, 3, 1, -1e20]);
console.log(precise); // 4 ✅
const amounts = [0.1, 0.2, 0.3, 1e15, -1e15, 0.1];
console.log(Math.sumPrecise(amounts)); // 0.7 ✅
Math.sumPrecise(iterable) 接收一个可迭代对象(数组、Set、生成器都行),内部使用类似 Neumaier / Klein 的**补偿求和(compensated summation)**算法:在累加主和的同时额外维护一个"被舍入掉的小尾巴",最后把尾巴加回去,从而把大数量级差异下的精度损失压到最低。
2.3 实战要点
// 1) 接受任意可迭代对象,不限于数组
const gen = function* () { yield 0.1; yield 0.2; yield 0.3; };
Math.sumPrecise(gen()); // 0.6 ✅
// 2) 空迭代 → 返回 0(符合数学直觉,不是 NaN/throw)
Math.sumPrecise([]); // 0
// 3) 非有限值会"污染"结果(和常规加法一致)
Math.sumPrecise([1, Infinity]); // Infinity
Math.sumPrecise([1, NaN]); // NaN
什么时候该用? 任何涉及"多笔金额/度量累计""科学计数下的求和对账""和第三方精度库(decimal.js/big.js)二选一"的场景。注意:它解决的是求和顺序导致的精度问题,不是"0.1 + 0.2 的显示误差"——后者是单笔运算本身的浮点表示问题,本就需要 toFixed 或定点数方案。
三、Iterator.concat:迭代器世界的"数组合并"
3.1 痛点
ES2024/2025 给迭代器对象加了一堆辅助方法(map/filter/take/drop/toArray 等),但"把多个迭代器顺序拼起来"一直缺原生能力。以前要么先 toArray() 再 concat(一次性吃掉内存),要么手写递归生成器。
3.2 ES2026 的答案
// 把多个迭代器/可迭代对象顺序拼接成一个
const it = Iterator.concat(
[1, 2, 3],
(function* () { yield* [4, 5]; })(),
new Set([6, 7]).values()
);
for (const v of it) console.log(v); // 1 2 3 4 5 6 7
Iterator.concat(...iterables) 是一个静态方法,返回一个标准的 Iterator 对象,所以它天然带有 map/filter/take/toArray 等辅助方法。
3.3 关键点:惰性求值
这是它和 Array.prototype.concat 最大的区别——拼接是惰性的,源头不会被一次性消费:
const big = Iterator.concat(
Array(1_000_000).keys(),
Array(1_000_000).keys()
);
// 只取前 3 个,后面 2,000,000 个元素根本没被拉取
console.log(big.take(3).toArray()); // [0, 1, 2]
3.4 实战:分批处理超大序列
// 拼接多个数据源,按窗口流式处理,内存恒定为 O(window)
function* chunk(it, size) {
let buf = [];
for (const v of it) {
buf.push(v);
if (buf.length === size) { yield buf; buf = []; }
}
if (buf.length) yield buf;
}
const all = Iterator.concat(
readIdsFromDB(), // 生成器:从数据库游标吐 id
readIdsFromCache(), // 生成器:从缓存吐 id
readIdsFromFile() // 生成器:从文件流吐 id
);
for (const batch of chunk(all, 500)) {
await syncToRemote(batch); // 每批 500 条,永不全量进内存
}
注意:Iterator.concat 处理的是同步迭代器。如果你的源头是异步的(async function*),对应的是 AsyncIterator.concat(异步迭代器对象上的同名静态方法),用法一致但返回的是异步迭代器,需要 for await 消费。
四、Array.fromAsync:异步数据原住民
4.1 痛点
Array.from 只能处理同步可迭代对象。遇到异步生成器、Promise 数组、fetch 流,你只能手写 for await 一个一个 push——这是每个 Node/前端开发者都写过的样板代码。
4.2 ES2026 的答案
// 1) 异步生成器
const data = await Array.fromAsync(async function* () {
for (let i = 1; i <= 3; i++) yield i * 10;
})();
console.log(data); // [10, 20, 30]
// 2) 带 mapFn(和 Array.from 一致)
const doubled = await Array.fromAsync(
(async function* () { yield 1; yield 2; yield 3; })(),
x => x * 2
);
console.log(doubled); // [2, 4, 6]
// 3) 直接接收「Promise 数组」
const settled = await Array.fromAsync([Promise.resolve(1), Promise.resolve(2)]);
console.log(settled); // [1, 2]
4.3 实战:并发分页抓取并归一化
async function* fetchPage(url) {
const data = await (await fetch(url)).json();
for (const row of data) yield row;
}
// 把多个分页接口的结果流,物化成统一的数组,并归一化字段
const allUsers = await Array.fromAsync(
(async function* () {
for (const page of [1, 2, 3]) {
yield* fetchPage(`https://api.example.com/users?page=${page}`);
}
})(),
row => ({ id: row.uid, name: row.fullName, fetchedAt: Date.now() })
);
console.log(`共拉取 ${allUsers.length} 个用户`);
Array.fromAsync 返回的是 Promise<Array>,所以它是 async 上下文里的"一等公民",配合 await 用起来比手写循环清爽太多。
五、Error.isError:跨 Realm 的错误判定
5.1 一个被忽视的坑
如果你的代码跑在多 iframe、Web Worker、或 Node 的 vm 模块里,x instanceof Error 会突然失灵——因为不同 Realm 有各自的 Error 构造器。
function isErrorOld(x) { return x instanceof Error; }
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const foreignErr = new iframe.contentWindow.Error('boom');
isErrorOld(foreignErr); // false ❌ 不同 Realm,构造器不是同一个
这种 bug 在"错误边界捕获""RPC 反序列化""沙箱通信"场景里极难排查。
5.2 ES2026 的答案
// 跨 Realm 稳健判定
Error.isError(foreignErr); // true ✅
Error.isError(new TypeError()); // true(Error 的子类也算)
Error.isError(new DOMException()); // true(DOMException 也是 Error 子类)
Error.isError({}); // false
Error.isError(null); // false
Error.isError('oops'); // false
Error.isError(x) 检查的是对象内部是否带有 [[ErrorData]] 内部槽,而不是"是不是某个特定构造器生的"——所以天然免疫 Realm 边界问题。
5.3 实战:统一的错误守卫
function toMessage(e) {
// 再也不用担心跨 Worker / iframe 时漏判
if (Error.isError(e)) return e.message;
if (typeof e === 'string') return e;
return String(e);
}
// 在 postMessage 的 onmessage 里、在 Worker 的错误处理里都安全
worker.onerror = (ev) => logger.error(toMessage(ev.error));
六、Map/WeakMap.getOrInsert & getOrInsertComputed:缓存模式一行闭环
6.1 痛点
"键不存在就初始化,存在就累加/复用"是最高频的模式之一,但老写法又长又容易写错:
const counter = new Map();
function bump(key) {
if (!counter.has(key)) counter.set(key, 0); // 两次查找
counter.set(key, counter.get(key) + 1); // 又两次查找
}
6.2 ES2026 的答案
// getOrInsert:键存在返回原值,不存在则插入指定值并返回
function bumpV2(key) {
counter.set(key, counter.getOrInsert(key, 0) + 1);
}
// getOrInsertComputed:键存在返回原值,不存在才调用回调计算(懒求值)
const cache = new Map();
function getConfig(name) {
return cache.getOrInsertComputed(name, (k) => {
console.log(`cache miss: ${k}`); // 只在 miss 时执行
return expensiveLoad(k);
});
}
getConfig('db'); // 打印 "cache miss: db",计算结果并存入
getConfig('db'); // 直接命中,回调不再执行 ✅
WeakMap 同样新增了 getOrInsert / getOrInsertComputed,这对"给对象贴元数据"的场景非常有用:
const meta = new WeakMap();
function visitCount(obj) {
const m = meta.getOrInsert(obj, () => ({ visits: 0 }));
m.visits++;
return m.visits;
}
const user = {};
visitCount(user); // 1
visitCount(user); // 2(对象回收时 meta 自动释放,无内存泄漏)
6.3 为什么这两个方法值得专门记
- 原子语义:
getOrInsertComputed保证回调只在"确实缺失"时执行一次,避免并发初始化重复计算的竞态(在单线程 JS 里更多是避免重复 I/O/计算)。 - 可读性:把"查—判—插—取"四步合成一步,代码即意图。
- 性能:内部只做一次哈希查找,比手写
has + get + set省掉重复查找。
七、Uint8Array 的 Base64/Hex 互转:浏览器与 Node 终于统一
7.1 痛点
在浏览器里做 Base64 要 btoa(String.fromCharCode(...)),遇到二进制还得绕 Uint8Array;在 Node 里是 Buffer.from(bytes).toString('base64')。同一段逻辑要写两份,而且浏览器的 btoa 对超过 Number 范围的字节还会炸。ES2026 把这套能力直接装到了 Uint8Array 上。
7.2 ES2026 的答案
const bytes = new Uint8Array([1, 2, 3, 250]);
// → Base64
const b64 = bytes.toBase64(); // "AQID+g=="
Uint8Array.fromBase64(b64); // Uint8Array(4) [1, 2, 3, 250]
// → URL-safe Base64(无需再手动把 +/ 换成 -_)
bytes.toBase64({ alphabet: 'base64url' }); // "AQID-g"
// → Hex
const hex = bytes.toHex(); // "010203fa"
Uint8Array.fromHex(hex); // Uint8Array(4) [1, 2, 3, 250]
7.3 实战要点与坑
// 1) 大文件分块转 Base64:用 lastChunkHandling 控制末块行为
const bigFile = new Uint8Array(10_000_000);
const encoded = bigFile.toBase64({
alphabet: 'base64',
lastChunkHandling: 'stop-before-partial', // 末尾不足 3 字节的部分先停,避免静默丢数据
});
// 2) 反向解析带校验:base64url 也能直接读
const recovered = Uint8Array.fromBase64('AQID-g', { alphabet: 'base64url' });
console.log([...recovered]); // [1, 2, 3, 250]
// 3) 跨平台统一写法(浏览器/Node 不再分家)
function sha256Hex(input) {
const bytes = typeof input === 'string'
? new TextEncoder().encode(input)
: input;
return crypto.subtle.digest('SHA-256', bytes).then(
(h) => new Uint8Array(h).toHex()
);
}
lastChunkHandling 三个取值:
'loose':宽松,允许末尾不完整的填充(默认);'strict':严格,遇到非法填充抛错;'stop-before-partial':遇到不足一块的末段先停下,适合流式场景。
这套 API 最大的意义是消除 Buffer vs btoa 的分叉——同一份代码在浏览器和 Node、Deno、Bun 里行为一致。
八、JSON.parse reviver 第三参数:拿回原始文本
8.1 痛点
JSON.parse 的 reviver 只能拿到"已经被解析成 JS 值"的结果,丢掉了原始书写文本。这导致两件事做不了:① 大整数被转成 Number 后再也拿不回精确值;② 想"按原始格式保留字段",只能在外面另存一份原始字符串。
8.2 ES2026 的答案
ES2026 给 reviver 增加第三个参数:该属性值在 JSON 源中的原始文本。
const raw = JSON.parse(
'{"price": 19.90, "big": 9007199254740993}',
(key, value, source) => {
if (key === 'big') {
console.log('原始文本:', source); // "9007199254740993"
return source; // 返回字符串,精确保留,不被截断成 Number
}
return value;
}
);
console.log(raw.big); // "9007199254740993"(字符串,精确)
console.log(typeof raw.big); // "string"
8.3 实战:保留原始字面量
// 把 JSON 里的"日期字符串""大整数"按原始文本留档,避免二次解析失真
function preserveRaw(obj) {
return JSON.parse(JSON.stringify(obj), (k, v, src) => {
if (typeof v === 'number' && !Number.isSafeInteger(v)) {
return { __raw__: src }; // 不安全整数,用原始文本包一层
}
return v;
});
}
注意 source 永远是字符串,即该值在 JSON 文本里的原始写法(包括引号、指数记法等)。它是"原始书写",不是"反序列化后的值"。
九、JSON.rawJSON:把"原始值"原样塞进 JSON
9.1 痛点
JSON.stringify 会把一切 JS 值按规则序列化:数字会变成 Number,字符串必带引号。但有些场景你不想被规则处理——比如嵌入一个超出 Number 安全范围的大整数、一个已经是合法 JSON 片段的表达式、或者一个需要"不带引号"的日期字面量。
9.2 ES2026 的答案
JSON.rawJSON(string) 创建一个"原始 JSON 包装对象":JSON.stringify 遇到它时,把里面的字符串原样吐出,不加引号、不做转义。
// 1) 精确嵌入大整数(不被 Number 截断)
const payload = {
id: JSON.rawJSON('9007199254740993'),
name: 'Alice',
};
console.log(JSON.stringify(payload));
// {"id":9007199254740993,"name":"Alice"} ← id 不带引号、完整保留
// 2) 嵌入一个合规的日期字面量
const doc = {
createdAt: JSON.rawJSON('"2026-07-10T00:00:00Z"'),
};
console.log(JSON.stringify(doc));
// {"createdAt":"2026-07-10T00:00:00Z"}
// 3) 判定是不是 rawJSON
JSON.isRawJSON(payload.id); // true
9.3 实战要点
// rawJSON 的入参是「字符串」,输出时原样嵌入
JSON.stringify({ a: JSON.rawJSON(123) });
// 等价于 JSON.rawJSON(String(123)) → {"a":123}
// 可以用它组装"已经序列化的子文档",避免二次 stringify 破坏结构
const partial = JSON.rawJSON('{"k1":1,"k2":[2,3]}');
JSON.stringify({ wrap: partial });
// {"wrap":{"k1":1,"k2":[2,3]}}
// 嵌套与数组同样生效
JSON.stringify([JSON.rawJSON('null'), JSON.rawJSON('1e100')]);
// [null,1e100]
一句话记忆:JSON.rawJSON 给的是"我已经是合法 JSON 了,你别动我"的特权。它特别适合数据管道、与强类型后端对齐、保留大整数/高精度数值的场景——过去这类需求要靠手写字符串拼接或 JSON.parse 后 Object.defineProperty 上 toJSON 黑魔法,现在有了标准出路。
十、组合实战:用新特性搭一条流式 ETL 管道
把上面几个特性串起来,看看它们如何自然协作。场景:从多个分页接口拉用户数据,做校验/过滤/归一化,再分批写入下游。
// 多源异步归并:用 Array.fromAsync + 生成器 + mapFn
async function* fetchPage(url) {
const res = await fetch(url);
const data = await res.json();
for (const row of data) yield row;
}
async function* mergeSources(...gens) {
for (const g of gens) {
for await (const item of g()) yield item;
}
}
// 校验 + 过滤:用 Iterator 辅助方法处理同步后的序列
function validate(row) {
return row && typeof row.uid === 'number' && !!row.fullName;
}
async function etl() {
// 1) 流式拉取并归一化,Array.fromAsync 一次性物化结果数组
const rows = await Array.fromAsync(
mergeSources(
() => fetchPage('/api/users?page=1'),
() => fetchPage('/api/users?page=2')
),
(row) => ({ id: row.uid, name: row.fullName, ts: Date.now() })
);
// 2) 用 Iterator 辅助方法做惰性过滤
const clean = Iterator.from(rows)
.filter(validate)
.map(r => ({ ...r, name: r.name.trim() }));
// 3) 分批写下游,内存恒定
const out = [];
for (const batch of chunk(clean, 200)) {
out.push(await writeBatch(batch));
}
return out;
}
// 缓存重算逻辑,用 getOrInsertComputed 防止重复 I/O
const schemaCache = new Map();
function getSchema(table) {
return schemaCache.getOrInsertComputed(table, (t) => loadSchemaFromDB(t));
}
这条管道里:Array.fromAsync 解决异步收集,Iterator 辅助方法解决惰性过滤,getOrInsertComputed 解决缓存——每一处都是 ES2026(及近几版)刚收编进语言的能力,零第三方依赖。
十一、兼容性与工程落地
11.1 运行时支持
ES2026 于 2026-06-30 获批,主流 JS 引擎(V8 / SpiderMonkey / JavaScriptCore)在获批前后的稳定版本中已陆续默认开启这些特性。具体落地版本请以 caniuse 和你目标运行时的 release notes 为准(Node.js 的最新 LTS、Chrome / Edge、Firefox、Safari 的最新稳定版均已覆盖大部分特性)。
11.2 生产环境兜底
如果你的目标环境还较老,两条路:
// 方案 A:core-js 按需 polyfill(推荐,只补用到的特性)
import 'core-js/proposals/math-sum-precise';
import 'core-js/proposals/array-from-async';
import 'core-js/proposals/iterator-helpers'; // Iterator.concat 等
// 方案 B:交给构建链 downlevel
// Babel: @babel/preset-env(targets 配好后会自动注入对应 transform)
// TypeScript: target 设低版本 + 开启相应 lib
11.3 几个落地建议
- 先评估再引入:
Math.sumPrecise、getOrInsert这类"替代手搓工具函数"的特性,收益最高、风险最低,建议优先用。 Iterator.concatvsAsyncIterator.concat:同步/异步别混用,异步源用异步版。JSON.rawJSON谨慎对外:它绕过了stringify的转义,务必确保传入的是可信且合法的 JSON 文本,否则会产出非法 JSON。- 测试覆盖跨 Realm:
Error.isError的收益要在 iframe / Worker 场景里专门写测试验证。
十二、总结与展望
ES2026 延续了 TC39 这几年的务实路线:不追求语法革命,而是把开发者每天手搓的"标准库补丁"收编进语言。对一线工程师来说,这一版最实在的三件事是:
- 数值与精度:
Math.sumPrecise让金额/度量累计不再依赖第三方库;JSON.rawJSON+ reviver 第三参数让大整数、日期的精确往返有了标准方案。 - 迭代与异步:
Iterator.concat/Array.fromAsync把"拼接、收集异步流"从样板代码变成一行调用,配合近几版的Iterator辅助方法,函数式数据处理彻底原生化了。 - 健壮性与一致性:
Error.isError堵上跨 Realm 的漏判,Uint8Array的 Base64/Hex 互转抹平浏览器与 Node 的分叉,Map/WeakMap.getOrInsert让缓存/计数模式干净利落。
可以预见,随着这些特性进入基线,lodash 里那批"小工具函数"会进一步被语言原生替代,前端/Node 代码会更瘦、更一致、更不容易在边界条件下翻车。
建议你现在就做一件事:打开你的项目,全局搜一下 instanceof Error、btoa、for await 收集、has(key) ? get : set 这几类模式——每一个命中,都是 ES2026 能立刻帮你删掉的"技术债"。
参考:ECMA International 于 2026-06-30 批准 ECMAScript 2026(第 17 版);特性细节以官方规范与各引擎 release notes 为准。