浅拷贝与深拷贝
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-es(import { 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.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]

勘误与补全:原文只提到函数和正则两个缺点,实际坑还有很多,完整清单如下:
undefined、函数、Symbol 值:对象属性被直接丢弃,数组元素位置变成null;NaN、Infinity:被序列化成null;Date:变成 ISO 字符串,不再是 Date 对象;RegExp、Map、Set:变成空对象{},内容全部丢失;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——函数不能克隆
}
它的能力与限制:
- 支持:循环引用、
Date、RegExp、Map、Set、BigInt、TypedArray、ArrayBuffer(可配合第二参数 transfer 转移所有权,移动而非复制)、普通对象与数组、原始值。 - 不支持(抛
DataCloneError):函数、DOM 节点、Symbol()(注册表 Symbol 除外)、属性描述符层面的信息(getter/setter 变成普通属性值)、[[Prototype]](类实例退化为普通对象)、错误对象的部分属性(message/cause保留,其余自定义字段在部分引擎不保留)。
一句话选型:纯数据结构深拷贝优先 structuredClone();需要保留原型/getter 等更复杂的语义用成熟库(如 lodash cloneDeep);JSON 方法只用于纯 JSON 数据。
原文勘误与现代化说明
- 修复了所有被复制损坏的代码块:删除了掘金页面复制按钮带来的残留文字,恢复了换行与注释(原文代码被压成一行)。
var a1 = {b: {c: {}};缺少右括号、var a3 = deepClone(a3)应为deepClone(a1),均已修正。- 「赋的其实是该对象的在栈中的地址」保留为教学简化,同时补充了「复制引用而不是复制内容」的准确说法。
- JSON 方法的缺点清单从「函数、正则」两项补全为完整列表(undefined/Symbol/NaN/Infinity/Date/Map/Set/BigInt/循环引用/原型丢失等)。
$.extend(true, ...)标注为 jQuery 时代方案并说明现状;ConardLi 原文链接失效,替换为其 GitHub 仓库。- 手写
deepClone标注了教学边界(Map/Set、TypedArray、Symbol 键、getter/setter、原型等不覆盖)。 - 新增
structuredClone()一节:结构化克隆算法的能力与限制、兼容性基线、与 transfer 的关系。 - lodash 示例保留
require('lodash')历史写法,补充lodash-esESM 写法,并注明它不是本仓库依赖。
图片来源与授权说明
正文图片已本地化到 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(原文末尾附带的关联讨论)。