# 解构赋值

Source: https://codewiki.com/zh/javascript/destructuring/

> - **what**: 解构赋值用一个模式从可迭代对象或对象属性中取值，并把这些值绑定或赋给多个目标。
> - **trap**: 默认值只替换 `undefined`，嵌套模式也不会自动保护 `null` 或缺失的中间对象。
> - **fix**: 先定义输入契约，再给每个可能缺失的层级提供默认值；对外输出则使用字段白名单，不要把对象剩余属性当作脱敏机制。

## 是什么，为什么存在

解构赋值（destructuring assignment）是一组 JavaScript 语法，用左侧的解构模式（destructuring pattern）描述怎样从右侧值中取出数据。数组模式按迭代顺序取值，对象模式按属性键取值。它不会改变源数组或源对象，但模式中的赋值目标和默认表达式可能产生其他副作用。

没有解构时，读取同一个结构中的多个值需要反复写属性访问或索引。解构把“这段代码依赖哪些字段”放在一次声明、赋值或参数绑定中。它适合稳定、较浅的数据形状，例如配置对象、函数返回的元组，以及遍历 `Map` 时得到的键值对。

解构可以出现在三种位置。变量声明会创建绑定，例如 `const { id } = order`；赋值表达式会更新已有目标，例如 `({ id } = order)`；函数参数则在调用开始时绑定，例如 `function print({ id }) {}`。三者共享大部分模式语法，但声明位置只能创建合法的绑定名称，赋值位置还可以写 `target.id` 这样的赋值目标。

数组和对象模式看起来相似，读取规则却不同。`const [first] = value` 要求右侧实现可迭代协议，而 `const { first } = value` 查找名为 `first` 的属性。数组解构不是“读取数字属性 0”的简写；自定义可迭代对象（iterable）、字符串、`Set` 和生成器同样能被数组模式消费。

对象模式也不要求右侧是普通对象。除 `null` 和 `undefined` 外，原始值可以参与对象解构；例如字符串可以提供 `length` 属性。属性查找遵循普通 JavaScript 规则，因此继承属性和 getter 也可能被读取。

模式中的冒号表示从属性键映射到本地目标，不是类型标注。`const { id: orderId } = order` 读取 `id`，但只创建 `orderId`。如果后续代码仍使用 `id`，运行时不会因为对象中存在该属性而自动创建同名变量。

解构模式里的 `...` 是剩余元素（rest element）或剩余属性。它把模式尚未取走的内容收集到新数组或新对象中。它与函数声明里的剩余参数以及数组、对象字面量里的展开语法共用标记，但上下文和数据流方向不同：解构负责收集，展开负责展开。

### 适用边界

解构最清楚的场景，是消费方只需要一个稳定数据形状中的少数字段。模式与使用点靠得很近，读者无需在多次属性访问之间推断依赖。遍历 `Object.entries()` 或 `Map` 时写 `for (const [key, value] of entries)`，也能直接表达每个迭代值是一对数据。

当属性名在运行时才知道时，计算属性模式仍能工作，但直接的方括号访问往往更短。只读取一次的深层字段也不一定值得写成多层模式。解构的目标是让数据依赖更清楚，不是尽量消除所有点号。

解构不会验证数据是否可信，也不会生成运行时类型检查。一个对象拥有正确的键，不代表对应值具有业务所需的类型。来自网络、存储或用户输入的数据，应先经过明确的模式验证，再交给假定形状稳定的解构代码。

对象剩余属性和数组剩余元素都会分配新容器。普通业务数据中应优先根据清晰度选择是否使用，而不是猜测微小的性能差异。若分配确实位于已测量的热点，再用代表性基准验证替代方案。

返回值的顺序本身有稳定含义时，数组解构很适合表达小型元组。若接口将来可能增加可选字段，具名对象通常更容易演进，因为调用方不依赖新增字段的位置。

参数位置的深层解构会丢失对完整输入的直接引用。这会让错误报告、审计日志或多字段验证更难写；这些需求存在时，先接收一个命名参数，再在函数体中分步解构。

重命名应服务于作用域中的含义，而不是机械缩短名称。两个来源都有 `id` 时，`orderId` 与 `customerId` 能避免冲突，也让之后的代码审查知道每个值来自哪里。

## 工作原理

JavaScript 先求值右侧表达式一次，再按模式从左到右处理目标。一次求值不等于一次读取：同一个属性在模式里出现两次时，getter 仍可能执行两次。计算属性名也会在对应位置求值，所以键表达式本身可以产生可观察的副作用。

数组模式从右侧取得迭代器（iterator），每处理一个元素位置就请求下一项。空位置只是不绑定名称，并不跳过迭代器调用。模式提前结束而迭代器尚未结束时，运行时会执行迭代器关闭流程；自定义迭代器的 `return()` 因而可能运行。

数组剩余元素必须位于模式末尾。它会持续消费迭代器，把余下的值放进一个新数组。由于这个过程是立即执行的，对无限迭代器使用剩余元素不会结束，对大型生成器使用它也会一次性实现全部消费。

对象模式按属性键执行普通取值。简写 `{ id }` 等价于从键 `id` 取值并绑定到名称 `id`，而 `{ id: orderId }` 把同一个属性值绑定到 `orderId`。`{ [key]: value }` 会先计算 `key`，再用结果作为属性键。

对象剩余属性会创建一个普通的新对象。它复制尚未排除的自有可枚举字符串键和 Symbol 键，不复制继承属性或不可枚举属性。复制过程中会读取源属性，因此 getter 会执行；目标对象得到数据属性，而不是原访问器描述符。

这个副本是浅拷贝（shallow copy）。第一层容器是新的，但嵌套对象仍保留原来的对象标识。修改剩余对象里的嵌套对象，可能同时改变源对象中看到的内容。

模式中写 `target = initializer` 时，只有取出的值严格为 `undefined` 才会求值 `initializer`。缺失属性、数组越界和数组空槽通常都会产生 `undefined`，所以能触发默认值。`null`、`false`、`0` 和空字符串都不会触发它。

嵌套默认值要分层理解。`const { shipping: { city = 'pickup' } = {} } = order` 中，`= {}` 保护 `shipping` 为 `undefined` 的情况，`city = 'pickup'` 保护 `city` 为 `undefined` 的情况。如果 `shipping` 明确为 `null`，内层对象模式仍会抛出 `TypeError`。

执行过程可以概括为：

1. 求值右侧表达式，并确认它满足当前模式的最低要求。
2. 按模式顺序读取迭代值或属性值。
3. 当读取结果为 `undefined` 时，才求值对应的默认表达式。
4. 把结果写入新绑定或已有赋值目标，最后收集剩余内容。

### 模式语法速查

下表把常见写法对应到实际目标。方括号模式依赖位置和迭代顺序，花括号模式依赖属性键。

| 目的 | 模式 | 结果 |
|---|---|---|
| 数组首项 | `const [first] = values` | 创建 `first` |
| 跳过一项 | `const [, second] = values` | 消费两项，只绑定第二项 |
| 数组剩余项 | `const [head, ...tail] = values` | `tail` 是新数组 |
| 对象简写 | `const { id } = value` | 从键 `id` 创建同名绑定 |
| 对象重命名 | `const { id: orderId } = value` | 只创建 `orderId` |
| 计算键 | `const { [key]: picked } = value` | 先求值 `key` |
| 对象剩余项 | `const { id, ...rest } = value` | `rest` 是新的浅层对象 |
| 默认值 | `const { id = fallback } = value` | 仅当结果为 `undefined` 时求值 `fallback` |

模式可以任意组合，但组合并不总能改善可读性。一个模式若同时包含多层默认值、重命名、计算键和剩余属性，通常应该拆成几个有名称的步骤。分步写法还能在每个边界给出不同的错误消息。

### 绑定与赋值目标

声明模式受词法绑定规则约束。`const` 创建的名称不能重新赋值，`let` 创建的名称受暂时性死区约束，同一作用域也不能重复声明。解构不会绕开这些规则，只是在一次声明中创建多个名称。

赋值模式不创建绑定。数组模式或对象模式中的目标必须已经可赋值，因此可以是已有变量、对象属性或合适的索引表达式。整个赋值表达式本身会产生右侧值，这一点偶尔可用于表达式组合，但单独成句通常更容易读。

参数模式在函数调用时运行。它可以同时使用参数默认值和模式内部默认值，两者处理不同层级。参数默认值决定传入 `undefined` 时拿什么值开始解构，内部默认值则处理从该值读取出的 `undefined` 成员。

模式失败前已经完成的 getter、默认表达式或赋值不会自动回滚。若后续属性 getter 抛错，先前目标可能已经被更新。因此，不要把带大量副作用的赋值目标塞进一个复杂模式，也不要把解构当作原子事务。

## 示例

下面三个示例依次加入重命名与嵌套、参数默认值，以及已有变量赋值和剩余属性。所有输出均由 Node 24.14.0 实际执行得到。

### 读取订单的稳定形状

第一个例子同时使用对象键、数组位置、重命名、嵌套模式、默认值和数组剩余元素。模式直接说明后续逻辑需要订单编号、客户名、第一条明细和余下明细。

<!-- quick -->

```javascript
// file: basics.js
const order = {
  id: 'A-17',
  customer: { name: 'Lin' },
  lines: [
    { sku: 'KB-1', quantity: 2 },
    { sku: 'MS-2', quantity: 1 },
  ],
};

const {
  id: orderId,
  customer: { name: customerName },
  lines: [firstLine, ...remainingLines],
  currency = 'EUR',
} = order;

console.log(orderId, customerName, currency);
console.log(firstLine.sku, firstLine.quantity);
console.log(remainingLines.map(({ sku }) => sku).join(','));
```

```text
A-17 Lin EUR
KB-1 2
MS-2
```

<!-- /quick -->

对象模式按键读取，所以属性在 `order` 中的排列顺序不影响结果。数组模式按位置处理 `lines`，第一项进入 `firstLine`，余下项组成新的 `remainingLines` 数组。源对象没有 `currency`，读取结果为 `undefined`，因此使用默认值 `EUR`。

这里的嵌套形状是明确的输入契约。若 `customer` 或 `lines` 可能缺失，就不应假装这个模式天然安全。可以为相应层级设置默认值，也可以在解构前验证输入；选择哪种方式取决于缺失数据是正常状态还是无效数据。

### 用参数模式规范化输入

参数解构可以让一个函数在入口处规范化允许缺失的字段。外层 `= {}` 处理没有实参或实参为 `undefined` 的调用，内层默认值分别处理缺失的 `shipping`、`totals` 及其成员。

```javascript
// file: defaults.js
function normalizeOrder({
  id = 'untracked',
  shipping: { city = 'pickup' } = {},
  totals: [subtotal = 0, tax = 0] = [],
  note = 'none',
} = {}) {
  return { id, city, total: subtotal + tax, note };
}

const complete = normalizeOrder({
  id: 'A-17',
  shipping: {},
  totals: [80, 16],
  note: null,
});

console.log(JSON.stringify(complete));
console.log(JSON.stringify(normalizeOrder({ id: 'B-04' })));
console.log(JSON.stringify(normalizeOrder()));
```

```text
{"id":"A-17","city":"pickup","total":96,"note":null}
{"id":"B-04","city":"pickup","total":0,"note":"none"}
{"id":"untracked","city":"pickup","total":0,"note":"none"}
```

第一行保留了 `note: null`，因为解构默认值不处理 `null`。第二行分别使用对象、数组和叶子默认值。第三行还使用了整个参数的默认对象，所以完全省略参数不会抛错。

这种签名适合“字段可以缺失并有明确替代值”的内部配置。对外部 API 响应则应先验证类型和必要字段。用默认值把损坏的输入悄悄改成业务值，会让问题在更远处才暴露。

### 更新已有变量并检查浅拷贝

赋值给已有变量时，行首的对象模式要放进括号，否则解析器会把 `{` 当成语句块的开始。这个例子还用计算键选择属性，并展示对象剩余属性只复制第一层。

```javascript
// file: assignment-and-rest.js
const account = {
  id: 'U-3',
  name: 'Mira',
  role: 'admin',
  passwordHash: 'not-for-output',
  profile: { theme: 'dark' },
};

let accountId;
let displayName;
({ id: accountId, name: displayName } = account);

const requestedKey = 'role';
const { [requestedKey]: selectedRole } = account;
const { passwordHash: removed, ...withoutPassword } = account;
withoutPassword.profile.theme = 'light';

console.log(accountId, displayName, selectedRole);
console.log(Object.keys(withoutPassword).join(','));
console.log(account.profile.theme, removed.length);
```

```text
U-3 Mira admin
id,name,role,profile
light 14
```

`passwordHash` 没有进入 `withoutPassword`，但这个写法只排除了当前已知的一个键。以后若对象增加 `refreshToken`，剩余属性会自动包含它。因此，这种模式适合一般的数据变换，不适合建立稳定的安全边界。

最后一行证明复制是浅层的。`account.profile` 与 `withoutPassword.profile` 指向同一个对象，所以通过后者修改 `theme` 后，前者也看到 `light`。若需要隔离嵌套状态，应针对数据形状显式复制或使用合适的结构化克隆方案。

## 陷阱

### 整体参数默认值不保护 `null`

> **陷阱:** `function read({ id } = {}) {}` 只在参数被省略或值为 `undefined` 时使用空对象。显式传入 `null` 仍会在对象模式开始时抛出 `TypeError`。

**修复方法：** 先决定 `null` 是否是合法的“无值”。如果合法，可在函数体内对 `input ?? {}` 解构；如果不合法，应在边界处拒绝并给出明确错误。不要在没有契约的情况下到处补 `|| {}`，因为它也会替换 `0`、`false` 和空字符串。

### 默认值不会替换所有假值

> **陷阱:** 生成代码常把 `{ count = 10 }` 理解成“缺少或无效时使用 10”。事实上，只有 `undefined` 会触发默认表达式，`null`、`0`、`false` 和 `''` 都会原样保留。

**修复方法：** 若 `null` 和 `undefined` 都表示缺失，先解构再使用 `count ?? 10`。如果值还要满足范围或类型要求，就执行显式验证。`count || 10` 会错误覆盖合法的 `0`，不能代替输入规则。

### 叶子默认值不保护中间层级

> **陷阱:** `const { profile: { name = 'guest' } } = user` 只为 `name` 提供默认值。`profile` 缺失、为 `undefined` 或为 `null` 时，内层模式还没有读取 `name` 就会失败。

**修复方法：** 对正常缺失的中间层写出 `profile: { name = 'guest' } = {}`，并记住它仍不接受 `profile: null`。外部数据形状不可信时，先做模式之外的验证。嵌套超过两层后，分步读取通常更容易表达每层的错误策略。

### 冒号不是创建两个变量

> **陷阱:** 在 `{ name: displayName }` 中，`name` 是源属性键，`displayName` 才是新绑定。随后读取 `name` 可能命中另一个外层变量，或直接抛出 `ReferenceError`，让重命名错误显得很隐蔽。

**修复方法：** 在评审时把对象模式读成“属性键映射到目标”。赋值给已有变量时还要用 `({ name: displayName } = account)` 包住整条表达式。若既需要父对象又需要其中字段，就分别绑定，例如 `{ profile, profile: { name } }`，并确认重复 getter 是否可接受。

### 剩余属性不是安全脱敏

> **陷阱:** `const { password, ...publicUser } = user` 会把除 `password` 外的所有自有可枚举属性都复制出去。模型新增的 `token`、`mfaSecret` 或内部标志会自动泄漏，而且嵌套对象仍与输入共享引用。

**修复方法：** 对日志、API 响应和跨信任边界的数据使用允许字段列表，例如只解构 `id` 与 `displayName` 后构造新对象。对嵌套字段分别决定复制、冻结或序列化策略。对象剩余属性适合方便地保留未知数据，不适合保证未知数据不会离开边界。

### 数组空位仍会推进迭代器

> **陷阱:** `[first, , third]` 中的空位不会绑定名称，但仍会从迭代器取出并丢弃一个值。对生成器、流式适配器或带日志的自定义迭代器，这次消费可能改变外部状态。

**修复方法：** 评审数组模式时按位置计算 `next()` 调用，而不是只数变量。若每次消费都昂贵或需要显式确认，使用带名称的迭代步骤会更清楚。不要对无限迭代器使用剩余元素，因为它会尝试收集所有后续值。

<!-- deep -->

## 求值与可观察操作

解构不是纯粹的文本缩写。数组模式会驱动迭代器，对象模式会执行属性读取，而默认表达式、getter、Proxy trap 和迭代器的 `return()` 都可能让求值顺序变得可观察。理解这条边界，有助于审查自定义集合和生成代码中的隐藏副作用。

下面的数组模式包含一个空位置。空位置没有目标名称，但仍要调用一次 `next()`，所以第三个绑定得到值 `3`。模式取到第三项后结束，而自定义迭代器没有报告 `done: true`，运行时随后调用 `return()` 完成关闭。

对象部分用 Proxy 记录操作。`a` 与 `b` 先按模式顺序读取，`b` 得到 `undefined` 后才执行默认表达式。对象剩余属性接着枚举键；已被排除的 `a` 与 `b` 不会复制，`c` 则要检查可枚举性并读取值。

### 提前结束与异常

数组模式只绑定有限前缀时，不等于把迭代器自然消费到 `done: true`。运行时知道模式已经不再需要值，就会尝试关闭仍未结束的迭代器。生成器可以借此执行 `finally` 清理，自定义迭代器则可以在 `return()` 中释放资源。

如果 `next()`、赋值目标或默认表达式抛错，迭代器关闭规则仍可能运行。关闭本身也可能失败，所以真正暴露的异常取决于规范定义的完成记录处理。应用代码不应依赖多个异常之间的偶然覆盖关系；资源清理应保持简单并单独测试。

对象模式没有相同的迭代器关闭步骤，因为它执行的是属性访问。不过，getter 或 Proxy trap 随时可以抛错。前面已完成的绑定或赋值仍然保留，这就是复杂解构不能提供全有或全无语义的原因。

普通数组和普通数据对象通常没有这些自定义钩子，但库边界不一定如此。若一个 API 接受任意 iterable 或代理对象，就应在接口文档中说明消费数量、提前关闭和异常行为。调用方才能判断使用解构是否符合资源生命周期。

```javascript
// file: operations.js
const iteratorTrace = [];
const numbers = {
  [Symbol.iterator]() {
    let current = 1;
    return {
      next() {
        iteratorTrace.push(`next:${current}`);
        return { value: current++, done: false };
      },
      return() {
        iteratorTrace.push('return');
        return { done: true };
      },
    };
  },
};

const [first, , third] = numbers;
console.log(first, third, iteratorTrace.join(' | '));

const propertyTrace = [];
const source = new Proxy({ a: 1, b: undefined, c: 3 }, {
  get(target, key, receiver) {
    propertyTrace.push(`get:${String(key)}`);
    return Reflect.get(target, key, receiver);
  },
  ownKeys(target) {
    propertyTrace.push('ownKeys');
    return Reflect.ownKeys(target);
  },
  getOwnPropertyDescriptor(target, key) {
    propertyTrace.push(`descriptor:${String(key)}`);
    return Reflect.getOwnPropertyDescriptor(target, key);
  },
});

const fallback = () => (propertyTrace.push('default:b'), 2);
const { a, b = fallback(), ...rest } = source;
console.log(a, b, rest.c);
console.log(propertyTrace.join(' | '));
```

```text
1 3 next:1 | next:2 | next:3 | return
1 2 3
get:a | get:b | default:b | ownKeys | descriptor:c | get:c
```

第一行还说明，数组解构处理的是迭代序列，不是源对象的固定索引集合。若迭代器的 `next()` 会读取网络流、文件或共享队列，空位置照样消费数据。评审此类代码时，要把每个逗号视为一次潜在的迭代推进。

第三行说明默认表达式是惰性的。如果 `b` 为 `0` 或 `null`，就不会出现 `default:b`。默认表达式也能引用模式中更早创建的绑定，但引用尚未初始化的后续绑定会触发暂时性死区错误；依赖这种顺序通常会损害可读性。

### 对象剩余属性的复制边界

剩余属性的排除集合使用属性键，不使用最终变量名。`const { id: orderId, ...rest } = value` 排除的是源键 `id`。计算属性名则排除计算后的键；如果计算结果是 Symbol，对应 Symbol 键也不会进入 `rest`。

只有自有且可枚举的属性才会复制。原型链上的 getter 可能被显式属性模式读取，却不会自动进入剩余对象。不可枚举属性也被略过，因此剩余对象不保证保留源对象的完整行为或元数据。

复制时读取 getter 的当前返回值，再在新对象上创建普通数据属性。之后访问剩余对象不会再次执行原 getter。这个变化有时正是所需的快照语义，有时却会丢失动态行为，必须根据接口契约判断。

属性值本身不会递归复制。数组、对象、`Map`、`Set` 和类实例仍可由源对象与剩余对象共同引用。要获得真正的隔离，应先定义哪些值可以克隆，再选择 `structuredClone()`、领域专用复制逻辑或不可变数据结构；没有一种通用深拷贝适合所有 JavaScript 值。

对象剩余属性的键顺序遵循对象自有键的既定顺序，不应当被当成安全筛选。Proxy 还能对 `ownKeys`、属性描述符读取或 getter 执行任意逻辑。面对不可信对象时，解构并不会提供隔离；它只是使用语言的普通对象与迭代协议。

这种可观察性通常不是性能问题，也不需要为了普通数组或数据对象避开解构。它是一个语义问题：当源值拥有 getter、Proxy 或自定义迭代器时，模式会执行协议定义的操作。测试应该验证调用顺序和异常清理，而不是假设语法只复制了几个值。

<!-- /deep -->

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

## 延伸阅读

- [MDN：解构赋值](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment)
- [MDN：迭代协议](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols)
- [MDN：默认参数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters)
- [MDN：剩余参数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/rest_parameters)
- [MDN：展开语法](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax)
