ARTICLE DETAIL

资讯详情

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

前端必备五大利器:深拷贝、发布订阅、节流防抖与懒加载

前端必备五大利器:深拷贝、发布订阅、节流防抖与懒加载 深拷贝、发布订阅、节流、防抖、懒加载——这五个工具函数前端面试八股文里的钉子户也是你日常开发中几乎每天都要打交道的基础设施。我见过太多人背了答案却写不出代码或者写出来能跑但一碰到边界情况就翻车。这篇文章不止是把这五个函数的标准实现丢给你更重要的是讲清楚每次实现背后的关键决策点为什么深拷贝要处理循环引用、为什么发布订阅要区分同步异步、为什么防抖节流有那么多版本、懒加载用IntersectionObserver到底比scroll监听强在哪。整个文章我会按照从原理到实现、再到实战坑位的顺序来拆解每个函数都会给出可直接拷贝的源码和细到边角料的注意事项适合正在准备面试的开发者也适合那些已经在用、但想弄明白内部机制的工作党。1. 整体设计与思路拆解先说一个很实在的问题这些工具函数明明大部分都能从lodash里一键引入为什么还要自己写面试是一方面更重要的是当你真正理解它们的内部实现你才有能力在碰到性能问题、诡异bug时从底层找到根源而不是靠玄学修bug。另外不少团队会严格控制依赖体积或者需要定制化行为这时候手写版本就是刚需。1.1 五个工具函数的分类与设计共性这五个函数从设计目标上可以分成三类。深拷贝解决的是“数据安全”问题发布订阅解决的是“模块通信”问题节流、防抖、懒加载解决的是“性能优化”问题。虽然看起来完全不同但它们在设计上有一个共同的底层思维用可控的额外复杂度去换取更稳定的系统行为和更好的用户体验。设计一个优秀的工具函数我总结下来有三个关键点要做对。第一入参的边界情况要考虑完整比如深拷贝遇到循环引用、防抖传入非函数第二函数本身要保持纯粹和无状态不要在做拷贝时修改原对象也不要在发布订阅里搞一堆全局状态第三性能开销要可控防抖的定时器别到处抖、懒加载的观察器不能无限挂载不销毁。1.2 为什么这些实现总是“说起来简单写起来翻车”你去看面试题深拷贝反复被问可不是让你JSON.parse(JSON.stringify())糊弄过去就完了。这道题考察的是候选人对 JavaScript 数据类型的底层掌握程度普通对象、数组、Date、RegExp、Map、Set、Symbol作为键、函数、undefined、循环引用。任何一类没处理到深拷贝就名不副实。发布订阅看着简单核心就是两个数组一个触发函数。但是一旦你要支持once只监听一次、off取消订阅、清理所有监听、返回值给发布者就需要仔细设计事件名管理和回调存储的姿势了。节流和防抖更是重灾区刚入行的人最难搞懂两者的区别写出来的代码经常出现“防抖节流了等于没防抖没节流”的窘境。懒加载则涉及现代API IntersectionObserver 和传统 scroll 监听方案之间的取舍。一个合格的实现必须经受住三个维度的考验边界条件的正确处理、内存占用是否合理、在真实业务场景下语义是否清晰。后面我按每个函数单独拆解把每一个细节决策点都讲透。2. 深拷贝最简单的需求最复杂的边界深拷贝的目标很明确生成一个与原数据完全独立、互不影响的新对象。在业务中最常见的需求就是编辑表单数据时先深拷贝一份原始数据用户改坏了也能一键还原。没有深拷贝你还原的可能还是修改后的数据那就直接事故了。2.1 JSON方案为什么只能做“浅尝辄止”不少开发者一上来就是JSON.parse(JSON.stringify(obj))。这个方案对纯JSON数据的场景勉强可用但它有五个硬伤只能处理 Object、Array 等JSON原生类型Date 会被转成字符串RegExp、Map、Set、函数、undefined、Symbol 会丢失或变成{}对象中的循环引用会直接抛出错误值为undefined或函数时对应键会被直接删除NaN和Infinity会被转成null原型链上的属性、不可枚举属性、属性描述符完全丢失如果要处理的就是后端接口返回的普通 JSON 数据用 JSON 方案完全没问题因为接口本身就不会返回函数和 undefined。但是如果你要拷贝的是配置对象、脚手架数据、经过复杂处理的状态树就必须上递归深拷贝。2.2 递归实现深拷贝的完整代码写递归深拷贝我的做法是从“类型判断”入手。先判断基础类型非对象类型直接返回再判断数组、Date、RegExp、Map、Set 这些特殊对象最后处理普通对象。整体流程如下function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } // 处理循环引用 if (map.has(target)) { return map.get(target); } const Constructor target.constructor; // 处理特殊对象类型 if (target instanceof Date) return new Constructor(target.getTime()); if (target instanceof RegExp) return new Constructor(target.source, target.flags); // 处理 Map if (target instanceof Map) { const result new Constructor(); map.set(target, result); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } // 处理 Set if (target instanceof Set) { const result new Constructor(); map.set(target, result); target.forEach(value { result.add(deepClone(value, map)); }); return result; } // 处理数组和普通对象 const result Array.isArray(target) ? [] : {}; map.set(target, result); // 需要保留 Symbol 作为键的情况 const symbolKeys Object.getOwnPropertySymbols(target); if (symbolKeys.length 0) { symbolKeys.forEach(symKey { result[symKey] deepClone(target[symKey], map); }); } // 遍历可枚举属性 Object.keys(target).forEach(key { result[key] deepClone(target[key], map); }); return result; }这段代码里我最想强调三个细节。第一个是map.set(target, result)的位置。必须在遍历子属性之前、创建 result 之后马上执行这样当某个子属性又引用了父级对象时map.has(target)才能拦截到形成正确的循环引用处理。第二个是target.constructor的巧妙用法。对 Date、RegExp、Map、Set 这类对象直接用构造器创建新实例比手动new Date()、new Map()更通用子类继承也能正确拷贝类型。第三个是 Symbol 键的处理。Object.keys只能拿到字符串键Symbol 键需要单独通过Object.getOwnPropertySymbols获取。多数教程不加这段但实际项目里如果用了带 Symbol 键的对象漏掉就是bug。2.3 WeakMap 的选择理由与性能说明循环引用处理用的 Map 还是 WeakMap这里我明确推荐 WeakMap。WeakMap 的键是弱引用当原对象被垃圾回收时WeakMap 里对应的条目也能被回收不会产生内存泄漏。在深拷贝这种一次性递归操作里用普通 Map 虽然影响不大但作为一个长期运行的工具函数WeakMap 是更安全的选择。提示不是所有深拷贝都必须递归处理所有类型。如果你明确知道业务里只有普通对象和数组过度设计反而是负担。我把完整版写在这里日常用的时候可以按需裁剪。2.4 深拷贝在使用场景中的实战对照深拷贝最常见的两个应用场景一个是表格编辑回显另一个是状态管理中的不可变数据更新。表格编辑场景里用户点开编辑弹窗我对当前行数据做一次深拷贝弹窗内的表单绑定拷贝后的数据编辑不保存也不影响原列表。如果用的是浅拷贝嵌套对象被修改后原列表数据会跟着变弹窗还没保存表格里的数据已经变了用户会觉得系统“不稳定”。状态管理场景里Redux 或 Vuex 要求用新对象替换旧对象才能触发更新深拷贝就是生成新对象最直接的手段。这里要特别提醒深拷贝虽然好用但不要滥用。大对象深拷贝是有性能开销的特别是对象层级深、数据量大时频繁深拷贝会造成明显的卡顿。我的经验是能用浅拷贝解决就用浅拷贝确实需要隔离才用深拷贝并且尽量缩小拷贝范围。3. 发布订阅事件驱动架构的基石发布订阅模式解决的核心问题是把“事件的发布者”和“事件的监听者”解耦。发布者不需要知道谁在听订阅者也不需要知道谁在发布。你只需要一个事件名和一个事件总线两边通过总线传递消息。说到发布订阅你一定会想到 MQTT 订阅与发布消息。MQTT 是物联网场景下最常用的消息协议设备通过主题topic订阅和发布消息broker 负责消息转发。前端里的发布订阅模式和 MQTT 有异曲同工之妙都是通过主题名来匹配消息都是发布与订阅解耦只是前端的事件总线跑在浏览器内存里而 MQTT 跑在网络协议层。理解了这套思想你写前端事件总线时的设计会更清晰。3.1 观察者模式 vs 发布订阅模式很多面试者会把观察者模式和发布订阅模式混为一谈这两个确实长得很像但本质区别在于观察者模式里Subject目标直接维护 Observer观察者列表状态变化时直接通知观察者观察者和目标互相知道对方的存在发布订阅模式里发布者和订阅者之间隔着一个事件总线两者互不感知。前端里的 DOM 事件监听就是典型的观察者模式element.addEventListener直接由元素维护监听列表。而 EventBus事件总线是标准的发布订阅模式Vue 里的$emit/$on、Node.js 里的EventEmitter都是这个思路。实现发布订阅你就是在实现一个小型 EventEmitter。3.2 手写一个功能完善的 EventBus要支持的功能点拆开来看就是on注册监听、once只触发一次、off取消监听、emit触发事件、offAll清空所有监听。每个功能都要考虑边界情况。核心代码如下class EventBus { constructor() { this.events new Map(); } on(eventName, callback) { if (typeof callback ! function) { throw new TypeError(callback must be a function); } if (!this.events.has(eventName)) { this.events.set(eventName, []); } this.events.get(eventName).push(callback); return this; } once(eventName, callback) { const wrapper (...args) { callback(...args); this.off(eventName, wrapper); }; wrapper.original callback; this.on(eventName, wrapper); return this; } off(eventName, callback) { if (!this.events.has(eventName)) return this; if (!callback) { this.events.delete(eventName); return this; } const callbacks this.events.get(eventName); const index callbacks.findIndex(item item callback || item.original callback); if (index ! -1) { callbacks.splice(index, 1); } return this; } emit(eventName, ...args) { if (!this.events.has(eventName)) return false; const callbacks [...this.events.get(eventName)]; callbacks.forEach(callback { callback(...args); }); return true; } offAll() { this.events.clear(); } }once的实现是这段代码里最精妙的地方。它不是直接把 callback 存进数组而是包了一层 wrapperwrapper 执行完原始回调后马上把自己从监听列表里移除。这样一来即使用户传的是具名函数在once之后也可以正常使用off取消监听。emit里我用[...this.events.get(eventName)]做了一次浅拷贝目的是防止回调函数内部又触发off删除其他监听导致遍历过程中数组长度变化、跳过某些回调。这个问题在实战中非常容易踩坑比如第一个回调里发了一个事件第二个回调还没执行就被删掉了。事件名用 Map 而不是普通对象来存储是因为 Map 的键可以是任意类型而且自带 size 属性和迭代器做事件数量统计、批量清理都比普通对象优雅。3.3 发布订阅在业务中的使用场景前端里发布订阅最典型的场景就是跨组件通信。兄弟组件之间传数据如果通过 props 逐层传递会非常痛苦EventBus 可以直接在任意组件里emit另一个组件里on完全绕过层层传参。另一个常见场景是全局状态同步。比如用户登录状态变化后需要同时更新导航栏、个人中心、购物车等多个模块的 UI。登录模块只需要发一个user_logged_in事件其他模块各自监听并更新自己模块之间互不依赖。MQTT 订阅与发布消息也是同样的套路设备状态变化时通过主题发布消息所有订阅了该主题的设备都能收到通知。注意EventBus 在 Vue 3 中已经不再是官方推荐方案官方更推荐 mitt 或 Pinia。因为 EventBus 的事件名是字符串项目大了以后难以追溯和维护事件来源不清晰。我的建议是小项目或临时跨组件通信可以用大项目还是优先考虑状态管理库。3.4 发布订阅中的内存泄漏问题EventBus 用起来一时爽忘了清理就是火葬场。组件销毁后如果还保留着事件监听回调函数会一直持有组件实例的引用导致组件无法被垃圾回收内存泄漏就这么来的。正确的做法是在组件销毁的生命周期里调用off或bus.offAll()。而且要注意once监听虽然在触发后会自动移除但如果事件一直没触发监听会一直存在同样需要手动清理。这也是我在off里支持item.original匹配的原因当你拿原始函数去取消一个once包装过的监听时也能正确找到并删除。4. 节流与防抖性能优化的黄金搭档节流和防抖常被放在一起讨论因为它们的目的一样限制函数的执行频率。但两者控制频率的思路截然不同搞混了代码写出来就是错的。4.1 先用一个生活场景彻底区分两者防抖的经典场景是电梯关门。电梯门快要关上的时候又有人进来了那么电梯门重新打开等待时间重新计时。只有等待一段时间后没有人再进来电梯才真正关门运行。对应到函数事件触发后不立即执行等待 N 秒N 秒内再次触发则重新计时直到 N 秒内没有新触发才执行。节流的经典场景是地铁安检。不管人流多大安检员处理物品的速度是固定的每隔一段时间处理一个不会因为人多就跑得更快也不会因为人少就停下来。对应到函数事件触发后按固定时间间隔执行N 秒内无论触发多少次都最多只执行一次。一句话总结防抖是“我只关心最后一次”节流是“我保证每隔一段时间至少执行一次”。4.2 防抖的具体实现与参数解析防抖的实现核心就是setTimeoutclearTimeout。事件每次触发都把之前的定时器清掉重新开一个定时器。function debounce(fn, wait 300, immediate false) { let timer null; let isInvoked false; return function(...args) { const context this; const callNow immediate !isInvoked; if (timer) clearTimeout(timer); if (callNow) { fn.apply(context, args); isInvoked true; } timer setTimeout(() { fn.apply(context, args); isInvoked true; timer null; }, wait); }; }这里增加了一个immediate参数控制的是第一次触发时是否立即执行。这个参数非常实用比如搜索框输入时用户可能希望输入第一个字就立刻出结果而不是等 300ms。如果不加这个参数首次触发也要等 300ms体验会卡。另外一个细节值得注意isInvoked这个标志位的作用。它保证immediate模式下在等待窗口内多次触发不会连续执行只有等待窗口结束后再次触发callNow才又为 true。4.3 节流的具体实现与两种模式节流有两种主流实现方案时间戳版和定时器版。两者行为上有细微差别面试时也常被追问。时间戳版记录上次执行时间每次触发时判断当前时间与上次执行时间之差是否大于等待间隔。这个方案的特点是立即执行停止触发后不会再有多余执行。function throttleTimestamp(fn, wait 300) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime wait) { lastTime now; fn.apply(this, args); } }; }定时器版用一个 flag 标记是否正在执行中执行结束前不再响应新的触发。这个方案的特点是延迟执行停止触发后还会再执行一次。function throttleTimer(fn, wait 300) { let timer null; return function(...args) { if (timer) return; timer setTimeout(() { fn.apply(this, args); timer null; }, wait); }; }如果面试官继续追问“能否把两个方案结合实现一个既立即执行又保证最终执行一次的节流”那就要上完整版了function throttle(fn, wait 300) { let timer null; let lastTime 0; return function(...args) { const now Date.now(); const remaining wait - (now - lastTime); if (remaining 0) { if (timer) { clearTimeout(timer); timer null; } lastTime now; fn.apply(this, args); } else if (!timer) { timer setTimeout(() { lastTime Date.now(); fn.apply(this, args); timer null; }, remaining); } }; }这个版本的思路是如果距离上次执行已经超过了等待时间立即执行否则看是否已经有一个“收尾定时器”在排队没有就设一个等remaining毫秒后执行最后一次。4.4 防抖和节流的业务选型实战在真实的项目里哪一个场景该用防抖、哪一个该用节流我总结出一张实战对照表场景推荐方案原因搜索框实时输入请求防抖用户输入频率极高只关心最终输入结果浏览器窗口 resize 后重新计算布局节流需要持续响应但降低频率即可滚动加载更多数据节流用户滚动是连续事件需要周期性判断是否触底表单按钮重复提交防抖只希望提交一次多余的点击全部拦截页面滚动时固定导航栏样式切换节流需要及时反馈但不需要每次滚动像素都触发这里有一个很容易踩的坑搜索场景如果用节流用户输入的频率和节流的间隔如果没对齐可能会出现输入完还在等下一次节流窗口的情况导致结果刷新不够及时。而按钮防抖如果wait设置过长用户点击后感觉“卡了一下才提交”体验反而更差。所以 wait 参数要根据业务场景反复调没有一个万能值。4.5 防抖电路带给前端开发的启示在整理资料时我看到一个很有意思的知识点防抖这个词最早从硬件领域来防抖电路debounce circuit在电子工程里是真实存在的。机械按键在按下和释放的瞬间内部弹簧触点会因物理振动产生多次通断信号会在一瞬间出现数十次抖动。防抖电路就是用 RC 电路或施密特触发器把这些抖动信号过滤成一次干净的电平变化。这个思想跟前端的防抖本质上一模一样机械按键的抖动对应前端里的高频事件RC 电路的低通滤波对应 setTimeout 延时合并最终输出稳定的信号对应最终只执行一次回调。搞懂了硬件防抖你会发现知识是相通的前端里很多“奇怪的设计”在底层都是有迹可循的。5. 懒加载把昂贵的资源往后拖懒加载的核心思想是延迟初始化资源非用不可的时候才去加载/创建。前端主要应用在两个场景图片懒加载和组件/路由懒加载。这里重点讲图片懒加载这也是面试里最常考的。5.1 图片懒加载的业务价值一个电商页面可能有上百张商品图片如果首屏一次全部加载带宽消耗大页面白屏时间也会变长。图片懒加载的思路是首屏只加载可视区域内的图片其余图片给一个占位或默认图当用户滚动到它们所在位置时再真正发起图片请求。这个优化带来的效果是实打实的首屏加载时间下降、带宽费用降低、整体页面流畅度提升。特别是移动端弱网环境下懒加载几乎是在线业务标配。5.2 基于 IntersectionObserver 实现懒加载现代浏览器实现懒加载首选 IntersectionObserver。它的作用是异步监听一个元素是否进入视口浏览器底层用原生实现性能比在 scroll 事件里做大量计算高得多。function lazyload(images) { const observer new IntersectionObserver( (entries) { entries.forEach((entry) { if (entry.isIntersecting) { const img entry.target; const realSrc img.dataset.src; if (realSrc) { img.src realSrc; img.removeAttribute(data-src); } observer.unobserve(img); } }); }, { rootMargin: 0px 0px 50px 0px, threshold: 0.01 } ); images.forEach((img) { observer.observe(img); }); return observer; }代码里的>function lazyloadWithScroll(images) { let timer null; const viewportHeight window.innerHeight || document.documentElement.clientHeight; function check() { images.forEach((img) { const rect img.getBoundingClientRect(); if (rect.top viewportHeight rect.bottom 0) { const realSrc img.dataset.src; if (realSrc) { img.src realSrc; img.removeAttribute(data-src); } images images.filter((item) item ! img); } }); if (images.length 0) { window.removeEventListener(scroll, onScroll); } } function onScroll() { if (timer) return; timer setTimeout(() { check(); timer null; }, 100); } window.addEventListener(scroll, onScroll); check(); }这里必须配合节流才能用否则滚动事件每秒触发几十次每次都遍历所有图片做getBoundingClientRect计算性能会非常差。我在代码里把节流时间设成 100ms也就是用户滚动时最多每秒执行 10 次检查这个频率足够响应滚动场景又不会对主线程造成负担。传统方案的另一个优势是可控性强你可以在check里加各种业务逻辑比如图片进入视口后记录曝光埋点、延迟加载动画等。IntersectionObserver 相对黑盒精细控制的灵活性稍差。5.4 前端组件与路由懒加载的进阶应用图片懒加载只是懒加载思想的一种体现。工程上更常见的是路由懒加载和组件懒加载本质上也是“进入对应路由或条件满足时才加载 JS 代码块”。Webpack/Vite 下最标准的做法是利用动态import()语法构建工具会自动把动态导入的模块拆成独立 chunk在运行时按需加载const routes [ { path: /home, component: () import(./views/Home.vue) }, { path: /detail, component: () import(./views/Detail.vue) } ];浏览器首次打开页面时会先加载主包当用户跳转到/detail路由时才触发import(./views/Detail.vue)去加载详情页代码。如果项目里有十几个路由、每个页面组件都还包含图表库、复杂表格不拆懒加载的话首包可能上 MB拆了之后首包能砍掉一半以上。React 里的React.lazy和Suspense也是同样的道理只是包了一层组件级的等待状态。Vue 3 里同样可以用defineAsyncComponent实现异步组件加载。5.5 懒加载的 SEO 考量和降级方案懒加载并非万能药它有一个明显的副作用搜索引擎爬虫不一定执行 JavaScript如果图片src是空的爬虫无法抓取到图片内容对 SEO 会有影响。针对这个问题常规做法是给图片加noscript标签作为降级方案img>utils/ ├── index.js # 统一出口 ├── clone.js # 深拷贝相关 ├── eventBus.js # 发布订阅 ├── performance.js # 防抖、节流 └── lazy.js # 懒加载每个模块只做一件事模块之间不互相依赖。index.js负责统一导出方便业务代码里import { debounce, deepClone } from /utils一把梭。6.2 模块导出与统一入口index.js的导出方式我推荐具名导出export { deepClone } from ./clone; export { EventBus } from ./eventBus; export { debounce, throttle } from ./performance; export { lazyload } from ./lazy;用具名导出的好处是支持 Tree Shaking业务代码里只 import 用到的函数没有用到的工具函数会被构建工具摇掉不会进最终产物。这对依赖体积敏感的项目很重要。6.3 业务代码集成示例假设你正在做一个带搜索和列表加载的电商页面可以这样组合使用import { debounce, throttle, lazyload, EventBus, deepClone } from /utils; // 搜索框防抖 const searchInput document.getElementById(search); searchInput.addEventListener(input, debounce((e) { bus.emit(search, e.target.value); }, 300)); // 滚动加载节流 window.addEventListener(scroll, throttle(() { if (getScrollBottom() 200) { loadMore(); } }, 200)); // 图片懒加载 const images document.querySelectorAll(img[data-src]); lazyload(images); // 事件总线通信 const bus new EventBus(); bus.on(search, (keyword) { fetchList({ keyword }); }); // 编辑回显时深拷贝 const originalData deepClone(row);这个示例把五个工具函数全部用上了每个函数在真实业务里都找到了自己的位置。代码可读性高逻辑也清晰这就是工具函数体系化的价值。6.4 工具函数需要配套的单元测试工具函数是纯逻辑非常适合写单元测试。我用 Vitest 写一组核心断言防止以后改动时不小心破坏了原有行为import { describe, it, expect } from vitest; import { deepClone } from ../clone; import { debounce, throttle } from ../performance; describe(deepClone, () { it(should deep clone nested object, () { const obj { a: { b: 1 }, c: [1, 2] }; const cloned deepClone(obj); cloned.a.b 2; expect(obj.a.b).toBe(1); }); it(should handle circular reference, () { const obj {}; obj.self obj; const cloned deepClone(obj); expect(cloned.self).toBe(cloned); }); }); describe(debounce, () { it(should execute after delay, (done) { let count 0; const fn debounce(() { count; }, 100); fn(); fn(); fn(); setTimeout(() { expect(count).toBe(1); done(); }, 200); }); });为什么工具函数一定要有测试因为它们被全局复用一旦出现问题影响面是爆炸性的。深拷贝要是坏了所有表单页面的数据隔离都会失效防抖要是坏了所有搜索接口都会被打爆。写测试虽然花时间但省下的排查时间远超投入。7. 常见问题与排查技巧实录最后整理一份我在实际开发中踩过的坑和排查经验这些内容普通文档里不会写但实战价值极高。7.1 深拷贝相关高频问题问题一浅拷贝后修改嵌套对象原对象跟着变。这种情况大概率是用了Object.assign或展开运算符。Object.assign只复制一层属性嵌套对象仍然共享引用。排查时可以用深拷贝替换或者明确业务逻辑只处理需要隔离的层级。问题二JSON 方案深拷贝日期变成字符串。我遇到过接口返回的时间字段是 Date 对象经过 JSON 深拷贝后端到端变成字符串导致时间格式化函数出错。如果你明确对象里包含 Date 类型不要用 JSON 方案直接上递归版。问题三深拷贝循环引用报错。最典型的场景是从全局状态里取数据状态树里某处引用了一个对象自身JSON 方案直接抛错Converting circular structure to JSON。用 WeakMap 的递归版就能解决。7.2 发布订阅相关高频问题问题一组件销毁后事件还在触发控制台疯狂报错。这是典型的忘了off。排查方法是在组件卸载生命周期里统一调用bus.offAll()或针对具体事件off同时建议在 EventBus 的emit里包一层 try/catch监听回调出错不要影响其他监听器。问题二once里off失效。这是对once包装机制不理解导致的。我提供的实现里off支持通过item.original匹配原始回调所以用原始函数可以正确取消。如果你自己实现时不处理这层包装off就会找不到目标。问题三多个模块监听同一事件其中一个报错导致其他都不执行。EventBus 的emit里如果不用 try/catch一个回调抛异常后面的回调就会被中断。修正方案是遍历时对每个回调单独 try/catch或者把异常交给全局错误处理器。7.3 防抖节流相关高频问题问题一滚动加载时用防抖滚到底部后数据迟迟不加载。这是方案选择错了。滚动加载应该用节流因为用户持续滚动防抖会不断重置定时器导致“永远等不到执行”。发现我这个问题的场景是移动端 H5 列表页用户在底部快速滑动时加载接口一直不触发翻遍代码才发现防抖计时器和滚动事件互相打架。问题二防抖的 this 指向丢失。如果直接把对象方法传给防抖函数this会指向undefined或全局对象回调里this.field报错。实现里用了fn.apply(context, args)就是为了修正这个但前提是外层函数能拿到正确的context值。如果你在回调里用了箭头函数this会沿作用域链查找那就不依赖 apply 了。问题三防抖等待时间过长用户操作反馈滞后。最典型的场景是按钮防抖时间设置成了 1000ms用户点击后要等 1 秒才能看到“提交中”的状态。解决方法是把按钮 loading 状态的设置放在防抖外部立即响应 UI接口请求本身做防抖控制或者用节流替代防抖。7.4 懒加载相关高频问题问题一图片不显示浏览器控制台 net 请求正常但页面空白。走查代码后发现>if (IntersectionObserver in window) { // 使用 IntersectionObserver 方案 } else { // 使用 scroll getBoundingClientRect 方案 }问题三懒加载图片后页面高度变化导致滚动位置错乱。这是因为图片加载前没有预留空间。解决技巧是在 HTML 里给 img 设置width和height或者用 CSSaspect-ratio属性预留宽高比加载前后布局结构不跳动。7.5 一个综合排查案例我最近在处理一个后台管理系统时遇到一个有意思的问题。页面里同时使用了 Vue Router 懒加载、图片懒加载和节流滚动加载首屏加载性能确实优化了很多但是用户反馈“偶尔点击菜单没有反应”。排查了半天发现Vue Router 懒加载的 chunk 在弱网环境下加载超时点击路由时资源还没有加载完成同时菜单点击事件又被做成了防抖连续点击被吞掉了。最终方案是给路由组件加 loading 状态同时在防抖里设置immediate: true保证首次点击一定能触发跳转。这类问题单独看任何一个实现都没毛病但组合在一起就会出现奇怪的联动。排查思路是逐个禁用可疑功能二分定位后再分析交互链路不要一上来就猜代码bug。8. 写在最后的一点体会这些工具函数看起来简单但它们承载的思想值得反复咀嚼。深拷贝考验的是对数据类型的完整认知发布订阅考验的是对解耦架构的理解防抖节流考验的是对用户交互本质的洞察懒加载考验的是对资源生命周期的把控。面试时能写出完整实现的人不少但能讲清楚为什么这样设计的人真的不多。我个人在实际操作中的一个习惯是每过一段时间就会重新写一遍这五个工具函数不参考任何资料纯凭记忆和思考去重构。每次重写都会发现之前忽略的细节比如某种边界类型没处理、某个定时器边角条件有误。这种“更新工具库”的练习比看十篇技术文章都管用。最后再分享一个小技巧这些工具函数的完整实现和测试用例建议沉淀到你们团队自己的工具库里而不是每个项目都重新从网上抄一份。统一维护、统一测试、统一文档长期下来能省掉大量重复劳动和隐性问题。当你把基础工具做扎实了写业务代码的时候会非常顺畅因为你不用担心底层函数的正确性可以全神贯注在业务逻辑上。
返回列表