解构赋值

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

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

对象模式也不要求右侧是普通对象。除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] = 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。

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

用参数模式规范化输入

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

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.profile与withoutPassword.profile指向同一个对象,所以通过后者修改theme后,前者也看到light。若需要隔离嵌套状态,应针对数据形状显式复制或使用合适的结构化克隆方案。

陷阱

整体参数默认值不保护null

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

默认值不会替换所有假值

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

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

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

冒号不是创建两个变量

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

剩余属性不是安全脱敏

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

数组空位仍会推进迭代器

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

深入 求值与可观察操作

求值与可观察操作

解构不是纯粹的文本缩写。数组模式会驱动迭代器,对象模式会执行属性读取,而默认表达式、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或代理对象,就应在接口文档中说明消费数量、提前关闭和异常行为。调用方才能判断使用解构是否符合资源生命周期。

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()会读取网络流、文件或共享队列,空位置照样消费数据。评审此类代码时,要把每个逗号视为一次潜在的迭代推进。

第三行说明默认表达式是惰性的。如果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或自定义迭代器时,模式会执行协议定义的操作。测试应该验证调用顺序和异常清理,而不是假设语法只复制了几个值。

延伸阅读

检查点

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

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