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 去检查索引或直接数 Object.keys 的长度,结果会不一样。
1.2 对象展开:只挑可枚举的自有属性
typescript复制const source = { a: 1, b: 2 };
const target = { ...source };
对象展开走的是属性枚举逻辑:拿 source 的自有可枚举属性(包括 Symbol 类型的键),逐个复制到目标对象。这里有几个关键点:
- 原型链上的属性不会被复制。
class实例的方法都在原型上,所以{...new User()}得到的是一个没有方法的普通对象。 - getter 会被求值。对象展开拿到的是 getter 执行后的结果值,而不是 getter 本身。也就是说,如果你展开的对象里有
get token() { return this._token; },展开后得到的不是这个访问器,而是一个数据属性。 - 属性顺序有规则:整数型键按升序排在最前,字符串键按插入顺序排,Symbol 键最后。这个顺序在
Object.keys的结果里能直接看到。
我觉得对象展开最重要的一个心智模型是:它是一次"属性快照",不是"对象复制"。快照的含义是,复制的是当前时刻的数据属性集合,与源对象后续变化没有任何关系。这个模型建立起来之后,很多坑都不会踩。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 函数参数的两副面孔:展开实参与剩余参数
2.1 展开实参的元组要求
把数组展开成函数实参,是展开运算符最早吸引人的场景之一:
typescript复制const numbers: number[] = [3, 1, 4, 1, 5];
Math.max(...numbers);
在 ES6 之前,这个需求要写 `Math.max.apply(null, numbers
