系列文章目录《JavaScript 基础与进阶笔记》前期偏基础巩固与常见面试点后续进入闭包、异步、工程化等进阶主题第 01 篇数据类型与类型判断第 02 篇变量声明与作用域第 03 篇闭包与高阶函数第 04 篇函数工厂第 05 篇this 指向与绑定第 06 篇原型与原型链第 07 篇类与继承第 08 篇JS 执行机制与异步队列第 09 篇数组常用方法第 10 篇字符串算法第 11 篇常见手写题合集上本文文章目录系列文章目录前言一、浅拷贝与深拷贝1.1 概念1.2 浅拷贝常用写法1.3 JSON 方案与局限1.4 原生 structuredClone1.5 手写深拷贝WeakMap 处理循环引用二、防抖Debounce2.1 定义2.2 应用场景2.3 基础实现默认尾部执行2.4 立即执行immediate / leading三、节流Throttle3.1 定义3.2 应用场景3.3 时间戳方案3.4 定时器方案3.5 时间戳 定时器补 trailing3.6 与 requestAnimationFrame四、发布订阅与观察者4.1 观察者模式简要4.2 发布订阅模式Event Bus4.3 手写 EventBus4.4 与 Promise、回调的关系五、拷贝与性能函数速查六、易混淆点归纳七、思考与练习总结前言前几篇已覆盖数组、字符串等算法向题目本篇进入面试另一类高频拷贝、性能控制函数与设计模式雏形。深拷贝要能说清「浅与深的边界、原生 API 的局限、循环引用如何处理」防抖强调「延迟执行 重置计时器、最终只执行最后一次」节流强调「触发后立即执行一次、间隔内忽略后续、固定频率采样」实现上要能写出带immediate的防抖以及时间戳 / 定时器两种节流并说清差异。发布订阅则要能对比观察者模式并手写on/off/emit。以下按「拷贝 → 防抖节流 → 发布订阅」展开。一、浅拷贝与深拷贝1.1 概念类型含义典型后果浅拷贝只复制第一层嵌套对象仍共享引用改copy.nested.x会影响原对象深拷贝递归复制各层新对象与原对象完全独立互改嵌套属性不互相影响赋值const b a不是拷贝只是多一个引用指向同一对象。consta{x:1,nested:{y:2}};constba;b.nested.y99;console.log(a.nested.y);// 99 — 同一引用1.2 浅拷贝常用写法constobj{a:1,list:[1,2]};// 展开运算符constc1{...obj};// Object.assignconstc2Object.assign({},obj);// 数组constarr[1,[2,3]];constc3arr.slice();constc4Array.from(arr);以上方式对list、nested等引用类型字段仍是共享。若对象只有原始值字段浅拷贝在效果上接近「整对象独立」但语义上仍是浅拷贝。1.3 JSON 方案与局限constsrc{a:1,fn:(){},undef:undefined,sym:Symbol(s),date:newDate(),map:newMap([[1,2]]),};constcopyJSON.parse(JSON.stringify(src));// copy 中fn、undef、sym 丢失date 变成字符串Map 变成 {}局限归纳丢失undefined、函数、Symbol、BigIntBigInt会直接报错。Date变为 ISO 字符串RegExp变为{}。存在循环引用时JSON.stringify抛错。只能处理JSON 可序列化的数据结构。适合「纯 JSON 配置对象」的快速克隆不适合通用深拷贝。1.4 原生structuredCloneconsta{x:1,nested:{y:2}};constdeepstructuredClone(a);deep.nested.y99;console.log(a.nested.y);// 2constcircular{name:loop};circular.selfcircular;constcstructuredClone(circular);c.selfc;// true — 支持循环引用支持Date、RegExp、Map、Set、ArrayBuffer、循环引用等遵循 HTML 结构化克隆算法。不支持或需注意函数、DOM 节点、属性描述符getter/setter 不会按描述符复制、原型链上的属性克隆结果多为普通对象。Node.js 较新版本与现代浏览器均已提供面试手写仍以递归 WeakMap为主。1.5 手写深拷贝WeakMap 处理循环引用constdeepClone(value,cachenewWeakMap()){// 原始类型与 nullif(valuenull||typeofvalue!object)returnvalue;// 已拷贝过 — 返回缓存打破环if(cache.has(value))returncache.get(value);// 特殊对象可按需分支示例保留 Dateif(valueinstanceofDate)returnnewDate(value);constcloneArray.isArray(value)?[]:{};cache.set(value,clone);// 只遍历自有可枚举属性Symbol 键需 Object.getOwnPropertySymbols 另行处理for(constkeyofReflect.ownKeys(value)){clone[key]deepClone(value[key],cache);}returnclone;};constcircular{a:1};circular.selfcircular;constcopydeepClone(circular);copy.selfcopy;// truecopy.a2;console.log(circular.a);// 1为何用 WeakMap键是对象引用不阻止垃圾回收拷贝结束后原对象可被回收避免内存泄漏。若用普通Map且忘记清理可能长期持有大对象。面试可口述的边界函数通常直接返回同一引用或按题意忽略。Map/Set需新建容器并递归克隆元素。原型完整实现要处理Object.create与constructor简版面试够用即可。二、防抖Debounce2.1 定义防抖— 事件触发后等待一段时间若期间再次触发则重新计时最终只执行最后一次。防抖的核心是「延迟执行 重置计时器」适用于只关心最终结果的场景与节流「固定频率执行」相对。2.2 应用场景场景说明搜索框输入联想用户停下输入后再请求避免每个按键都发请求窗口resize尺寸变化完成后再重算布局表单重复提交拦截短时间多次点击只提交一次请求优化避免短时间内向后端发起大量重复请求防抖 节流组合首次立即响应 期间节流 结束后再执行一次复杂交互可组合使用2.3 基础实现默认尾部执行用闭包保存timer每次触发clearTimeout重置倒计时到期后执行later。constdebounce(fn,ms,immediatefalse){lettimernull;return(...args){constlater(){timernull;fn.apply(this,args);};if(immediate!timer)fn.apply(this,args);clearTimeout(timer);timersetTimeout(later,ms);};};constonInputdebounce((v)console.log(search:,v),300);// 连续触发 10 次 onInput约 300ms 后只输出最后一次: search: q9immediate false默认只走later即「停下来」后的那一次。immediate true见下节对应易混点里的leading语义。2.4 立即执行immediate/ leadingleadingleading edge在首次触发时立即执行一次开启immediate后尾部不再重复执行同一次连续输入的最终回调与 Lodash 的leading选项同义本篇代码参数名统一用immediate。// immediate 为 true第一次按键立刻 searchwait 内再次输入只重置计时不在尾部再搜一次constonInputNowdebounce((v)console.log(search:,v),300,true);面试口述可记防抖 等停下来再执行需要首击即响时加leading / immediate。cancel工程实践返回函数上挂cancel()内部clearTimeout(timer)并置timer null组件卸载时调用避免泄漏。三、节流Throttle3.1 定义节流— 事件触发后立即执行一次之后在固定间隔内忽略所有后续触发。节流的核心是「固定频率执行」适用于需要均匀采样或降低触发频率的场景。防抖 ≠ 节流一句话对照防抖等停下来再执行节流匀速采样执行3.2 应用场景场景说明滚动事件监听滚动过程中按间隔采样而非每像素触发鼠标移动追踪降低绘制或上报频率按钮高频点击限流限制单位时间内的有效点击次数可与防抖组合首次立即响应 期间节流 结束后最终执行一次见防抖应用场景。3.3 时间戳方案last记录上一次执行时刻间隔满足now - last ms才执行。constthrottle(fn,ms){letlast0;return(...args){constnowDate.now();if(now-lastms){lastnow;fn.apply(this,args);}};};constonScrollthrottle(()console.log(scroll,Date.now()),200);// 连续触发 10 次约按 200ms 间隔均匀输出而非 10 次全打特点符合「触发后立即执行」的语义边界更精准用时间差判断不依赖定时器排队。停止触发后一般不会再补最后一次无 trailing除非再合并定时器。3.4 定时器方案functionthrottle(fn,delay){lettimernull;returnfunction(...args){if(!timer){timersetTimeout((){timernull;fn.apply(this,args);},delay);}};}特点保证在间隔结束后能执行最后一次trailing 语义首次触发往往要等delay才执行第一次与时间戳方案的「立刻执行」不同。易混点定时器方案 vs 时间戳方案— 前者偏最后一次落地后者边界更精准、首开即执行。3.5 时间戳 定时器补 trailing既要开头响应又要在「最后一次滚动」后补一次可合并两种思路对应trailing在间隔结束后补执行最后一次constthrottle(fn,wait200){letlast0;lettimernull;returnfunction(...args){constnowDate.now();constremainingwait-(now-last);clearTimeout(timer);if(remaining0){lastnow;fn.apply(this,args);}else{timersetTimeout((){lastDate.now();fn.apply(this,args);},remaining);}};};口述时间戳负责 leading、控制间隔定时器在「本轮间隔将结束」时补 trailing。3.6 与 requestAnimationFrame滚动动画、视觉相关逻辑可用requestAnimationFrame将更新对齐到浏览器重绘约 60fps本质也是一种节流。与setTimeout节流可并存rAF 管渲染节流管业务上报。四、发布订阅与观察者4.1 观察者模式简要观察者目标对象Subject维护观察者列表状态变化时直接通知每个观察者update。发布者与订阅者互相知道的存在耦合相对更高。Vue 2 的依赖收集与派发更新可理解为该思路的变体。4.2 发布订阅模式Event Bus发布订阅通过事件中心中介发布者emit、订阅者on双方不直接引用。Vue 2 的$emit/$on同一实例或全局 EventBus、Node 的EventEmitter均属此类。对比观察者发布订阅通信方式Subject 直接调用 observer经 EventBus 转发耦合订阅者需注册到目标只依赖事件名典型 APIattach/notifyon/off/emit4.3 手写 EventBusclassEventBus{#eventsnewMap();on(event,handler){if(!this.#events.has(event)){this.#events.set(event,newSet());}this.#events.get(event).add(handler);// 返回取消订阅函数便于组件卸载return()this.off(event,handler);}once(event,handler){constwrap(...args){this.off(event,wrap);handler(...args);};returnthis.on(event,wrap);}off(event,handler){if(!handler){this.#events.delete(event);return;}this.#events.get(event)?.delete(handler);}emit(event,...args){constsetthis.#events.get(event);if(!set)return;// 复制一份再遍历避免回调里 off 导致漏执行或死循环[...set].forEach((fn)fn(...args));}}constbusnewEventBus();constunsubbus.on(login,(user)console.log(欢迎,user.name));bus.on(login,(user)console.log(记录日志,user.name));bus.emit(login,{name:Alice});// 欢迎 Alice// 记录日志 Aliceunsub();bus.emit(login,{name:Bob});// 仅记录日志 Bob注意emit时拷贝监听器列表防止回调内off修改正在遍历的集合。once用包装函数在首次执行后off自身。内存泄漏组件销毁时务必off或调用on返回的取消函数。4.4 与 Promise、回调的关系发布订阅解决的是一对多、按事件名解耦的同步或异步通知Promise解决的是单次异步结果。实际项目里常组合请求完成emit(dataLoaded)或 Redux 的subscribe监听 store 变化。五、拷贝与性能函数速查需求推荐注意仅一层、无嵌套引用{ ...obj }、Object.assign嵌套仍共享纯 JSON 数据JSON.parse(JSON.stringify())丢函数、循环引用报错现代环境通用深拷贝structuredClone不支持函数、DOM面试手写deepCloneWeakMap说明 Symbol、Map 等扩展搜索输入debounce默认尾部一次immediate: true对应 leading尾部不再重复滚动、resize 采样throttle时间戳或 rAF要「滚停再算」用定时器 trailing 或合并方案跨模块通信EventBus记得off六、易混淆点归纳浅拷贝 ≠ 赋值赋值无新对象浅拷贝有新对象但嵌套仍共享。JSON深拷贝不是万能循环引用、函数、undefined、Date等都会出问题。structuredClone与手写前者适合工程面试重点在WeakMap 递归。防抖 ≠ 节流防抖「等停下来再执行」节流「匀速采样执行」。防抖immediateleading首次触发立即执行一次尾部不再重复leading为同义表述。节流trailing间隔结束后补执行最后一次时间戳方案边界更精准且首开即执行定时器方案更利于保证最后一次执行。观察者 vs 发布订阅有没有事件中心、双方是否直接依赖。debounce/throttle的this返回的包装函数须用fn.apply(this, args)否则类方法会丢this。七、思考与练习1.下列代码输出什么说明原因。consto{a:{b:1}};consts{...o};s.a.b2;console.log(o.a.b);解析输出2。展开运算符是浅拷贝s.a与o.a指向同一对象。2.用户连续输入 8 个字符debounce(fn, 300)immediate为 false大约执行几次fn解析1 次— 符合「最终只执行最后一次」期间每次输入都会重新计时。3.debounce(fn, 300, true)与默认防抖在连续快速输入时的区别解析immediate true时首次触发立即执行且在该实现下尾部不再重复默认版只在停止输入约 300ms 后执行最后一次。4.滚动事件用时间戳节流用户快速滚到底后松手是否一定会再触发一次「触底检测」解析不一定。时间戳方案边界更精准、首开即执行但停止后通常不补最后一次若需要滚停再算用定时器方案或3.5 合并方案trailing。5.emit过程中某个监听器调用了off删除了后续监听器为何仍建议[...set].forEach解析避免在遍历 Set 的同时修改 Set导致跳过或异常拷贝快照后本次emit的调用列表固定。6.为何循环引用深拷贝用WeakMap而不是把「原对象 → 克隆对象」存在克隆对象的属性上解析会污染克隆结果的结构WeakMap 作为外部缓存键为原对象、值为对应克隆且不阻止原对象被 GC。总结拷贝分清浅/深工程优先structuredClone面试手写deepClone WeakMap** 处理循环引用**JSON仅适合纯数据。防抖触发后等待期间再触发则重新计时最终只执行最后一次核心延迟执行 重置计时器首击用immediateleading。节流触发后立即执行一次间隔内忽略后续核心固定频率执行时间戳边界更精准定时器保证最后一次trailing与防抖记清「停下来 vs 匀速采样」。发布订阅on/off/emit/onceemit遍历拷贝监听器对比观察者模式的耦合差异。下一篇为常见手写题合集下手写Promise全流程、LRU缓存、虚拟列表等与异步和性能专题衔接。