编程 ECMAScript 2026 深度实战:JS 第 17 版 8 大新特性全拆解,从 Math.sumPrecise 到 JSON.rawJSON(附完整代码)

2026-07-10 03:42:57 +0800 CST views 15

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.withResolversObject.groupBy;ES2025 的 Iterator 辅助方法、RegExp.escapeSet 操作方法;到了 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缓存、计数一行闭环
Uint8ArraytoBase64/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.parseObject.definePropertytoJSON 黑魔法,现在有了标准出路。


十、组合实战:用新特性搭一条流式 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 几个落地建议

  1. 先评估再引入Math.sumPrecisegetOrInsert 这类"替代手搓工具函数"的特性,收益最高、风险最低,建议优先用。
  2. Iterator.concat vs AsyncIterator.concat:同步/异步别混用,异步源用异步版。
  3. JSON.rawJSON 谨慎对外:它绕过了 stringify 的转义,务必确保传入的是可信且合法的 JSON 文本,否则会产出非法 JSON。
  4. 测试覆盖跨 RealmError.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 Errorbtoafor await 收集、has(key) ? get : set 这几类模式——每一个命中,都是 ES2026 能立刻帮你删掉的"技术债"。

参考:ECMA International 于 2026-06-30 批准 ECMAScript 2026(第 17 版);特性细节以官方规范与各引擎 release notes 为准。

推荐文章

PHP设计模式:单例模式
2024-11-18 18:31:43 +0800 CST
Go 开发中的热加载指南
2024-11-18 23:01:27 +0800 CST
paint-board:趣味性艺术画板
2024-11-19 07:43:41 +0800 CST
程序员茄子在线接单