在前端开发中,有三个点 ... 出现的频率极高。不管你是写 React/Vue,还是处理日常的数组对象,都离不开它。
很多人对它的认知停留在 “用来替代 concat 拼接数组” 或者 “用来浅拷贝”。但实际上,扩展运算符背后的机制非常精妙。今天,我们就拔开这层迷雾,从“能看懂”到“能玩透”。## 一、 它的两个身份:展开 vs 收集
首先,... 在不同场景下,扮演着完全不同的角色。我们可以用一个简单的比喻:拆快递 vs 装快递。
1. 展开操作符:拆快递
当它出现在 等号右边 或者 函数参数调用时,它负责把一个“打包”的东西拆成一个个独立的零件。
// 数组的拆解
const fruits = ['apple', 'banana', 'orange'];
console.log(...fruits);
// 等同于 console.log('apple', 'banana', 'orange');
// 函数调用的拆解
const sum = (x, y, z) => x + y + z;
const nums = [1, 2, 3];
sum(...nums); // 等同于 sum(1, 2, 3)
2. 剩余参数:装快递
当它出现在 函数定义的参数位 或者 解构赋值的等号左边 时,它负责把散落的零件收集起来,打包成一个数组。
// 函数定义时的收集
function sum(...nums) { // 这里的 ... 是把传进来的参数收集成一个数组
return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3); // 内部 nums 为 [1, 2, 3]
// 解构赋值时的收集
const student = { name: '张三', age: 18, city: '北京' };
const { name, ...rest } = student;
// name 是 '张三',rest 收集剩下的,变成 { age: 18, city: '北京' }
💡 记忆口诀:左边收集(装),右边展开(拆)。
二、 深度解析:为什么 [...obj] 会报错?
这是一个经典的面试题:你能用扩展运算符展开一个普通对象吗?
const obj = { a: 1, b: 2 };
const arr = [...obj]; // ❌ 报错:TypeError: obj is not iterable
为什么数组可以,对象不行?这就触及到了扩展运算符的底层灵魂:Iterable(可迭代协议)。
什么是 Iterable?
扩展运算符在“拆快递”时,并不是瞎拆的。它只拆符合 Iterable 协议的容器。
什么是符合协议?只要这个对象内部部署了 [Symbol.iterator] 方法,它就能被 ... 展开。
当我们执行 [...arr] 时,引擎实际上在偷偷做这件事:
- 找到
arr的arr[Symbol.iterator]方法。 - 调用这个方法,返回一个迭代器对象(里面有个
next()方法)。 - 不断调用
next(),直到next().done === true,把每次拿到的value组成新数组。 普通对象{ a: 1 }内部没有实现[Symbol.iterator],所以直接报错。 注:那为什么{ ...obj }可以?因为那是 ES2018 专门为对象字面量增加的语法糖,它走的是另一套逻辑(遍历对象自身的可枚举属性),跟 Iterable 协议无关。
🌟 装逼技巧:让普通对象也能被数组展开
既然知道了原理,我们就可以“黑入”这个机制:
const person = { name: '李四', age: 20 };
// 手动给对象加上迭代器协议
person[Symbol.iterator] = function() {
let index = 0;
const keys = Object.keys(this);
return {
next() {
if (index < keys.length) {
return { value: person[keys[index++]], done: false };
} else {
return { done: true };
}
}
};
};
console.log([...person]); // 输出: [ '李四', 20 ]
看懂了吗?这就是掌握底层原理的魅力。
三、 最大的陷阱:你以为的深拷贝,其实是浅拷贝
99% 的初学者都会在这个坑里跌倒。
const state = {
user: { name: '王五', score: 100 }
};
// "拷贝" 一份 state 用来修改
const newState = { ...state };
newState.user.score = 0;
console.log(state.user.score); // 输出: 0 🚨 危险!原对象被污染了!
为什么?
扩展运算符只做了一层遍历。它在展开 { ...state } 时,只是把 user 这个属性的内存引用地址拷贝了过来。新旧对象的 user 指向的是同一块堆内存。
如何解决?
- 一层对象:
{ ...obj }足矣。 - 两层对象:
{ ...state, user: { ...state.user } }。 - 无限层级:别用扩展运算符了,老老实实用
structuredClone(obj)(现代浏览器原生支持)或者lodash.cloneDeep(obj)。
四、 扩展运算符的 4 个高级实战场景
除了简单的合并,它在这些场景下表现堪称完美:
1. 字符串转数组的“最佳实践”
你可能用过 split(''),但它遇到 emoji 或者特殊字符会炸。
'哈哈😊'.split(''); // 输出: [ '哈', '哈', '\uD83D', '\uDE0A' ] (被拆成了乱码)
[...'哈哈😊']; // 输出: [ '哈', '哈', '😊' ] (完美识别)
原因:扩展运算符遵循 ES6 的字符串迭代器,能够正确识别 Unicode 编码(代理对),而 split 只认简单字符。
2. 类数组转真数组
在 DOM 操作或老代码中,常遇到“长得像数组,但其实不是数组(没有 push/pop 方法)”的东西,比如 arguments 或 NodeList。
function test() {
// 老方法:Array.prototype.slice.call(arguments)
// 新方法:
const args = [...arguments];
args.push('新元素'); // 可以正常使用数组方法了
}
const divs = document.querySelectorAll('div');
const divArray = [...divs];
3. React/Vue 中的不可变数据更新
这是扩展运算符在现代框架中使用最频繁的场景。因为 React/Vue 靠引用对比来判断状态是否改变,我们绝不能直接修改原对象。
// React 中的典型写法
setUser(prevState => ({
...prevState, // 保留原有所有属性
age: prevState.age + 1 // 只覆盖修改的属性
}));
4. 替代 Math.max 获取数组最大值
const scores = [85, 92, 78, 99];
// 老方法:Math.max.apply(null, scores)
// 新方法(更直观):
Math.max(...scores); // 99
五、 性能避坑指南:别把 ... 当万能药
虽然 ... 很好用,但在超大数据量面前,它是有性能隐患的。
// 假设有 100 万条数据
const hugeArray = new Array(1000000).fill(1);
// 糟糕的做法:扩展运算符会在内存中瞬间开辟一块新空间存放 100 万个元素
const copy = [...hugeArray]; // 很慢,甚至可能引发浏览器卡顿
// 推荐的做法:如果只是为了遍历,直接用 for...of 或者迭代器
for (const item of hugeArray) { ... }
核心原因:扩展运算符的本质是一次性消费整个迭代器,并把结果全部塞进一个新的内存空间里。它不是“惰性”的。在处理流数据或超大数组时,要警惕内存溢出(OOM)。 另外,在 React 中给组件传递 props 时:
// 不推荐:每次渲染都会创建一个新的对象,导致子组件不必要的 re-render
<Component {...props} />
// 推荐:明确传递需要的 props,利于子组件做记忆化
<Component name={props.name} age={props.age} />
六、 总结
把 ... 看透,你的 JS 功底就厚了一层。最后我们用一句话总结它的核心要点:
扩展运算符(...)是基于
for...of循环的语法糖,它依赖Symbol.iterator协议进行消费,在等号左边负责收集(Rest),在右边负责展开,且永远只进行一层浅拷贝。 下次再写...的时候,希望你的脑海中能浮现出这篇文章里的底层逻辑,而不是仅仅把它当成一种“复制粘贴”的快捷键。