Skip to content

语言演进:从 ES6 基础到当前标准能力 ​

语言按版本发布,学习却应该按知识关系展开。ES6 的解构、模块与 Promise 已经是前文的基础工具,不能把它们统一放在“高级语法”里。这里将版本信息放回实际问题:新能力替换了哪些重复操作,保留了哪些责任,又要求怎样的运行环境。

标准发布、提案完成和引擎支持是三条记录 ​

截至 2026-09-15,Ecma 已发布 ECMAScript 2026,第 17 版。TC39 的 Stage 4 表示提案完成,但具体归入哪次年度出版仍要单独看;例如 Temporal 和显式资源管理在当前完成列表中预期归入 2027。引擎可能提前实现:本篇使用的 Node 26.0.0 已原生提供 Temporal 与 using,不需要实验开关。

因此“规范草案里能找到”“年度标准已经发布”“目标浏览器能运行”不能互相代替。本篇不是全部新增条目的目录,而是对集合、序列和数据边界中的几项能力展开实验。阅读后面的新能力时,要分别记录它属于哪一年度出版、是否已完成标准流程,以及例子在哪个引擎版本运行。

ES6+ 的知识已经在哪里展开 ​

声明、解构、参数和箭头函数在第一、二章;类、Symbol、Proxy 和 Reflect 在第四章;Promise 与 async/await 在第五章;迭代器、生成器和异步迭代在第六章;ESM 与动态导入在第七章。它们跨越多个年度版本,本书没有把它们都误称为 ES6。

回访时按变化检查语义:把 var 改为 let 会改变块作用域和循环绑定;把普通函数改为箭头函数会改变 this;把原型方法迁移为 class 方法会改变可枚举性;把数组迭代改为 Iterator helpers 会改变求值时机。前面的同名专题解释这些规则,旧项目维护则把它们放回共享状态和模块迁移。

较近的复制数组、分组、Set 操作、Promise.try 与迭代器辅助方法,分别见第三章和现代协议。时间建模单独读 Temporal。这里从已经掌握的 Map、JSON、迭代与二进制继续。

先准备能真正运行的环境 ​

以下每个代码块独立保存为 .mjs,在 Node 26.0.0 下执行;使用版本管理器时选择该版本,先运行 node --version 确认。不要把网站自身依赖升级当作运行教材的前提。

js
console.log(process.version)
for (const [name, value] of [
  ['Map.getOrInsertComputed', Map.prototype.getOrInsertComputed],
  ['Iterator.concat', Iterator.concat],
  ['Uint8Array.fromBase64', Uint8Array.fromBase64],
  ['Error.isError', Error.isError]
]) {
  if (typeof value !== 'function') throw new Error(`缺少运行能力:${name}`)
}

这个检查缺少能力就明确失败,不把跳过正文算作完成。Node 22 的既有基础例子仍可保留;新能力在独立环境学习,部署时再核对实际目标。

Map:不存在与值为 undefined 必须区分 ​

一个缓存可以把 undefined 保存为有效结果。若使用 cache.get(key) ?? create(),每次读取该项都会误以为缺失。先用已经熟悉的写法表达正确规则:

js
function getOrCreate(cache, key, create) {
  if (cache.has(key)) return cache.get(key)
  const value = create(key)
  cache.set(key, value)
  return value
}
const cache = new Map([['empty', undefined]])
let calls = 0
console.log(getOrCreate(cache, 'empty', () => ++calls)) // undefined
console.log(calls) // 0

判断的是键是否存在,随后才取得值。getOrInsertComputed 把这个常用流程直接表达出来:

js
const cache = new Map([['empty', undefined]])
let calls = 0
const create = key => { calls++; return { key } }
console.log(cache.getOrInsertComputed('empty', create)) // undefined
const first = cache.getOrInsertComputed('new', create)
const second = cache.getOrInsertComputed('new', create)
console.log(first === second, calls) // true 1
const failed = new Error('初始化失败')
try { cache.getOrInsertComputed('bad', () => { throw failed }) }
catch (error) { console.log(error === failed, cache.has('bad')) } // true false

回调仅在键缺失时执行;返回的对象就是存入 Map 的对象,不会复制。回调自己抛错时,本次默认值没有插入,因此后续可重试。若回调自己修改同一 Map,其副作用不会回滚,前面的简化函数也不应被当成覆盖所有可重入情况的 polyfill。

getOrInsert(key, value) 则直接接收默认值。写 getOrInsert(key, expensive()) 时,expensive 在调用方法之前就执行,即使键已经存在;延后创建要使用 Computed 形式。回调返回 Promise 时存入的是 Promise,也不会自动在拒绝后删除;异步缓存失效仍是调用方责任。

JSON:恢复整数需要原文,而不是已经舍入的数字 ​

第三章已经说明 Number 的安全整数边界。下面原文含有一个超出精确表示范围的 ID:

js
const text = '{"id":9007199254740993,"name":"笔记"}'
const ordinary = JSON.parse(text)
console.log(ordinary.id) // 9007199254740992
console.log(BigInt(ordinary.id)) // 9007199254740992n

精度在 JSON 数字转为 Number 时已经丢失。事后转换成 BigInt,只会把错误数值精确保存。新的 reviver 上下文为原始值提供 source,允许按接口契约使用原文:

js
const text = '{"id":9007199254740993,"name":"笔记"}'
const record = JSON.parse(text, (key, value, context) => {
  if (key === 'id') {
    if (typeof value !== 'number' || !/^-?\d+$/.test(context.source)) {
      throw new TypeError('id 必须是十进制整数字面量')
    }
    return BigInt(context.source)
  }
  return value
})
console.log(record.id, record.name) // 9007199254740993n 笔记

本例约定所有名为 id 的字段都使用整数字面量;复杂结构应该进一步按 schema 校验。context.source 用于原始值,不是整棵对象的原始文本。保留其他字段时必须返回 value,否则 reviver 返回 undefined 会删除相应属性。

把 id 改成 1e3,虽然数值是整数,本例也会拒绝,因为接口要求的是普通十进制整数字面量。若服务端能够调整协议,直接用字符串传大整数常常更容易跨语言与旧环境使用。source 解决解析时保留原文,不会让 JSON.stringify 自动支持 BigInt。

Iterator.concat:依次打开来源,而不是先收集所有值 ​

两个来源都可以逐项产生数据。旧写法用一个生成器先后 yield* 两个来源;新方法将这种拼接直接返回为迭代器。先记录每个来源何时真的开始:

js
const log = []
function* source(name, values) {
  log.push(`打开${name}`)
  try { yield* values }
  finally { log.push(`关闭${name}`) }
}
const cursor = Iterator.concat(source('甲', [1, 2]), source('乙', [3]))
console.log(log) // []
console.log(cursor.next().value) // 1
console.log(log) // ['打开甲']
cursor.return()
console.log(log) // ['打开甲', '关闭甲']

concat 没有把两边变成完整数组。第一次消费进入甲;甲尚未结束时,乙没有开始。本例提前关闭消费,正在使用的生成器执行 finally,未开始的乙不会突然打开。换成 cursor.toArray(),则会依次消费两边;如果第一段无限,第二段没有机会开始。

这里演示的是同步可迭代来源,concat 不会把 async iterator 自动变成同步输入。它也不是 zip:拼接得到 1、2、3,按位置配对属于另一种协议。返回值是一次性迭代器,要再遍历就重新创建来源和拼接过程。

字节与 Base64:文本表示不能替代字符编码 ​

Base64 将字节编码为文本;它不知道这些字节是不是 UTF-8。先编码字符串,再编码字节,读取时按反方向恢复:

js
const bytes = new TextEncoder().encode('笔记')
const encoded = bytes.toBase64()
const restored = Uint8Array.fromBase64(encoded)
console.log(encoded) // 56yU6K6w
console.log(new TextDecoder('utf-8', { fatal: true }).decode(restored)) // 笔记
console.log([...Uint8Array.fromHex('00ff10')]) // [0, 255, 16]
try { Uint8Array.fromHex('0') }
catch (error) { console.log(error.name) } // SyntaxError

toBase64/fromBase64 处理字节的文本表示,TextEncoder/TextDecoder 处理字符与字节的对应。把 Unicode 字符直接交给只接受单字节字符串的旧接口,容易在这两层之间丢失信息。Base64 也没有保密作用,拿到文本即可还原字节。

十六进制两个字符表示一个字节,所以奇数长度不是完整输入。固定容量缓冲区还有 setFromBase64/setFromHex 方法,用于写入已有空间;它们需要考虑容量与读取进度,不能把返回的进度对象当成新数组。本节只要求掌握完整输入的创建与往返,增量缓冲区留到确有分块需求时再按其协议实现。

错误识别:构造器来源不等于错误身份 ​

第九章介绍过跨环境对象。另一执行环境生成的 Error,可能不是当前 Error 构造器的实例:

js
import vm from 'node:vm'
const foreign = vm.runInNewContext('new Error("远端错误")')
console.log(foreign instanceof Error) // false
console.log(Error.isError(foreign)) // true
console.log(Error.isError({ name: 'Error', message: '伪装' })) // false

Error.isError 不通过普通 name/message 字段猜测,也不依赖当前构造器的原型链。它识别错误对象这一问题,不决定错误是否可重试、消息是否适合显示或日志是否含敏感数据;这些仍由错误边界和业务契约处理。

精确求和:减少中间舍入,不制造更精确的输入 ​

把 1e16、1、-1e16 依次相加,数学上的结果是 1。但逐步使用 Number 相加时,第一步的 1 太小,不能保留在 1e16 附近的结果中;第二步再抵消大数,只剩 0。换一个顺序,先把两个大数抵消,却又得到 1。

Math.sumPrecise 以输入 Number 已表示的数学值求和,再将最终结果舍入为 Number,避免每次中间加法都丢信息。Node 26.0.0 本身尚未提供该 API;下面使用 math.sumprecise@1.0.1 的兼容实现学习相同契约,不将结果称为原生实现验证。

在独立的实验目录运行 npm init -y,再运行 npm install --save-exact math.sumprecise@1.0.1。保存 sum.mjs,执行 node sum.mjs:

js
import sumPrecise from 'math.sumprecise/implementation'
const values = [1e16, 1, -1e16]
console.log(values.reduce((sum, value) => sum + value, 0)) // 0
console.log(sumPrecise(values)) // 1
console.log(sumPrecise([-1e16, 1e16, 1])) // 1
console.log(sumPrecise([0.1, 0.2])) // 0.30000000000000004

最后一行说明边界:0.1 和 0.2 在进入函数之前就已是附近的二进制浮点值。更精确地累加这两个已表示的值,不会把它们恢复成原来的十进制有理数。它也不将输出类型变成任意精度数;需要十进制定价时仍应按第一章的单位与范围设计表示。

本例直接导入 implementation,确保运行的是兼容算法,不修改全局 Math。目标环境原生支持后可以改用 Math.sumPrecise(values),再以相同输入重新验证;两个实现的性能不能由功能例子推断。

空集合、特殊数字和类型错误也属于契约:

js
import sumPrecise from 'math.sumprecise/implementation'
console.log(Object.is(sumPrecise([]), -0)) // true
console.log(Number.isNaN(sumPrecise([Infinity, -Infinity]))) // true
for (const input of [[1, '2'], [1n]]) {
  try { sumPrecise(input) }
  catch (error) { console.log(error.name) } // 两次 TypeError
}

它接收可迭代的 Number 值,不将字符串或 BigInt 自动转换。空输入的负零需要 Object.is 才能与正零区分;这和以 0 为初始值的 reduce 又是一项差别。消费无限来源不会得到最终和,因此“更精确”也不意味着可以不考虑输入终止。

如何继续追踪新能力 ​

Temporal 与资源管理已有独立正文和可运行环境,不因年度出版尚未到来就只保留名词。尚在推进的提案也不能直接写成语言保证;查看提案状态、年度规范和目标实现后,再决定是否值得引入。这是一项持续维护工作,不是把新 API 名字不断加在目录末尾。

练习 ​

  1. 编写 getBucket(map, key),当键不存在时创建空数组;已有键即使值为 undefined 也原样返回,不覆盖它。验证重复读取返回相同数组,修改数组可从 Map 观察到,另一个键的数组独立。

  2. 编写 parseIds(text),解析含任意嵌套 id 字段的 JSON,并把所有 id 按本节契约恢复为 BigInt。验证正数、负数、大整数,拒绝 id 为字符串、小数和指数写法;保留普通 name 字段。

  3. 分别完整消费与提前关闭两个带日志的有限生成器,验证 Iterator.concat 的来源启动顺序及关闭顺序;不能用输出数组正确代替资源日志断言。

  4. 将包含“笔记”的字符串先编码成字节,再经 Base64 往返,断言字节相同及文本相同。加入空字符串和非法十六进制输入,分别验证正常空结果与 SyntaxError。

  5. 使用精确求和实现,对 [1e16, 1, -1e16] 的三种排列验证结果都为 1;加入空输入、混入字符串以及 0.1/0.2,分别断言负零、TypeError 与最终浮点结果。明确写出测试使用原生还是兼容实现。

查看本篇答案

用心记录每一步成长