先记住这个答案
数组字面量里的 ...x 走的是迭代器协议:先调用 x[Symbol.iterator]() 拿到一个迭代器,然后反复调用它的 next(),把每次返回的 value 依次放进新数组,直到 done 为 true。数组的默认迭代器按索引输出元素,所以展开看起来就是复制数组;Set 的迭代器按插入顺序输出成员,Map 输出 [key, value] 键值对,字符串按字符输出。新数组是一个独立的容器,但里面的对象还是原来那些,所以是浅拷贝。对象展开 {...obj} 虽然写法很像,但它复制的是对象自己的可枚举属性,和迭代器没有关系。
- 数组展开依赖
Symbol.iterator,不是按索引复制 - 能展开的有数组、
Set、Map、字符串和arguments - 展开是浅拷贝,对象元素和原数组共享
- 对象展开
{...obj}复制属性,和迭代器无关
用两个例子看清展开读的是什么
第一个例子验证浅拷贝,第二个例子给数组换一个迭代器,看展开的结果会不会跟着变。
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第一行:copy 和 source 是两个数组,但 copy[0] 和 source[0] 是同一个对象,所以改了副本里的 count,原数组也变成了 2。
第二个例子更能说明问题:custom 的索引 0 上存的是 'stored',但它的迭代器被换成了只产出 'yielded' 的生成器函数,展开得到的就是 ["yielded"]。展开根本没有去读索引,它只认迭代器。实际项目里不要这样改数组的迭代器,这里只是为了演示原理。
数组展开和对象展开是两回事
[...set] 调用 Set 的迭代器,得到成员组成的数组。{...set} 却是按对象属性复制,Set 的成员不是它的属性,所以得到一个空对象 {}。
- 对数组用对象展开
{...['a', 'b']}会得到{ 0: 'a', 1: 'b' },一个以索引为键的普通对象,没有length,也不能调用数组方法。反过来,普通对象不能放进数组展开,[...{ a: 1 }]会抛TypeError,提示对象不可迭代。 所以记住一点:方括号里的展开找迭代器,花括号里的展开找属性。
容易答错的地方
- 把数组展开当深拷贝
- 展开只复制了一层。数组里是对象或嵌套数组时,副本和原数组共享它们,改嵌套内容两边都会变。要深拷贝用
structuredClone,或者只复制要修改的那一层。 - 以为有数字索引和
length就能展开 document.querySelectorAll返回的NodeList能展开,是因为它实现了迭代器,不是因为它有length。自己写的{ 0: 'a', length: 1 }没有迭代器,展开会报错,要用Array.from转成数组。
面试官还会怎么问?
展开 Map 得到什么?
得到键值对组成的二维数组,比如 [['a', 1], ['b', 2]]。只要键就展开 map.keys(),只要值就展开 map.values()。
展开稀疏数组,空位还在吗?
不在了。数组迭代器会按索引逐个读,空位读到的是 undefined,写进新数组后变成真实存在的 undefined。所以 [...arr] 会把稀疏数组变成稠密数组,而 arr.slice() 会保留空位。
展开一个很大的数组或者无限迭代器会怎样?
展开会一口气读完迭代器的所有值,数据很大时会占用大量内存。遇到无限迭代器,比如一个不停 yield 的生成器,展开永远不会结束。另外,函数调用里的展开 fn(...arr) 还受参数个数上限的限制,超大数组可能报错,求最大值这类场景用 reduce 更稳。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。