☰
彻底理解 JavaScript 浅拷贝与深拷贝:原理、实现与应用
2026/9/26 3:16:07 网站建设 项目流程

1. 从一次线上脏数据说起:为什么浅拷贝会“串味”

前端项目里最让人后背发凉的 bug,往往不是报错,而是数据悄悄被改了。我遇到过一个典型场景:列表页把接口返回的userList存进状态,详情弹窗打开时想“复制一份”做临时编辑,于是写了const draft = { ...currentUser }。用户改完取消,列表里的头像和昵称却跟着变了。排查半天才发现,currentUser里嵌套了profile和tags,展开运算符只复制了第一层,嵌套对象仍然是同一个内存地址。

这就是浅拷贝与深拷贝要解决的核心问题。JavaScript 里值类型(string、number、boolean、null、undefined、symbol、bigint)赋值时复制的是值本身,而引用类型(Object、Array、Date、RegExp、Function等)赋值时复制的是内存地址。浅拷贝只处理第一层,嵌套引用依然共享;深拷贝递归复制每一层,最终两份数据完全独立。

这篇文章面向正在做状态管理、配置合并、缓存复用的前端开发者。我会把可复制的拷贝工具函数、structuredClone与 JSON 方案的对比配置、以及引用嵌套、循环引用、特殊对象三类验证用例和断言动作都交付出来。你可以直接拿去改项目里的工具目录,也可以当成面试前的实操复习。理解拷贝的本质,本质上是在理解“引用共享”这件事会在哪些地方咬你一口。

2. 前置准备:用 TaoToken 快速搭一个可验证的 JS 实验环境

要验证拷贝行为,最省事的方式是有一个能随时跑 Node 或浏览器控制台的环境。如果你本地已经有 Node,直接node进 REPL 就行。但如果你想让 AI 帮你解释某段拷贝代码为什么输出不符合预期,或者让它生成针对循环引用的测试用例,可以借助 TaoToken 的模型对话能力来加速排查。

TaoToken 是一个聚合多家大模型能力的 API 平台,对前端开发者来说,它的价值在于:你不需要在多个模型控制台之间来回切换,用一个 API Key 就能在脚本里调用不同模型做代码解释、用例生成和报错分析。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api (这个不加 UTM)。

具体操作上,你可以先到控制台创建 Key:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,然后在 API Keys 页面拿到密钥:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。拿到之后,就可以在 Node 脚本里发请求,让模型帮你判断一段深拷贝实现是否漏掉了Symbol键或循环引用。

如果你更习惯在对话界面里贴代码问问题,直接用模型对话入口即可:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。接入细节和参数说明在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。长期做编码和 Agent 场景的话,可以了解 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

注意:TaoToken 在这里的角色是帮你解释代码、生成测试用例和排查报错的辅助工具,不是替代你的编辑器或运行时。拷贝逻辑最终还是要靠本地断言来验证。

3. 可复制配置:浅拷贝、深拷贝与 structuredClone 的完整实现

3.1 浅拷贝的三种写法与它们的边界

对象浅拷贝最常用的是展开运算符和Object.assign:

const source = { name: 'Alice', profile: { city: 'Hangzhou' }, tags: ['a', 'b'] }; const copy1 = { ...source }; const copy2 = Object.assign({}, source); copy1.profile.city = 'Shanghai'; console.log(source.profile.city); // 'Shanghai',嵌套对象共享引用

数组浅拷贝可以用展开、slice()、concat():

const arr = [1, 2, { a: 3 }]; const arrCopy = [...arr]; arrCopy[2].a = 99; console.log(arr[2].a); // 99,嵌套元素共享引用

这三种写法都只复制第一层。判断标准很简单:只要你的数据结构里存在嵌套的引用类型,浅拷贝就会留下共享隐患。

3.2 手写深拷贝:处理循环引用与特殊对象

下面这个deepClone是我在项目里实际用过的版本,核心是WeakMap记录已复制对象,避免循环引用导致栈溢出,同时单独处理Date、RegExp、Map、Set:

function deepClone(target, visited = new WeakMap()) { // 值类型与 null 直接返回 if (target === null || typeof target !== 'object') { return target; } // 特殊内置对象 if (target instanceof Date) return new Date(target.getTime()); if (target instanceof RegExp) return new RegExp(target.source, target.flags); if (target instanceof Map) { const mapCopy = new Map(); visited.set(target, mapCopy); target.forEach((value, key) => { mapCopy.set(deepClone(key, visited), deepClone(value, visited)); }); return mapCopy; } if (target instanceof Set) { const setCopy = new Set(); visited.set(target, setCopy); target.forEach((value) => setCopy.add(deepClone(value, visited))); return setCopy; } // 循环引用:已复制过就直接返回缓存 if (visited.has(target)) { return visited.get(target); } const clone = Array.isArray(target) ? [] : {}; visited.set(target, clone); // 复制自身可枚举属性,包含 Symbol 键 Reflect.ownKeys(target).forEach((key) => { clone[key] = deepClone(target[key], visited); }); return clone; }

这里用Reflect.ownKeys而不是for...in,是为了把Symbol键也复制进去,同时避免遍历到原型链上的属性。WeakMap的键是对象,不会阻止垃圾回收,比Map更适合做这种临时缓存。

3.3 structuredClone 与 JSON 方案对比

现代浏览器和 Node 17+ 提供了原生structuredClone,它基于结构化克隆算法,能处理循环引用、Date、RegExp、Map、Set、ArrayBuffer等,但不能克隆函数、Symbol键和 DOM 节点。

const obj = { a: 1, b: undefined, c: Symbol('key'), d: new Date('2024-01-01'), e: /abc/g, f: function () { return 1; }, }; obj.self = obj; // 循环引用 const clone = structuredClone(obj); console.log(clone.d instanceof Date); // true console.log(clone.self === clone); // true console.log(clone.f); // undefined,函数无法克隆 console.log(clone.c); // undefined,Symbol 键丢失

JSON 方案JSON.parse(JSON.stringify(obj))的局限更明显:undefined、Symbol、函数会被直接丢弃,Date会变成字符串,循环引用会抛TypeError。下面这张表可以帮你快速选型:

方案循环引用Date/RegExp函数/SymbolMap/Set性能
展开运算符不处理共享引用共享引用共享引用高
JSON 方案报错Date 变字符串丢失丢失中
手写 deepClone支持支持Symbol 键支持,函数共享支持中低
structuredClone支持支持不支持支持较高

提示:如果你的数据只包含 JSON 安全类型且没有循环引用,JSON 方案够用;一旦涉及Date、Map、循环引用,优先structuredClone;需要保留Symbol键或做精细控制,用手写deepClone。

4. 验证请求与成功结果:三类用例的断言动作

4.1 引用嵌套用例

const nested = { level1: { level2: { value: 1 } } }; const shallow = { ...nested }; const deep = deepClone(nested); shallow.level1.level2.value = 100; console.assert(nested.level1.level2.value === 100, '浅拷贝应共享嵌套引用'); deep.level1.level2.value = 200; console.assert(nested.level1.level2.value === 100, '深拷贝不应影响原对象');

运行后第一个断言通过,说明浅拷贝确实共享了level2;第二个断言通过,说明深拷贝切断了引用链。

4.2 循环引用用例

const circular = { name: 'root' }; circular.self = circular; const cloned = deepClone(circular); console.assert(cloned.self === cloned, '循环引用应指向克隆后的自身'); console.assert(cloned !== circular, '克隆对象与原对象不是同一个引用');

如果不用WeakMap,这段代码会直接RangeError: Maximum call stack size exceeded。加上缓存后,cloned.self正确指向cloned本身。

4.3 特殊对象用例

const special = { date: new Date('2024-06-01'), regex: /hello/gi, map: new Map([['k', { v: 1 }]]), set: new Set([1, 2, 3]), sym: Symbol('s'), }; special[special.sym] = 'symbol-value'; const specialClone = deepClone(special); console.assert(specialClone.date instanceof Date, 'Date 类型应保留'); console.assert(specialClone.regex instanceof RegExp, 'RegExp 类型应保留'); console.assert(specialClone.map instanceof Map, 'Map 类型应保留'); console.assert(specialClone[special.sym] === 'symbol-value', 'Symbol 键应被复制');

四个断言全部通过,说明手写deepClone在特殊对象上没有丢类型。如果你换成 JSON 方案,date会变成字符串,map和set会变成空对象,Symbol键直接消失。

5. 本篇常见错排查:深拷贝踩过的坑

5.1 展开运算符被当成深拷贝

最常见的误区就是const copy = { ...obj }之后直接改嵌套属性。判断方法:改完嵌套属性后看原对象有没有变。如果变了,说明你用的是浅拷贝。解决方案是对嵌套层级明确使用deepClone,或者只对需要隔离的那一层做深拷贝。

5.2 JSON 方案丢字段却不报错

JSON.parse(JSON.stringify(obj))最危险的地方在于它不报错,只是静默丢数据。undefined、函数、Symbol键都会消失,Date变成 ISO 字符串。如果你在配置合并场景里用了这个方案,合并后的配置可能缺少关键字段,而且很难从日志里看出来。排查时可以用Object.keys对比原对象和克隆对象的键数量。

5.3 循环引用导致栈溢出

当对象存在obj.self = obj这类结构时,递归深拷贝如果没有缓存机制,会无限递归直到栈溢出。报错信息通常是RangeError: Maximum call stack size exceeded。解决方案就是前面deepClone里的WeakMap,每次进入递归前先检查是否已经复制过。

5.4 structuredClone 克隆函数失败

structuredClone遇到函数会抛DataCloneError,遇到Symbol键会直接忽略。如果你从接口拿到的数据里混入了回调函数,直接structuredClone会中断整个流程。排查时可以先JSON.stringify看看有没有函数字段,或者用typeof逐层检查。

5.5 深拷贝性能拖垮高频操作

在requestAnimationFrame或高频事件回调里做深拷贝,很容易造成卡顿。深拷贝的时间复杂度随嵌套层级增长,数据量大时开销明显。优化思路是:只对真正需要隔离的字段做深拷贝,其余保持浅拷贝;或者改用不可变数据结构,从源头避免拷贝。

6. 选型建议与后续接入

回到实际工程,我的经验是:状态管理里如果用了 Redux 或 Pinia,优先遵循不可变更新原则,用展开运算符做浅层更新,只在需要完整快照时用深拷贝。配置合并场景里,如果配置对象包含Date或嵌套数组,直接上deepClone或structuredClone,别用 JSON 方案。缓存复用场景要特别注意,缓存对象被外部修改后,下次读出来的可能已经是脏数据。

如果你想让 AI 帮你审查一段拷贝代码是否覆盖了循环引用和特殊对象,可以把代码贴到模型对话里:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 。需要在自己的 Node 脚本里批量生成测试用例,就去 API Keys 页面拿 Key:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入方式参考文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。长期做编码 Agent 的话,Coding Plan 入口在这里:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

最后留一个实用技巧:在项目里建一个clone.js,把deepClone和几个断言用例放进去,每次改动拷贝逻辑就跑一遍node clone.js。断言不通过就说明有引用没切断,比等到线上出脏数据再排查省事得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询