Skip to content

展开运算符:数据的魔法扩散

想象一下,你有一盒乐高积木,需要把它们全部倒出来与另一盒积木混合在一起。你不会一块一块地拿,而是直接把整盒积木倾倒出来。JavaScript 的展开运算符(...)就像这样一个便捷的"倾倒"操作——它能将数组或对象的所有元素"展开",让我们可以轻松地合并、复制或传递数据。这个看似简单的三个点,却蕴含着强大的力量,让许多常见操作变得异常简洁。

数组展开基础

展开运算符最常见的用途是在数组操作中。它可以将一个数组的所有元素展开,插入到另一个数组中。

基本用法

javascript
let fruits = ["apple", "banana", "orange"];

// 展开数组
let newArray = [...fruits];
console.log(newArray); // ["apple", "banana", "orange"]

// 这相当于把数组里的每个元素都"拿出来"
// 等同于:
let manual = [fruits[0], fruits[1], fruits[2]];

虽然看起来结果相同,但展开运算符创建了一个新数组,原数组保持不变。这在需要复制数组时特别有用。

合并数组

展开运算符让数组合并变得简单优雅:

javascript
let breakfast = ["coffee", "toast"];
let lunch = ["salad", "sandwich"];
let dinner = ["pasta", "wine"];

// 传统方式:使用 concat
let meals1 = breakfast.concat(lunch, dinner);

// 使用展开运算符
let meals2 = [...breakfast, ...lunch, ...dinner];
console.log(meals2);
// ["coffee", "toast", "salad", "sandwich", "pasta", "wine"]

// 在合并时添加新元素
let allMeals = ["water", ...breakfast, "snack", ...lunch, ...dinner, "dessert"];
console.log(allMeals);
// ["water", "coffee", "toast", "snack", "salad", "sandwich", "pasta", "wine", "dessert"]

这种语法不仅简洁,而且让合并的意图一目了然。你可以在任意位置插入新元素或展开其他数组。

复制数组

展开运算符提供了一种简单的方式来创建数组的浅拷贝:

javascript
let original = [1, 2, 3, 4, 5];
let copy = [...original];

// 修改副本不影响原数组
copy.push(6);
console.log(original); // [1, 2, 3, 4, 5]
console.log(copy); // [1, 2, 3, 4, 5, 6]

// 对比直接赋值
let notACopy = original;
notACopy.push(7);
console.log(original); // [1, 2, 3, 4, 5, 7] - 原数组也变了!

这里的"浅拷贝"很重要。如果数组包含对象,展开运算符只复制对象的引用,而不是对象本身:

javascript
let users = [
  { name: "Alice", age: 25 },
  { name: "Bob", age: 30 },
];

let usersCopy = [...users];

// 修改复制数组中的对象会影响原数组
usersCopy[0].age = 26;
console.log(users[0].age); // 26 - 原数组也变了!

// 因为它们引用同一个对象
console.log(users[0] === usersCopy[0]); // true

实际应用:添加/删除元素

javascript
let todos = [
  { id: 1, task: "Buy groceries", done: false },
  { id: 2, task: "Clean house", done: true },
  { id: 3, task: "Pay bills", done: false },
];

// 在开头添加新任务
let newTodo = { id: 4, task: "Call dentist", done: false };
let updatedTodos = [newTodo, ...todos];

// 删除特定任务(创建新数组,不修改原数组)
let todoIdToRemove = 2;
let filteredTodos = todos.filter((todo) => todo.id !== todoIdToRemove);

// 更优雅的删除:使用展开和 slice
let indexToRemove = 1;
let removedTodos = [
  ...todos.slice(0, indexToRemove),
  ...todos.slice(indexToRemove + 1),
];
console.log(removedTodos);
// [
//   { id: 1, task: "Buy groceries", done: false },
//   { id: 3, task: "Pay bills", done: false }
// ]

对象展开

ES2018 引入了对象展开语法,让对象操作同样简洁。

基本用法

javascript
let user = {
  name: "Sarah",
  age: 28,
  email: "[email protected]",
};

// 展开对象
let userCopy = { ...user };
console.log(userCopy);
// { name: "Sarah", age: 28, email: "[email protected]" }

// 浅拷贝:修改副本不影响原对象
userCopy.name = "Sarah Smith";
console.log(user.name); // "Sarah" - 原对象未变

合并对象

展开运算符让对象合并变得非常直观:

javascript
let basicInfo = {
  name: "Michael",
  age: 35,
};

let contactInfo = {
  email: "[email protected]",
  phone: "555-1234",
};

let address = {
  city: "New York",
  country: "USA",
};

// 合并多个对象
let completeProfile = {
  ...basicInfo,
  ...contactInfo,
  ...address,
};

console.log(completeProfile);
// {
//   name: "Michael",
//   age: 35,
//   email: "[email protected]",
//   phone: "555-1234",
//   city: "New York",
//   country: "USA"
// }

当属性名冲突时,后面的属性会覆盖前面的:

javascript
let defaults = {
  theme: "light",
  language: "en",
  fontSize: 14,
};

let userPreferences = {
  theme: "dark",
  fontSize: 16,
};

// 用户偏好覆盖默认设置
let finalSettings = { ...defaults, ...userPreferences };
console.log(finalSettings);
// { theme: "dark", language: "en", fontSize: 16 }

添加或覆盖属性

javascript
let product = {
  id: 101,
  name: "Laptop",
  price: 1299,
  brand: "TechPro",
};

// 添加新属性
let productWithStock = {
  ...product,
  stock: 15,
  available: true,
};

// 修改现有属性
let discountedProduct = {
  ...product,
  price: 999, // 覆盖原价格
  onSale: true,
};

console.log(discountedProduct);
// {
//   id: 101,
//   name: "Laptop",
//   price: 999,      // 已更新
//   brand: "TechPro",
//   onSale: true
// }

实际应用:不可变更新

在 React 等框架中,不可变数据更新很重要。展开运算符让这变得简单:

javascript
// 模拟 React state 更新
let state = {
  user: {
    name: "Emma",
    email: "[email protected]",
    settings: {
      theme: "light",
      notifications: true,
    },
  },
  cart: [],
  isLoading: false,
};

// ❌ 错误:直接修改 state
// state.user.name = "Emma Smith"; // 不推荐

// ✅ 正确:创建新对象
let newState = {
  ...state,
  user: {
    ...state.user,
    name: "Emma Smith", // 只更新这一个属性
  },
};

// 更新嵌套对象
let updatedState = {
  ...state,
  user: {
    ...state.user,
    settings: {
      ...state.user.settings,
      theme: "dark", // 只更新 theme
    },
  },
};

console.log(state.user.settings.theme); // "light" - 原对象未变
console.log(updatedState.user.settings.theme); // "dark"

在函数调用中展开

展开运算符可以将数组展开为函数参数。

替代 apply

javascript
let numbers = [5, 2, 8, 1, 9];

// 传统方式:使用 apply
let max1 = Math.max.apply(null, numbers);

// 使用展开运算符
let max2 = Math.max(...numbers);
console.log(max2); // 9

// 这相当于:Math.max(5, 2, 8, 1, 9)

// 同样适用于其他函数
let min = Math.min(...numbers);
console.log(min); // 1

混合展开和普通参数

javascript
function createURL(protocol, domain, ...paths) {
  return `${protocol}://${domain}/${paths.join("/")}`;
}

let pathSegments = ["api", "users", "123"];

// 展开数组作为参数
let url = createURL("https", "example.com", ...pathSegments);
console.log(url); // "https://example.com/api/users/123"

// 混合使用
let url2 = createURL(
  "https",
  "api.example.com",
  "v2",
  ...pathSegments,
  "profile"
);
console.log(url2); // "https://api.example.com/v2/api/users/123/profile"

实际应用:动态函数调用

javascript
function logEvent(timestamp, level, message, ...metadata) {
  console.log(`[${timestamp}] ${level}: ${message}`);
  if (metadata.length > 0) {
    console.log("Metadata:", ...metadata);
  }
}

let eventData = [
  "2024-12-05T10:30:00",
  "ERROR",
  "Database connection failed",
  { server: "db-1", port: 5432 },
  { retryCount: 3 },
];

// 展开数组作为参数
logEvent(...eventData);
// [2024-12-05T10:30:00] ERROR: Database connection failed
// Metadata: { server: "db-1", port: 5432 } { retryCount: 3 }

字符串展开

字符串也可以被展开,因为字符串是可迭代的:

javascript
let greeting = "Hello";

// 展开字符串为数组
let letters = [...greeting];
console.log(letters); // ["H", "e", "l", "l", "o"]

// 实际应用:字符统计
function countChars(str) {
  let chars = [...str];
  let counts = {};

  for (let char of chars) {
    counts[char] = (counts[char] || 0) + 1;
  }

  return counts;
}

console.log(countChars("hello"));
// { h: 1, e: 1, l: 2, o: 1 }

// 反转字符串
let reversed = [...greeting].reverse().join("");
console.log(reversed); // "olleH"

Rest vs Spread

展开运算符(...)和剩余参数(rest parameters)使用相同的语法,但作用相反:

javascript
// Spread:将数组/对象展开
let arr = [1, 2, 3];
let newArr = [...arr, 4, 5]; // 展开

// Rest:将多个元素收集为数组
function sum(...numbers) {
  // 收集
  return numbers.reduce((total, n) => total + n, 0);
}
console.log(sum(1, 2, 3, 4, 5)); // 15

// 解构中的 Rest
let [first, second, ...rest] = [1, 2, 3, 4, 5];
console.log(first); // 1
console.log(second); // 2
console.log(rest); // [3, 4, 5] - 收集剩余元素

// 对象中的 Rest
let { name, email, ...otherInfo } = {
  name: "Alice",
  email: "[email protected]",
  age: 25,
  city: "London",
};
console.log(otherInfo); // { age: 25, city: "London" }

简单记忆:

  • Spread(展开):从一个变量取出多个值 → ...arr 变成 1, 2, 3
  • Rest(收集):将多个值收集到一个变量 → 1, 2, 3 变成 [1, 2, 3]

实战案例:购物车系统

让我们综合运用展开运算符构建一个购物车管理系统:

javascript
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  // 添加商品
  addItem(product) {
    this.items = [...this.items, { ...product, addedAt: new Date() }];
  }

  // 添加多个商品
  addItems(...products) {
    this.items = [
      ...this.items,
      ...products.map((p) => ({ ...p, addedAt: new Date() })),
    ];
  }

  // 删除商品
  removeItem(productId) {
    this.items = this.items.filter((item) => item.id !== productId);
  }

  // 更新商品数量
  updateQuantity(productId, quantity) {
    this.items = this.items.map((item) =>
      item.id === productId ? { ...item, quantity } : item
    );
  }

  // 应用折扣
  applyDiscount(discountPercent) {
    this.items = this.items.map((item) => ({
      ...item,
      originalPrice: item.price,
      price: item.price * (1 - discountPercent / 100),
      discounted: true,
    }));
  }

  // 清空购物车
  clear() {
    this.items = [];
  }

  // 获取总价
  getTotal() {
    return this.items.reduce((sum, item) => {
      return sum + item.price * (item.quantity || 1);
    }, 0);
  }

  // 合并另一个购物车
  merge(otherCart) {
    let existingIds = new Set(this.items.map((item) => item.id));

    // 只添加不存在的商品
    let newItems = otherCart.items.filter((item) => !existingIds.has(item.id));

    this.items = [...this.items, ...newItems];
  }

  // 导出购物车状态
  export() {
    return {
      items: [...this.items], // 返回副本,防止外部修改
      total: this.getTotal(),
      count: this.items.length,
      exportedAt: new Date(),
    };
  }
}

// 使用示例
let cart = new ShoppingCart();

// 添加商品
cart.addItem({
  id: 1,
  name: "Laptop",
  price: 1299,
  quantity: 1,
});

// 批量添加
cart.addItems(
  { id: 2, name: "Mouse", price: 29, quantity: 2 },
  { id: 3, name: "Keyboard", price: 89, quantity: 1 }
);

console.log(cart.items.length); // 3

// 更新数量
cart.updateQuantity(2, 3);

// 应用 10% 折扣
cart.applyDiscount(10);

console.log(cart.getTotal()); // 计算折后总价

// 合并另一个购物车
let cart2 = new ShoppingCart();
cart2.addItem({ id: 4, name: "Monitor", price: 399, quantity: 1 });

cart.merge(cart2);
console.log(cart.items.length); // 4

// 导出购物车
let cartData = cart.export();
console.log(cartData);
// {
//   items: [...],
//   total: ...,
//   count: 4,
//   exportedAt: ...
// }

性能考虑

展开运算符虽然便捷,但在某些情况下需要注意性能:

大型数组的展开

javascript
let largeArray = Array.from({ length: 100000 }, (_, i) => i);

// ❌ 在循环中重复展开大数组会影响性能
console.time("spread in loop");
let result1 = [];
for (let i = 0; i < 100; i++) {
  result1 = [...result1, i]; // 每次都创建新数组
}
console.timeEnd("spread in loop");

// ✅ 使用 push 更高效
console.time("push in loop");
let result2 = [];
for (let i = 0; i < 100; i++) {
  result2.push(i); // 直接修改数组
}
console.timeEnd("push in loop");

// ✅ 如果需要不可变性,考虑批量操作
let updates = [1, 2, 3, 4, 5];
let result3 = [...result2, ...updates]; // 一次性展开多个值

深度嵌套对象

javascript
let deepObject = {
  level1: {
    level2: {
      level3: {
        level4: {
          value: 42,
        },
      },
    },
  },
};

// ❌ 展开运算符只做浅拷贝
let copy = { ...deepObject };
copy.level1.level2.level3.level4.value = 100;
console.log(deepObject.level1.level2.level3.level4.value); // 100 - 原对象也变了!

// ✅ 深拷贝需要递归或使用库
function deepClone(obj) {
  if (obj === null || typeof obj !== "object") return obj;

  if (Array.isArray(obj)) {
    return obj.map((item) => deepClone(item));
  }

  let cloned = {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloned[key] = deepClone(obj[key]);
    }
  }
  return cloned;
}

// 或者使用 JSON(有限制)
let jsonCopy = JSON.parse(JSON.stringify(deepObject));

常见陷阱与最佳实践

1. 展开 undefined 或 null

javascript
// ❌ 展开 null 或 undefined 会报错
// let arr = [...null];      // TypeError
// let obj = { ...undefined }; // TypeError

// ✅ 使用默认值
let safeArray = [...(nullableArray || [])];
let safeObject = { ...(nullableObject || {}) };

// ✅ 使用可选链
let result = [...(data?.items || [])];

2. 展开不可迭代对象

javascript
let number = 123;

// ❌ 数字不可迭代
// let arr = [...number]; // TypeError

// ✅ 只有可迭代对象才能展开为数组
let validSpreads = [
  ...[1, 2, 3], // 数组 ✓
  ..."abc", // 字符串 ✓
  ...new Set([1, 2, 3]), // Set ✓
  ...new Map([[1, "a"]]), // Map ✓ (展开为键值对数组)
];

3. 属性覆盖顺序

javascript
let user = { name: "Alice", age: 25 };

// 顺序很重要!
let updated1 = { ...user, name: "Alice Smith" };
console.log(updated1.name); // "Alice Smith"

let updated2 = { name: "Alice Smith", ...user };
console.log(updated2.name); // "Alice" - 被原对象覆盖了!

4. 不要过度使用

javascript
// ❌ 简单操作不需要展开
let arr = [1, 2];
let newArr = [...arr];
newArr.push(3);

// ✅ 如果会修改数组,直接用 slice
let simpler = arr.slice();
simpler.push(3);

// ❌ 过度展开降低可读性
let complex = {
  ...{ ...{ ...baseConfig, ...mixin1 }, ...mixin2 },
  ...override,
};

// ✅ 分步骤更清晰
let step1 = { ...baseConfig, ...mixin1 };
let step2 = { ...step1, ...mixin2 };
let final = { ...step2, ...override };

总结

展开运算符(...)是 ES6+ 中最强大和最常用的特性之一:

  • 数组展开 - 合并数组、复制数组、转换为函数参数
  • 对象展开 - 合并对象、克隆对象、更新属性
  • 灵活组合 - 可以在任意位置展开,与普通值混合使用
  • 浅拷贝 - 创建数组/对象的浅拷贝,适合不可变更新

展开运算符让代码更简洁、更声明式,但要注意它是浅拷贝的特性,不要在嵌套数据结构中期望深拷贝效果。在处理大型数据或频繁操作时,也要考虑性能影响。

掌握展开运算符是现代 JavaScript 开发的必备技能,它与解构赋值、剩余参数等特性结合使用,能让你的代码更优雅、更易维护。记住:简洁并不意味着过度简化——在可读性和简洁性之间找到平衡才是关键。