☰
ES6复习笔记:深拷贝、Map与高频细节全解析
2026/10/13 3:23:21 网站建设 项目流程

这篇是ES6复习总结笔记系列的第三篇,前两篇整理了基础语法和函数相关的知识点,这篇把重点放在开发中真正用得最多、也最容易出问题的几个点上:深拷贝、Map,以及几个平时容易忽视但面试和工作里经常绊一脚的细节。这三个点看起来关联不大,但它们共同决定了你对“引用类型”和“数据流”的理解程度,而这两样东西恰恰是前端工程化项目里最容易出事故的地方。这篇笔记适合正在系统复习ES6的初中级前端,也适合准备面试时用来快速过一遍高频考点的同学。

1. 深拷贝:面试必考,开发必用

1.1 为什么浅拷贝解决不了嵌套对象——一次事故复盘

先讲一段我自己的事故。刚工作时维护一个表格编辑功能,选中一行数据后要打开一个编辑弹窗,弹窗里改完确认再覆盖原数据。当时图省事,用展开运算符直接传了一份数据过去:

const copy = { ...row };

结果用户改了嵌套对象里的字段,还没点确认,表格里的原数据就已经变了。查了很久才发现,展开运算符只拷贝了最外层的一层引用,当对象里面还有对象或者数组时,内层仍然指向原对象的地址。我当时在弹窗里执行的对象修改其实是直接改在同一个内存地址上的,等于我一边展示原表格一边改原数据,数据当然就乱了。

这个问题在面试里最常见的问法就是“展开运算符能实现深拷贝吗”。答案是不能,它只能做一个浅拷贝。浅拷贝和深拷贝的本质区别是:深拷贝要求对象里的每一个嵌套层级都生成全新的内存地址,修改副本时源对象完全不受影响;浅拷贝只解决最外层,内层还是共享的。

还有一种常见误区是以为Object.assign是深拷贝。实际上它跟展开运算符是一个级别的,做的是浅拷贝。数组的slice、concat方法也很容易被当作深拷贝用,但它们处理的单层数组还能应付,一旦数组里存的是对象,一层层深挖下去仍然是引用。所以判断拷贝方式有没有问题,最简单的方法是看你的数据有没有“第二层”对象。没有嵌套结构的情况下浅拷贝随便用,一旦有嵌套对象或者数组,就必须考虑深拷贝方案。

1.2 JSON深拷贝:好用的背后全是坑

很多人一提到深拷贝,第一反应就是JSON.parse(JSON.stringify(data))。这确实是最快捷的深拷贝方案,我在写临时脚本的时候也会常用它,它足够应付大量纯数据结构的场景,但它在背后埋了至少五个明显的坑,如果你想靠它应付所有深拷贝场景,迟早要出问题。

第一个坑是函数会被丢弃。对象里的方法经过stringify之后直接就没了,序列化之后的字段只剩纯数据。这个在很多人看来“无所谓”,但如果你拷贝的是一个 Vue 组件配置对象、一个富文本编辑器内容对象,里面很多字段是函数回调,拷贝完等于把行为逻辑全删了。

第二个坑是undefined、Symbol类型的值会被直接忽略,NaN和Infinity会被转成null。这意味着如果你拷贝的数据里有这些特殊值,拷贝完你会拿到一个面目全非的对象,而且没有任何报错提示。

第三个坑是Date对象会被转化成字符串。JSON.stringify序列化 Date 时调用了它的toJSON方法,结果存的是 ISO 格式的字符串,不是原来的 Date 对象。拷贝出来的字段失去了 Date 对象的方法,后面你要调.getTime()之类的 API 就会直接报错。

第四个坑是RegExp、Map、Set这类特殊对象会被序列化成空对象或普通对象。正则表达式没了,Map、Set 变成{},内部数据全部丢失。我在项目里吃过这个亏,缓存数据里的 Set 结构经过一层 JSON 深拷贝后直接变成空对象了,排查了半天才发现是拷贝方式埋的雷。

第五个坑也是最重要的——循环引用会让它直接抛异常。数据A的某个字段引用了A自身,这在维护树形结构、做依赖图或者构建ORM关联数据时非常常见,但JSON.stringify遇到循环引用会直接报错TypeError: Converting circular structure to JSON。

所以我的结论是:JSON深拷贝适合纯数据结构的场景,比如接口返回的普通JSON数据、配置表数据、接受后端传来的纯对象数据,这些场景里它简单高效。但数据里有函数、正则、Date、Map、Set或者循环引用时,要立刻放弃这个思路。

1.3 structuredClone:原生深拷贝的新选择

后来我了解到浏览器其实提供了原生深拷贝 API,叫structuredClone,它解决了很多痛点是 JSON 方案做不到的。这个方法能够拷贝大部分现代 JavaScript 内置对象,包括 Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob 等,而且在面对循环引用时也不会报错,也就是说前面提到的几个坑在原生 API 面前基本都不是问题。

const original = { name: 'test', date: new Date(), map: new Map([['key', 'value']]), nested: { list: [1, 2, 3] } }; original.self = original; // 循环引用 const clone = structuredClone(original); console.log(clone === original); // false console.log(clone.self === clone); // true,循环引用也被正常拷贝

看到这里你可能想问,既然原生有这玩意,为什么还要手动写深拷贝?我的判断是structuredClone有两个现实限制。第一个是兼容性,它在 2022 年之后的主流浏览器里可用,Node.js 17 以上可用,但如果你的项目需要兼容老环境,还是不能依赖它。第二个是它有明确的能力边界:函数它不拷贝,DOM 节点它不支持,Error 对象它会变成普通对象,原型链也会丢失。

这让我逐渐形成了一套判断标准:如果是纯数据,用 JSON 方案可以,用structuredClone也可以;如果数据里有 Date、Map、Set、循环引用,优先structuredClone;如果数据里有函数、自定义类实例,那就得考虑挂载函数的手写递归方案,或者直接用 lodash 的cloneDeep,它是目前社区里最成熟的深拷贝实现,各种边界情况都处理得很到位。

1.4 手写一个靠谱的深拷贝

尽管有现成的库,我依然建议每个人都能够动手写一遍深拷贝,因为这直接关系到你对“引用”“类型判断”“递归”这几个概念的理解深度,面试时手写范围也爱考这个。我自己写过的一个可用的核心版本是这个样子:

function deepClone(target, cache = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; } if (cache.has(target)) { return cache.get(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 result = new Map(); cache.set(target, result); target.forEach((value, key) => { result.set(deepClone(key, cache), deepClone(value, cache)); }); return result; } if (target instanceof Set) { const result = new Set(); cache.set(target, result); target.forEach((value) => { result.add(deepClone(value, cache)); }); return result; } if (Array.isArray(target)) { const result = []; cache.set(target, result); target.forEach((item, index) => { result[index] = deepClone(item, cache); }); return result; } // 处理普通对象和 Symbol 键 const result = {}; cache.set(target, result); const symbolKeys = Object.getOwnPropertySymbols(target); [...Object.keys(target), ...symbolKeys].forEach((key) => { result[key] = deepClone(target[key], cache); }); return result; }

这个版本的设计思路从浅到深逐个解决难题。最基本的递归是处理嵌套对象的核心,每层调用时都会重新创建新的对象或数组。cache这个 WeakMap 专门处理循环引用,每次创建新对象前先判断原对象是否已经出现在缓存中,出现过就直接返回缓存里的新对象,这样在拷贝循环引用时不会造成无限递归。把 cache 放进参数里而不是在函数内部创建一个 Map,是为了保证整个递归过程中的所有层都共享同一个“已拷贝对象记录表”。

Date 和 RegExp 分别用构造函数重建,Map 和 Set 各自创建新实例再递归拷贝内部的键和值。数组单独判断是因为数组需要保持索引结构,而且它的遍历方式和普通对象不太一样。普通对象会把自有属性取出来,包括 Symbol 类型的键也一起拷贝,这是完整拷贝的一部分。

值得提醒的是,这个版本没有处理函数的情况。函数在深拷贝时我一般直接引用原函数,因为函数本身没有“状态”,拷贝一份函数其实没有业务意义。如果你的场景要求函数也变成新的引用,那基本等于把函数重新 eval 一遍,既不安全也没必要。

1.5 深拷贝的边界场景与性能实测

深拷贝不是万能油,它本质上是用“内存开销”换“数据安全”,所以在性能敏感的大型数据场景里要斟酌。我实测过一组一百万个普通对象,手动递归方案耗时大约是 JSON 方案的几倍,lodash.cloneDeep也比 JSON 慢不少。也就是说,如果你的数据非常庞大,但数据本身是纯 JSON 结构,确实JSON.parse(JSON.stringify())在性能上反而有优势。

这里我的实操建议是:接口通信层的数据,优先 JSON 方案或structuredClone;业务组件里的复杂对象,优先手写深拷贝或 lodash;能传引用就不拷贝,能用不可变数据结构(比如用展开运算符创建新对象)就不做整体深拷贝。最典型的场景是 React 的 setState,它本质上要求你创建新对象引用,这其实是一种更轻量的“深拷贝”替代方案——你只需要拷贝发生变化的路径,而不是整棵树。

还要提一下不可变数据结构的概念。在处理复杂状态时,更多时候应该考虑做“结构共享”,比如对象只有一层变化,只新建一层新对象,其余引用直接复用。这是immer这类库的核心思想,它的价值在于既保持数据不可变,又避免了整棵树的深拷贝开销。深拷贝在解决安全问题的同时会带来性能问题,而不可变数据思路是同时兼顾两者的方案。

2. Map:被低估的键值对容器

2.1 Object和Map,到底该选谁

ES6 引入的 Map 在很长一段时间里被我当成“升级版的 Object”,后来在实际开发中才逐渐意识到它并不是替代关系,而是不同场景下的差异化工具。要搞清哪个更适合业务,得先从底层设计差异说起。Object 的键只能是字符串或者 Symbol,而 Map 的键可以是任何类型,包括对象、函数、NaN、乃至正则表达式。

这个特性的直接作用体现在一个场景里:如果你想用某个对象作为键来维护一系列关联数据,面向对象写法里就得给对象加一个唯一ID字段,再用这个ID作为 Object 的键,非常别扭。但在 Map 里,直接让对象本身作为键,整个设计会自然很多。

两者的迭代顺序也很不一样。Map 是按照键值对的插入顺序迭代,顺序稳定可预测;Object 虽然现在也基本能保证字符串键按插入顺序迭代,但数字型键会被单独提到最前面,这常常导致一些难以察觉的循环顺序问题。如果你依赖键值对的顺序处理逻辑,Map 是更安全的选择。

Map 还自带size属性,一个属性就拿到元素个数,而 Object 的数据量你得Object.keys(obj).length一把,代码啰嗦不说,内部多创建了一个临时数组。最后就是增删频繁时的执行性能问题,需要频繁增删键值对的场景下 Map 的哈希表实现通常比 Object 好一些,尤其在键为对象时两者差距更为明显。

对比维度ObjectMap
键的类型字符串或 Symbol任意类型,包括对象、函数
迭代顺序整数键优先,其余按插入序严格按插入顺序
元素数量获取Object.keys().length直接使用size属性
性能表现中小规模正常频繁增删场景更有优势
原型链有原型链,易产生意外继承纯键值对结构,无继承
序列化原生支持需要手动转换
解构/扩展可直接展开或解构需要转成数组再操作

2.2 Map的完整API与遍历细节

Map 的 API 设计很极简,核心就几个方法,但每个都能在具体场景里派上用场。set(key, value)写入键值对,get(key)取值,has(key)判断是否存在,delete(key)删除键值对,clear()清空全部,size属性直接读取元素数量。它的链式写法用起来很方便,map.set('a', 1).set('b', 2)就能连续写入。

遍历 Map 有几种方式,它们的行为差异容易被忽略。map.keys()返回键的迭代器,map.values()返回值的迭代器,map.entries()返回键值对迭代器。需要注意的坑是,这些方法返回的是迭代器而不是数组,所以不能直接调用map.keys()[0]去取第一个键,得先Array.from(map.keys())转成数组,或者直接用扩展运算符。

forEach的回调参数顺序想必有不少人在面试里答错过。普通数组的是forEach((item, index) => ),Map 的官方定义是forEach((value, key, map) => ),第一参数是“值”,第二参数才是“键”。当初我自己写 Map 遍历时就犯过错,把第二个参数当成值用了,结果处理逻辑全乱。记住这个规律:Map 的遍历回调永远是值在前、键在后,这个顺序跟entries()返回的[key, value]结构刚好相反。

Map 转数组和数组转 Map 有两个特别常用的 API。Array.from(map.entries())或者[...map]把 Map 转成[key, value]构成的二维数组;new Map(array)则把二维数组转回 Map。另外一个极少有人用但确实很香的是Object.fromEntries,它可以把一个[key, value]的二维数组直接转成对象:

const map = new Map([['name', '张三'], ['age', 28]]); const obj = Object.fromEntries(map); console.log(obj); // { name: '张三', age: 28 }

这在对接后端接口、往表单组件里灌数据时特别省事。相同方向还有一个Object.entries(obj)可以拿到对象的键值对数组,连起来看就是 Object 转 Map:

const obj = { name: '张三', age: 28 }; const map = new Map(Object.entries(obj));

2.3 WeakMap:解决内存泄漏的最后一公里

Map 和 WeakMap 长得像,但底层的设计目标完全不同,面试里也经常被拿来对比考察对内存管理的理解。WeakMap 的键必须是对象,原始值类型不能作为键。它之所以叫“Weak”,是因为它持有的是对象的弱引用——对象不会因为出现在 WeakMap 里而被阻止垃圾回收。

这个特性直接带来了两个使用上的差异。第一,WeakMap 不可遍历,没有size,也没有keys、values、entries这些方法,因为弱引用的键数量随时可能因为垃圾回收而改变,遍历这个动作在语义上就不成立。第二,WeakMap 非常适合做“私有数据存储”或者“对象关联数据”的场景。

举一个最常见的例子。如果你在一个全局 Map 里缓存了某个 DOM 节点对应的数据,但页面逻辑已经把这个节点移除了,此时如果没有人释放 Map 中的关联,节点和数据就会被永久留在内存里,形成泄漏。而如果把 Map 换成 WeakMap,节点被移除后,垃圾回收器会带走这个键值对,内存空间自然释放,不需要手动管理。使用方式就是把键设置为真实存在的对象,值放你想关联的数据:

const stateMap = new WeakMap(); // 关联状态数据到对象上 function setState(obj, state) { stateMap.set(obj, state); } function getState(obj) { return stateMap.get(obj); } const component = {}; setState(component, { mounted: true, count: 0 }); console.log(getState(component)); // { mounted: true, count: 0 }

把临时产生的对象放进普通 Map 时,一定要考虑生命周期问题。尤其在做配置文件、全局状态处理时,如果对象的生命周期短于 Map 本身,优先考虑 WeakMap。反过来,如果你的键本来就是字符串等原始类型,WeakMap 就用不上了,老老实实用 Map 或普通对象。

2.4 实际业务中Map的典型落点

Map 在业务代码里最常见的场景主要有以下三类。第一类是“配置中心”,用键值对维护映射关系,例如权限编码映射成操作名称,状态码映射成展示文案,界面语言映射表等。Map 相对 Object 的优势是支持键值为非字符串类型,以及更容易做增删和遍历。第二类是“对象映射”,典型就是用 WeakMap 维护组件实例与它内部状态的关系,或者用 Map 维护路由对象与文件模块的对应关系。第三类是“频繁增删的数组依赖场景”,用 Map 来维护队列、弹窗栈、缓存池时比对象更顺手。

我实际开发中经常用到 Map 的场景是缓存算法的实现。例如一个 LRU 缓存,构造一个 Map,每次读数据时先删掉旧键再重新set到末尾来实现排序,这个过程如果用对象做会非常痛苦,而 Map 因为天然记录插入顺序并且支持直接删除和重新追加,代码写起来非常简洁:

function createLRUCache(limit = 10) { const cache = new Map(); return { get(key) { if (!cache.has(key)) return undefined; const value = cache.get(key); cache.delete(key); cache.set(key, value); // 最近访问的放到最后 return value; }, set(key, value) { if (cache.has(key)) cache.delete(key); if (cache.size >= limit) { const firstKey = cache.keys().next().value; cache.delete(firstKey); // 淘汰最久未访问的 } cache.set(key, value); }, clear() { cache.clear(); } }; }

这里cache.keys().next().value就是取出第一个键,也就是最久没访问的项,这是“利用 Map 插入顺序”实现 LRU 的核心思想。这种写法一方面展示了 Map 的有序性,另一方面也演示了如何用 Map 自带操作组合出复杂数据结构。

3. 容易被忽略的ES6实用细节

3.1 Set:不只是去重工具

提到 Set,大多数人的第一反应是数组去重,事实上它的能力和价值远比“去掉重复项”要广。Set 的底层结构保证元素唯一性,基于这个特性可以直接实现集合运算,在业务里做权限交集、标签过滤、列表差异比对时很实用。

数组去重的经典写法是[...new Set(array)],这个写法足够简洁,但要注意它处理不了对象数组的去重问题。Set判断元素是否相同的逻辑基于严格相等,两个内容完全一致但地址不同的对象在 Set 中会被认为是不同的元素,所以如果你想按对象某个字段去重,还是得靠手写。

集合运算实现起来也很直接。取交集就是把数组B转成 Set,然后过滤数组A中在 Set 里存在的元素;取差集反过来过滤不在 Set 里的元素;并集可以直接合并两个数组再转 Set。如果数据规模大,把其中一个数组转 Set 去查另一个数组的性能会比直接includes好很多,因为 Set 的检索是哈希级别的,不是数组那种线性扫描。

还有一个细节值得注意,new Set()在遍历时也保持插入顺序,这和 Map 一样。这个特性可以让你用一个 Set 实现一个“手动去重但保序”的队列,或者一个自动去重的历史记录列表。

3.2 Symbol:保护属性名,赋能迭代器

Symbol 目前在业务代码里用得不算多,但它在设计者眼里是一个非常关键的机制。它提供了一种“永远不会重复”的原始类型,所以最大的用途是生成安全属性名,避免命名冲突。当你书写第三方对象时,如果不小心用了字符串键加几个已有属性,很容易覆盖对方逻辑,而 Symbol 因为唯一性天然规避了这个问题。

Symbol('描述')这种写法里的描述字符串仅仅用于调试和生产日志,并不影响值的唯一性。也就是说,Symbol('a')和Symbol('a')不是同等的值。如果希望全局共享同一个 Symbol,可以用Symbol.for('a'),它会在全局注册表里查找,找到就返回既有 Symbol,找不到就创建新的。

Symbol 在实现“自定义迭代方式”时也是关键角色。任何对象只要实现了[Symbol.iterator]方法,就可以被for...of遍历。数组、字符串、Set、Map 内部都实现了这个接口,这也是它们能直接使用for...of的统一原因。如果你写了这样一个自定义对象:

const range = { start: 1, end: 5, [Symbol.iterator]() { let current = this.start; const end = this.end; return { next() { return current <= end ? { value: current++, done: false } : { value: undefined, done: true }; } }; } }; for (const num of range) { console.log(num); // 1 2 3 4 5 }

实现的完整度值得说一句,[Symbol.iterator]需要返回一个迭代器对象,迭代器带着next(),执行后返回{ value, done },done为 true 表示迭代结束。这个知识点在理解 for...of 和扩展运算符内部原理时很有用,也是面试常考的“如何让一个普通对象支持 for...of”的标准解。

3.3 扩展运算符、解构、默认值中的隐藏陷阱

扩展运算符被视为“用起来很爽但藏着浅拷贝陷阱”的经典操作。它能展开数组和对象,在写代码时的确是神器,但如果要拷贝的数据有嵌套结构,就容易踩雷。之前讲浅拷贝时提到的展开运算符,是造成系列事故的高风险操作,通解是记住“扩展运算符适用于拷贝不嵌套的普通结构,复杂结构一定用深拷贝”。

解构赋值的默认值也有一个微小但容易误判的坑:只有“值为undefined”时才会触发默认值,null不会触发默认值。代码{ a = '默认' } = obj在obj.a = null时得到的还是null,不是默认值。同理,函数参数默认值也只会在传undefined时生效,传null不生效。很多人在接口返回 data 字段为 null 时发现拿到的不是预期默认值,查半天才发现是这个机制。

解构的“重命名+默认值”组合写法很多人会忘:

const { data: result = [] } = res;

这个写法保留了data原始字段名,但给新变量起名result,并且默认值是空数组。这在接口访问里使用频率极高,写一个字段就能同时拿到更安全的变量。

函数参数的默认值还可以利用前面定义的参数,例如function destructure({ a = 1, b = a + 1 } = {}) { ... },这种写法不仅代码简洁,而且能表达“参数间默认值依赖”的逻辑。

3.4 可选链与空值合并的搭配原则

可选链和空值合并是我平时用得最频繁的两个ES6之后的操作符,它们配合起来可以大幅减少防御性判断代码。原来写if (obj && obj.data && obj.data.name)的场景现在可以写成obj?.data?.name,逻辑上又安全又简洁。使用可选链时要注意它的行为特征——一旦中间某个环节为null或undefined,整个表达式直接短路返回undefined,后续的链式调用不再执行,所以也不会有“读取 null 的属性”这类报错。

空值合并操作符??的核心价值是和传统||区分开。||会直接过滤掉所有 falsy 值,包括 0、空字符串、false,这在很多场景下会造成错误。例如一个字段表示数量,值是 0,你写成count || 10,最后得到的是 10,而不是预期的 0。??只会在左侧为null或undefined时取值右侧默认值,0和空字符串都能安全通过。所以规则是:当你想要默认值只针对“空”而不针对“假”时,优先??;反之如果你确实想过滤所有 falsy 值,再考虑||。

这里有个搭配点:可选链可以跟空值合并组合用。const name = obj?.detail?.name ?? '未知'这样写就能一次性完成“安全访问+兜底默认值”,既不用多层 if 嵌套,也不需要临时变量做中间态。

4. 综合案例:用Map储存待克隆数据,做一套带缓存的数据清洗管线

4.1 场景设计

把深拷贝和 Map 串起来用,比单独学哪个都更容易形成体系化记忆。我这里搭了一个典型场景:后端接口返回一批订单数据,每笔订单有嵌套的商品列表、有状态码、有时间字段(Date),同时数据里可能存在重复订单号。处理流程需要做到三点——按订单号去重、保留每条订单的副本、清洗时不影响原始接口返回的数据。

这里会用到 Set 做订单号去重,Map 维护订单号与数据副本的映射关系,深拷贝保证备份不受后续清洗影响,WeakMap 则负责缓存每个原始订单在清洗后对应的临时对象,防止内存过度占用。这个组合用下来逻辑非常清楚,代码也不难写。

4.2 代码实现

function cleanOrders(orders) { // 用订单号去重,保持原顺序 const seen = new Set(); const orderMap = new Map(); const cloneCache = new WeakMap(); for (const order of orders) { if (seen.has(order.orderId)) { continue; } seen.add(order.orderId); orderMap.set(order.orderId, deepClone(order, cloneCache)); } for (const [orderId, order] of orderMap) { // 对订单副本做清洗,不影响原数据 order.statusText = STATUS_TEXT[order.status] || '未知状态'; order.createDate = order.createDate ? new Date(order.createDate) : null; order.items = order.items.filter(item => item.quantity > 0); order.totalPrice = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0); } return Array.from(orderMap.values()); }

先把原始订单for...of遍历一遍,seen这个 Set 用来判断当前订单号是否出现过,避免重复订单进入管线。然后用orderMap.set(orderId, deepClone(order))保存第一份副本。关键点在deepClone的第二个参数cloneCache是一个 WeakMap,深拷贝过程中用它缓存循环引用和中间对象,比每次都创建新的普通 Map 缓存要安全得多,也不会因为局部缓存被外部引用而泄漏。

清流阶段对 Map 做for...of遍历,取出副本进行操作,增删属性、改日期、过滤商品行、计算总价,任何一轮修改都不会污染接口返回的原始 orders 数据。最后Array.from(orderMap.values())把键值对里的“值”取出来组成数组返回,就是不带订单号的去重后订单数组。

4.3 代码复盘

这套设计的决策点值得逐条对照。为什么用 Deep Clone 而不是浅拷贝?因为清洗环节里有items.filter这种修改嵌套结构的操作,浅拷贝会导致原始订单数组被过滤掉部分商品,接口返回数据就不再可信。为什么用 Map 而不是 Object?因为这里要对订单号做增删和按序遍历,Map 的插入序和增删性能都更合适。为什么用 WeakMap 做深拷贝缓存?因为并发大批量订单数据时,临时中间对象累计起来可能是较大内存开销,WeakMap 不阻碍垃圾回收,比普通 Map 更轻量。

实际跑一遍就能感受到这套结构的优点:管线化清晰,每轮循环做的事单一,后续如果要加“计算折扣”“增加税费”等步骤,直接继续遍历 Map 即可,不需要改主结构。这套模式在数据处理量不是特别大的业务场景里完全值得复用。

5. 复习自测:常见坑点速查表与两道面试题解析

5.1 坑点速查表

复习到最后,我把这次涉及的高频坑点整理成了一张速查表,回头刷知识点时按照这张表过一遍,效率会高不少。

错误认知实际行为正确做法
展开运算符等于深拷贝只拷贝第一层嵌套结构使用深拷贝方案
JSON.parse(JSON.stringify()) 万能函数/undefined/Symbol丢失,Date变字符串,循环引用报错根据数据结构至少选 structuredClone 或手写方案
Map可以用 Object 完全替代Map支持任意键、有序、自带size对象映射、频繁增删用 Map
WeakMap 和 Map 用法相同WeakMap键必须是对象,不可遍历做对象关联缓存、私有数据存储时优先 WeakMap
Set 只能用来做数组去重可做集合运算、保序队列集合运算时把大数组转 Set 查询
?? 和 || 基本等价??只处理 null/undefined,||过滤所有 falsy默认值只想兜底空值时用??
解构默认值在 null 时也生效只有 undefined 触发默认值明确区分 null 与 undefined 场景

5.2 一道深拷贝面试题

面过不少公司,深拷贝面试题几乎百问百出,题目从简单到难大概会有这三个考法。最简单的是“写一个能正确处理嵌套对象的深拷贝”,到中层是“你的深拷贝怎么处理循环引用”,到高层就是“你怎么处理 Date、RegExp、Map、Set,以及为什么”——这基本就是在考察你遇到过多少真实问题。

一个基于本篇实现可以给出的完整回答思路是:先判断类型,基础类型直接返回;特殊类型分别用构造函数重建;普通对象创建新对象并递归复制自身属性和 Symbol 键;在递归过程中用一个 WeakMap 记录“原对象—新对象”的映射,每次新对象创建前查一遍,有映射就复用缓存的新对象,避免循环引用导致的栈溢出。

关键加分点是候选人能说出“WeakMap 用作缓存而不是 Map 的原因”——因为拷贝过程是一次性的,如果使用 Map 做缓存,即使拷贝结束,对原始对象的强引用仍然存在,这会影响后续大对象被垃圾回收的时机。这是一个很少有面试者能主动讲到的点,每次我听到都会高看一眼。

5.3 一道Map与WeakMap的面试题

面试题里还有一类问法会给出场景:要在浏览器中给一组 DOM 节点绑定它们对应的点击次数和业务数据类型,怎么维护这些数据。用 Object 做就有被覆盖语义的风险,用 Map 可直接把节点作为键,修改和取出都很直观。但如果节点被用户操作移除,Map 里对应键值对还在,会造成内存泄漏。这时候换成 WeakMap 就是标准的“对象生命周期与数据生命周期一致”的最优解。

我自己的回答习惯是分三步:先摆 Map 的键可以是对象这个核心特性,然后解释 Map 用强引用、WeakMap 用弱引用带来的回收差异,最后落到业务场景里说明前端 DOM 管理和组件生命周期里为什么必须优先 WeakMap。这三个层次顺下来,答案就天然达到面试官对“这人不只是背 API,是真的理解”的预期。

我个人复习 ES6 的体会是,单单背 API 定义是记不牢的,真正让我把这些知识点内化的是把每一个点放进“数据流动的过程”里去理解。深拷贝解决的是“数据在传递中被污染”的问题,Map 解决的是“如何有序高效地组织数据集合”的问题,Set 解决的是“如何安全高效地判断唯一性”的问题,Symbol 解决的是“如何在复杂对象上不冲突地挂载能力”的问题。带着“它到底解决了什么痛点”去看 ES6 的每个新特性,知识的留存率会明显高很多。后续如果时间允许,我会继续整理 ES6 里异步编程相关的复习笔记,Promise 和 async/await 又是另一个话题量巨大的深水区。

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

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

立即咨询