【每日一面】深浅拷贝

2026-09-11 19 min 6615 字 -- 次阅读
摘要

浅拷贝只复制第一层、嵌套引用依旧共享,深拷贝则要递归到每一层,还得用 WeakMap 兜住循环引用、用类型分支兜住 Date / RegExp / Map / Set。

基础问答

问:浅拷贝和深拷贝有什么区别?为什么需要深拷贝?

: 浅拷贝和深拷贝的核心区别在于 是否递归复制嵌套的引用类型

JavaScript 的数据类型分为基本类型(string、number、boolean、null、undefined、symbol、bigint)和引用类型(object、array、function 等)。基本类型值直接存储在栈内存中,赋值即复制值;引用类型的栈内存只存指向堆内存的地址,赋值仅拷贝地址。

  • 浅拷贝:只复制对象的第一层属性,嵌套的引用类型拷贝的是引用地址,新旧对象共享嵌套数据。

  • 深拷贝:递归复制所有层级,新旧对象完全独立,修改拷贝后的对象不会影响原始对象。

    使用深拷贝的目的很简单,就是为了防止因共享引用而意外篡改原始数据。例如 React 中 setState 要求不可变性,Vue 响应式系统中也需要深拷贝来隔离数据。

扩展延伸

值与引用

在讨论拷贝之前,必须先理解 JavaScript 的传值方式

javascript
// 基本类型:传值
let a = 10;
let b = a;
b = 20;
console.log(a); // 10 —— 互不影响

// 引用类型:传引用
let obj1 = { x: 10 };
let obj2 = obj1;
obj2.x = 20;
console.log(obj1.x); // 20 —— 改一个全改

如上这种差异是深浅拷贝问题的根源。

常见浅拷贝方式

方式适用对象特点
Object.assign({}, obj)对象仅拷贝自身可枚举属性,不拷贝继承属性
扩展运算符 { ...obj }对象/数组语法简洁,ES6 常用
Array.prototype.slice()数组返回新数组,适合截取子集

这里用代码展示浅拷贝的共享引用问题

javascript
const original = { name: 'Alice', address: { city: 'Beijing' } };
const copy = { ...original };

copy.name = 'Bob';            // 基本类型:不影响原对象
copy.address.city = 'Shanghai'; // 引用类型:原对象也被改了

console.log(original.name);          // 'Alice'
console.log(original.address.city);  // 'Shanghai' —— 被意外修改了

深拷贝的实现

深拷贝的核心难题有三个:递归遍历、循环引用、特殊类型处理

  1. JSON 序列化

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

    这种方式最常用,但是存在一些问题,比如循环引用无法拷贝,部分类型序列化会丢失等:

    javascript
    const problematic = {
      func: () => {},
      undef: undefined,
      sym: Symbol('id'),
      big: 123n,          // BigInt
      date: new Date(),
      reg: /test/g,
      self: null          // 故意制造循环引用标记
    };
    problematic.self = problematic; // 循环引用
    
    // JSON.parse(JSON.stringify(problematic)) 会直接报错
    类型序列化结果原因
    Function丢失(键被删除)JSON 不支持函数
    undefined丢失(键被删除)JSON 无对应类型
    Symbol丢失JSON 无对应类型
    BigInt抛出 TypeError无法序列化
    Date转为字符串反序列化后是字符串而非 Date 对象
    RegExp转为空对象 {}无法正确还原
    循环引用抛出 TypeError无限递归
  2. 手写深拷贝

    手写深拷贝是过去前端面试的一个必考点,不过现在也很少见考这个了,但是仍旧是需要理解的内容。

    实现的基本逻辑:递归遍历 + WeakMap 缓存 + 特殊类型分支

    flowchart TD
        A[输入 target] --> B{target 是 null 或<br/>非 object?}
        B -->|是| C[直接返回 target]
        B -->|否| D{WeakMap 中<br/>已缓存该对象?}
        D -->|是| E[返回缓存副本]
        D -->|否| F{判断 target 类型}
        F -->|Date| G["new Date(target)"]
        F -->|RegExp| H["new RegExp(target)"]
        F -->|Map| I["new Map() 递归 set"]
        F -->|Set| J["new Set() 递归 add"]
        F -->|Array| K["new Array()"]
        F -->|Object| L[保持原型链创建]
        G & H & I & J & K & L --> M[缓存到 WeakMap]
        M --> N[遍历属性递归拷贝]
        N --> O[返回 copy]
    javascript
    function deepClone(target, cache = new WeakMap()) {
      // 基本类型直接返回
      if (target === null || typeof target !== 'object') {
        return target;
      }
    
      // 处理循环引用
      if (cache.has(target)) {
        return cache.get(target);
      }
    
      let result;
    
      // 处理 Date
      if (target instanceof Date) {
        result = new Date(target);
        cache.set(target, result);
        return result;
      }
    
      // 处理 RegExp
      if (target instanceof RegExp) {
        result = new RegExp(target.source, target.flags);
        result.lastIndex = target.lastIndex;
        cache.set(target, result);
        return result;
      }
    
      // 处理 Map
      if (target instanceof Map) {
        result = new Map();
        cache.set(target, result);
        target.forEach((value, key) => {
          result.set(deepClone(key, cache), deepClone(value, cache));
        });
        return result;
      }
    
      // 处理 Set
      if (target instanceof Set) {
        result = new Set();
        cache.set(target, result);
        target.forEach(value => {
          result.add(deepClone(value, cache));
        });
        return result;
      }
    
      // 处理数组和普通对象(保持原型链)
      result = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target));
      cache.set(target, result);
    
      // 遍历自身属性(包括 Symbol 属性)
      const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
      for (const key of keys) {
        result[key] = deepClone(target[key], cache);
      }
    
      return result;
    }
    
    // 测试
    const obj = {
      name: 'test',
      date: new Date(),
      reg: /hello/gi,
      map: new Map([['key', 'value']]),
      set: new Set([1, 2, 3]),
      nested: { a: { b: 1 } },
      [Symbol('id')]: 'symbol-value'
    };
    obj.self = obj; // 循环引用
    
    const cloned = deepClone(obj);
    console.log(cloned.self === cloned); // true —— 循环引用保留结构
    console.log(cloned === obj);         // false —— 完全独立
    console.log(cloned.date instanceof Date); // true
    console.log(cloned.reg instanceof RegExp); // true

小结

对比维度浅拷贝深拷贝
拷贝层级仅第一层属性递归所有层级
嵌套引用共享引用完全独立
内存占用较小较大
性能快(O(n))慢(O(n * depth))
循环引用不需处理需要 WeakMap 缓存
特殊对象(Date/RegExp/Map)直接拷贝引用需要类型分支处理
实现复杂度简单复杂
天然处理 Symbol 属性Object.assign/扩展运算符可以手写需显式处理

面试追问

追问1:Object.assign() 和扩展运算符 {...obj} 在浅拷贝上有区别吗?

两者都做浅拷贝,但有一个小的差异,Object.assign() 会触发源对象的 setter,而扩展运算符不会。此外,Object.assign() 接收多个源对象时有合并效果,扩展运算符仅操作单个源。

javascript
const obj = {
  get a() { return 1; },
  set a(v) { console.log('setter called', v); }
};
const copy1 = { ...obj };          // 不触发 setter
const copy2 = Object.assign({}, obj); // 触发 setter

追问2:手写深拷贝时,用 WeakMap 和普通 Map 有什么区别?为什么选 WeakMap

WeakMap 的 key 是弱引用,当原对象被垃圾回收时,WeakMap 中对应的条目会被自动清除,不会造成内存泄漏。如果用普通 Map,即使原对象已经不再使用,只要深拷贝过程中的 Map 还在引用它,就无法被 GC 回收。

javascript
// 用 Map 的隐患
function deepCloneWithMap(target, map = new Map()) {
  // ... 即使 target 不再使用,map 里的引用会阻止 GC
}

追问3:以下代码输出什么?为什么?

javascript
const a = { x: 1 };
const b = { ...a, x: undefined };
const c = JSON.parse(JSON.stringify(a));

console.log(b.x);          // ?
console.log(c.x);          // ?

delete a.x;
console.log('x' in a);     // ?
console.log('x' in b);     // ?
console.log('x' in c);     // ?

输出依次为:undefined1falsetruefalse

  • b.x = undefined 是显式赋值,键仍然存在,值为 undefined

  • JSON.parse(JSON.stringify({ x: 1 })) 正常序列化/反序列化,x 仍为 1

  • delete a.x 移除了键,所以 'x' in a 为 false

  • 'x' in b 为 true,因为键还在(即使值为 undefined)

  • JSON.stringify忽略值为 undefined 的键,所以 c 中没有 x 键,'x' in c 为 false

追问4:实现一个按需深拷贝函数,给定一个 paths 数组,只对指定路径做深拷贝,其他层级用浅拷贝。

javascript
function selectiveClone(obj, paths) {
  // paths: [['a', 'b'], ['c']]
  // 要求:obj.a.b 深拷贝,obj.c 深拷贝,其余浅拷贝
  // 请实现
}

答案:

javascript
function selectiveClone(obj, paths) {
  const deepPaths = new Set(paths.map(p => p.join('.')));

  function clone(value, path = '') {
    if (value === null || typeof value !== 'object') return value;

    // 当前路径在深拷贝集合中,进入深拷贝
    if (deepPaths.has(path)) {
      // 简单的深拷贝(处理基本嵌套,略去特殊类型)
      if (Array.isArray(value)) {
        return value.map((item, i) => clone(item, `${path}[${i}]`));
      }
      const result = {};
      for (const key of Object.keys(value)) {
        const newPath = path ? `${path}.${key}` : key;
        result[key] = clone(value[key], newPath);
      }
      return result;
    }

    // 不在深拷贝集合中,浅拷贝
    if (Array.isArray(value)) return [...value];
    return { ...value };
  }

  return clone(obj, '');
}

// 测试
const data = {
  a: { b: { c: 1 }, d: 2 },
  e: { f: 3 }
};
const cloned = selectiveClone(data, [['a', 'b'], ['e']]);
console.log(cloned.a.b === data.a.b); // false —— 深拷贝
console.log(cloned.a.d === data.a.d); // true —— 浅拷贝,共享引用
console.log(cloned.e === data.e);     // false —— 深拷贝

追问5:在 React/Vue 项目中,深拷贝的不当使用可能导致什么性能问题?怎么解决?

深拷贝在以下场景容易成为性能瓶颈:

  1. 大对象频繁拷贝:比如 Redux reducer 中每次 dispatch 都深拷贝整个 state tree,O(n * depth) 的开销在大型表单或数据表格中可能导致页面卡顿

  2. Props 层层穿透:父组件深拷贝数据后传入子组件,子组件再做一次深拷贝,O(n²) 复杂度

  3. 不可变数据的低效实现: 用深拷贝代替 Immutable.js 或 Immer 的结构共享

    解决方案

    方案原理适用场景
    Immer (produce)Proxy 拦截 + 结构共享Redux reducer、复杂状态
    Immer (patch)增量变更 + 补丁回放撤销/重做、协同编辑
    Immutable.js持久化数据结构(Trie)大型嵌套数据,需要高效比较
    浅比较 + 不可变更新展开运算符手动更新指定层级简单状态、小规模数据
    javascript
    // Immer 替代深拷贝
    import { produce } from 'immer';
    
    const baseState = { users: [{ name: 'Alice', scores: [90, 95] }] };
    
    const nextState = produce(baseState, draft => {
      draft.users[0].scores.push(100);
    });
    // Immer 只复制变更的节点,未变更的节点共享引用
    // 性能远优于 JSON.parse(JSON.stringify(...))
评论