2026/10/13 3:23:23

ES6复习笔记:深拷贝、Map与高频细节全解析

ES6复习笔记:深拷贝、Map与高频细节全解析 这篇是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 转 Mapconst 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对象映射、频繁增删用 MapWeakMap 和 Map 用法相同WeakMap键必须是对象不可遍历做对象关联缓存、私有数据存储时优先 WeakMapSet 只能用来做数组去重可做集合运算、保序队列集合运算时把大数组转 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 又是另一个话题量巨大的深水区。