image-20230430203830743

在程序开发中,我们经常会遇到需要复制一个对象的场景。对象复制有两种方式:浅拷贝和深拷贝,这也是面试中经常会碰到的经典问题。

前置知识

数据分类

如下图所示, JS 中数据类型大体可划分为, 基本数据引用数据 两大类

image-20230501214956487

基本数据存储方式

JS基本数据 是存储在 栈内存(Stack Memory) 中, 它们的值是直接存储在变量访问的位置

那么什么是 栈内存 呢? 它是一种计算机内存中划分出来的一块 连续存储区域, 它的主要特点是 先进后出

当我们创建一个 基本数据 的变量时, 因为它占用空间小、大小固定, 所以会在 栈内存 中分配一个固定大小的空间来存储这个值, 当这个变量不再被使用时, 它所占用的空间会被自动释放, 因此 基本数据 的赋值和拷贝操作非常快速和高效

引用数据存储方式

JS引用数据 是存储在 堆内存(Heap Memory) 中的, 因为它们的大小是不确定的, 对象的属性和方法可能会动态增加或删除

那么什么是 堆内存 呢? 它是一种计算机内存中划分出来的一块 非连续存储区域, 它的特点是可以动态分配和释放内存空间, 但它需要手动管理内存空间

当我们创建一个 引用数据, 会在 堆内存 中分配一个内存空间用来存储对象的所有属性和方法, 然后在 栈内存 中创建一个指向该内存空间的 指针, 这个指针存储在变量访问的位置, 当这个变量不再被使用时,栈内存 中的指针被销毁, 但 堆内存 中的对象空间不会自动释放, 需要手动调用 垃圾回收机制 来释放这些空间

浅拷贝

手写实现

通过手写一个浅拷贝方法, 来更深入了解 浅拷贝, 总体思路如下:

  1. 如果拷贝对象是个 基本数据, 则直接返回该值
  2. 新建一个对象
  3. 循环对象的所有属性, 并拷贝属性值, 如果该属性是 引用s数据 拷贝的则是数据的引用地址
const clone = (target) => {
  // 1. 对于基本数据类型(string、number、boolean……), 直接返回
  if (typeof target !== 'object' || target === null) {
    return target
  }

  // 2. 创建新对象
  const cloneTarget = Array.isArray(target) ? [] : {}

  // 3. 循环 + 递归处理
  Object.keys(target).forEach(key => {
    cloneTarget[key] = target[key];
  })

  return cloneTarget
}
const res= clone({ name: 1, user: { age: 18 } }) 

Object.assign()

Object.assign(target, ...sources) 方法将 sources 中所有的源对象的可枚举属性复制到目标对象 target 中, 最后返回修改后的 target 对象

const address = ['杭州']

const base1 = {
  age: 18,
  name: 'lh',
}

const base2 = {
  age: 20,
  address,
  name: 'moyuanjun',
}

// 将 base1 base2 中的属性添加到, 对象 {} 中
// base1 base2 存在相同属性, 会被 base2 的覆盖掉
const res = Object.assign({}, base1, base2)

res.address === address // true
res.address === base2.address // true
复制代码

image.png

关于 Object.assign() 更多细节参考 MDN

展开运算符 ...

展开运算符 ..., 可以数组或对象在语法层面展开, 从而实现数组或对象的一个浅拷贝

const address = ['杭州']

const base1 = {
  age: 18,
  name: 'lh',
}

const base2 = {
  age: 20,
  address,
  name: 'moyuanjun',
}

const res = { ...base1, ...base2 }

res.address === address // true
res.address === base2.address // true
复制代码

关于 展开运算符 更多细节参考 MDN

数组方法

对于数组可以使用, 数组的一些方法进行拷贝, 比如: Array.prototype.concat() Array.prototype.slice() Array.from 等方法, 它们的特点都是不改变原数组、同时返回一个新的数组

const base = {
  age: 18,
  name: 'lh',
}

const arr = [1, 'moyuanjun', base]

arr.concat([])
arr.slice()
Array.from(arr)
复制代码

第三方库

可以使用一些第三方库提供的工具方法来实现拷贝, 比如: lodash 中的 clone 方法

const base = {
  name: '张三',
  age: 18,
}

const obj = {
  base,
  address: ['杭州'],
}

const res = _.clone(obj)

深拷贝

手写实现

通过手写一个 深拷贝 方法, 来更深入了解 深拷贝, 总体思路如下:

  1. 目标类型判断, 如果是非 引用数据 则直接返回
  2. 针对特殊的 引用数据 进行单独处理
  3. 判断当前拷贝的目标数据, 是否已经拷贝过, 如果拷贝过则返回上次拷贝的数据: 目的是解决 共同引用循环引用 等问题
  4. 新建一个对象
  5. 循环对象的所有属性, 如果属性值是个 基本数据, 则直接返回该值, 如果属性值是个 引用数据, 则需要递归调用(新建对象、拷贝属性……)
// map 用于记录出现过的对象, 解决循环引用
const deepClone = (target, map = new WeakMap()) => {
  // 1. 对于基本数据类型(string、number、boolean……), 直接返回
  if (typeof target !== 'object' || target === null) {
    return target
  }

  // 2. 函数 正则 日期 MAP Set: 执行对应构造题, 返回新的对象
  const constructor = target.constructor
  if (/^(Function|RegExp|Date|Map|Set)$/i.test(constructor.name)) {
    return new constructor(target)
  }

  // 3. 解决 共同引用 循环引用等问题
  // 借用 `WeakMap` 来记录每次复制过的对象, 在递归过程中, 如果遇到已经复制过的对象, 则直接使用上次拷贝的对象, 不重新拷贝
  if (map.get(target)) {
    return map.get(target)
  }

  // 4. 创建新对象
  const cloneTarget = Array.isArray(target) ? [] : {}
  map.set(target, cloneTarget)

  // 5. 循环 + 递归处理
  Object.keys(target).forEach(key => {
    cloneTarget[key] = deepClone(obj[key], map);
  })

  // 6. 返回最终结果
  return cloneTarget
}

JSON.parse(JSON.stringify())

这里利用 JSON.stringify 将对象转成 JSON 字符串, 再用 JSON.parse 把字符串解析成对象, 如此一来一去就能够实现 引用数据 的一个深拷贝

const obj = {
  age: 18,
  name: 'moyuanjun',
}

const res = JSON.parse(JSON.stringify(obj))
复制代码

注意该方法的 6 个局限性:

  1. NaN Infinity -Infinity 会被序列化为 null
  2. Symbol undefined function 会被忽略(对应属性会丢失)
  3. Date 将得到的是一个字符串
  4. 拷贝 RegExp Error 对象,得到的是空对象 {}
const obj = {
  num1: NaN,
  num2: Infinity,
  num3: -Infinity,

  symbol: Symbol('xxx'),
  name: undefined,
  add: function(){},

  date: new Date(),

  reg: /a/ig,
  error: new Error('错误信息')
}

console.log(JSON.parse(JSON.stringify(obj)))
// 打印结果
// {
//   num1: null,
//   num2: null,
//   num3: null,
//   date: '2023-03-03T03:40:38.594Z',
//   reg: {},
//   error: {}
// }
复制代码
  1. 多个属性如果复用同一个 引用数据 A 时, 拷贝的结果和原数据结构不一致(会完整拷贝多个 引用数据 A), 如下代码所示: 对象 objbasechildren 指向同一个对象, 但是 JSON.parse(JSON.stringify()) 复制出来的对象 resbasechildren 指向了不同的对象, 也就是说拷贝后的 res 对象和原对象 obj 数据结构不一致
const base = {
  name: '张三',
  age: 18,
}

const obj = {
  base,
  children: base
}

const res = JSON.parse(JSON.stringify(obj))

// 原对象, obj.base obj.children 指向同一个对象
obj.base.name = '李四'
console.log(obj.base === obj.children) // true
console.log(obj.children.name) // 李四 

// 拷贝后, res.base res.children 指向了不同对象, 拷贝了两个(数据结构被改了)
res.base.name = '李四'
console.log(res.base === res.children) // false
console.log(res.children.name) // 张三 
复制代码

下图是对象 obj 和拷贝后对象 res 的内存结构图

image.png

  1. 循环引用对象中使用将会报错

使用 JSON.stringify() 序列化循环引用的对象, 将会抛出错误

const base = {
  name: '张三',
  age: 18,
}

base.base = base

// TypeError: Converting circular structure to JSON
const res = JSON.parse(JSON.stringify(base))
复制代码

更对细节可参考 MDN

使用 structuredClone

structuredClone 是一个新的 API 可用于对数据进行 深拷贝, 同时还支持循环引用

const base = {
  name: '张三',
  age: 18,
}

const obj = { base }

obj.obj = obj

const res = structuredClone(obj) 
复制代码

注意: 使用 structuredClone 进行拷贝, 如果有个属性值是个函数, 方法会抛出错误

// DOMException [DataCloneError]: () => {} could not be cloned.
const res = structuredClone({
  add: () => {}
})
复制代码

有关 structuredClone 更多信息查看 MDN

使用第三方库

可以使用一些第三方库提供的工具方法来实现拷贝, 比如: lodash 中的 cloneDeep 方法

const base = {
  name: '张三',
  age: 18,
}

const obj = { base }

obj.obj = obj
const res = _.cloneDeep(obj)

参考文章:

八股文: 讲讲什么是浅拷贝、深拷贝? - 掘金 (juejin.cn)