深拷贝、发布订阅、节流、防抖、懒加载——这五个工具函数,前端面试八股文里的钉子户,也是你日常开发中几乎每天都要打交道的基础设施。我见过太多人背了答案却写不出代码,或者写出来能跑但一碰到边界情况就翻车。这篇文章不止是把这五个函数的标准实现丢给你,更重要的是讲清楚每次实现背后的关键决策点:为什么深拷贝要处理循环引用、为什么发布订阅要区分同步异步、为什么防抖节流有那么多版本、懒加载用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 存进数组,而是包了一层 wrapper,wrapper 执行完原始回调后马上把自己从监听列表里移除。这样一来,即使用户传的是具名函数,在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 防抖的具体实现与参数解析
防抖的实现核心就是setTimeout+clearTimeout。事件每次触发都把之前的定时器清掉,重新开一个定时器。
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. 写在最后的一点体会
这些工具函数看起来简单,但它们承载的思想值得反复咀嚼。深拷贝考验的是对数据类型的完整认知,发布订阅考验的是对解耦架构的理解,防抖节流考验的是对用户交互本质的洞察,懒加载考验的是对资源生命周期的把控。面试时能写出完整实现的人不少,但能讲清楚为什么这样设计的人真的不多。
我个人在实际操作中的一个习惯是:每过一段时间就会重新写一遍这五个工具函数,不参考任何资料,纯凭记忆和思考去重构。每次重写都会发现之前忽略的细节,比如某种边界类型没处理、某个定时器边角条件有误。这种“更新工具库”的练习,比看十篇技术文章都管用。
最后再分享一个小技巧:这些工具函数的完整实现和测试用例,建议沉淀到你们团队自己的工具库里,而不是每个项目都重新从网上抄一份。统一维护、统一测试、统一文档,长期下来能省掉大量重复劳动和隐性问题。当你把基础工具做扎实了,写业务代码的时候会非常顺畅,因为你不用担心底层函数的正确性,可以全神贯注在业务逻辑上。