news 2026/9/21 17:41:54

深拷贝与浅拷贝全解析:从内存机制到工程实践避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深拷贝与浅拷贝全解析:从内存机制到工程实践避坑指南

别小看“深拷贝”和“浅拷贝”这六个字,我见过不少写了三五年业务的前端,一到对象复制就踩坑。有的是表单提交前改了数据,结果上一页的状态跟着变了;有的复制一份配置对象想改着玩,结果把全局配置给改了;还有的在面试的时候被问“深拷贝和浅拷贝有什么区别”,支支吾吾就只能蹦出来个“一个是复制一层,一个是全部复制”,然后就没有然后了。

这篇文章我打算换个讲法,不从面试八股文出发,而是从“你复制对象的时候到底复制了什么”这个根源问题聊起。把深浅拷贝的底层机制、常见实现、边界场景、以及在实际项目里怎么选型一次讲清楚,顺便把我这几年踩过的坑和排查思路一起放出来。这篇内容适合刚入门前端、被对象引用搞得头疼的新人,也适合准备面试、想系统梳理这块知识点的同学,哪怕你已经在用 lodash 的 cloneDeep,我也建议你花几分钟看看它到底帮你处理了哪些你没意识到的问题。

1. 先搞清楚一个前提:你复制的是什么

很多教程一上来就讲Object.assignJSON.parse(JSON.stringify()),然后对比一通说前者是浅拷贝、后者是深拷贝。这么学不是说不对,而是你只记住了结论,没记住结论背后的原因。真正理解深浅拷贝,你得先搞清楚 JavaScript 里变量到底存的是什么。

1.1 原始类型和引用类型的内存差异

JavaScript 的数据类型分成两大类。一类是原始类型,比如stringnumberbooleannullundefinedsymbolbigint。另一类是引用类型,主要就是object,往下细分还有数组、函数、日期、正则、Map、Set 这些,本质都是对象。

这两类数据在内存里的存储方式完全不同。原始类型存的是“值本身”,你声明let a = 10,内存里就有一块地方放了10,然后a指向这块地方。你再写let b = a,相当于重新复制了一份10,给b用。所以后面你改b = 20a完全不受影响,因为两者已经没有关系了。

引用类型就不是这么回事了。你声明let obj = { name: 'oyx' },内存里干了什么事呢?它先在大脑里(堆内存)造了一个对象{ name: 'oyx' },然后obj这个变量里存的不是这个对象本体,而是指向这个对象的一把钥匙,或者说一个门牌号。真正访问对象的时候,是拿着这个门牌号去找。

这就是关键了。当你写let obj2 = obj,你复制的是那把“钥匙”,不是那个“房间”。两把钥匙都能打开同一个房间,你通过obj2进去把房间里的家具换了,obj下次进去看到的也是换过的家具。这就是引用传递。

1.2 为什么“复制”会翻车

理解了上面这个机制,你就明白为什么直接赋值、展开运算符、Object.assign这些操作在某些场景下会“翻车”了。因为它们复制的都是对象的引用,或者说只复制了最外面一层的值,里面嵌套的对象仍然共享引用。

我在实际开发里遇到最多的翻车现场,就是 Vue 或者 React 里的状态管理。组件从 store 里拿了一个对象,想本地改一下做个草稿,于是直接const draft = store.data,然后开始改draft.name。结果一改,发现 store 里的数据也变了,全局状态被污染了,整个界面跟着乱跳。这种 bug 的隐蔽性在于它不会立刻报错,而是要在某个特定操作路径下才暴露,排查起来特别费劲。

所以这里的核心结论是:深浅拷贝的分水岭在于“嵌套对象的引用到底如何处理”。浅拷贝默认共享内层引用,深拷贝则要把嵌套结构递归复制一遍,让新对象和旧对象彻底脱钩。

2. 浅拷贝:复制了一层皮

浅拷贝在很多场景下是够用的,而且它开销小、性能好。关键是你要知道它到底做了什么、局限在哪。我会从最常见的几种浅拷贝方式讲起,再把它们的用法和注意点掰开揉碎说清楚。

2.1 展开运算符和 Object.assign 的实际表现

先看展开运算符。const newObj = { ...oldObj },这行代码干的活儿是:创建一个新对象,然后枚举oldObj的自身属性,把属性和值逐一拷贝到新对象里。注意这里是“逐一拷贝”,如果某属性的值本身是对象,那拷贝的就是这个内层对象的引用。

来一个例子感受一下:

const original = { name: 'oyx', address: { city: 'Hangzhou' } } const copied = { ...original } copied.name = 'new name' // 不影响 original copied.address.city = 'Shanghai' // original.address.city 也变成了 Shanghai console.log(original.address.city) // "Shanghai"

copied.name是字符串,拷贝的是值,所以改了不影响原对象。但copied.address拷贝的是对象引用,你通过copied.address.city去改,改的是original.address指向的同一个对象。这就是典型的“只复制了一层皮”。

Object.assign的原理也差不多,它把第二个及以后参数对象的属性合并到第一个参数对象上。一个常见的坑是:

const target = {} const source = { name: 'oyx' } const result = Object.assign(target, source)

这里resulttarget指向同一个对象,如果你以为result是一份全新的独立拷贝,后面直接拿result去改,等于也在改target。我见过有人因为这个在项目里莫名奇妙改了初始配置对象,排查半天才发现是Object.assign的第一个参数被误用了。

2.2 数组浅拷贝容易被忽略的坑

数组也是对象,所以展开运算符和Object.assign对数组同样适用,另外数组还有slice()concat()这两个自带方法可以做浅拷贝。很多人以为newArr = oldArr.slice()就万事大吉了,其实它只复制了数组外层,如果数组元素里套了对象,一样会翻车。

const arr = [{ count: 1 }, { count: 2 }] const sliced = arr.slice() sliced[0].count = 100 console.log(arr[0].count) // 100

这里sliced[0]arr[0]指向的还是同一个对象。这种场景在处理表格数据、多维数组的时候非常典型,你以为复制了一份数据随便改,结果原数组一样被改动。

2.3 浅拷贝的适用场景

浅拷贝不是洪水猛兽,它在很多场景下反而是最优选择。比如你只是想给对象增加一个属性、不想动原始对象的最外层引用,或者你明确知道这个对象只有一层结构,不包含嵌套对象,那浅拷贝就够了,完全没有必要为了深拷贝付出额外的递归开销。

还有一类典型场景是函数式编程里的状态更新,比如 Redux 里更新 state 时经常写{ ...state, key: newValue }。这本质就是浅拷贝,但因为只修改了顶层的一个字段,其他顶层字段的引用没变,性能很好,也符合不可变数据的思想。如果整个状态是一个多层嵌套的结构,你才需要考虑更深层的处理。

3. 深拷贝:连骨带肉全部复制

深拷贝要解决的问题很直接:不管对象嵌套了多少层,都要创建一个完全独立的新结构,新旧对象之间没有任何共享引用。实现方式五花八门,但每一种都有它的边界和代价。

3.1 JSON 方案的局限性和翻车现场

JSON.parse(JSON.stringify(obj))是大伙最熟悉的一种深拷贝方式,因为它一行代码就搞定,看起来非常优雅。面试的时候你这么说,面试官一般会点点头,然后紧接着问“那它有什么缺点吗”。你要是一愣,那这个点就扣分了。

这个方案的底层逻辑是:先把对象序列化成 JSON 字符串,再把这个字符串解析回一个全新的对象结构。整个过程相当于给对象拍了张照片,再用照片1:1重建一个。但问题在于,不是所有 JavaScript 数据都能被拍进这张照片里的。我列一下我实际踩过的坑:

数据类型JSON.stringify 之后的行为
undefined对象属性被直接丢弃;数组元素变成null
function对象属性被直接丢弃
symbol对象属性被直接丢弃;数组元素变成null
Date被转成字符串,不再是 Date 对象
RegExp变成空对象{}
Map/Set变成空对象{},内容全丢
NaN/Infinity变成null
BigInt直接抛错TypeError
循环引用直接抛错TypeError

这里面的坑我不止踩过一次。最典型的是日期对象,后端返回的数据里有个Date类型的字段,你用 JSON 深拷贝一处理,这个字段就变成了 ISO 字符串,后续代码又把它当 Date 调用getTime(),直接报错。还有一次是拷贝一个包含函数的配置对象,函数字段莫名其妙消失,运行的时候才发现找不到方法了。

所以我现在的习惯是:如果对象经过我手的环节涉及DateRegExpMapSet、函数这类特殊类型,我绝不直接用 JSON 方案。它只适合处理纯数据对象,也就是“JSON 安全”的数据。

3.2 手写深拷贝要考虑哪些边界条件

自己手写深拷贝是面试高频题,也是真正理解深拷贝的好方式。很多人上来就写一版递归:

function deepClone(obj) { if (typeof obj !== 'object' || obj === null) { return obj } const result = Array.isArray(obj) ? [] : {} for (const key in obj) { result[key] = deepClone(obj[key]) } return result }

这版能处理嵌套对象和数组,但也就仅此而已。面试官问一句“循环引用怎么办”,很多人的递归就栈溢出了。

所谓循环引用,就是对象属性直接或间接指向了自身:

const obj = {} obj.self = obj

上面那版深拷贝一碰到obj.self就会无限递归,最终栈溢出。解决思路是引入一个WeakMap做缓存:每克隆一个对象之前,先查一下缓存里有没有,有就直接返回缓存里的拷贝结果,没有就创建并存入缓存。这样就打断了循环。

我再给你一版考虑更全面的写法:

function deepClone(obj, map = new WeakMap()) { if (typeof obj !== 'object' || obj === null) { return obj } if (map.has(obj)) { return map.get(obj) } let result if (obj instanceof Date) { result = new Date(obj.getTime()) } else if (obj instanceof RegExp) { result = new RegExp(obj.source, obj.flags) } else if (obj instanceof Map) { result = new Map() map.set(obj, result) obj.forEach((value, key) => { result.set(deepClone(key, map), deepClone(value, map)) }) return result } else if (obj instanceof Set) { result = new Set() map.set(obj, result) obj.forEach(value => { result.add(deepClone(value, map)) }) return result } else if (Array.isArray(obj)) { result = [] map.set(obj, result) for (const item of obj) { result.push(deepClone(item, map)) } return result } else { result = {} map.set(obj, result) for (const key of Object.keys(obj)) { result[key] = deepClone(obj[key], map) } return result } }

这版处理了DateRegExpMapSet、数组和普通对象,也处理了循环引用。但是你会发现它仍然没有处理函数、Symbol属性、原型链、getter/setter这些更刁钻的场景。函数其实没必要拷,直接共享引用就行,因为函数本身没有“状态”这个概念;Symbol作为属性名的话,你还需要用Reflect.ownKeys来遍历;原型链要不要保留,取决于你的业务需求。

这就是手写深拷贝的真相:完整处理所有边界条件的代码量远超你的想象。面试的时候你不需要把全部情况都写出来,但你要能一句一句讲清楚自己在处理什么、没处理什么、后续可以怎么扩展,这比套路化背一个版本强得多。

3.3 结构化克隆 structuredClone

浏览器和 Node.js 其实提供了一个原生 API,叫structuredClone,它就是为结构化克隆算法设计的。这个 API 能处理DateRegExpMapSetArrayBufferBlob,也能处理循环引用,而且在底层实现上性能非常优秀。

const cloned = structuredClone(original)

一句话就能完成之前手写一大堆才能覆盖的边界场景。那么问题来了:既然有这个 API,为什么大家还是很少用?

首先它是近年才被广泛支持的 API,如果你要兼容老浏览器,可能还是得靠 polyfill 或者第三方库。其次它也有明确的能力边界,函数和 DOM 节点这是两个典型的“死穴”,structuredClone遇到函数会直接扔DataCloneError。如果你要克隆的是组件对象、类实例、或者带函数的配置对象,这个 API 就不适用了。

不过在当前大多数现代浏览器环境里,structuredClone对纯业务数据处理已经非常够用,而且它解决了我前面说的 Date、Map、Set 这些问题,日常开发里完全可以作为默认选择。如果你不确定自己的目标环境支持情况,可以用typeof structuredClone === 'function'先判断一下。

3.4 业务代码里我推荐的做法

如果你要问我实际开发里到底用哪种方案,我的排序是这样的:

  • 处理 JSON 安全的数据,优先用JSON.parse(JSON.stringify()),简单直观,在老环境里也都能跑。
  • 环境允许且数据包含 Date、Map、Set 等类型,用structuredClone
  • 数据里有函数、类实例、原型链等复杂结构,且公司已经在用 lodash,直接用_.cloneDeep

lodash.cloneDeep是经过大量生产环境验证过的方案,它的实现覆盖了绝大多数场景,包括TypedArrayDataViewBufferDOM节点等一系列边界情况。如果你不想为深拷贝这件事反复操心,这是最省心的路子。

但我也要提醒一点,别把 lodash 整个包一把梭地引入项目,尽量按模块引入:

import cloneDeep from 'lodash/cloneDeep'

这样配合 tree-shaking,打包体积不会太夸张。还有一个小提示:cloneDeep底层也是递归实现,非常深的对象、递归代价高的场景下,性能依然不如浅拷贝,但业务场景里能碰到这种极端情况的概率极低,大部分时候你感知不到差异。

4. 面试和实战:从原理到避坑

前面聊了这么多理论和方案,这一部分我集中讲讲面试里这个东西怎么答、实战里有哪些值得参考的选用经验和方法论。这块可能对你拿 offer 和避坑最有直接帮助。

4.1 面试官到底想考什么

深拷贝和浅拷贝这道题,在面试里考察的绝不只是“你会不会用 API”,而是你知不知道对象在内存里怎么存的、能不能预判引用共享带来的副作用、有没有考虑过边界条件和性能。所以面试官通常从浅到深一步步问,你回答的层次决定了你的评分。

第一层问题一般是“浅拷贝和深拷贝有什么区别”。这一层你就直接说结论:浅拷贝只复制对象的第一层属性,内层对象仍然共享引用;深拷贝会递归复制所有层级的对象结构,最终得到完全独立的新对象。这句话说完,就能过最基础的关卡。

第二层问题一般是“有哪些实现方式”。你从Object.assign和展开运算符讲起,再讲 JSON 的深拷贝方案,然后讲手写递归的要点,最后如果能提到structuredClone、lodash 的cloneDeep,并且说明它们各自的适用边界,那就已经超出大多数人的回答水平了。

第三层问题也是最容易拉开差距的,一般是“手写一个深拷贝,要支持循环引用”。这时候你不仅要写出代码,还要随口解释为什么用WeakMap而不是MapWeakMap的好处是不影响垃圾回收、不会造成内存泄漏,对象在外部被回收后,WeakMap里的键值对也会被回收。这种细节才是面试官最想听到的东西。

给你一个记忆锚点:面试回答这个题,你就按“原理 -> API 分类 -> 边界条件 -> 扩展思考”这个路径往下走,基本是稳的。

4.2 深拷贝的性能和数据量问题

深拷贝是有代价的,这个代价主要体现在递归遍历对象结构上。对象越深、越宽、属性越多,耗时和内存占用就越明显。所以在性能敏感的场景里,你要警惕滥用深拷贝。

我举个例子。一个表格组件,用了虚拟滚动,一屏要渲染 1000 行数据,每行数据是一个嵌套对象。如果你在每次渲染前都对整个数据源做一次深拷贝,这 1000 行的数据会被完整遍历一遍又一遍,在低端机器上就会出现肉眼可见的卡顿。更合理的方式是在数据源头就做处理,改数据之前先拷贝,改的时候尽量局部更新,避免每次重新遍历全量数据。

还有一个场景是 Web Worker 里postMessage传数据。这个 API 实际上也是结构化克隆的语义,数据会从主线程复制一份到 Worker 线程,这是由引擎底层的序列化机制干活的,比你手写深拷贝高效得多。遇到这种跨线程传输的需求,优先考虑postMessage而不是手动克隆。

如果你对性能要求极高,还想彻底绕开深拷贝,可以考虑引入不可变数据结构库,比如 Immer 或者 immutable.js。Immer 的思路是用 Proxy 拦截修改操作,自动生成新的不可变状态,内部尽量复用未修改的节点,从根上就不需要全量深拷贝。

4.3 我踩过的坑和一些实用心得

聊到最后,分享几个我这几年在深拷贝上实打实踩过的坑,每个都是血泪换来的。

第一个坑是复制 Map 之后发现 Map 变成空对象。那次我把一个包含Map字段的业务数据用JSON.parse(JSON.stringify())处理完,后续遍历这个字段的时候拿到的是空对象,调试了好久才反应过来是 JSON 序列化吞掉了 Map 的内容。从那以后,凡是涉及MapSetDate的数据,我都会先问一句:这里能用 JSON 方案吗?不能就直接换structuredClone或 lodash。

第二个坑是严格模式下Object.assigngetter行为。Object.assign在复制属性的时候会触发源对象的getter,然后在目标对象上赋值时又会触发目标对象的setter。如果你的对象上有副作用逻辑的访问器属性,浅拷贝和深拷贝的结果可能和你预期的不一样。这类问题排查起来极其隐蔽,因为它不报错,只是行为诡异。

第三个坑是关于类实例的拷贝。用普通深拷贝方法克隆一个类实例,得到的对象往往丢失原型链上的方法,因为大多数实现只拷贝可枚举的自身属性。比如你有一个User类的实例,里面定义了getFullName()方法,克隆后的对象没有一个这样的方法,调用就直接TypeError。这种场景我后来通常改为手动“重建”对象,把需要的字段显式复制到新的User实例上,而不是无脑深拷贝。

最后再补充一个我觉得很实用的小技巧:如果你想判断一个对象是不是在当前环节被“意外共享”了引用,可以在修改前打印新旧对象的引用地址,或者在控制台里给对象打一个标记属性,看标记是否在同一处出现。当然最可靠的还是从代码逻辑上杜绝引用共享,这也是为什么我现在在项目里和团队约定:凡是跨模块传递的数据,原则上一律按不可变数据来使用,确实要改就显式创建新对象,不隐式依赖深拷贝。这样做虽然一开始要写多一点代码,但长期维护省心得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/21 17:41:46

AI前端面试核心:TypeScript+流式处理+SSE实战指南

1. 这不是鸡汤,是9月AI前端面试现场的真实切片“最后提醒一次,9月的AI前端面试不用太老实”——这句话不是标题党,是我上个月连续陪跑6场一线大厂和明星创业公司AI方向前端终面后,把录音逐字稿重听三遍、把面试官追问的27个问题归…

作者头像 李华
网站建设 2026/9/21 17:40:20

Java关键字详解:核心作用与工程实践

1. 关键字在Java中的核心作用Java关键字是这门语言中最基础的构建模块,就像建筑工地上的钢筋水泥。这些被Java语言保留的特殊单词,每个都承载着特定的语法功能。作为从业15年的Java老司机,我见过太多开发者因为对关键字理解不透彻而写出"…

作者头像 李华
网站建设 2026/9/21 17:15:57

Java包装类常量池缓存机制解析与优化

1. Java包装类常量池缓存机制深度解析在Java开发中,我们经常需要在基本数据类型和它们的包装类之间进行转换。但很多开发者并不清楚,Java对部分包装类实现了一个精妙的优化机制——常量池缓存。这个机制直接影响着对象比较的结果,也是面试中经…

作者头像 李华
网站建设 2026/9/21 17:13:25

rich._unicode_data.unicode17-0-0 缺失?Trae Solo 走 TaoToken 改 build.spec

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/21 17:08:03

Spring AI实战:Java开发者快速集成AI能力指南

1. Spring AI初探:当Java生态遇上智能时代Spring框架作为Java开发者最熟悉的老朋友,如今正以全新姿态拥抱AI浪潮。去年我在一个企业级项目中首次尝试将Spring Boot与AI模型集成,原本需要两周开发的智能分类模块,用Spring AI仅用三…

作者头像 李华