深浅拷贝与数组方法

23 分钟

为什么拷贝和数组方法总是面试重灾区

拷贝问题的本质是对 JavaScript 数据类型和内存模型的理解。你在项目里改了一个对象的属性,结果另一个"看起来无关"的变量也跟着变了——这种 bug 几乎每个前端都踩过。面试官通过拷贝问题考察的是你对引用类型的掌握深度。

数组方法则是日常编码的基本功。mapfilterreduce 用得顺不顺手,直接影响代码可读性和维护成本。面试中手写 reduce 实现数组去重、用 flat 拍平嵌套结构这类题目出现频率极高。

这篇文章先把拷贝讲透,再系统过一遍高频数组方法,最后覆盖面试中常见的追问。

值类型与引用类型:拷贝问题的根源

JavaScript 的数据类型分两大类:

值类型(原始类型):numberstringbooleanundefinednullsymbolbigint。变量直接存储值本身,赋值时复制的是值的副本。

引用类型objectarrayfunctionMapSet 等。变量存储的是堆内存中的地址(引用),赋值时复制的是引用,而不是对象本身。

// 值类型:互不影响
let a = 1;
let b = a;
b = 2;
console.log(a); // 1

// 引用类型:共享同一块内存
let obj1 = { name: 'Alice' };
let obj2 = obj1;
obj2.name = 'Bob';
console.log(obj1.name); // 'Bob' —— obj1 也被改了

obj2 = obj1 这行代码并没有创建新对象,只是让 obj2obj1 指向了同一块内存。想要"真正复制一个对象",就需要拷贝。

浅拷贝:只复制第一层

浅拷贝会创建一个新对象,把原对象的第一层属性复制过来。如果属性值是值类型,拷贝的是值本身;如果属性值是引用类型,拷贝的是引用地址。换句话说,嵌套对象还是共享的。

Object.assign

const original = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, original);

copy.a = 99;
console.log(original.a); // 1 —— 值类型,互不影响

copy.b.c = 99;
console.log(original.b.c); // 99 —— 嵌套对象还是同一个引用

Object.assign 的行为是把所有可枚举的自有属性从源对象复制到目标对象。它不会复制原型链上的属性,也不会复制不可枚举属性。

展开运算符

const original = { a: 1, b: { c: 2 } };
const copy = { ...original };

行为和 Object.assign({}, original) 完全一致,本质上也是浅拷贝。语法更简洁,项目中用得更多。

Array.from 与数组展开

对数组做浅拷贝有几种方式:

const arr = [1, [2, 3], { x: 4 }];

const copy1 = [...arr];
const copy2 = Array.from(arr);
const copy3 = arr.slice();
const copy4 = arr.concat();

这四种方式都是浅拷贝,嵌套的数组和对象仍然共享引用。

浅拷贝的适用场景

当你确定对象只有一层结构,或者你只需要修改第一层属性时,浅拷贝就够用了。典型场景包括:React 中更新 state 的第一层字段、合并配置项默认值等。

深拷贝:完全独立的副本

深拷贝会递归复制对象的所有层级,拷贝后的新对象和原对象完全独立,修改任何一方都不会影响另一方。

JSON.parse(JSON.stringify()) —— 简单但有坑

const original = { a: 1, b: { c: 2 }, d: [3, 4] };
const copy = JSON.parse(JSON.stringify(original));

copy.b.c = 99;
console.log(original.b.c); // 2 —— 完全独立

这是最简单的深拷贝方式,但它有一堆限制:

const problematic = {
  fn: function() {},       // 函数 → 丢失
  undef: undefined,        // undefined → 丢失
  sym: Symbol('id'),       // Symbol → 丢失
  date: new Date(),        // Date → 变成字符串
  regex: /abc/g,           // RegExp → 变成空对象 {}
  map: new Map(),          // Map → 变成空对象 {}
  set: new Set(),          // Set → 变成空对象 {}
  nan: NaN,                // NaN → 变成 null
  infinity: Infinity,      // Infinity → 变成 null
};

const copy = JSON.parse(JSON.stringify(problematic));
// 函数、undefined、Symbol 直接消失
// Date 变成 "2026-05-13T..." 字符串
// RegExp、Map、Set 变成 {}

另一个致命问题:循环引用直接报错

const obj = { a: 1 };
obj.self = obj;
JSON.parse(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON

适用场景:纯数据对象(只包含 numberstringbooleannull、普通对象和数组),且没有循环引用。比如从接口拿到的 JSON 数据做快照,用这个方式完全没问题。

structuredClone —— 现代浏览器的原生方案

structuredClone 是浏览器和 Node.js(v17+)提供的原生深拷贝 API,基于结构化克隆算法。

const original = {
  date: new Date(),
  regex: /abc/g,
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3]),
  nested: { deep: { value: 42 } },
};

const copy = structuredClone(original);
copy.nested.deep.value = 0;
console.log(original.nested.deep.value); // 42

它能正确处理 DateRegExpMapSetArrayBufferError 等内置类型,也能处理循环引用:

const obj = { a: 1 };
obj.self = obj;
const copy = structuredClone(obj); // 正常工作,不会报错
console.log(copy.self === copy); // true —— 循环引用关系被保留

structuredClone 也有限制:

  • 不能克隆函数:会抛出 DataCloneError
  • 不能克隆 DOM 节点
  • 不会保留原型链:克隆出来的对象原型是 Object.prototype
  • 不会复制属性描述符gettersetterwritable: false 等都不会保留

适用场景:绝大多数需要深拷贝的业务场景。如果你的对象里没有函数和 DOM 节点,优先用它。

手写深拷贝:面试必考

面试中经常要求手写一个能处理循环引用的深拷贝函数。核心思路是递归 + 用 WeakMap 记录已经拷贝过的对象来处理循环引用。

function deepClone(source, cache = new WeakMap()) {
  // 值类型和 null 直接返回
  if (source === null || typeof source !== 'object') {
    return source;
  }

  // 命中缓存,说明存在循环引用,直接返回之前的拷贝
  if (cache.has(source)) {
    return cache.get(source);
  }

  // 处理特殊内置类型
  if (source instanceof Date) return new Date(source);
  if (source instanceof RegExp) return new RegExp(source.source, source.flags);
  if (source instanceof Map) {
    const mapCopy = new Map();
    cache.set(source, mapCopy);
    source.forEach((value, key) => {
      mapCopy.set(deepClone(key, cache), deepClone(value, cache));
    });
    return mapCopy;
  }
  if (source instanceof Set) {
    const setCopy = new Set();
    cache.set(source, setCopy);
    source.forEach((value) => {
      setCopy.add(deepClone(value, cache));
    });
    return setCopy;
  }

  // 创建新对象或数组,保留原型链
  const target = Array.isArray(source) ? [] : Object.create(Object.getPrototypeOf(source));

  // 先存入缓存,再递归(防止循环引用时无限递归)
  cache.set(source, target);

  // 拷贝所有自有属性(包括 Symbol 键)
  const keys = [...Object.keys(source), ...Object.getOwnPropertySymbols(source)];
  for (const key of keys) {
    target[key] = deepClone(source[key], cache);
  }

  return target;
}

验证一下:

const obj = {
  name: 'test',
  nested: { value: 42 },
  arr: [1, 2, [3]],
  date: new Date(),
  regex: /abc/gi,
  map: new Map([['a', 1]]),
};
obj.self = obj; // 循环引用

const cloned = deepClone(obj);
console.log(cloned.self === cloned);         // true
console.log(cloned.nested === obj.nested);   // false
console.log(cloned.date.getTime() === obj.date.getTime()); // true

面试中写到这个程度基本就够了。如果面试官追问"还能优化什么",可以提:

  • 使用 Reflect.ownKeys() 替代手动合并 Object.keysgetOwnPropertySymbols
  • 处理 ArrayBufferTypedArray 等二进制类型
  • 保留属性描述符(通过 Object.getOwnPropertyDescriptors

拷贝方式对比

方式深/浅循环引用函数Date/RegExp性能
Object.assign / 展开保留引用保留引用
JSON.parse/stringify❌ 报错❌ 丢失❌ 转换异常
structuredClone❌ 报错
手写递归✅ (WeakMap)可支持取决于实现

数组方法全景:按功能分类

JavaScript 的数组方法有几十个,面试中不会全考,但需要你有全局认知。按功能可以分为四大类:

遍历类

方法返回值是否改变原数组用途
forEachundefined纯遍历,不产生新数组
map新数组遍历并转换每个元素

查找类

方法返回值用途
find第一个匹配的元素 / undefined找单个元素
findIndex第一个匹配的索引 / -1找索引
indexOf第一个匹配的索引 / -1按值查找(用 ===
includesboolean判断是否存在
someboolean是否存在至少一个满足条件的
everyboolean是否全部满足条件

转换类

方法返回值用途
filter新数组过滤出满足条件的元素
reduce累积值将数组归约为单个值
flat新数组拍平嵌套数组
flatMap新数组先 map 再 flat(1)
slice新数组截取子数组
concat新数组合并数组

排序与修改类(会改变原数组)

方法用途注意事项
sort排序默认按字符串 Unicode 排序,数字排序必须传比较函数
reverse反转直接改变原数组
splice增删改直接改变原数组
push / pop尾部增删直接改变原数组
unshift / shift头部增删直接改变原数组

map:转换每个元素,返回新数组

map 对数组每个元素执行回调函数,把返回值收集到一个新数组中。

const numbers = [1, 2, 3, 4];
const doubled = numbers.map(num => num * 2);
// [2, 4, 6, 8]

回调函数接收三个参数:(currentValue, index, array)

const users = [
  { name: 'Alice', age: 25 },
  { name: 'Bob', age: 30 },
];

const names = users.map(user => user.name);
// ['Alice', 'Bob']

注意点map 一定会返回和原数组等长的新数组。如果你在回调里没有 return,对应位置会是 undefined

[1, 2, 3].map(num => {
  if (num > 1) return num * 2;
  // num === 1 时没有 return,结果是 undefined
});
// [undefined, 4, 6]

forEach 与 map 的区别

这是面试高频追问。核心区别:

维度forEachmap
返回值undefined新数组
用途执行副作用(打印、修改外部变量等)数据转换
能否链式调用不能
能否中断不能(除非抛异常)不能

经验法则:如果你需要一个新数组,用 map;如果只是"对每个元素做点什么"但不需要返回值,用 forEach。如果你需要中途跳出循环,两个都不合适,用 for...of + break

filter:筛选满足条件的元素

const numbers = [1, 2, 3, 4, 5, 6];
const evenNumbers = numbers.filter(num => num % 2 === 0);
// [2, 4, 6]

回调函数返回 truthy 值的元素会被保留。filter 不会改变原数组。

组合使用 filter + map 是非常常见的模式:

const users = [
  { name: 'Alice', active: true, age: 25 },
  { name: 'Bob', active: false, age: 30 },
  { name: 'Charlie', active: true, age: 35 },
];

// 先筛选活跃用户,再提取名字
const activeNames = users
  .filter(user => user.active)
  .map(user => user.name);
// ['Alice', 'Charlie']

reduce:数组方法里的"万能工具"

reduce 把数组"归约"成一个值。它接收一个回调函数和一个初始值,回调函数的参数是 (accumulator, currentValue, index, array)

const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((acc, num) => acc + num, 0);
// 10

执行过程:

  1. acc = 0, num = 1 → 返回 1
  2. acc = 1, num = 2 → 返回 3
  3. acc = 3, num = 3 → 返回 6
  4. acc = 6, num = 4 → 返回 10

始终传初始值。不传初始值时,reduce 会用数组第一个元素作为初始 acc,空数组会直接报错。

reduce 实现分组

const people = [
  { name: 'Alice', department: 'Engineering' },
  { name: 'Bob', department: 'Marketing' },
  { name: 'Charlie', department: 'Engineering' },
  { name: 'Diana', department: 'Marketing' },
];

const grouped = people.reduce((groups, person) => {
  const dept = person.department;
  if (!groups[dept]) {
    groups[dept] = [];
  }
  groups[dept].push(person);
  return groups;
}, {});

// {
//   Engineering: [{ name: 'Alice', ... }, { name: 'Charlie', ... }],
//   Marketing: [{ name: 'Bob', ... }, { name: 'Diana', ... }]
// }

现代 JavaScript 已经有了 Object.groupBy()(ES2024),功能等价但语义更清晰。不过面试里通常还是考 reduce 实现。

reduce 实现管道

把一组函数串联起来,前一个函数的输出作为后一个的输入:

const pipe = (...fns) => (initialValue) =>
  fns.reduce((acc, fn) => fn(acc), initialValue);

const add10 = x => x + 10;
const multiply2 = x => x * 2;
const toString = x => `Result: ${x}`;

const transform = pipe(add10, multiply2, toString);
console.log(transform(5)); // "Result: 30"

reduce 实现去重

const numbers = [1, 2, 2, 3, 3, 3, 4];

const unique = numbers.reduce((acc, num) => {
  if (!acc.includes(num)) {
    acc.push(num);
  }
  return acc;
}, []);
// [1, 2, 3, 4]

这种方式时间复杂度是 O(n²),因为 includes 每次都要遍历 acc。更好的做法是用 Set(见后文去重专题)。

find 与 some / every

find 返回第一个满足条件的元素,找不到返回 undefined

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
];

const bob = users.find(user => user.name === 'Bob');
// { id: 2, name: 'Bob' }

some 检查是否存在至少一个满足条件的元素,返回 boolean

const hasAdmin = users.some(user => user.role === 'admin');

every 检查是否所有元素都满足条件:

const allActive = users.every(user => user.active);

someevery 都有短路特性——some 找到第一个 true 就停止,every 找到第一个 false 就停止。在大数组上性能比 filter(...).length > 0 好得多。

flat 与 flatMap

flat 把嵌套数组拍平,参数是拍平的深度,默认 1 层:

const nested = [1, [2, 3], [4, [5, 6]]];

nested.flat();    // [1, 2, 3, 4, [5, 6]]
nested.flat(2);   // [1, 2, 3, 4, 5, 6]
nested.flat(Infinity); // [1, 2, 3, 4, 5, 6] —— 无论多深都拍平

flatMap 等价于先 mapflat(1),但只会拍平一层,性能比分开调用稍好:

const sentences = ['Hello World', 'Goodbye Moon'];

const words = sentences.flatMap(sentence => sentence.split(' '));
// ['Hello', 'World', 'Goodbye', 'Moon']

// 等价于:
// sentences.map(s => s.split(' ')).flat()
// [['Hello', 'World'], ['Goodbye', 'Moon']].flat()

flatMap 的一个实用技巧是用它来同时做 filter + map

const numbers = [1, 2, 3, 4, 5, 6];

// 只保留偶数并翻倍
const result = numbers.flatMap(num =>
  num % 2 === 0 ? [num * 2] : []
);
// [4, 8, 12]

返回空数组等于过滤掉,返回包含一个元素的数组等于转换,拍平后就得到了想要的结果。

数组去重的多种方式

这是面试中出现频率极高的题目。按照方案从简单到复杂排列:

Set 去重(推荐)

const unique = [...new Set([1, 2, 2, 3, 3, 4])];
// [1, 2, 3, 4]

一行搞定,时间复杂度 O(n)。能处理原始类型,但对引用类型(对象)做的是引用比较,所以两个 { id: 1 } 不会被认为是重复的。

filter + indexOf

const unique = arr.filter((item, index) => arr.indexOf(item) === index);

原理:indexOf 返回的是第一次出现的位置,如果当前索引不等于第一次出现的位置,说明是重复元素。时间复杂度 O(n²)。

reduce 去重

const unique = arr.reduce((acc, item) => {
  return acc.includes(item) ? acc : [...acc, item];
}, []);

时间复杂度同样 O(n²)。

对象数组按某个字段去重

实际项目中更常见的需求是按对象的某个字段去重:

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 1, name: 'Alice (duplicate)' },
];

// 方式一:Map
const uniqueUsers = [...new Map(users.map(user => [user.id, user])).values()];

// 方式二:reduce
const uniqueUsers2 = users.reduce((acc, user) => {
  if (!acc.some(existing => existing.id === user.id)) {
    acc.push(user);
  }
  return acc;
}, []);

Map 方式的特点是后出现的会覆盖先出现的(保留最后一个),reduce 方式保留的是第一个。具体用哪种取决于业务需求。

sort 的陷阱

sort 是最容易踩坑的数组方法。默认情况下,它会把元素转成字符串再按 Unicode 码点排序:

[10, 9, 2, 1, 100].sort();
// [1, 10, 100, 2, 9] —— 不是你想要的

数字排序必须传比较函数:

// 升序
[10, 9, 2, 1, 100].sort((a, b) => a - b);
// [1, 2, 9, 10, 100]

// 降序
[10, 9, 2, 1, 100].sort((a, b) => b - a);

sort 会改变原数组,这在 React 等框架中容易引发状态污染。安全做法是先拷贝再排序:

const sorted = [...original].sort((a, b) => a - b);

ES2023 新增的 toSorted 方法返回新数组,不改变原数组:

const sorted = original.toSorted((a, b) => a - b);

面试高频追问汇总

Q:如何判断一个变量是数组?

Array.isArray(arr) 是最可靠的方式。typeof [] 返回 'object',不能用来判断数组。instanceof Array 在跨 iframe 场景下会失效(不同 iframe 有独立的 Array 构造函数)。

Q:map 里能用 async/await 吗?

能用,但 map 不会等待 Promise resolve。map 的回调如果是 async 函数,返回的新数组里每个元素都是 Promise,你需要用 Promise.all 包一层:

const results = await Promise.all(
  urls.map(async (url) => {
    const response = await fetch(url);
    return response.json();
  })
);

Q:for...offorEach 的区别?

  • for...of 可以用 breakcontinuereturn 中断循环,forEach 不行
  • for...of 可以遍历任何可迭代对象(MapSetstringNodeList),forEach 只能用在数组和类数组上
  • for...of 支持 await(配合 for await...of),forEach 内的 await 不会让迭代等待

Q:哪些数组方法会改变原数组?

pushpopshiftunshiftsplicesortreversefillcopyWithin

ES2023 新增了不改变原数组的对应版本:toSortedtoReversedtoSplicedwith

Q:lodash 的 _.cloneDeepstructuredClone 怎么选?

如果项目已经引入了 lodash 且需要克隆包含函数的对象,用 _.cloneDeep。否则优先用 structuredClone——它是原生 API,零依赖,性能也更好。structuredClone 不支持函数克隆,这是唯一需要 _.cloneDeep 的场景。

总结

拷贝和数组方法看似是两个独立话题,但面试中经常组合考察。比如"用 reduce 实现深拷贝""为什么 map 返回的新数组里的对象改了原数组也会变"——这些问题背后都是引用类型的理解。

几个关键要点:

  • 理解值类型和引用类型的内存模型是理解拷贝的前提
  • 浅拷贝只复制第一层,嵌套对象仍然共享引用
  • 深拷贝优先用 structuredClone,需要兼容函数时用递归实现或 lodash
  • 手写深拷贝的核心是递归 + WeakMap 处理循环引用
  • 数组方法要区分会不会改变原数组,这在 React/Vue 等框架中直接影响状态管理
  • reduce 是最灵活的数组方法,分组、管道、去重、扁平化都能实现
  • 数组去重优先用 Set,O(n) 时间复杂度且代码最简洁