吃透 ES6 扩展运算符(...):从表面语法到底层逻辑,避开所有坑

在前端开发中,有三个点 ... 出现的频率极高。不管你是写 React/Vue,还是处理日常的数组对象,都离不开它。

很多人对它的认知停留在 "用来替代 concat 拼接数组" 或者 "用来浅拷贝" 。但实际上,扩展运算符背后的机制非常精妙。今天,我们就拔开这层迷雾,从"能看懂"到"能玩透"。## 一、 它的两个身份:展开 vs 收集

首先,... 在不同场景下,扮演着完全不同的角色。我们可以用一个简单的比喻:拆快递 vs 装快递


1. 展开操作符:拆快递

当它出现在 等号右边 或者 函数参数调用时,它负责把一个"打包"的东西拆成一个个独立的零件。

javascript 复制代码
// 数组的拆解
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. 剩余参数:装快递

当它出现在 函数定义的参数位 或者 解构赋值的等号左边 时,它负责把散落的零件收集起来,打包成一个数组。

javascript 复制代码
// 函数定义时的收集
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] 会报错?

这是一个经典的面试题:你能用扩展运算符展开一个普通对象吗?

javascript 复制代码
const obj = { a: 1, b: 2 };
const arr = [...obj]; // ❌ 报错:TypeError: obj is not iterable

为什么数组可以,对象不行?这就触及到了扩展运算符的底层灵魂:Iterable(可迭代协议)

什么是 Iterable?

扩展运算符在"拆快递"时,并不是瞎拆的。它只拆符合 Iterable 协议的容器。

什么是符合协议?只要这个对象内部部署了 [Symbol.iterator] 方法,它就能被 ... 展开。

当我们执行 [...arr] 时,引擎实际上在偷偷做这件事:

  1. 找到 arrarr[Symbol.iterator] 方法。
  2. 调用这个方法,返回一个迭代器对象(里面有个 next() 方法)。
  3. 不断调用 next(),直到 next().done === true,把每次拿到的 value 组成新数组。
    普通对象 { a: 1 } 内部没有实现 [Symbol.iterator],所以直接报错。
    注:那为什么 { ...obj } 可以?因为那是 ES2018 专门为对象字面量增加的语法糖,它走的是另一套逻辑(遍历对象自身的可枚举属性),跟 Iterable 协议无关。

🌟 装逼技巧:让普通对象也能被数组展开

既然知道了原理,我们就可以"黑入"这个机制:

javascript 复制代码
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% 的初学者都会在这个坑里跌倒。

javascript 复制代码
const state = {
  user: { name: '王五', score: 100 }
};
// "拷贝" 一份 state 用来修改
const newState = { ...state };
newState.user.score = 0;
console.log(state.user.score); // 输出: 0 🚨 危险!原对象被污染了!

为什么?

扩展运算符只做了一层遍历。它在展开 { ...state } 时,只是把 user 这个属性的内存引用地址 拷贝了过来。新旧对象的 user 指向的是同一块堆内存。
如何解决?

  1. 一层对象{ ...obj } 足矣。
  2. 两层对象{ ...state, user: { ...state.user } }
  3. 无限层级 :别用扩展运算符了,老老实实用 structuredClone(obj)(现代浏览器原生支持)或者 lodash.cloneDeep(obj)

四、 扩展运算符的 4 个高级实战场景

除了简单的合并,它在这些场景下表现堪称完美:

1. 字符串转数组的"最佳实践"

你可能用过 split(''),但它遇到 emoji 或者特殊字符会炸。

javascript 复制代码
'哈哈😊'.split(''); // 输出: [ '哈', '哈', '\uD83D', '\uDE0A' ] (被拆成了乱码)
[...'哈哈😊'];      // 输出: [ '哈', '哈', '😊' ] (完美识别)

原因 :扩展运算符遵循 ES6 的字符串迭代器,能够正确识别 Unicode 编码(代理对),而 split 只认简单字符。

2. 类数组转真数组

在 DOM 操作或老代码中,常遇到"长得像数组,但其实不是数组(没有 push/pop 方法)"的东西,比如 argumentsNodeList

javascript 复制代码
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 靠引用对比来判断状态是否改变,我们绝不能直接修改原对象。

javascript 复制代码
// React 中的典型写法
setUser(prevState => ({
  ...prevState,      // 保留原有所有属性
  age: prevState.age + 1 // 只覆盖修改的属性
}));

4. 替代 Math.max 获取数组最大值

javascript 复制代码
const scores = [85, 92, 78, 99];
// 老方法:Math.max.apply(null, scores)
// 新方法(更直观):
Math.max(...scores); // 99

五、 性能避坑指南:别把 ... 当万能药

虽然 ... 很好用,但在超大数据量面前,它是有性能隐患的。

javascript 复制代码
// 假设有 100 万条数据
const hugeArray = new Array(1000000).fill(1);
// 糟糕的做法:扩展运算符会在内存中瞬间开辟一块新空间存放 100 万个元素
const copy = [...hugeArray]; // 很慢,甚至可能引发浏览器卡顿
// 推荐的做法:如果只是为了遍历,直接用 for...of 或者迭代器
for (const item of hugeArray) { ... }

核心原因 :扩展运算符的本质是一次性消费整个迭代器,并把结果全部塞进一个新的内存空间里 。它不是"惰性"的。在处理流数据或超大数组时,要警惕内存溢出(OOM)。

另外,在 React 中给组件传递 props 时:

jsx 复制代码
// 不推荐:每次渲染都会创建一个新的对象,导致子组件不必要的 re-render
<Component {...props} />
// 推荐:明确传递需要的 props,利于子组件做记忆化
<Component name={props.name} age={props.age} />

六、 总结

... 看透,你的 JS 功底就厚了一层。最后我们用一句话总结它的核心要点:

扩展运算符(...)是基于 for...of 循环的语法糖,它依赖 Symbol.iterator 协议进行消费,在等号左边负责收集(Rest),在右边负责展开,且永远只进行一层浅拷贝。

下次再写 ... 的时候,希望你的脑海中能浮现出这篇文章里的底层逻辑,而不是仅仅把它当成一种"复制粘贴"的快捷键。

相关推荐
Pedantic1 小时前
SwiftUI 手势层级(Gesture Hierarchy)详解
前端
飘尘1 小时前
前端转型全栈(Java后端)的快速上手指引
前端·后端·全栈
一颗烂土豆1 小时前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
浏览器工程师2 小时前
AI Agent 接浏览器任务,先别让它一路点到底
前端·后端
雨季mo浅忆2 小时前
VSCode自动格式化三要素
前端
爱勇宝3 小时前
深扒 Anthropic 1680 位工程师简历:应届生几乎没机会,AI 公司最缺的不是博士
前端·后端·程序员
kyriewen4 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
user20585561518136 小时前
Windows 项目安装时报 `node-sass` 错误,如何快速处理
前端
LiaCode6 小时前
Redis 在生产项目的使用
前端·后端