# 现代 JavaScript 特性

Source: https://codewiki.com/zh/javascript/es6-features/

> - **what**: ES6 是 ES2015 的旧称。「ES6+」通常泛指 ES2015 及后续版本；它们增加了语法和内置对象， 但没有替换 JavaScript 的核心模型。
> - **trap**: 简洁语法可能藏起原有语义：`const` 对象仍可修改，展开只做浅拷贝，可选链只保护明确写出的链， Promise 也不会自行取消。
> - **fix**: 根据语义选择特性，测试 `null`、`0` 和共享嵌套对象等边界值，并分别验证解析器支持与所需的 运行时 API。

## 是什么，为什么存在

ES6 是第六版 ECMAScript 的非正式名称。它在 2015 年标准化，
所以正式名称是 ES2015。
ECMAScript 版本（ECMAScript edition）定义 JavaScript 语言；
浏览器和 Node 等运行时实现这份规范。「ES6+」没有精确的上限，通常包含 ES2015 以及后续每年加入的
`async` 函数、对象展开、可选链和空值合并等特性。

重点不在于背一张新标点清单。现代特性为原有 JavaScript 语义
提供了更清楚的写法：块级绑定减少
`var` 引起的意外交互；解构为值的各部分命名；迭代协议让不同集合共享遍历契约；Promise 则把
异步工作的结果变成可组合的值。底层规则符合任务时，这些特性可以省去粘合代码。

当前应用代码、软件包源码、构建配置和生成代码中都会出现这层语言特性。
即使项目最终发布的是
转换后的产物，源码通常仍采用现代语法。目标运行时依然重要，因为解析器、语法转换、内置对象与
宿主 API 是四道不同的兼容性边界。

这个主题梳理这些边界以及跨越边界的常用特性。解构、集合、类、模块和异步控制流分别有专门主题
展开讲解。阅读本篇是为了看清它们怎样配合，以及哪些假设必须通过测试验证。

## 工作原理

现代 JavaScript 特性大致分为两类。语法变更影响源码的解析方式，
例如 `const`、箭头函数、解构模式、
可选链和 `import` 声明。运行时新增内容则是程序调用的对象或方法，例如 `Map`、`Set`、`Promise` 和
较新的集合方法。有些提案会同时增加语法与运行时行为。

`let` 和 `const` 创建词法绑定，作用域是最近的块。进入该块后、声明完成初始化前，两者都处于
暂时性死区。`const` 禁止给绑定重新赋值，但不会冻结通过该绑定访问的对象。绑定应保持同一标识时
使用 `const`；算法本来就需要重新赋值时使用 `let`。

箭头函数缩短了函数表达式，并从外层词法环境捕获 `this`。
它没有自己的 `this`、`arguments`，也不能
作为构造函数。这使它很适合小型回调，却不适合机械地替换对象方法；对象方法的接收者可能需要由
调用位置决定。

解构是一种模式，不是通用的深拷贝操作。数组模式按顺序消费可迭代对象
（iterable），对象模式则读取具名属性。默认值只在取出的值为 `undefined` 时执行。剩余语法
收集余下输入，展开语法则把输入展开到函数调用、数组字面量或对象字面量中。

数组展开遵循迭代协议。对象展开的规则不同，它把自有的可枚举字符串属性和 `Symbol` 属性复制到
新的普通对象。在两种常见容器中，结果都是浅拷贝（shallow copy）：
嵌套对象的引用仍然共享。属性顺序也很重要，因为后出现的对象展开或属性定义会覆盖先出现的值。

当左侧值为 `null` 或 `undefined` 时，可选链使用短路求值
（short-circuit evaluation）。空值合并表达式 `left ?? fallback` 也只为这两个值选择回退。
`left || fallback` 则不同，它还会替换 `0`、`false`、空字符串和 `NaN`。

`Map`、`Set` 与迭代协议明确了集合行为。
`Map` 可以用任意类型的值作为键，并在迭代时保持插入顺序。
`Set` 按照 SameValueZero 相等规则为每个不同值保留一个条目。生成器通过包含 `yield` 的函数管理
迭代器状态，因此使用方可以通过 `for...of` 或展开语法逐项取得值。

Promise 表示最终完成的结果。`async` 函数总是返回 Promise；`await` 会暂停该函数，直到等待的值落定，
但不会阻塞 JavaScript 线程。`Promise.all` 等组合器表达一组操作的处理策略。ECMAScript 模块通过
明确的 `import` 和 `export` 建立关系，模块加载与软件包解析则由宿主运行时提供。

实际的兼容性检查分为四步：

1. 解析器必须能识别源码语法。
2. 语法转换必须保留代码依赖的语义。
3. 运行时必须提供所需的内置对象和方法。
4. 宿主必须提供 DOM 或 Node 文件系统模块等平台 API。

构建成功只能证明该构建实际执行过的检查。它不能证明目标运行时具备每个内置对象，也不能证明
浏览器提供 Node API，或转换后的代码保留迭代器关闭、属性访问顺序等可观察细节。

## 示例

### 词法绑定与简洁函数

这个示例组合了块级绑定、箭头函数、参数解构、默认参数、模板字符串和 `for...of`。每项特性都省掉
少量样板，但数据流仍然清楚：`taxRate` 是不会重新赋值的绑定，`lowStockCount` 则会重新赋值。

<!-- quick -->

```javascript
// file: bindings-and-functions.js
const products = [
  { sku: 'A-10', price: 12, stock: 2 },
  { sku: 'B-20', price: 8, stock: 0 },
];

const taxRate = 0.2;

const labelProduct = ({ sku, price, stock = 0 }, currency = 'EUR') => {
  const gross = price * (1 + taxRate);
  return `${sku}: ${gross.toFixed(2)} ${currency}, stock=${stock}`;
};

let lowStockCount = 0;
for (const product of products) {
  console.log(labelProduct(product));
  if (product.stock < 1) lowStockCount += 1;
}

console.log(`low stock: ${lowStockCount}`);
```

```text
A-10: 14.40 EUR, stock=2
B-20: 9.60 EUR, stock=0
low stock: 1
```

<!-- /quick -->

参数模式说明了格式化函数会读取哪些商品字段。`stock = 0` 默认值适用于缺失属性或显式的
`undefined`，但不适用于 `null`。循环中的 `product` 使用 `const`，因为每轮迭代都会创建一个绑定，
而循环体不会给它重新赋值。

### 规范化记录而不抹掉合法假值

边界代码经常需要分层设置默认值。解构处理缺失字段，可选链处理缺失的中间对象，`??` 保留合法的
零值，对象展开让用户偏好覆盖默认偏好。函数返回新结果，不会修改输入。

```javascript
// file: normalize-customer.js
function normalizeCustomer(raw = {}) {
  const {
    id = 'guest',
    contact,
    preferences = {},
    retries = 3,
  } = raw;

  return {
    id,
    email: contact?.email ?? 'missing',
    preferences: { theme: 'light', ...preferences },
    retries,
  };
}

const customer = normalizeCustomer({
  id: 'U-7',
  contact: null,
  preferences: { density: 'compact' },
  retries: 0,
});

console.log(JSON.stringify(customer));
```

```text
{"id":"U-7","email":"missing","preferences":{"theme":"light","density":"compact"},"retries":0}
```

显式的 `null` 联系信息会在 `contact?.email` 处停止，因此表达式得到 `undefined`，再由 `??` 提供
`missing`。代码没有使用 `||`，所以重试次数 `0` 得以保留。嵌套的偏好对象也是新对象，但其中若存放
其他对象，这些对象仍会共享。

### 用集合协议为事件建立索引

`Map` 表示以用户 ID 为键的索引，每个 `Set` 则去掉同一用户的重复操作。生成器按需提供摘要。最后的
展开会把生成器消费成数组，这说明内置集合与自定义生成器采用相同的迭代协议。

```javascript
// file: index-events.js
const events = [
  ['U-1', 'view'],
  ['U-2', 'buy'],
  ['U-1', 'buy'],
  ['U-1', 'view'],
];

const actionsByUser = new Map();

for (const [userId, action] of events) {
  const actions = actionsByUser.get(userId) ?? new Set();
  actions.add(action);
  actionsByUser.set(userId, actions);
}

function* summarize(index) {
  for (const [userId, actions] of index) {
    yield `${userId}:${[...actions].join(',')}`;
  }
}

console.log([...summarize(actionsByUser)].join(' | '));
```

```text
U-1:view,buy | U-2:buy
```

输出遵循 Map 键与 Set 值的首次插入顺序。再次为 `U-1` 添加 `view` 不会产生第二个 Set 条目，也不会
移动原条目。需要排序时应明确执行排序，而不是假设另一种集合类型会给出所需次序。

### 组合相互独立的异步工作

两次等待相互独立，因此 `Promise.all` 会在使用任一结果前观察两者。尽管商品 Promise 先落定，
`async` 函数恢复时拿到的结果数组仍按输入顺序排列。`.mjs` 扩展名告诉 Node 24 把这个自包含文件
视为模块，因此文件可以使用顶层 `await`。

```javascript
// file: load-order.mjs
const wait = (value, milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds, value));

async function loadOrderSummary() {
  const [customer, items] = await Promise.all([
    wait({ name: 'Mira' }, 20),
    wait(
      [
        { quantity: 2, unitPrice: 6.25 },
        { quantity: 1, unitPrice: 5 },
      ],
      5,
    ),
  ]);

  const total = items.reduce(
    (sum, { quantity, unitPrice }) => sum + quantity * unitPrice,
    0,
  );

  return `${customer.name}: ${items.length} items, EUR ${total.toFixed(2)}`;
}

console.log(await loadOrderSummary());
```

```text
Mira: 2 items, EUR 17.50
```

`Promise.all` 会让结果位置与输入位置保持对应，落定顺序不会改变结果次序。任一输入拒绝时，组合后的
Promise 也会拒绝。除非底层操作的 API 支持取消，而且代码明确提出取消请求，否则其他操作仍会继续。

## 陷阱

> **陷阱:** 把 `const` 当成不可变数据，会把修改藏在稳定的绑定后面。`const settings = {}` 会阻止
> `settings = other`，但 `settings.theme = 'dark'` 仍然有效。

**修复方法：** 使用 `const` 表达绑定意图，再单独选择数据所有权策略。调用方需要快照时构造新对象；
状态本来就要变化时，通过范围明确的所有者公开修改，并测试嵌套引用。`Object.freeze()` 也是浅层操作，
不能代替对嵌套值所有权的分析。

> **陷阱:** 把所有默认值都改写成 `||` 会丢失合法假值。生成的配置代码经常把 `0`、`false` 或 `''` 换成回退值，
> 尽管只有缺失数据才应该触发回退。

**修复方法：** 只处理 `undefined` 时使用解构默认值，`null` 与 `undefined` 都表示缺失时使用 `??`。
如果 `null` 表示格式错误而非缺失，应单独验证。不要只测试缺失属性，还要为领域允许的每个假值添加测试。

> **陷阱:** 展开是浅层属性操作，不是通用克隆或脱敏边界。嵌套值仍是别名，对象展开可能调用 getter，
> `{ password, ...publicUser }` 这种排除列表还会悄悄暴露以后新增的敏感字段。

**修复方法：** 按明确的允许列表构造公开输出。在所有权必须改变的层级复制领域值，也不要假设展开会
保留原型、访问器或属性描述符。修改结果中的嵌套值，并向输入新增意外凭据，以这两种测试检查边界。

> **陷阱:** 可选链只保护链中明确标为可空的步骤。如果 `account` 存在而 `account.profile` 缺失，
> `account?.profile.name` 仍会尝试从 `undefined` 读取 `name`。写成 `(account?.profile).name` 也会结束受保护链。

**修复方法：** 标出每个确实可选的跳转，例如 `account?.profile?.name`，并决定缺失值应在哪一层变成
错误或回退。不要到处追加 `?.`；必需字段仍应在验证时失败，不能悄悄传播 `undefined`。

> **陷阱:** 某个输入拒绝时，`Promise.all` 会拒绝，但不会取消其他操作。生成代码常常先报告失败并释放资源，
> 此时另一个请求却仍在使用这些资源。

**修复方法：** 对支持协作式取消的操作传入 `AbortSignal`，或者等必要的清理完成后再释放共享资源。
只有调用方确实需要每项结果时才选择 `Promise.allSettled`；它改变的是报告策略，不是取消行为。

<!-- deep -->

## 版本、转换与运行时支持

ECMAScript 2015 的改动范围很大，因此「ES6」这个简称一直沿用。此后的规范按年份发布。当前运行时
不会开启一个整体的「ES6 模式」，而是实现来自多个版本的一组语法与运行时语义。本主题 frontmatter 中的
`Node 24` 记录了可运行示例使用的环境，并不表示每个浏览器都具有相同支持。

版本名称回答的是某项语言特性在哪里标准化，却不能单独回答项目能否使用它。这个决定还取决于目标引擎、
文件的解析目标、构建转换和可用全局对象。即使 JavaScript token 本身有效，软件包元数据和文件扩展名
也可能改变 Node 把文件解析为 ECMAScript 模块还是 CommonJS。

### 解析发生在特性检测之前

不支持的语法通常会在解析时失败。如果引擎无法解析同一文件其他位置的可选链，就没有机会运行
`if (supportsOptionalChaining)` 之类的条件。构建工具可以先降低语法再交给目标，也可以向该目标发布
另一份文件。运行时特性检测只适用于引擎已经能够解析的值。

内置对象的失败方式不同。即使某个引擎没有 `Map`，包含 `new Map()` 的源码也可能通过解析，随后在
执行该行时抛出引用错误。语法转换器不一定会安装全局对象或方法。项目如果使用 polyfill，就必须让它
符合目标环境、加载顺序和应用所需语义。

### 简洁语法仍有可观察操作

源码更短不代表语义步骤更少。对象解构会读取属性，可能调用 getter 或 Proxy trap。数组解构会请求
迭代器值，并可能在模式提前结束时关闭迭代器。对象展开会按规定顺序枚举符合条件的键并读取其值。
之后发生的失败不会回滚已经产生的效果。

这在信任边界尤其重要。解构本身不验证类型，可选链无法区分有意缺失的字段与格式错误的数据，对象展开
也不会定义输出 schema。应把这些形式视为访问和构造语法，另行验证外部值，并单独列出公开字段。

### 模块会改变加载契约

ECMAScript 模块拥有自己的顶层作用域，以严格模式运行，并且只通过明确的 export 暴露绑定。静态
`import` 声明作为模块加载的一部分解析，不是普通函数调用。顶层 `await` 属于模块语法，所以宿主必须
先把文件识别为模块。

ECMAScript 规范定义模块记录和语言行为，Node 与浏览器则定义如何通过说明符定位资源。在打包工具中
能够解析的路径，直接交给 Node 时未必能解析。应验证实际部署入口、软件包元数据和 import 说明符，
不要根据编辑器的语法高亮推断模块行为。

### 兼容性声明需要可执行证据

有效的兼容性测试应该运行目标实际收到的产物。对于未经转换的 Node 代码，可以执行 `node entry.mjs`。
对于打包后的浏览器应用，应使用生产目标配置构建，并在支持范围内最旧的引擎中打开产物。对源码文件运行
单元测试可以覆盖应用行为，却不一定覆盖部署时使用的解析器和加载器。

在兼容性声明旁记录目标和构建路径。「现代 JavaScript」对于发布决策过于宽泛；「未打包的 `.mjs`
入口可以在 Node 24 运行」则可测试。支持情况改变后，应重复同一命令或浏览器测试，而不是猜测上一位作者
所说的「ES6+」究竟指什么。

<!-- /deep -->

[检查点: javascript/es6-features](https://codewiki.com/zh/javascript/es6-features/#checkpoint)

## 延伸阅读

- [ECMAScript 语言规范](https://tc39.es/ecma262/)
- [ECMAScript 规范：`let` 与 `const` 声明](https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-let-and-const-declarations)
- [ECMAScript 规范：对象初始化器与展开属性](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-object-initializer)
- [ECMAScript 规范：可选链](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-optional-chains)
- [Node.js 24 文档：ECMAScript 模块](https://nodejs.org/docs/latest-v24.x/api/esm.html)
