解构赋值

用可运行示例讲清数组与对象解构、默认值、嵌套模式和剩余元素,以及生成代码最容易忽略的空值与浅拷贝边界。

难度 进阶 时长 标准深度约 16分钟
版本 Node 24
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 和生成器同样能被数组模式消费。

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

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

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

适用边界

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

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

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

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

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

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

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

工作原理

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

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

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

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

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

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

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

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

执行过程可以概括为:

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

模式语法速查

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

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

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

绑定与赋值目标

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

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

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

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

示例

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

读取订单的稳定形状

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

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(','));
A-17 Lin EUR
KB-1 2
MS-2

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

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

用参数模式规范化输入

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

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()));
{"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 响应则应先验证类型和必要字段。用默认值把损坏的输入悄悄改成业务值,会让问题在更远处才暴露。

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

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

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);
U-3 Mira admin
id,name,role,profile
light 14

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

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

陷阱

整体参数默认值不保护 null

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

默认值不会替换所有假值

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

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

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

冒号不是创建两个变量

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

剩余属性不是安全脱敏

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

数组空位仍会推进迭代器

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

深入 求值与可观察操作

求值与可观察操作

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

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

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

提前结束与异常

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

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

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

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

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(' | '));
1 3 next:1 | next:2 | next:3 | return
1 2 3
get:a | get:b | default:b | ownKeys | descriptor:c | get:c

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

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

对象剩余属性的复制边界

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

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

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

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

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

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

延伸阅读

检查点

4个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?