技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

浅拷贝与深拷贝

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/106-浅拷贝与深拷贝
本文目录9 个章节
  1. 前言
  2. 浅拷贝与深拷贝
  3. 赋值和深/浅拷贝的区别
  4. 浅拷贝的实现方式
  5. 深拷贝的实现方式
  6. 原文勘误与现代化说明
  7. 图片来源与授权说明
  8. 原文归属
  9. 参考链接

浅拷贝与深拷贝

Category(分类): JavaScript Status: 已整理

本文保留了原文(2020 年)的结构与示例主线:赋值/浅拷贝/深拷贝的区别、五种浅拷贝方式、JSON 方法与手写递归深拷贝。整理时修复了被复制损坏的代码块,补全了 JSON 方法的缺点清单,并新增了当前标准方案 structuredClone() 的说明。文末附勘误清单。

前言

在 JavaScript 中有不同的方法来复制对象,如果你还不熟悉这门语言的话,复制对象时就会很容易掉进陷阱里,那么我们怎样才能正确地复制一个对象呢?

读完本文,希望你能明白:

  • 什么是深/浅拷贝,他们跟赋值有何区别?
  • 深/浅拷贝的实现方式有几种?
  • (现代补充)标准 API structuredClone() 能做什么、不能做什么?

浅拷贝与深拷贝

  • 浅拷贝是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是引用(内存地址),所以如果其中一个对象改变了这个地址指向的内容,就会影响到另一个对象
  • 深拷贝是将一个对象完整地拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
var a1 = { b: { c: {} } }

var a2 = shallowClone(a1) // 浅拷贝方法
console.log(a2.b.c === a1.b.c) // true,新旧对象还是共享同一块内存

var a3 = deepClone(a1) // 深拷贝方法(原文误写为 deepClone(a3))
console.log(a3.b.c === a1.b.c) // false,新对象跟原对象不共享内存

借助 ConardLi 大佬 以下两张图片,帮我们更好地理解两者的含义:

浅拷贝示意图(原文插图)

深拷贝示意图(原文插图)

总而言之,浅拷贝只复制指向某个对象的引用,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。

赋值和深/浅拷贝的区别

这三者的区别如下,不过比较的前提都是针对引用类型

  • 当我们把一个对象赋值给一个新的变量时,赋的其实是该对象的引用(地址),而不是数据本身。也就是两个变量指向的是同一个存储空间,无论哪个对象发生改变,其实都是改变的存储空间的内容,因此,两个对象是联动的。(原文的「栈中地址/堆中数据」是常见的教学简化,理解成「复制引用而不是复制内容」即可。)
  • 浅拷贝:重新创建一个对象,拷贝前后对象的基本数据类型属性互不影响,但拷贝前后对象的引用类型属性因共享同一块内存,会相互影响。
  • 深拷贝:从堆内存中开辟一个新的区域存放新对象,对对象中的子对象进行递归拷贝,拷贝前后的两个对象互不影响。

我们先来看下面的例子,对比赋值与深/浅拷贝得到的对象修改后对原始对象的影响:

// 对象赋值
let obj1 = {
  name: '浪里行舟',
  arr: [1, [2, 3], 4],
}
let obj2 = obj1
obj2.name = '阿浪'
obj2.arr[1] = [5, 6, 7]
console.log('obj1', obj1) // { name: '阿浪', arr: [ 1, [ 5, 6, 7 ], 4 ] }
console.log('obj2', obj2) // { name: '阿浪', arr: [ 1, [ 5, 6, 7 ], 4 ] }
// 浅拷贝
let obj1 = {
  name: '浪里行舟',
  arr: [1, [2, 3], 4],
}

// 这是个浅拷贝的方法
function shallowClone(source) {
  var target = {}
  for (var i in source) {
    if (source.hasOwnProperty(i)) {
      target[i] = source[i]
    }
  }
  return target
}

let obj3 = shallowClone(obj1)
obj3.name = '阿浪'
obj3.arr[1] = [5, 6, 7] // 新旧对象还是共享同一块内存
console.log('obj1', obj1) // { name: '浪里行舟', arr: [ 1, [ 5, 6, 7 ], 4 ] }
console.log('obj3', obj3) // { name: '阿浪', arr: [ 1, [ 5, 6, 7 ], 4 ] }
// 深拷贝
let obj1 = {
  name: '浪里行舟',
  arr: [1, [2, 3], 4],
}

// 这是个深拷贝的方法(教学版,边界见文末勘误)
function deepClone(obj) {
  if (obj === null) return obj
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj)
  if (typeof obj !== 'object') return obj
  let cloneObj = new obj.constructor()
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 实现一个递归拷贝
      cloneObj[key] = deepClone(obj[key])
    }
  }
  return cloneObj
}

let obj4 = deepClone(obj1)
obj4.name = '阿浪'
obj4.arr[1] = [5, 6, 7] // 新对象跟原对象不共享内存
console.log('obj1', obj1) // { name: '浪里行舟', arr: [ 1, [ 2, 3 ], 4 ] }
console.log('obj4', obj4) // { name: '阿浪', arr: [ 1, [ 5, 6, 7 ], 4 ] }

上面例子中,obj1 是原始对象,obj2 是赋值操作得到的对象,obj3 是浅拷贝得到的对象,obj4 是深拷贝得到的对象,通过下面的表格,我们可以很清晰看到他们对原始数据的影响:

赋值、浅拷贝、深拷贝对比表(原文插图)

浅拷贝的实现方式

1. Object.assign()

Object.assign() 方法可以把任意多个源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象。

let obj1 = { person: { name: 'kobe', age: 41 }, sports: 'basketball' }
let obj2 = Object.assign({}, obj1)
obj2.person.name = 'wade'
obj2.sports = 'football'
console.log(obj1) // { person: { name: 'wade', age: 41 }, sports: 'basketball' }

现代注解Object.assign 拷贝的是「自有可枚举属性」,读取源属性时走 getter,写入目标时走 setter;Symbol 键属性也会被拷贝。如果只需要合并普通对象,展开运算符通常更直观。

2. 函数库 lodash 的 _.clone 方法

该函数库也提供 _.clone 用来做 Shallow Copy,后面我们会再介绍利用这个库实现深拷贝。

var _ = require('lodash')
var obj1 = {
  a: 1,
  b: { f: { g: 1 } },
  c: [1, 2, 3]
}
var obj2 = _.clone(obj1)
console.log(obj1.b.f === obj2.b.f) // true

现代注解:lodash 不是本仓库的依赖,示例仅作演示。现代项目里可用 lodash 的 ESM 版本 lodash-esimport { clone } from 'lodash-es')。只做浅拷贝时,原生 Object.assign / 展开运算符通常已足够。

3. 展开运算符 ...

展开运算符是一个 ES6 / ES2015 特性,它提供了一种非常方便的方式来执行浅拷贝,与 Object.assign({}, source) 对普通对象的效果相同(细节差异:展开只拷贝自有可枚举属性,不会触发 setter)。

let obj1 = { name: 'Kobe', address: { x: 100, y: 100 } }
let obj2 = { ...obj1 }
obj1.address.x = 200
obj1.name = 'wade'
console.log('obj2', obj2) // { name: 'Kobe', address: { x: 200, y: 100 } }

数组同理:[...arr]Array.from(arr) 也是一层浅拷贝。

4. Array.prototype.concat()

let arr = [1, 3, {
  username: 'kobe'
}]
let arr2 = arr.concat()
arr2[2].username = 'wade'
console.log(arr) // [ 1, 3, { username: 'wade' } ]

5. Array.prototype.slice()

let arr = [1, 3, {
  username: 'kobe'
}]
let arr3 = arr.slice()
arr3[2].username = 'wade'
console.log(arr) // [ 1, 3, { username: 'wade' } ]

注意 concat/slice 只拷贝数组第一层:元素如果是对象,拷贝的仍是引用(上面例子里改 arr2[2].username 会影响原数组)。另外它们不拷贝「稀疏空洞」的语义差异(concat 会保留空洞,Array.from 会把空洞变成 undefined),一般业务可忽略。

深拷贝的实现方式

1. JSON.parse(JSON.stringify())

let arr = [1, 3, {
  username: 'kobe'
}]
let arr4 = JSON.parse(JSON.stringify(arr))
arr4[2].username = 'duncan'
console.log(arr, arr4)
// [ 1, 3, { username: 'kobe' } ] [ 1, 3, { username: 'duncan' } ]

JSON 方法深拷贝输出(原文截图)

这也是利用 JSON.stringify 将对象转成 JSON 字符串,再用 JSON.parse 把字符串解析成对象,一去一来,新的对象产生了,实现深拷贝。

这种方法虽然可以实现数组或对象的深拷贝,但不能处理函数和正则,因为这两者基于 JSON.stringify 和 JSON.parse 处理后,得到的正则就不再是正则(变为空对象 {}),得到的函数就不再是函数(被丢弃,数组位置变成 null)了。

比如下面的例子:

let arr = [1, 3, {
  username: 'kobe'
}, function() {}]
let arr4 = JSON.parse(JSON.stringify(arr))
arr4[2].username = 'duncan'
console.log(arr, arr4)
// [1, 3, { username: 'kobe' }, [Function]] [1, 3, { username: 'duncan' }, null]

JSON 方法无法处理函数(原文截图)

勘误与补全:原文只提到函数和正则两个缺点,实际坑还有很多,完整清单如下:

  • undefined、函数、Symbol 值:对象属性被直接丢弃,数组元素位置变成 null
  • NaNInfinity:被序列化成 null
  • Date:变成 ISO 字符串,不再是 Date 对象;
  • RegExpMapSet:变成空对象 {},内容全部丢失;
  • BigInt:直接抛出 TypeError
  • 循环引用:抛出 TypeError: Converting circular structure to JSON
  • 丢失原型:类实例会退化为普通对象,instanceof 不再成立;
  • 只处理可枚举的自有属性,getter/setter、enumerable: false 的属性都不会保留。

因此这种方式只适合「纯 JSON 数据」的快照(比如给纯数据对象做简单备份),不适合通用深拷贝。

2. 函数库 lodash 的 _.cloneDeep 方法

该函数库也提供 _.cloneDeep 用来做 Deep Copy。

var _ = require('lodash')
var obj1 = {
  a: 1,
  b: { f: { g: 1 } },
  c: [1, 2, 3]
}
var obj2 = _.cloneDeep(obj1)
console.log(obj1.b.f === obj2.b.f) // false

_.cloneDeep 能处理循环引用、Map/Set、TypedArray、原型恢复等大量边界,长期以来是深拷贝的事实标准之一(同样注意:lodash 不是本仓库依赖)。

3. jQuery.extend() 方法

jQuery 提供一个 $.extend 可以用来做 Deep Copy。

$.extend(deepCopy, target, object1, [objectN])
// 第一个参数为 true,就是深拷贝
var $ = require('jquery')
var obj1 = {
  a: 1,
  b: { f: { g: 1 } },
  c: [1, 2, 3]
}
var obj2 = $.extend(true, {}, obj1)
console.log(obj1.b.f === obj2.b.f) // false

现代注解:这是 jQuery 时代的方案。jQuery 官方文档目前并不推荐深度合并($.extend(true, ...) 未被正式文档化,行为以实现为准),新项目应优先使用 structuredClone()_.cloneDeep。此节保留作历史资料。

4. 手写递归方法

递归方法实现深度克隆原理:遍历对象、数组直到里边都是基本数据类型,然后再去复制,就是深度拷贝

有种特殊情况需注意就是对象存在循环引用的情况,即对象的属性直接或间接引用了自身的情况。解决循环引用问题,我们可以额外开辟一个存储空间(一般用 WeakMap),来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解了循环引用的问题。关于这块如有疑惑,请仔细阅读 ConardLi 大佬的《如何写出一个惊艳面试官的深拷贝?》

function deepClone(obj, hash = new WeakMap()) {
  if (obj === null) return obj // 如果是 null 就不进行拷贝操作
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj)
  // 可能是对象或者普通的值,如果是函数的话是不需要深拷贝
  if (typeof obj !== 'object') return obj
  // 是对象的话就要进行深拷贝
  if (hash.get(obj)) return hash.get(obj)
  let cloneObj = new obj.constructor()
  // 找到的是所属类原型上的 constructor,而原型上的 constructor 指向的是当前类本身
  hash.set(obj, cloneObj)
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      // 实现一个递归拷贝
      cloneObj[key] = deepClone(obj[key], hash)
    }
  }
  return cloneObj
}
let obj = { name: 1, address: { x: 100 } }
obj.o = obj // 对象存在循环引用的情况
let d = deepClone(obj)
obj.address.x = 200
console.log(d) // { name: 1, address: { x: 100 }, o: [Circular] }(o 指向克隆对象自身)

教学边界:这个手写版本仍不覆盖很多真实边界——Map/Set 会被克隆成空对象、TypedArray/ArrayBuffer 处理不对、Symbol 键不会被 for...in 遍历到、getter/setter 被读值后变成普通数据属性、原型只通过 obj.constructor 间接保留(跨 realm 时不可靠)。面试手写没问题,生产环境请使用下面的标准 API 或成熟库。

5.(现代补充)structuredClone()

structuredClone() 是 WHATWG HTML 定义的内置深拷贝 API,浏览器(Chrome 98+、Firefox 94+、Safari 15.4+)和 Node.js(17+)都提供,它使用结构化克隆算法(Worker postMessage 用的同一套规则):

const original = {
  date: new Date(0),
  regexp: /x/g,
  map: new Map([['a', 1]]),
  set: new Set([1, 2]),
  typed: new Uint8Array([1, 2, 3]),
  big: 9007199254740993n,
  nested: { deep: { deeper: [1, 2, 3] } },
}
original.self = original // 循环引用

const copy = structuredClone(original)
console.log(copy.date instanceof Date) // true,Date 保留
console.log(copy.regexp instanceof RegExp) // true,RegExp 保留
console.log(copy.map === original.map) // false,Map 内容也深拷贝
console.log(copy.self === copy) // true,循环引用正确还原
console.log(original.nested.deep === copy.nested.deep) // false

try {
  structuredClone({ fn() {} })
} catch (e) {
  console.log(e.name) // DataCloneError——函数不能克隆
}

它的能力与限制:

  • 支持:循环引用、DateRegExpMapSetBigIntTypedArrayArrayBuffer(可配合第二参数 transfer 转移所有权,移动而非复制)、普通对象与数组、原始值。
  • 不支持(抛 DataCloneError:函数、DOM 节点、Symbol()(注册表 Symbol 除外)、属性描述符层面的信息(getter/setter 变成普通属性值)、[[Prototype]](类实例退化为普通对象)、错误对象的部分属性(message/cause 保留,其余自定义字段在部分引擎不保留)。

一句话选型:纯数据结构深拷贝优先 structuredClone();需要保留原型/getter 等更复杂的语义用成熟库(如 lodash cloneDeep);JSON 方法只用于纯 JSON 数据。

原文勘误与现代化说明

  1. 修复了所有被复制损坏的代码块:删除了掘金页面复制按钮带来的残留文字,恢复了换行与注释(原文代码被压成一行)。
  2. var a1 = {b: {c: {}}; 缺少右括号、var a3 = deepClone(a3) 应为 deepClone(a1),均已修正。
  3. 「赋的其实是该对象的在栈中的地址」保留为教学简化,同时补充了「复制引用而不是复制内容」的准确说法。
  4. JSON 方法的缺点清单从「函数、正则」两项补全为完整列表(undefined/Symbol/NaN/Infinity/Date/Map/Set/BigInt/循环引用/原型丢失等)。
  5. $.extend(true, ...) 标注为 jQuery 时代方案并说明现状;ConardLi 原文链接失效,替换为其 GitHub 仓库。
  6. 手写 deepClone 标注了教学边界(Map/Set、TypedArray、Symbol 键、getter/setter、原型等不覆盖)。
  7. 新增 structuredClone() 一节:结构化克隆算法的能力与限制、兼容性基线、与 transfer 的关系。
  8. lodash 示例保留 require('lodash') 历史写法,补充 lodash-es ESM 写法,并注明它不是本仓库依赖。

图片来源与授权说明

正文图片已本地化到 images/ 目录,均来自原文(掘金 浪里行舟《浅拷贝与深拷贝》)的图床资源。其中浅/深拷贝示意图的原始作者为 ConardLi。图片的转载授权无法仅凭下载确认,公开发布前应向原作者确认许可:

  • images/106-image-01.webp:浅拷贝示意图。来源:原文插图(原作者 ConardLi)。
  • images/106-image-02.webp:深拷贝示意图。来源:原文插图(原作者 ConardLi)。
  • images/106-image-03.webp:赋值/浅拷贝/深拷贝对比表格。来源:原文插图。
  • images/106-image-04.webp:JSON 深拷贝控制台输出。来源:原文截图。
  • images/106-image-05.webp:JSON 无法处理函数的输出。来源:原文截图。

原文归属

作者:浪里行舟。历史来源:掘金《浅拷贝与深拷贝》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。

另可参考:axuebin/articles#20(原文末尾附带的关联讨论)。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS