八股文: 讲讲什么是浅拷贝、深拷贝?

5,586 阅读12分钟

引言

说起 浅拷贝深拷贝 可以说是面试中经常碰到的经典问题, 并且在实际项目开发过程中, 也常常会因为数据拷贝问题, 导致一些隐藏的 BUG

javascript 中有很多方法能够复制对象, 但是如果你对数据拷贝不是很了解, 在复制对象时就会很容易掉进陷阱里, 那么我们怎样才能正确地复制一个对象呢, 本文将会慢慢进行揭秘

一、前置知识

本节将对 JS 中数据存储方式进行简单介绍, 这里提到的是目前网上普遍的一个看法, 实际上 JS 中数据到底如何存储还是存在争议的,这里就不作深究, 后续如果有机会再进行详细的讲解。

1.1 数据分类

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

image.png

1.2「基本数据」存储方式

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

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

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

创建值: 下面是演示代码以及 栈内存 信息展示, 代码中创建了 3 个变量, 对应的 栈内存 中也开辟了 3 块空间用于存储数据

const name = 'moyuanjun'
const age = 18
const address = '杭州'

image.png

修改值: 直接根据变量找到 栈内存 中对应值进行修改即可, 下面是演示代码以及对应的 栈内存 修改前后的的变更

const name = 'moyuanjun'
let age = 18
const address = '杭州'

// 修改值
age = 20

image.png

复制值: 新开辟一个空间, 根据变量找到 栈内存 中对应值, 拷贝一份新的值, 下面是演示代码以及对应的 栈内存 信息, 代码中对 age 进行了拷贝, 同时修改了 age, 会发现只有变量 age 发生变更, 因为 ageage2 的存储是独立的两个空间

const name = 'moyuanjun'
let age = 18
const address = '杭州'

// 拷贝一份
const age2 = age

// 修改值
age = 20

image.png

1.3「引用数据」存储方式

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

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

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

如下代码声明了两个 基本数据 分别是 nameage, 它们将被直接存储在 栈内存 中, 同时还声明了 引用数据 user, 它会被分两部分进行存储, 实体部分会被存储在 堆内存 中, 在 栈内存 中将存储着 实体 的地址

const name = 'moyuanjun'
const age = 18
const user = { age: 18 }

image.png

上面例子中, 当我们访问 引用数据 时, 会先查找 栈内存 找到实体在 堆内存 中的地址, 取得地址后在 堆内存 中获得实体

修改变量为 基本数据: 当我们修改变量的值时, 实际上是修改了 栈内存 中的 引用地址, 下面是演示代码、以及对应内存修改前后的状态

const name = 'moyuanjun'
const age = 18
let user = { age: 18 }
user = 'lh'

image.png

修改变量(重新赋一个对象): 当我们修改变量的值时, 实际上是修改了 栈内存 中的 引用地址, 下面是演示代码、以及对应内存修改前后的状态

const name = 'moyuanjun'
const age = 18
let user = { age: 18 }
uuser = { name: 'lh' }

image.png

修改对象属性: 先从 栈内存 找到实体的 引用地址, 然后再根据 引用地址 找到实例, 再对 实体 进行修改, 下面是演示代码、以及修改前后内存状态

const name = 'moyuanjun'
const age = 18
const user = { age: 18 }

user.age = 20
user.name = 'lh'

image.png

注意: 在 JS 中对于 原始类型 的拷贝是直接复制数据的, 并没有深浅拷贝的区别, 我们讨论的深浅拷贝都只针对 引用数据

二、赋值

首先我们需要先区分一下赋值操作和拷贝的区别, 赋值 是将一个变量 A 赋给另一个变量 B

  1. 对于 基本数据 来说, 就是完全的复制了一份新值进行赋值
  2. 对于引用数据则是将 A引用地址 拷贝了一份给了 B, 但他们公用的是一个实体

下面是 基本数据、和 引用数据 赋值的演示代码、以及赋值后内存的情况, 代码中将 基本数据 赋给了对象的属性, 作为属性值进行使用

const age =18
const name = 'moyuanjun'

const user1 = { name, age }
const user2 = user1

image.png

赋值, 在我理解上也算是 拷贝, 是对变量值的拷贝; 但我们今天要讲的是对 引用数据 的拷贝, 在这儿就有了 浅拷贝深拷贝 的区分了

三、深拷贝

深拷贝: 创建一个 新对象, 拷贝对象的所有属性, 如果属性是 基本数据, 拷贝的就是 基本数据 的值; 如果是 引用数据, 则需要重新分配一块内存, 拷贝该 引用数据 的所有属性, 然后将 引用地址 赋值给对应的属性, 如果该 引用数据 中某个属性也是 引用数据 则需要继续一层层递归拷贝……

简单来说: 深拷贝 就是完整的拷贝了一份一模一样结构的数据, 拷贝后的数据和源数据是没有任何关联的, 修改原数据不会修改到拷贝后的数据

image.png

3.1 手写深拷贝

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

  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(target[key], map);
  })

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

3.2 JSON.parse(JSON.stringify())

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

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

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

注意该方法的 6 个局限性: 因为 JSON 不是 JS 独有的数据格式, 所以 JSON.stringify 需要抹平和其他语言的差异

  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

3.3 使用 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

3.4 使用第三方库

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

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

const obj = { base }

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

四、浅拷贝

浅拷贝: 会新建一个对象, 拷贝对象的所有属性值, 对于 基本数据 来说就是拷贝一份对应的值, 但是对于 引用数据 则是拷贝一份 引用数据 的引用地址

image.png

4.1 手写浅拷贝

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

  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 } }) 

4.2 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

4.3 展开运算符 ...

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

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

4.4 数组方法

对于数组可以使用, 数组的一些方法进行拷贝, 比如: 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)

4.5 第三方库

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

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

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

const res = _.clone(obj)

五、是浅拷贝还是深拷贝

如下代码: 当一个 引用数据 中所有属性都是 基本数据, 那么对它使用上文提到的浅拷贝方法对它进行了拷贝

const obj = {
  age: 18,
  name: 'lh',
  address: '杭州',
}

const res = {...obj}

image.png

请问上面例子是 浅拷贝 还是 深拷贝 呢? 这里个人看法是 深拷贝, 因为从结果来看 objres 从内存、数据结构上来看是两个完全独立、毫不相干的, 并且对 obj 进行操作也都不会影响到 res (当然你如果操作 Object.prototype 那另当别论)

六、总结

操作基本类型引用数据结果
赋值重新创建值复制引用地址具有相同的变量、属性值
深拷贝重新创建值递归遍历, 拷贝所以属性拷贝对象和源对象完成隔离
浅拷贝重新创建值复制引用地址拷贝对象和源对象存在共同的引用对象

七、参考

Group 3143