解构赋值用一个模式从可迭代对象或对象属性中取值,并把这些值绑定或赋给多个目标。
默认值只替换 undefined,嵌套模式也不会自动保护 null 或缺失的中间对象。
先定义输入契约,再给每个可能缺失的层级提供默认值;对外输出则使用字段白名单,不要把对象剩余属性当作脱敏机制。
是什么,为什么存在
解构赋值(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。
执行过程可以概括为:
- 求值右侧表达式,并确认它满足当前模式的最低要求。
- 按模式顺序读取迭代值或属性值。
- 当读取结果为
undefined时,才求值对应的默认表达式。 - 把结果写入新绑定或已有赋值目标,最后收集剩余内容。
模式语法速查
下表把常见写法对应到实际目标。方括号模式依赖位置和迭代顺序,花括号模式依赖属性键。
| 目的 | 模式 | 结果 |
|---|---|---|
| 数组首项 | 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 实际执行得到。
读取订单的稳定形状
第一个例子同时使用对象键、数组位置、重命名、嵌套模式、默认值和数组剩余元素。模式直接说明后续逻辑需要订单编号、客户名、第一条明细和余下明细。
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 及其成员。
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 响应则应先验证类型和必要字段。用默认值把损坏的输入悄悄改成业务值,会让问题在更远处才暴露。
更新已有变量并检查浅拷贝
赋值给已有变量时,行首的对象模式要放进括号,否则解析器会把 { 当成语句块的开始。这个例子还用计算键选择属性,并展示对象剩余属性只复制第一层。
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 14passwordHash 没有进入 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 或代理对象,就应在接口文档中说明消费数量、提前关闭和异常行为。调用方才能判断使用解构是否符合资源生命周期。
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 道找错题