先记住这个答案
数组字面量中的 ... 会取得输入的同步迭代器,连续读取 next 的结果并写入新数组。普通数组默认迭代器按索引产生值,所以看起来像复制数组;Set 默认按插入顺序产生成员,Map 默认产生键值对。新数组是独立容器,但对象元素仍共享引用。若改写数组迭代器,展开结果也会改变;普通空位通常被读取为 undefined 后写成实际位置。对象字面量的 ... 则复制自有可枚举属性,不能把这两种同形语法当作同一读取算法。
- 数组展开消费同步迭代器产生的值
- 容器复制与元素对象复制是两层事情
- 对象展开按属性读取而非按迭代序列
浅复制可以通过对象身份直接验证
下面先复制包含对象的数组,然后修改副本中对象的属性。数组身份不同,但对象身份仍相同,因此源数组看到相同的属性变化;这说明新容器并没有自动提供深层数据隔离。
示例再给一个数组安装不同的迭代器,使展开产生替代值。它用于解释协议依赖,不建议为了隐藏数据而改写普通数组行为;这种自定义必须在接口上明确,否则消费者会很难排查。
const source = [{ count: 1 }];
const copy = [...source];
copy[0].count = 2;
console.log(copy === source, copy[0] === source[0], source[0].count);
const custom = ['stored'];
custom[Symbol.iterator] = function* () { yield 'yielded'; };
console.log(JSON.stringify([...custom]));
console.log(custom[0]);查看输出与解释
false true 2
["yielded"]
stored副本数组独立但元素对象共享。展开自定义数组得到 yielded,而索引零仍存 stored,直接证明展开读取迭代序列而非绕过协议复制所有索引。
同样三个点在对象里有不同规则
把 Set 写成 [...set] 会消费它的成员迭代器;写成 {...set} 则按对象属性复制,普通 Set 的集合条目不是自有可枚举字符串属性,因此不会变成想象中的成员记录。
数组对象展开得到的是索引属性构成的普通对象,也不会因此拥有数组原型和 length 语义。需要什么容器应通过 API 明确表达,不能只因为语法外观相同就期待返回同一种数据结构。
一次性物化要考虑容量和终止
展开会持续消费迭代器直到结束,对无限序列会无法正常完成,对很大的输入会一次性分配结果。若只要部分数据,应使用有界循环或分页消费,避免把惰性数据源整体拉进内存。
函数参数展开还受到调用参数数量等实现限制,与数组字面量展开不应混为一谈。对于共享状态,是否需要复制嵌套对象也必须单独决定;只有容器新引用并不能保证后续更新互不影响。
容易答错的地方
- 用数组展开当作通用深拷贝
- 展开保存的是原有元素值,对象值仍是引用,嵌套修改可能影响两边。应根据要修改的路径复制必要层级,或选择符合数据类型与性能要求的克隆方案,不能用三个点代替隔离设计。
- 认为任何有数字属性的对象都能展开进数组
- 数组展开要求可迭代协议,只有数字键和 length 的类数组可能没有这个能力。可以按明确类数组规则使用 Array.from,或为对象实现迭代器,不能把属性外观当作协议成立。
面试官还会怎么问?
展开 Map 得到的是值数组吗?
默认 Map 迭代器产生键值对,因此结果通常是由二元数组组成的数组。若只需要值,应显式展开 map.values;选择 keys、values 或 entries 能让读者直接理解希望保留的信息。
展开稀疏数组后空位还存在吗?
普通数组迭代器会读取这些位置,未找到属性时得到 undefined,再写入新数组,所以通常成为显式值。若原型上存在同名索引属性,读取结果还可能来自原型,不能忽略对象属性查找。
复制后原数组追加元素会同步出现吗?
不会,展开完成后的新数组长度与元素位置已经建立,后续追加不会自动同步容器结构。但两边已有对象元素仍可共享内部变化,因此应分别讨论结构快照和对象状态快照。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。