Skip to content

深浅拷贝

考点:浅拷贝 vs 深拷贝(JS 引用类型的核心)

面试官会结合“不可变数据”和“性能”来问,特别是你的 Redux/Vuex 场景。

1. 基础认知(概念区分)

  • 浅拷贝(Shallow Copy):只复制第一层引用。新对象的 基本类型 独立,但 对象类型 依然指向同一块堆内存。方法Object.assign({}, obj){ ...obj }Array.slice()
  • 深拷贝(Deep Copy):递归复制所有层级,新对象和原对象在堆内存中完全独立。方法JSON.parse(JSON.stringify(obj))(有缺陷)、structuredClone()(原生API)、递归遍历。

2. 进阶原理(三大死穴与终极实现)

  • JSON 方法的致命缺陷(面试必问)

    1. 无法处理 undefinedFunctionSymbol(会被忽略或变成 null)。
    2. 无法处理循环引用(obj.self = obj 会报错 Converting circular structure to JSON)。
    3. 无法处理 DateRegExpMapSet 等特殊对象(会被转成空对象或字符串)。
  • 终极实现(你已经有了 WeakMap 版,这里再强化一个 structuredClone 现代 API):

    “如果浏览器环境支持(Chrome 98+),structuredClone(obj) 是最快的原生深拷贝,天然支持 DateRegExpMapSetArrayBuffer,且支持循环引用!如果需兼容 IE 或复杂自定义类,我才会手写递归 + WeakMap。”

3. 结合你的简历(Redux / 点云数据处理)

“在 WeLink(React + Redux) 中,Reducer 要求 必须返回新对象才能触发更新。我大量使用浅拷贝({ ...state, messages: [...state.messages, newMsg] }),因为这足够快且符合 Redux 规范。但绝不用 JSON 方式深拷贝 Redux State,因为它会丢掉函数方法(比如 action 里的 Symbol 类型)。

IAMP 点云平台,处理 Float32Array 大块二进制数据时,我刻意避免深拷贝,因为百万级数组深拷贝会直接导致内存爆炸(卡死)。我采用 TypedArray.slice()(浅拷贝视图) + 不可变指针 来管理数据块,用 transfer 转移所有权给 Worker 线程,零拷贝传输。”

重点防坑补充

“如果面试官问我‘如何实现深拷贝的终极版’,我会说:生产环境优先用 lodash.cloneDeep,它已经处理了 99% 的边缘场景,不要重复造轮子。除非我要做极致性能优化,才会针对 Float32Array 写专门的拷贝函数。”

第一部分:浅拷贝(Shallow Copy)—— 基础与代码

  • 基础认知:只复制第一层引用。新对象里的基本类型(string/number)独立,但对象类型(object/array)依然指向同一块堆内存。修改拷贝后的对象里的数组,原对象也会变。
  • 4种实现方式(你务必会写)
javascript
const original = {
  name: 'Tom',
  age: 25,
  address: { city: '西安', zone: '高新' }
};

// 方法1:展开运算符(最常用)
const clone1 = { ...original };

// 方法2:Object.assign
const clone2 = Object.assign({}, original);

// 方法3:数组的 slice(针对数组)
const arr = [1, 2, { a: 3 }];
const arrClone = arr.slice(); // 或 [...arr]

// 方法4:手写极简浅拷贝(面试有时会让手写)
function shallowClone(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  const newObj = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      newObj[key] = obj[key]; // 直接赋值(只拷一层)
    }
  }
  return newObj;
}
// 验证:clone1.address.city = '北京',original.address.city 也跟着变(说明浅拷贝没断开引用)

第二部分:深拷贝(Deep Copy)—— 基础方法与致命缺陷

方法1:JSON.parse(JSON.stringify(obj))(最常见,但全是坑)

javascript
const original = {
  name: 'Tom',
  age: 25,
  address: { city: '西安' },
  birthday: new Date('1995-01-01'),
  sayHi: function() { console.log('hi'); },
  undef: undefined,
  symbolKey: Symbol('id')
};

const clone = JSON.parse(JSON.stringify(original));
// ❌ 缺陷:
// 1. birthday 变成了字符串 "1995-01-01T00:00:00.000Z",不再是 Date 对象
// 2. sayHi 函数直接被忽略(没了)
// 3. undef: undefined 被忽略(没了)
// 4. symbolKey 被忽略(没了)
// 5. 遇到循环引用(obj.self = obj)直接报错:Converting circular structure to JSON

方法2:structuredClone()(现代浏览器原生 API,Chrome 98+)

javascript
const clone = structuredClone(original);
// ✅ 优点:支持 Date、RegExp、Map、Set、ArrayBuffer、循环引用
// ❌ 缺陷:依然不能拷贝函数、Symbol、DOM 节点
// 面试时补一句:“如果我的项目用户都是 Chrome 现代浏览器,我会优先用这个,性能最好。”

第三部分:手写终极深拷贝(面试必背完整版)

面试官潜台词:我要看到 WeakMap 解决循环引用、Reflect.ownKeys 遍历 Symbol、Object.getPrototypeOf 保留原型链、以及 Date/RegExp/Map/Set 的特殊处理。

完整可运行代码(逐行带注释,背下来直接默写)

javascript
function deepClone(obj, hash = new WeakMap()) {
  // ---------- 第一步:处理基本类型 / null / 函数 ----------
  // 如果是基本类型(string/number/boolean/symbol)或 null,直接返回
  // 注意:函数我们直接返回原引用(因为拷贝函数没有实际意义,且难以序列化)
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // ---------- 第二步:解决循环引用(核心!) ----------
  // 如果 hash 中已经存过这个对象,直接返回之前存的那个拷贝,避免死递归
  if (hash.has(obj)) {
    return hash.get(obj);
  }

  // ---------- 第三步:处理特殊对象类型 ----------
  // 1. Date:创建新 Date 实例
  if (obj instanceof Date) {
    return new Date(obj);
  }
  // 2. RegExp:创建新 RegExp 实例(注意要带上 flags)
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
  // 3. Map:递归拷贝每个键值对(注意键也可能是对象,也要深拷贝)
  if (obj instanceof Map) {
    const cloneMap = new Map();
    hash.set(obj, cloneMap); // 先存入 hash,防止 Map 内部自引用
    obj.forEach((value, key) => {
      // key 和 value 都可能引用复杂类型,都要递归深拷贝
      cloneMap.set(deepClone(key, hash), deepClone(value, hash));
    });
    return cloneMap;
  }
  // 4. Set:递归拷贝每个值
  if (obj instanceof Set) {
    const cloneSet = new Set();
    hash.set(obj, cloneSet);
    obj.forEach(value => {
      cloneSet.add(deepClone(value, hash));
    });
    return cloneSet;
  }

  // ---------- 第四步:处理数组 / 普通对象(保留原型链) ----------
  // 关键点:用 Object.getPrototypeOf(obj) 保留 obj 的原型链(比如它是某个类的实例)
  const cloneObj = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
  // 存入 hash,后面如果子属性引用到 obj,直接返回 cloneObj
  hash.set(obj, cloneObj);

  // ---------- 第五步:遍历所有属性(包括 Symbol 和不可枚举属性) ----------
  // Reflect.ownKeys 返回所有自有属性(包括 Symbol 类型)
  Reflect.ownKeys(obj).forEach(key => {
    // 递归拷贝每一个属性的值
    cloneObj[key] = deepClone(obj[key], hash);
  });

  return cloneObj;
}

// ========== 测试用例(面试完可以自己验证) ==========
const obj = {
  name: 'Tom',
  age: 25,
  birthday: new Date(),
  reg: /test/i,
  map: new Map([['key1', { val: 1 }]]),
  set: new Set([{ val: 2 }]),
  symbolKey: Symbol('id'),
  nested: { a: 1, b: { c: 2 } }
};
obj.self = obj; // 制造循环引用

const cloned = deepClone(obj);
console.log(cloned);
console.log(cloned.birthday instanceof Date); // true
console.log(cloned.reg instanceof RegExp); // true
console.log(cloned.map instanceof Map); // true
console.log(cloned.self === cloned); // true(循环引用保持)

深拷贝最终版代码(背这个就够了)

javascript
function deepClone(obj, map = new WeakMap()) {
  // 基础类型 / null 直接返回
  if (obj === null || typeof obj !== 'object') return obj;
  // 处理特殊对象
  if (obj instanceof Date) return new Date(obj);
  if (obj instanceof RegExp) return new RegExp(obj);
  // 解决循环引用
  if (map.has(obj)) return map.get(obj);

  // 保持原型链
  const clone = new obj.constructor();
  map.set(obj, clone);

  // 遍历所有属性(包括 Symbol)
  Reflect.ownKeys(obj).forEach(key => {
    clone[key] = deepClone(obj[key], map);
  });
  return clone;
}

第四部分:面试官“变态”追问与你的回答

追问1:“为什么不直接用 for...in 遍历属性,要用 Reflect.ownKeys?”

你的回答:“for...in 只能遍历可枚举的字符串属性,会漏掉 Symbol 类型的属性,而且会顺着原型链往上找(虽然可以用 hasOwnProperty 过滤)。Reflect.ownKeys 能一次性拿到所有自有属性(字符串 + Symbol,包括不可枚举的),更彻底。比如我们 WeLink 项目里 Redux 的 Action 有时会带 Symbol 属性,如果用 for...in 就丢了。”

追问2:“为什么要用 WeakMap 而不是 Map 来做缓存?”

你的回答:“WeakMap 的键是弱引用。当 obj 被垃圾回收时,这个 WeakMap 里的条目会自动被清理,不会阻止垃圾回收,防止内存泄漏。如果用 Map,即使 obj 不用了,它还被 Map 强引用着,内存永远释放不了。这在 IAMP 处理大量临时点云数据时极其重要。”

追问3:“如果拷贝 Float32Array(二进制数组)怎么办?”

你的回答(结合你的 IAMP 项目,封神!):“在实际工程中,对于 TypedArray(如 Float32Array),我不会使用手写的递归深拷贝!因为数据量可能达到几十 MB,深拷贝会直接内存翻倍导致卡死。我会用 buffer.slice() 拷贝底层 ArrayBuffer,或者利用 postMessagetransfer 转移所有权,实现零拷贝(Zero-Copy)的数据共享。这也是我在 IAMP 点云平台处理百万级坐标数据的核心优化手段。”


第五部分:结合你简历的“业务选择题”(面试主动抛出来)

面试官可能会问:“你在项目中怎么区分用浅拷贝还是深拷贝?”

你的回答(直接复制背)

“面试官,我有一套清晰的决策逻辑:

1. 优先用浅拷贝(高性能场景):在 WeLink ReduxIRMP Pinia 中,所有状态更新我都用浅拷贝({ ...state, list: [...state.list, newItem] })。因为状态管理工具(Redux/Vuex)内部只做浅层引用对比来判断数据是否变化,深拷贝反而会破坏性能。而且我们项目数据层级不深,浅拷贝足够安全。

2. 特定场景用 structuredClone(最优解):在 MMMP 大屏仪表板,我需要深拷贝一份配置对象(含 Date 和嵌套对象)来做历史快照(撤销回滚)。因为用户都是 Chrome,我用 structuredClone 原生实现,比手写递归快得多。

3. 慎用或禁用深拷贝(大内存场景):在 IAMP 点云渲染,面对 100 万个 Float32Array 点,任何形式的深拷贝都是灾难。我通过 transferable 对象 + Worker 线程 实现主线程和 Worker 共享内存,避免拷贝。盲目深拷贝在数据密集型场景是性能红线。”


面试现场组合技(终极总结陈词)

“面试官,关于拷贝,我的核心理念是 ‘浅拷贝是常态,深拷贝是特例,手写递归是兜底方案’

在日常业务中(表单、列表、Redux),我 90% 的场景用浅拷贝(展开运算符) 足以应对。只有遇到配置快照、深层次复杂对象的克隆时,我才会考虑深拷贝。在 IAMP 项目中,我甚至刻意避免深拷贝来保护内存,改用 structuredCloneTransferable 对象。

如果面试官要求手写,我会迅速掏出这套 WeakMap + Reflect.ownKeys + 类型分支 的完整模版,并主动说出 Map/Set/Date/RegExp 的特殊处理。”

最近更新