TypeScript 里的展开运算符(...)大概是所有语法里最"看起来简单"的一个。你几乎每天都会写 {...props}、[...list],潜意识里把它当成"拷贝"来用,但真被问到"拷贝了几层""类型是怎么跟着变的""为什么对象展开 null 不报错、数组展开 null 就炸"时,能立刻说清楚的人并不多。这篇文章想从几段最日常的代码出发,把展开运算符在数组、对象、函数参数里的真实行为,以及 TypeScript 类型系统针对它定下的几条推导规则全部拆开讲一遍。无论你刚入门 TS 还是已经写了几年,相信都能找到之前没注意到的细节。
1. 展开运算符的复制本质:数组按迭代,对象按属性
1.1 数组展开背后的迭代协议
先看最普通的用法:
typescript复制const base = [1, 2, 3];
const copy = [...base];
[...base] 并不是什么魔法操作,它可以被理解为"依次取出 base 的每个元素,塞进一个新数组"。这个"依次取出"走的是 Iterator 协议,底层机制和 Array.from(base) 基本一致。意识到这一点很重要,因为它解释了很多非常规场景:
- 凡是可迭代对象都能被展开,不一定非得是数组。
Set、Map、NodeList、生成器对象,只要实现了迭代器协议,[...thing]都能用。 - 展开产生的是一个全新的数组。底层元素如果是引用类型,复制的是引用本身,数组容器是新的,但里面的对象还是同一个,这个边界我放到第 5 章专门讲。
一个很容易被忽略的边界是稀疏数组的"空洞"问题:
typescript复制const sparse = [1, , 3];
const spreadResult = [...sparse]; // [1, undefined, 3]
const concatResult = [].concat(sparse); // [1, empty, 3]
concat 会保留空洞,而展开走迭代器,会把空洞变成显式的 undefined。如果你在处理稀疏数组,这就是一个隐性差异:日志里两边看起来都有"缺了点什么",但数组内部结构完全不同。用 `hasOwnProperty
