面试官连环追问深浅拷贝,我竟被虐得体无完肤!

71 阅读8分钟

从 Object.assign () 开场:浅拷贝的 "入门级选手"

面试时一提到 "深浅拷贝",面试官十有八九会先甩来一句:"聊聊 Object.assign () 吧?" 别慌,这可不是单纯考 API,藏着对 "拷贝" 的底层考察呢。

先明确它的定义:Object.assign () 会把一个或多个源对象的 "可枚举属性" 复制到目标对象,最后返回修改后的目标对象。划重点:它不会返回全新对象,而是直接修改目标对象本身。

咱们拿代码举例,直观感受下:

// 目标对象和两个源对象
const target = { a: 1 };
const source1 = { b: 2 };
const source2 = { c: 3 };

// 用Object.assign()合并
const result = Object.assign(target, source1, source2);

// 打印看看:result和target是同一个对象!
console.log(result); // {a:1, b:2, c:3}
console.log(target); // {a:1, b:2, c:3}
console.log(result === target); // true——果然是同一个对象

要是源对象和目标对象有同名属性呢?简单,后面的会覆盖前面的

const target = { a: 1, b: 2 };
const source = { b: 3, c: 4 };

Object.assign(target, source);
console.log(target); // {a:1, b:3, c:4}——source的b把target的b覆盖了

但最关键的是它的 "浅拷贝" 特性 ——只拷贝表层属性,遇到嵌套对象(深层属性)只会复制引用。比如这样:

const target = { a: 1 };
const source = {
  b: { name: '小安', hobbies: ['篮球', '足球'] }, // 嵌套对象
  c: 1
};

// 用Object.assign()拷贝
Object.assign(target, source);

// 试着修改target的嵌套属性
target.b.name = '安安';
target.b.hobbies.push('唱歌');
// 再改个表层属性
target.c = 2;

// 看看源对象source的变化:
console.log(source.b.name); // "安安"——嵌套对象的属性被改了!
console.log(source.b.hobbies); // ["篮球", "足球", "唱歌"]——嵌套数组也被改了
console.log(source.c); // 1——表层属性没被改

瞧见没?嵌套的b对象其实是 "共享引用",改了 target 里的,source 里的也跟着变;但表层的c是直接复制值,改了不影响源对象。这就是浅拷贝的 "小心机"—— 只负责 "表面功夫"~

浅拷贝与深拷贝的区别:先搞懂 "赋值" 和 "引用"

很多人分不清 "赋值" 和 "拷贝",其实核心在 JavaScript 的 "数据类型存储方式":

  • 基本数据类型(number、string 等) :值存在栈内存,赋值时直接复制值。比如let a=1; let b=a; b=2;,a 还是 1,互不影响。

  • 引用数据类型(对象、数组等) :值存在堆内存,变量里存的是 "堆内存地址"(引用)。直接赋值时,复制的是引用 —— 比如let obj1={name:'张三'}; let obj2=obj1;,obj1 和 obj2 指向同一个对象,改 obj2 的属性,obj1 也会变。

而 "拷贝" 的目的,就是让新变量和原变量脱离关联

  • 浅拷贝:只复制 "表层"。如果属性是基本类型,直接复制值;如果是引用类型,复制引用(所以嵌套对象还是会关联)。
  • 深拷贝:不管嵌套多少层,都彻底复制一份新的,新对象和原对象完全独立,改谁都不影响对方。

浅拷贝的常用方法:不止 Object.assign ()

除了 Object.assign (),这些场景也常用浅拷贝,面试可能会问:

  • 数组的 slice () :返回新数组,但嵌套数组仍关联

    const arr = [1, 2, [3, 4]];
    const newArr = arr.slice();
    newArr[2][0] = 99; 
    console.log(arr); // [1,2,[99,4]]——嵌套数组被改了
    
  • 数组的 concat () :和 slice () 类似,表层拷贝

    const arr = [1, 2, [3, 4]];
    const newArr = [].concat(arr);
    newArr[2][0] = 99;
    console.log(arr); // [1,2,[99,4]]——同样关联
    
  • 扩展运算符(...) :对象和数组都能用,表层拷贝

    // 数组
    const arr = [1, 2, [3, 4]];
    const newArr = [...arr];
    // 对象
    const obj = { a: 1, b: { c: 2 } };
    const newObj = { ...obj };
    

这些方法的核心是 "只拷贝第一层",适合结构简单的对象 / 数组 —— 要是数据里没嵌套,浅拷贝足够用了~

简单深拷贝:JSON.parse (JSON.stringify ()) 的 "甜蜜陷阱"

如果需要深拷贝,很多人第一反应是JSON.parse(JSON.stringify(源对象))—— 这招确实简单,不用手写逻辑,比如:

const source = {
  b: { name: '小安', hobbies: ['篮球', '足球'] },
  c: 1
};

// 深拷贝
const newObj = JSON.parse(JSON.stringify(source));

// 修改newObj的属性
newObj.b.name = '安安';
newObj.c = 2;

// 看看源对象:完全没变化!
console.log(source.b.name); // "小安"
console.log(source.c); // 1

但它有个大问题:对 "特殊值" 不友好。因为 JSON 的序列化规则很严格,遇到这些情况会 "翻车":

  • 函数、Symbol、undefined:直接忽略(序列化后消失)

    const arr = [
      { name: '张三', hobbies: ['篮球'] },
      function() { console.log('我是函数'); } // 函数
    ];
    const newArr = JSON.parse(JSON.stringify(arr));
    console.log(newArr); // [ {name: '张三', hobbies: ['篮球'] }, null ]——函数没了,变成null
    
  • 循环引用:直接报错(比如对象自己引用自己)

    const obj = { a: 1 };
    obj.self = obj; // 循环引用:obj的self属性指向自己
    JSON.parse(JSON.stringify(obj)); // 报错:Converting circular structure to JSON
    

所以这招只适合 "纯数据对象"(没有特殊值、没有循环引用),别指望它能搞定所有场景哦~

高级深拷贝:手写实现(面试官的 "终极大招")

当面试官问 "你能手写一个深拷贝吗",其实是在考这几点:递归能力、边界处理(循环引用、特殊类型)。咱们一步步来。

第一步:基础递归(处理对象和数组)

先搞定最基本的:如果是对象 / 数组,就递归拷贝每个属性;如果是基本类型,直接返回。代码如下:

function clone(source) {
  // 先判断类型:如果是对象(且不是null,因为typeof null也是'object')
  if (typeof source === 'object' && source !== null) {
    // 数组和对象要分开处理:数组初始化[],对象初始化{}
    let cloneTarget = Array.isArray(source) ? [] : {};
    // 遍历属性,递归拷贝
    for (const key in source) {
      // 只拷贝自身属性(排除原型链上的)
      if (source.hasOwnProperty(key)) {
        cloneTarget[key] = clone(source[key]);
      }
    }
    return cloneTarget;
  } else {
    // 基本类型直接返回(值类型,不会关联)
    return source;
  }
}

测试下普通嵌套对象:

const target = {
  a: 1,
  b: { c: 2 },
  d: [3, 4]
};
const newObj = clone(target);
newObj.b.c = 99;
newObj.d[0] = 88;
console.log(target.b.c); // 2——没被改,成功!
console.log(target.d[0]); // 3——也没被改,不错~

但这版有个致命问题:遇到循环引用会栈溢出。比如拷贝obj.self = obj,递归会无限循环,直接报错。

第二步:用 WeakMap 解决循环引用

循环引用的核心是 "重复拷贝同一个对象",所以我们需要一个 "缓存容器":拷贝前先查缓存,若已拷贝过,直接返回拷贝后的对象;没拷贝过就存起来。

推荐用WeakMap做缓存(比 Map 好,键是弱引用,不影响垃圾回收):

function clone(target, map = new WeakMap()) {
  if (typeof target === 'object' && target !== null) {
    // 先查缓存:如果之前拷贝过这个对象,直接返回
    if (map.has(target)) {
      return map.get(target);
    }

    // 初始化拷贝目标(数组/对象)
    let cloneTarget = Array.isArray(target) ? [] : {};
    // 存到缓存:key是原对象,value是拷贝后的对象
    map.set(target, cloneTarget);

    // 递归拷贝属性
    for (const key in target) {
      if (target.hasOwnProperty(key)) {
        cloneTarget[key] = clone(target[key], map); // 把map传下去
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

再测循环引用:

const obj = { a: 1 };
obj.self = obj; // 循环引用
const newObj = clone(obj);
console.log(newObj.self === newObj); // true——拷贝成功,没报错!

这样就解决了循环引用问题~

第三步:扩展处理特殊对象(可选进阶)

实际开发中可能遇到 Date、RegExp 等特殊对象,它们的拷贝需要特殊处理(比如 Date 直接复制时间戳,RegExp 要保留 flags)。可以再加一层类型判断:

function clone(target, map = new WeakMap()) {
  // 处理Date
  if (target instanceof Date) {
    return new Date(target);
  }
  // 处理RegExp
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }

  // 剩下的逻辑和之前一样(对象/数组/基本类型)
  if (typeof target === 'object' && target !== null) {
    if (map.has(target)) {
      return map.get(target);
    }
    let cloneTarget = Array.isArray(target) ? [] : {};
    map.set(target, cloneTarget);
    for (const key in target) {
      if (target.hasOwnProperty(key)) {
        cloneTarget[key] = clone(target[key], map);
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

这样的手写深拷贝就比较完善了 —— 面试时能写出带循环引用处理的版本,基本就能让面试官点头了~

面试官的考察逻辑:从 API 到底层,一环扣一环

为啥面试官总揪着深浅拷贝不放?其实是想通过 "拷贝" 这个小切入点,考察你的知识链:

  1. API 细节:比如 Object.assign () 是否返回新对象?是否拷贝不可枚举属性?(答案:不返回新对象,只拷贝可枚举属性)

  2. 业务场景:你是否真的用过?比如用 Object.assign () 合并配置项(用户传的参数 + 默认参数):

    function createUser(options) {
      const defaults = { name: '匿名', age: 18 };
      // 用空对象当目标,避免修改defaults
      return Object.assign({}, defaults, options);
    }
    
  3. 底层原理:是否理解引用类型的存储机制?(堆内存 + 栈引用)

  4. 边界处理:是否考虑到循环引用、特殊类型?(这是区分 "会用" 和 "懂原理" 的关键)

总结:别死记,按场景选方法

最后给个 "拷贝工具选型指南",实际开发 / 面试都能用:

  • 简单数据类型:直接用=赋值,足够了。

  • 浅结构对象 / 数组(无嵌套):用 Object.assign ()、扩展运算符(...),简单高效。

  • 纯数据深拷贝(无函数、循环引用):用JSON.parse(JSON.stringify()),省事。

  • 复杂深拷贝(有嵌套、特殊类型、循环引用):手写深拷贝(按上面的步骤),或用成熟库(比如 lodash 的_.cloneDeep)。

深浅拷贝看似基础,实则藏着对 JavaScript 内存模型的理解。下次面试官再问,别只说 "Object.assign () 是浅拷贝",把 "场景 + 原理 + 实现" 串起来讲,保管能让他眼前一亮~