ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ES6深拷贝从入门到实践:深入浅出理解引用类型与循环引用

ES6深拷贝从入门到实践:深入浅出理解引用类型与循环引用 最近有朋友翻我的前端学习笔记看到一页 ES6 的总结随口问了一句这些新语法天天用可真让你解释深拷贝为什么还是说不利索我愣了一下然后意识到他问到了一个几乎所有小白都会卡住的地方。ES6 不是单纯的一堆新写法它其实是 JavaScript 从“散装的函数式写法”走向“工程化、模块化”的分水岭。而深拷贝恰恰是检验你到底有没有真正理解对象引用、类型判断、递归、循环引用这些问题的一块试金石。这份笔记我整理了很久专门写给正在学 ES6、但被深拷贝劝退的人。不管你是刚入行还是写了两年函数但没系统看过新特性都可以照着下面的思路重新捋一遍。我尽量用大白话把原理、代码和踩过的坑放在一起说。1. 变量声明的底层差异为什么 let/const 不是“替代 var”这么简单1.1 var 的历史遗留问题提升和全局污染小白初学 ES6 时最常见的误区是“以后都用 let/constvar 就没用了”。这个说法方向对但理由不完整。var 的问题不只是“可以重复声明”更麻烦的是它把变量提升到了函数顶部还会在全局作用域创建可配置属性。举个例子console.log(a); // undefined var a 1;因为 JavaScript 在编译阶段会把 var 声明提升到当前作用域顶部所以读取到的是一个 undefined而不是报错。这个特性在早期能掩盖一部分 bug但对工程化项目来说恰恰是隐患。另一个问题是在全局作用域里用 var 声明的变量会挂在 window 上你很难判断当前项目里哪些全局变量是自己创建的。当多个脚本引入时命名冲突风险成倍增加。let/const 则把“声明位置”和“变量真正生效范围”统一起来不存在提升后面临的“暂时性死区”。需要注意的是let/const 同样有提升但在声明前访问会直接抛 ReferenceError而不是静默地拿到 undefined。这一点和 var 有本质区别也让代码在早期就能发现错误。我见过不少线上 bug就是因为某个变量被提升后先返回了 undefined等真正赋值时已经太晚。切换成 let/const 之后这类问题几乎绝迹。1.2 const 到底能不能改别被“常量”两个字骗了另一个高频误解const 声明的对象属性为什么还可以改其实 const 锁定的只是“变量名和内存地址的绑定关系”不是锁对象本身。也就是说const obj {} 之后obj.value 1 是合法的因为 obj 指向的引用地址没变。如果你希望对象本身也不能被修改只能靠 Object.freeze但它是浅冻结嵌套对象仍然可以改。这个特性和深拷贝有什么关系后面会提到。深拷贝面对的就是“引用类型”问题而 const 在职场上最常见的坑是你以为常量对象不会变结果某个组件里改了它的属性其他模块读取时跟着变了。这种 bug 在多人协作里最难排查。最佳实践是基础类型值用 const对象本身如果真的不允许变更就要做不可变处理或者每次返回新对象。声明方式作用域提升重复声明绑定关系典型问题var函数/全局提升至作用域顶初始值为undefined允许可变全局污染、变量遮蔽let块级有提升但存在暂时性死区不允许可变无较安全const块级同理不允许不可重新绑定对象属性仍可变这个表格是我当时学习时自己画的后来给组里新人也讲过。核心就一句话let/const 让变量的生命周期更可控可控意味着不容易被意想不到的“共享”坑到。2. 函数与对象的语法糖箭头函数、解构、展开运算符2.1 箭头函数没有自己的 this这句话该怎么理解箭头函数是 ES6 里出现频率最高的新特性之一但很多小白只知道“简写”却不知道它存在的真正意义。箭头函数不绑定自己的 this它会沿作用域链捕获上一层普通函数的 this。所以在回调函数里你不写 const self this也能拿到外层 this。但“没有自己的 this”也带来两个实际问题一是箭头函数不能用作构造函数new 会直接报错二是 arguments 对象不可用需要使用剩余参数。比如const fn (...args) console.log(args);为什么设计成这样根本原因是 this 是函数调用时绑定而箭头函数希望把 this 固定成“定义时”的 this避免回调执行环境变化导致 this 丢失。这在事件监听、定时器里非常有用。我写代码时习惯顶层普通函数、对象里的方法、类里的方法用普通 function回调、数组遍历、需要预绑定 this 的场景用箭头函数。这个习惯帮我避免了很多坑。小白最容易犯的错误是在对象字面量里写箭头函数作为方法结果 this 指向了对象外面的环境因为对象字面量本身不产生作用域。2.2 解构和展开运算符让数据复制更优雅但陷阱也在这里解构赋值在取值时非常方便但要注意它本质上还是“引用拷贝”。比如const obj { a: 1, nested: { b: 2 } }; const { nested } obj; nested.b 100; console.log(obj.nested.b); // 100这里你只是拿到了 nested 的引用修改嵌套对象会直接影响原对象。展开运算符(...)同理const b { ...obj } 只能拷贝第一层第二层的 nested 仍然是原对象的引用。这就是浅拷贝最典型的例子。因此对小白来说解构和展开是“便利”还是“陷阱”取决于你清不清楚自己复制的是引用还是值。处理数据流时如果复制一份以后还要改里层数据就不能直接用展开运算符得走深拷贝。我见过不少新人在状态管理里用 ...state 然后发现嵌套数据被改了其实就是没搞清这层关系。深拷贝问题的种子从这时候就开始埋下了。3. 深拷贝才是检验 ES6 功力的试金石3.1 浅拷贝与深拷贝的分界点到底在哪这块要彻底说清楚。浅拷贝只复制房间里的家具清单不复制家具本身深拷贝是把整个房子连同家具重新造一套。日常见到的 Object.assign、数组的 slice、展开运算符都是浅拷贝。它们只能处理单层数据遇到嵌套对象两层以上的数据还是共享引用。我们用代码演示const origin { list: [1, 2, { x: 10 }], name: 小白 }; const shallow { ...origin }; shallow.list[2].x 999; console.log(origin.list[2].x); // 999这个结果说明 shallow 并没有真正脱离 origin。深拷贝的要求是对象里所有的值无论嵌套多少层都开辟新的内存空间。理解了这一点你才会明白为什么要亲手实现一个递归函数而不是依赖 JSON.parse(JSON.stringify(obj))。JSON 方案刚上手时确实好用它几行代码就能完成“序列化→反序列化”。但它的边界问题非常明显遇到函数、undefined、Symbol、正则、日期、Map、Set、循环引用都会丢失或报错。这不是说不能碰而是当数据里有这些类型时你不能再指望它。很多技术方案不是“错”而是“适用范围有限”JSON 深拷贝就是典型。3.2 手写递归深拷贝第一步要处理好基本类型手写深拷贝是很多面试的核心题目也是我学习 ES6 时的重点作业。写一版能处理基础类型、数组、普通对象和 Date 的深拷贝我建议按下面这几步走。先写一个基础框架function deepClone(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); // 处理循环引用如果当前对象已经拷贝过直接返回之前的副本 if (hash.has(obj)) return hash.get(obj); let result Array.isArray(obj) ? [] : {}; hash.set(obj, result); for (let key of Reflect.ownKeys(obj)) { result[key] deepClone(obj[key], hash); } return result; }这段代码里有几个关键点。第一基本类型直接返回因为基本类型是值传递没有深拷贝的必要。第二Date 和 RegExp 需要单独 new 一个实例否则拷贝出来的还是对象构造器不同的“假 Date”。第三Reflect.ownKeys 会把 Symbol 键也取出来这比 Object.keys 更完整。第四WeakMap 用来记录“原对象→新对象”的映射遇到循环引用时直接返回已经生成的新对象避免无限递归。这里为什么要用 WeakMap 而不是 Map因为 WeakMap 的键是弱引用当原对象被回收时映射表里的条目也能被回收不会造成内存泄漏。这个细节虽然看起来高级但在写库或长生命周期代码时非常重要。我当时写完这版拿它去拷了一个三层嵌套对象又试着往里塞了一个循环引用结果是能正常跑出来的。那一刻对递归和引用的理解才真正落地。3.3 循环引用和 WeakMap解决深拷贝里最难啃的骨头循环引用是深拷贝最常考的进阶点。比如const target { name: x }; target.self target;JSON.stringify(target) 会直接抛错因为序列化的时候陷入了环。如果手写递归没有缓存同样会无限递归导致栈溢出。WeakMap 在这里就扮演了“登记表”的角色当拷贝一个对象之前先查登记表如果没有就创建一个空对象并立刻登记然后开始拷贝属性如果属性中又回到了 origin登记表正好能告诉递归函数“这个对象已经有一个副本了直接拿去吧”。我自己第一次写带循环引用的深拷贝时最纠结的地方在于先创建 result 再 hash.set意味着其实是在“还没填属性”的情况下就登记了几行代码。这个顺序不能反否则属性复制时再次遇到同一个对象就查不到登记表等于回到了无限递归。你可以在代码里加一行 log查看 hash 的大小变化马上就能理解。很多人以为 WeakMap 是面试炫技其实不是。它解决的是一个很现实的问题深拷贝往往是递归进行的递归的过程中如果遇到环没有缓存就是死循环。而缓存如果一直持有原对象的引用又会造成内存无法释放。WeakMap 的弱引用特性让它成为这类缓存场景最自然的选择。所谓“学 ES6”很多时候学的不是 API 本身而是 API 背后那套内存和引用思维。4. Promise、class 和模块化进阶语法在小项目里的落地姿势4.1 Promise 链与异常处理别再把回调地狱搬过来Promise 是 ES6 里对异步编程最重要的改进。它不只是把回调函数包了一层而是引入了状态机pending、fulfilled、rejected状态一旦确定就不会再变。这个特性让异步代码更容易推理。小白学 Promise 时最大的困惑是 then 里 return 的值如何传给下一个 then。其实规则很简单then 回调里 return 一个普通值会被包装成 resolve 的值return 另一个 Promise则等那个 Promise 状态变化后继续。写实际项目时建议从一开始就养成两个习惯第一所有异步操作都要有失败分支.catch 不能省第二不要在 then 里面开启新的无关联异步任务否则代码又变成横向回调。ES6 的 Promise 虽然没有 async/await它属于 ES2017但理解 Promise 是理解 async/await 的基础。顺序上我强烈建议小白先彻底搞懂 Promise再碰 async/await。这里有一个实际调试技巧在 then 和 catch 里分别打印 error 的 stack可以很快定位到底哪个环节出问题。Promise 的错误如果不捕获会直接变成 unhandledrejection这在浏览器控制台提示得很明显但它不会终止页面所以容易被忽视。我见过不少项目上线后出现“接口失败但页面没变化”的情况就是因为某个 Promise 漏了 catch。4.2 class 的语法糖本质和继承细节class 是 JavaScript 里比较有争议的语法因为它的底层仍然是原型链。class 并没有带来真正的“类继承”模型但它把构造函数的用法标准化了。一个简单的类class Animal { constructor(name) { this.name name; } speak() { console.log(this.name makes a sound); } } class Dog extends Animal { constructor(name) { super(name); this.kind dog; } }这里最容易出错的是 super。为什么要在子类 constructor 里先调用 super因为 ES6 的 class 继承机制要求子类必须先通过 super 完成父类构造过程中的 this 初始化之后才能访问 this。如果漏掉 super运行时会直接报错。归根到底class 继承是基于“继承实例状态共享原型方法”的封装不是为了让你彻底抛弃原型而是让你写起来更清晰。对小白来说class 的价值更多体现在代码组织上尤其在 React 类组件时代。现在函数组件流行后class 的使用频率有所下降但理解它仍然重要比如 class 内部的方法默认不绑定 this事件回调中如果直接把方法作为回调传递会丢失 this需要 bind 或箭头函数。这个坑我踩过不止一次尤其在构造器里注册事件监听时漏掉绑定会让回调里的 this 变成 undefined。4.3 import/export浏览器和打包器里的细节ES6 模块系统是 JavaScript 第一次在语言层面支持模块化。它和 CommonJS 的核心区别有两个一是静态分析import 语句必须在模块顶层不能放在条件块里这样打包器才能在构建时做 tree-shaking二是模块的 export 是实时绑定import 拿到的是引用export 的变量变化后import 方也会感知到变化。实际项目里我会提醒小白默认导出和命名导出要分清。一个文件建议要么只用默认导出要么只用命名导出混合使用虽然语法允许但会让后续引用变得混乱。另外浏览器原生 ES Module 和打包器Vite/Webpack的行为有一定差异开发时尽量用打包器因为浏览器原生加载模块需要完整路径比较麻烦。模块化和深拷贝看着没关系其实都有共同的学习目标理解“变量/值/引用”在不同作用域、不同文件之间怎么流动。你 import 一个对象另一个文件里改了它当前文件的引用也会变。这种共享和“浅拷贝的共享”一个道理都指向同一个点——引用边界。5. 从手写深拷贝到生产环境我的避坑清单与实用建议5.1 现成工具 vs 手写什么时候该用哪个手写深拷贝是学习生产环境则要讲效率。目前常见方案有lodash 的 cloneDeep、structuredClone浏览器原生 API、自己实现的 deepClone。我的使用习惯是前端项目里优先考虑 structuredClone它由浏览器原生支持能处理 Date、RegExp、Map、Set、ArrayBuffer 等类型比 JSON.stringify 方案强太多。但要注意structuredClone 不支持函数和 Symbol遇到函数属性会直接抛错因为它会把函数理解为不可克隆的数据。lodash 的 cloneDeep 则是兼容性最好、最稳定的方案因为它处理了几乎所有类型还允许你定制。缺点是要引入 lodash 依赖如果你只需要深拷贝完全可以用 es-toolkit 或自己维护一个几十行的函数。手写深拷贝适合数据形状可控、类型简单的内部对象比如表单数据、组件配置。总之没有银弹要根据数据类型和运行环境选。5.2 性能、栈溢出和类型边界的处理手写深拷贝的递归实现有个隐藏问题如果对象嵌套层级非常深比如刻意构造的 10000 层嵌套递归会栈溢出。遇到这种极端情况就得考虑用迭代栈模拟递归。不过日常业务数据一般不会超过几十层很少触发这个边界面试和实际项目都不用太恐慌。另一个常见问题是数组中的空位Array 可能通过 const arr [1, , 3] 创建稀疏数组使用 for...in 或 Object.keys 遍历时会跳过空位导致拷贝结果变成不通透的稠密数组。手写深拷贝时如果在意这一点可以用 for 循环或者 Array.from 这类保留空位的遍历方式。这块属于“加分项”知道的人很少写出来能提高自己实现的质量。我在一次代码评审里看到有人手写深拷贝把数组遍历写成 for...in结果所有空位都被压缩成 undefined数据结构直接变了。这类问题不跑一遍用例根本发现不了。所以学深拷贝时一定要自己补一轮边界测试数组、对象、Date、正则、Symbol、循环引用、稀疏数组至少各来一个例子。5.3 几个真实项目里踩过的深拷贝坑最后分享几个我实际踩过的坑也可以叫“别信表面方案”。第一JSON 方案会把 NaN 变成 null。比如一个数值字段原本是 NaNJSON.stringify 之后变成 null接口再返回时数据就变了。第二拷贝带原型链的自定义对象时普通深拷贝会丢掉原型。如果你遇到需要保留实例类型的需求那就要用 Object.create(Object.getPrototypeOf(obj)) 来创建副本但大多数业务场景不需要。第三Map 和 Set 需要单独处理很多手写深拷贝只处理对象和数组遇到 Map 和 Set 时会退化成空对象。用 lodash 或者 structuredClone 就不会有这个 bug但是面试手写时你一定要在代码里补上对 Map、Set、Symbol 的处理至少提出来这能体现对类型边界的敏感度。另外深拷贝本身也是性能消耗大户。如果数据比较大尽量在服务端或数据入口处只拷一次不要在每次渲染时都调用深拷贝。如果一个组件里反复 clone 大规模数组可以明显感觉页面卡顿。我自己一般会用简单的结构化数据替代深拷贝比如把复杂对象拆成只读的普通对象然后配合 Object.freeze从源头上减少复制需求。这份笔记写到现在我自己最强烈的感受是ES6 的语法点看着零散其实围绕的都是“引用、作用域、异步”这三件事。深拷贝只是把“引用”这件事具象化了。你要是能独立写出一版健壮的 deepClone再回头看 let/const、解构、展开运算符、模块化思路会清晰很多。学习的时候别着急把每一段代码都亲手敲一遍敲完再改一版改完再加一个循环引用进步就在这个循环里。
返回列表