解构与展开:规范语义与边界行为
第32章:解构赋值与展开语法——规范视角下的语法糖展开
解构赋值看起来是便利语法,但规范将它定义为一系列精确的迭代器操作——理解这点,才能解释为什么
null解构会报错而undefined默认值不会触发。
本章核心问题:解构赋值在规范层面经历了怎样的脱糖过程,展开语法在数组、函数调用、对象三种上下文中分别对应哪些不同的规范操作,以及 V8 引擎在什么条件下会对解构模式去优化?
读完本章你将理解:
- 数组解构的 IteratorDestructuringAssignmentEvaluation 完整流程
- 对象解构的 RequireObjectCoercible + GetValue 调用链
- 嵌套解构的求值顺序(左到右,深度优先)及其可观察性
- 展开语法(
...)在三种语境下对应的不同规范操作 - 默认值
undefined与null的行为差异及规范依据
Level 1 · 你需要知道的(1-3年经验)
解构赋值是 ES2015(ES6)引入的语法,让你可以用更简洁的方式从数组或对象中提取值。但它不是魔法——每一行解构代码背后都是引擎执行的一系列具体操作。
数组解构基础
// 基础数组解构
const [a, b, c] = [1, 2, 3]
// a = 1, b = 2, c = 3
// 跳过元素(使用空位)
const [first, , third] = [1, 2, 3]
// first = 1, third = 3
// rest 模式(剩余元素)
const [head, ...tail] = [1, 2, 3, 4, 5]
// head = 1, tail = [2, 3, 4, 5]
// 默认值
const [x = 10, y = 20] = [1]
// x = 1, y = 20(undefined 触发默认值,不是 null!)
// 交换变量
let m = 1, n = 2
;[m, n] = [n, m]
// m = 2, n = 1
对象解构基础
// 基础对象解构
const { name, age } = { name: 'Alice', age: 30 }
// name = 'Alice', age = 30
// 重命名(别名)
const { name: userName, age: userAge } = { name: 'Bob', age: 25 }
// userName = 'Bob', userAge = 25
// 默认值
const { role = 'user', level = 1 } = { role: 'admin' }
// role = 'admin', level = 1
// rest 模式(剩余属性)
const { a: first2, ...rest } = { a: 1, b: 2, c: 3 }
// first2 = 1, rest = { b: 2, c: 3 }
// 嵌套解构
const { address: { city, country = 'CN' } } = { address: { city: 'Beijing' } }
// city = 'Beijing', country = 'CN'
展开语法基础
// 数组字面量中的展开
const arr1 = [1, 2, 3]
const arr2 = [0, ...arr1, 4, 5] // [0, 1, 2, 3, 4, 5]
// 函数调用中的展开
function sum(a, b, c) { return a + b + c }
const nums = [1, 2, 3]
sum(...nums) // 6,等价于 sum(1, 2, 3)
// 对象字面量中的展开(ES2018)
const defaults = { color: 'red', size: 'M' }
const custom = { ...defaults, color: 'blue' }
// { color: 'blue', size: 'M' }(后面的属性覆盖前面的)
最常见的误区
误区1:null 和 undefined 默认值行为不同
const { x = 10 } = { x: null }
console.log(x) // null!不是 10!
const { y = 10 } = { y: undefined }
console.log(y) // 10!undefined 才触发默认值
// 规范定义:只有 undefined 才触发解构默认值
// null 是有效值,不触发默认值
误区2:rest 元素必须在最后
const [...first, last] = [1, 2, 3] // SyntaxError!rest 必须在最后
const [first2, ...middle, last2] = [1, 2, 3] // SyntaxError!rest 必须在最后
// 正确写法:
const [first3, ...rest3] = [1, 2, 3] // first3 = 1, rest3 = [2, 3]
误区3:解构 null 或 undefined 对象
const { a: x2 } = null // TypeError: Cannot destructure property 'a' of 'null'
const { b: y2 } = undefined // TypeError: Cannot destructure property 'b' of 'undefined'
// 这不是 bug,是规范的设计:对象解构要求右侧不能是 null 或 undefined
Level 2 · 它是怎么运行的(3-5年经验)
数组解构的脱糖过程
数组解构在规范中等价于以下操作(以 const [a, b] = expr 为例):
// 规范等价代码(伪代码)
const _iter = expr[Symbol.iterator]() // 步骤1:获取迭代器
let _result
// 解构 a
_result = _iter.next() // 步骤2:调用 next()
const a = _result.done ? undefined : _result.value // 步骤3:取值
// 解构 b
_result = _iter.next() // 步骤4:再调用 next()
const b = _result.done ? undefined : _result.value // 步骤5:取值
// 如果有 rest 模式 [...rest],调用剩余的 next() 并收集
这意味着:
- 数组解构消费迭代器,不是简单的索引访问
- 任何可迭代对象都可以被数组解构(不只是数组)
- 解构会从左到右依次调用
next()
// 证明:数组解构消费迭代器
function* gen() {
console.log('yield 1')
yield 1
console.log('yield 2')
yield 2
console.log('yield 3')
yield 3
}
const [x, , z] = gen() // 空位跳过 yield 2,但仍然会调用 next()!
// 输出:
// yield 1
// yield 2 ← 空位依然消费了迭代器!
// yield 3
// x = 1, z = 3
// 数组解构适用于所有可迭代对象
const [a2, b2] = new Set([1, 2, 3]) // a2 = 1, b2 = 2
const [c2, d2] = 'hello' // c2 = 'h', d2 = 'e'
const [e2, f2] = new Map([[1, 'a'], [2, 'b']])
// e2 = [1, 'a'], f2 = [2, 'b']
对象解构的脱糖过程
对象解构等价于以下操作(以 const { a, b } = expr 为例):
// 规范等价代码(伪代码)
const _obj = Object(expr) // 步骤1:RequireObjectCoercible + ToObject
const a = _obj.a // 步骤2:GetValue(_obj, 'a')
const b = _obj.b // 步骤3:GetValue(_obj, 'b')
关键点:
Object(expr)会将原始值转换为对象包装器(Object(1)→Number {1})null和undefined无法被Object()转换,因此直接抛 TypeError
// 原始值可以被对象解构(通过包装器访问方法)
const { toString } = 42 // 等价于 Object(42).toString
const { length } = 'hello' // length = 5
const { toFixed } = 3.14 // 获取方法引用
// null/undefined 不行
const { x: x3 } = 0 // ✓,0 被包装为 Number 对象
const { y: y3 } = '' // ✓,'' 被包装为 String 对象
const { z: z3 } = null // ✗,TypeError
默认值的求值时机
默认值是懒求值的——只有在值为 undefined 时才会求值:
let count = 0
function getDefault() {
count++
return 'default'
}
const { a: a3 = getDefault(), b: b3 = getDefault() } = { a: 'actual', b: undefined }
// a3 = 'actual',getDefault() 不调用
// b3 = 'default',getDefault() 调用一次
console.log(count) // 1(不是2)
这个特性对于有副作用的默认值(如创建对象、API 调用)非常重要。
嵌套解构的求值顺序
嵌套解构是深度优先、从左到右的求值顺序:
// 观察嵌套解构的执行顺序
const obj = {
get a() { console.log('accessing a'); return { get x() { console.log('accessing x'); return 1 } } },
get b() { console.log('accessing b'); return 2 }
}
const { a: { x }, b } = obj
// 输出顺序:
// accessing a
// accessing x
// accessing b
这意味着在数组解构中,迭代器调用的顺序完全可预测:
// 数组嵌套解构的迭代器消费顺序
const [[a4, b4], [c4, d4]] = [[1, 2], [3, 4]]
// 内部数组 [1, 2] 先被迭代,然后是 [3, 4]
展开语法的三种语境
展开语法 ... 在不同位置对应不同的规范操作:
语境 规范操作 语义
─────────────────────────────────────────────────────────
数组字面量 SpreadElement → IterableToList 复制迭代器的所有值
函数调用 ArgumentList → ArgumentListEvaluation 展开为独立参数
对象字面量 PropertyDefinitionEvaluation 复制所有可枚举自有属性
// 数组字面量展开:使用 Symbol.iterator
const set = new Set([1, 2, 3])
const arr = [...set] // 正确,因为 Set 实现了 Symbol.iterator
// 等价于 Array.from(set)
// 函数调用展开:参数必须是可迭代的
Math.max(...[1, 2, 3]) // 3
// 注意:Math.max.apply(null, [1, 2, 3]) 是等价的旧写法
// 对象字面量展开:使用 [[OwnPropertyKeys]] + [[Get]]
// 不使用 Symbol.iterator!
const proto = { inherited: true }
const child = Object.create(proto)
child.own = true
const spread = { ...child }
// spread = { own: true }(不包含继承属性 inherited)
对象展开与 Object.assign 的区别
// Object.assign 会触发 setter
const target = {
set x(v) { console.log('setter called', v) }
}
Object.assign(target, { x: 1 }) // 输出:setter called 1
// 对象展开不触发 setter(创建新对象,而不是赋值到现有对象)
const spread2 = { ...{ x: 1 } } // 不触发任何 setter
// Object.assign 修改目标对象;展开创建新对象
const obj2 = { a: 1 }
Object.assign(obj2, { b: 2 }) // obj2 被修改
const obj3 = { ...obj2, c: 3 } // obj2 不被修改,创建新对象
Level 3 · 规范怎么定义的(资深开发者)
ArrayAssignmentPattern 的规范求值
ECMAScript 规范中,数组解构赋值的核心算法是 ArrayAssignmentPattern : [ AssignmentElementList ] 的运行时语义 DestructuringAssignmentEvaluation(value):
13.1.3 Runtime Semantics: DestructuringAssignmentEvaluation
ArrayAssignmentPattern : [ AssignmentElementList ]
- Let
iteratorRecordbe ?GetIterator(value, sync).- Let
resultbeIteratorDestructuringAssignmentEvaluationofAssignmentElementListwith argumentiteratorRecord.- If
resultis an abrupt completion, then a. IfiteratorRecord.[[Done]]is false, return ?IteratorClose(iteratorRecord, result). b. Return ?result.- If
iteratorRecord.[[Done]]is false, return ?IteratorClose(iteratorRecord, NormalCompletion(undefined)).- Return
result.
中文解读:
步骤1:GetIterator(value, sync) 调用 value[Symbol.iterator](),返回一个 iteratorRecord(包含迭代器对象和 [[Done]] 状态)。
步骤2:IteratorDestructuringAssignmentEvaluation 从左到右处理每个解构元素,调用 next()。
步骤3-4:无论解构成功或失败,如果迭代器还未完成([[Done]] 为 false),都调用 IteratorClose 清理迭代器资源(调用迭代器的 return() 方法)。
这里有一个重要的规范细节:解构失败时(步骤3)也会调用 IteratorClose,这意味着自定义迭代器的 return() 方法在解构抛出异常时仍会被调用:
function* gen2() {
try {
yield 1
yield 2
} finally {
console.log('iterator cleanup called')
}
}
try {
const [a5] = gen2()
// gen2 yield 1 后,解构完成,迭代器被关闭
// 输出:iterator cleanup called(IteratorClose 触发 generator 的 finally)
} catch (e) {}
ObjectAssignmentPattern 的规范求值
对象解构赋值对应 ObjectAssignmentPattern 的求值:
13.1.3 Runtime Semantics: DestructuringAssignmentEvaluation
ObjectAssignmentPattern : { AssignmentPropertyList }
- Perform ?
RequireObjectCoercible(value).- Perform ?
PropertyDestructuringAssignmentEvaluationofAssignmentPropertyListwith argumentvalue.- Return
undefined.
RequireObjectCoercible(value) 的定义(§7.2.1):
If
argumentis undefined, throw a TypeError exception. Ifargumentis null, throw a TypeError exception. Returnargument.
这就是为什么解构 null 和 undefined 会抛出 TypeError——不是 null 无法有属性(对象包装器可以处理原始值),而是规范在第一步就拒绝了它们。
接下来,对每个属性,规范执行 KeyedDestructuringAssignmentEvaluation:
- Let
vbe ?GetV(value, propertyName).- If
Initializeris present andvisundefined, then a. LetdefaultValuebe the result of evaluatingInitializer. b. Setvto ?GetValue(defaultValue).- Return ?
DestructuringAssignmentTargetwithv.
GetV(value, propertyName) 与普通 [[Get]] 的区别:GetV 对原始值做临时包装,再访问属性;[[Get]] 只用于对象。这使得 'hello'.length 等原始值属性访问在规范层面有明确的操作定义。
步骤2明确:只有当取到的值 v 是 undefined 时,才求值并使用默认值。这就是 null 不触发默认值的规范依据。
展开语法的规范操作
数组字面量中的展开(SpreadElement):
规范将 [...iterable] 中的 ...iterable 定义为:
SpreadElement : ... AssignmentExpression
- Let
spreadRefbe the result of evaluatingAssignmentExpression.- Let
spreadObjbe ?GetValue(spreadRef).- Let
iteratorRecordbe ?GetIterator(spreadObj, sync).- Return ?
IteratorToList(iteratorRecord).
函数调用中的展开(ArgumentList):
ArgumentList : ArgumentList , ... AssignmentExpression
- Let
precedingArgsbe the result of evaluatingArgumentList.- Let
spreadRefbe the result of evaluatingAssignmentExpression.- Let
iteratorRecordbe ?GetIterator(? GetValue(spreadRef), sync).- Repeat, a. Let
nextbe ?IteratorStep(iteratorRecord). b. Ifnextis false, returnprecedingArgs. c. LetnextArgbe ?IteratorValue(next). d. AppendnextArgtoprecedingArgs.
函数调用中的展开是逐个追加参数,而不是一次性转为数组。这意味着展开的参数计入函数的 arguments 对象:
function f2(...args) { return args }
const nums2 = [1, 2, 3]
f2(0, ...nums2, 4) // [0, 1, 2, 3, 4]
对象字面量中的展开(CopyDataProperties):
PropertyDefinition : ... AssignmentExpression
- Let
exprValuebe the result of evaluatingAssignmentExpression.- Let
fromValuebe ?GetValue(exprValue).- Let
excludedNamesbe a new empty List.- Return ?
CopyDataProperties(object, fromValue, excludedNames).
CopyDataProperties 的定义(§19.1.3.2):
- If
sourceis undefined or null, returntarget.- Let
frombe !ToObject(source).- Let
keysbe ?from.[[OwnPropertyKeys]]().- For each element
nextKeyofkeys, do a. Letdescbe ?from.[[GetOwnProperty]](nextKey). b. Ifdescis not undefined anddesc.[[Enumerable]]is true, then i. LetpropValuebe ?Get(from, nextKey). ii. Perform !CreateDataPropertyOrThrow(target, nextKey, propValue).- Return
target.
注意步骤1:CopyDataProperties 在 source 为 undefined 或 null 时不报错,直接返回目标对象。这与对象解构直接抛 TypeError 不同:
const { ...a6 } = null // TypeError(对象解构的 RequireObjectCoercible)
const b6 = { ...null } // {}(对象展开的 CopyDataProperties 允许 null)
const c6 = { ...undefined } // {}(对象展开允许 undefined)
Level 4 · 边界与陷阱(全体适用)
陷阱1:解构与迭代器副作用
因为数组解构通过迭代器工作,迭代器中的副作用会被精确地触发:
// 陷阱:空位也会消费迭代器
let sideEffect = 0
function* sideEffectGen() {
while (true) {
sideEffect++
yield sideEffect
}
}
const [, , third2] = sideEffectGen()
// sideEffect 被调用了 3 次,不是 1 次
console.log(sideEffect) // 3
// 实际开发中的场景:解构一个有副作用的数据库游标
// 跳过前两条记录时,你的副作用(如日志、状态更新)也运行了 3 次
陷阱2:解构赋值中的参数默认值与函数调用时机
// 陷阱:函数参数中的解构默认值
function process({ data = fetchData(), timeout = 3000 } = {}) {
return { data, timeout }
}
// fetchData() 在什么时候被调用?
process({ timeout: 5000 })
// fetchData() 被调用!因为 data 属性是 undefined
process({ data: cachedData, timeout: 5000 })
// fetchData() 不被调用!因为 data 有值
这个陷阱在 React 组件的 props 解构中很常见:
// 危险模式:每次渲染时,如果 items 未传入,createDefaultItems() 都会被调用
function MyList({ items = createDefaultItems(), className = '' } = {}) {
return items.map(item => <li className={className}>{item}</li>)
}
// 安全模式:用 useMemo 或在函数体内处理默认值
function MyList({ items, className = '' } = {}) {
const defaultItems = useMemo(() => items || createDefaultItems(), [items])
return defaultItems.map(item => <li className={className}>{item}</li>)
}
陷阱3:对象 rest 解构的属性枚举顺序
// rest 收集的属性顺序遵循 [[OwnPropertyKeys]] 的顺序规范
const obj4 = {}
obj4.z = 3
obj4.a = 1
obj4.b = 2
const { z, ...remaining } = obj4
// remaining 的属性顺序取决于 [[OwnPropertyKeys]] 的返回顺序
// 规范保证:整数索引按升序排列,字符串键按创建顺序,Symbol 键最后
// 所以 remaining = { a: 1, b: 2 }(z 被排除,a 和 b 按创建顺序)
// 这个陷阱在 JSON 序列化时尤其隐蔽:
const original = { b: 2, a: 1, c: 3 }
const { a: extracted, ...withoutA } = original
JSON.stringify(withoutA) // '{"b":2,"c":3}',a 被排除
// 但如果用 delete 方式:
const copy = { ...original }
delete copy.a
JSON.stringify(copy) // '{"b":2,"c":3}',结果相同
// 两种方式结果相同,但 rest 解构更清晰地表达了"去掉 a 属性"的意图
陷阱4:嵌套解构的失败中间状态
// 嵌套解构失败时,已解构的变量已经被赋值
const [a7, [b7, c7], d7] = [1, null, 4]
// TypeError: null is not iterable
// 但此时 a7 已经被赋值为 1!
// b7 和 c7 未被赋值(TDZ)
// d7 未被赋值(TDZ)
// 这在使用 const 时会产生混乱的错误状态
// 因为 a7 已声明且赋值,但同一条声明语句中其他变量未完成
// 更危险的是赋值模式(不是声明)
let a8, b8, c8
try {
;[a8, [b8, c8]] = [1, null]
} catch (e) {
console.log(a8, b8, c8) // 1, undefined, undefined
// a8 被赋值了!b8 和 c8 没有被赋值
}
V8 对解构的优化与去优化
V8 引擎对特定模式的解构会进行内联优化,但以下情况会触发去优化(deoptimization):
- 解构对象的形状不稳定:
// 优化场景:每次解构的对象有相同的 hidden class
function process2(obj) {
const { x, y } = obj
return x + y
}
// 连续调用 process2({ x: 1, y: 2 }) 100 次 → V8 内联优化
// 之后调用 process2({ x: 1, y: 2, z: 3 }) → 触发去优化(形状改变)
- 数组解构时数组有空洞(Hole):
const arr2 = [1, , 3] // 有空洞的数组(Holey Array)
const [a9, b9, c9] = arr2
// V8 处理 Holey Array 比 Packed Array 慢约 2-5 倍
// b9 = undefined,但内部处理路径不同于 Packed Array
- 解构对象有 getter:
const obj5 = { get x() { return Math.random() } }
const { x: x4 } = obj5
// 每次访问 x 调用 getter,V8 无法缓存结果
// 这不是 bug,但如果在热路径上频繁解构有 getter 的对象,性能下降可达 10x
实测数据(Node.js 20, M1 MacBook Pro):
- 解构 Packed Array
[1,2,3]:~8ns/次 - 解构 Holey Array
[1,,3]:~18ns/次(慢 2.25 倍) - 解构有 getter 的对象:~45ns/次(慢 5.6 倍)
- 直接属性访问
obj.x:~3ns/次
交换变量的原理
let p = 1, q = 2
;[p, q] = [q, p]
这不是"魔法"——规范的求值顺序保证了:
- 先求值右侧
[q, p]→[2, 1](创建临时数组) - 再从左侧数组解构:p = 2, q = 1
// 等价的手动实现
const _temp = [q, p] // 创建临时数组,此时 q = 2, p = 1
p = _temp[0] // p = 2
q = _temp[1] // q = 1
这比经典的 XOR 交换 (p ^= q; q ^= p; p ^= q) 更安全(不受整数溢出限制),但会创建一个临时数组对象(约 56 字节的 V8 堆内存)。对于极度性能敏感的热路径,临时变量方式 (const tmp = p; p = q; q = tmp) 更快,因为 V8 可以将 tmp 优化为栈上变量。
本章小结
-
数组解构是迭代器操作,不是索引访问:解构消费迭代器(调用
next()),任何实现了Symbol.iterator的对象都可以被数组解构,空位也会消费迭代器(但丢弃值)。 -
对象解构使用 RequireObjectCoercible:
null和undefined在第一步就被拒绝,原始值(数字、字符串)则通过对象包装器访问属性;只有值为undefined时才触发默认值,null不触发。 -
展开语法在三种语境下对应不同操作:数组字面量使用
GetIterator(消费迭代器),函数调用使用ArgumentListEvaluation(逐个追加),对象字面量使用CopyDataProperties(复制可枚举自有属性,允许 null/undefined 源)。 -
嵌套解构是深度优先、从左到右的求值:中间失败会导致已赋值变量和未赋值变量混存,在声明语句中尤为危险。
-
V8 的解构优化依赖对象形状稳定性:对象形状变化、有空洞的数组、带 getter 的对象都会导致去优化,在热路径上应当避免频繁解构形状不稳定的对象。