ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

前端工具函数实战:深拷贝、防抖节流、发布订阅与懒加载全解析

前端工具函数实战:深拷贝、防抖节流、发布订阅与懒加载全解析 前几年团队招人我面试过不少前端候选人聊到“手写工具函数”这一环十个人里有七八个都能把防抖、节流背得滚瓜烂熟代码也写得像模像样。但一问到“防抖和节流分别解决什么场景问题”“深拷贝遇到循环引用怎么处理”“发布订阅如何避免内存泄漏”这类问题时很多人就开始含糊了。这其实是前端工具函数学习里最典型的现象会背但不理解能写但不扎实。这篇内容我打算把自己在项目里沉淀下来的常用工具函数实现深拷贝、发布订阅、节流、防抖、懒加载完整拆一遍。这些函数都来自真实业务场景表单编辑器的深拷贝、跨组件通信的发布订阅、搜索框和滚动事件的性能优化、图片列表的懒加载。我会把每个函数的实现思路、关键代码、边界情况和踩过的坑都一一交代清楚。不管你是刚入行的前端新人还是准备面试的中级工程师或者单纯想把代码写得更稳一点的业务开发都能从这里拿走一些可以直接用的东西。1. 为什么说工具函数是前端能力的“试金石”1.1 高频面试只是表象真正考验的是JS底层功底说句实话这些工具函数之所以会出现在前端面试题里不是因为面试官想让你背代码而是因为每一个函数背后都牵扯着一系列JS核心机制。拿深拷贝来说它考察的绝不仅仅是递归。一个能打的深拷贝实现至少要牵扯到数据类型判断、引用类型的内存模型、循环引用的处理、Symbol作为属性的特性、访问器属性的读取方式以及新语法如WeakMap的应用场景。这个过程里你对语言本身的理解深度一览无余。再比如防抖和节流表面上是闭包加定时器实际上考察的是你对事件循环机制、函数执行的上下文this指向、参数传递、以及浏览器渲染性能瓶颈的理解。你会发现能把这几个问题讲清楚的人写业务代码时对性能的感知一定不差。1.2 一套可以沉淀到项目里的公共工具库除了面试价值这些函数更是日常开发中实实在在的“高频工具”。我自己就在公司的基础设施库里维护了一套这样的工具函数几乎每个项目都会引用表单数据提交前的深拷贝备份防止用户修改后无法还原全局事件总线用于处理跨组件、跨模块的通信搜索输入框的防抖处理避免每次按键都请求后端接口列表滚动加载、按钮重复提交的节流处理长列表图片的懒加载减少首屏请求数量和带宽消耗。所以这篇文章不只是为了应付面试更重要的是帮助你理解这些工具的底层逻辑从而在业务里用对、用好、用稳。下面我按顺序一个个拆解。2. 深拷贝不是JSON.parse(JSON.stringify())那么简单2.1 从浅拷贝到深拷贝问题的起点很多业务需求根本不需要深拷贝浅拷贝就够用了。比如你想给对象加个属性又不想改动原对象{...obj, newProp: 1}就解决了。但一旦对象内部嵌套了对象或数组展开运算符复制的是引用修改内层数据时原对象也会跟着变。浅拷贝在实际开发里的典型翻车场景是编辑弹窗。用户打开编辑弹窗时组件拿到一个对象并赋值给表单数据源如果没做深拷贝用户在表单里改了某个字段原始数据源也会被修改这就意味着取消编辑时数据已经回不去了。所以很多情况下我们必须做深拷贝。最简单的深拷贝方案确实就是JSON.parse(JSON.stringify(obj))。这个方案在面试时提出来没有任何问题但你得知道它的边界。它会丢失undefined、函数、Symbol类型的属性值会把Date变成字符串会把RegExp变成空对象还会忽略NaN和Infinity把它们变成null。2.2 处理循环引用和特殊对象类型在项目里我踩过一次因为循环引用导致的栈溢出。当时是从后端接口拿到一棵菜单树前端需要给每个节点添加“是否展开”的标记于是做了深拷贝。但接口里某个环节返回的数据本身存在循环引用子节点引用父节点直接递归深拷贝就爆栈了。当时第一次意识到深拷贝绝不能只考虑“长得规整”的数据。循环引用的标准解法是用WeakMap。它有两个关键特性第一键只能是对象第二键不会阻止垃圾回收。我们把每一个已拷贝的对象存进WeakMap在递归时先查一下如果已经拷贝过就直接返回之前的拷贝结果。这样循环引用就能被安全地切断。特殊类型的处理同样重要。Date需要重新new Date(value)RegExp需要复制其source和flagsMap和Set需要遍历各自的键值并递归拷贝。基本逻辑就是判断类型如果是基本类型直接返回如果是特殊对象走对应的拷贝分支如果是普通对象或数组就递归处理。2.3 完整实现与工程化建议下面这个实现是我在项目里打磨过的版本兼容了常见特殊类型和循环引用核心逻辑不长但覆盖了大多数边界情况function isObject(value) { return value ! null (typeof value object || typeof value function); } function deepClone(source, hash new WeakMap()) { if (!isObject(source)) return source; if (source instanceof Date) return new Date(source.getTime()); if (source instanceof RegExp) return new RegExp(source.source, source.flags); if (source instanceof Map) { const cloneMap new Map(); source.forEach((value, key) { cloneMap.set(deepClone(key, hash), deepClone(value, hash)); }); return cloneMap; } if (source instanceof Set) { const cloneSet new Set(); source.forEach(value { cloneSet.add(deepClone(value, hash)); }); return cloneSet; } if (hash.has(source)) return hash.get(source); // 使用 getOwnPropertyDescriptors 保留 symbol、不可枚举属性和访问器 const descriptors Object.getOwnPropertyDescriptors(source); const cloneObj Object.create(Object.getPrototypeOf(source), descriptors); hash.set(source, cloneObj); Reflect.ownKeys(source).forEach(key { const value source[key]; if (isObject(value)) { Object.defineProperty(cloneObj, key, { ...Object.getOwnPropertyDescriptor(source, key), value: deepClone(value, hash), }); } }); return cloneObj; }这里有几个细节值得展开说明。第一我用了Object.getOwnPropertyDescriptors配合Object.create来复制原型和所有属性描述符这样非枚举属性、Symbol键、getter/setter都不会丢失。第二Reflect.ownKeys会一次性拿到普通键和Symbol键比Object.keys更全面。第三在递归赋值时用Object.defineProperty而不是cloneObj[key] ...这是为了保留原属性的特性比如只读属性、不可枚举属性。不过话说回来实际开发时我不会让业务代码直接调用这么重的深拷贝通常会在上面包一层function safeDeepClone(source) { try { return deepClone(source); } catch (error) { console.error([safeDeepClone] failed:, error); return JSON.parse(JSON.stringify(source)); } }注意WeakMap只能处理对象和函数作为键如果你遇到基本值类型的循环关系不可能出现或者拷贝过程报错兜底方案还是要保留。3. 发布订阅从EventEmitter到消息机制3.1 发布订阅与观察者模式的区别很多人觉得发布订阅和观察者是一回事实际上二者有关系但不等同。观察者模式是“被观察者”直接持有“观察者”列表状态变化时逐个通知。发布订阅则多了一个“事件中心”发布者和订阅者互不认识所有消息都通过事件中心来转发。这样做的好处很明显模块之间彻底解耦。页面A只需要发布一个事件页面B和页面C都可以订阅发布方完全不需要关心谁在听。这在跨组件通信、跨模块协作、插件化架构里非常实用。面试中一个高频细节是手写发布订阅时需要区分“同步发布”还是“异步发布”。浏览器的自定义事件dispatchEvent是同步触发的但很多框架内部的事件总线是异步调度的比如 Vue 的$nextTick。我自己实现的工具库默认同步发布因为调用emit后立即执行订阅回调排查问题时心智负担最小如果需要异步场景可以在业务侧用Promise.resolve().then(handler)包一层。3.2 一个可靠的EventEmitter实现我实现过至少三版最终沉淀出一个既能用于业务又适合面试讲的版本。核心能力包括on、off、once、emit四种方法外加一个offAll用于清空事件。关键设计是内部用Map存储事件名和回调数组class EventEmitter { constructor() { this._events new Map(); } on(eventName, handler) { if (typeof handler ! function) { throw new TypeError(handler must be a function); } if (!this._events.has(eventName)) { this._events.set(eventName, []); } this._events.get(eventName).push(handler); return this; } once(eventName, handler) { const wrapper (...args) { this.off(eventName, wrapper); handler.apply(this, args); }; wrapper.original handler; return this.on(eventName, wrapper); } off(eventName, handler) { if (!this._events.has(eventName)) return this; if (!handler) { this._events.delete(eventName); return this; } const handlers this._events.get(eventName); const index handlers.findIndex(cb cb handler || cb.original handler); if (index -1) { handlers.splice(index, 1); } return this; } emit(eventName, ...args) { const handlers this._events.get(eventName); if (!handlers || handlers.length 0) return false; handlers.forEach(handler { try { handler.apply(this, args); } catch (error) { console.error([EventEmitter] error in handler for ${eventName}:, error); } }); return true; } offAll() { this._events.clear(); return this; } }这个实现里有几个关键决策once里用包装函数包了一层先off再执行即使执行过程中抛错也不会导致多次触发。off支持传入原始函数通过handler.original找到包装函数并移除这是once和off配合使用的细节。参考了 Node.js 的EventEmitter设计逻辑在 Node 里源码也是这么标记onceWrapper的。emit遍历回调时用try...catch包裹避免一个订阅者抛错导致后面订阅者全部无法执行。这在业务代码里能避免很多“莫名其妙”的线上问题。3.3 结合MQTT聊聊topic通配符我负责过一个物联网可视化大屏前端设备实时数据通过 MQTT 协议推送到后端后端再通过 WebSocket 转发给浏览器。MQTT 本身就是一种典型的发布/订阅协议它有清晰的topic概念和通配符规则。比如订阅device//status可以接收所有设备的状态消息订阅device/#可以接收所有 device 下的消息。这种场景给我们一个启发应用层的 EventEmitter 也可以引入“主题匹配”的概念。我在实际项目中扩展过一个版本允许emit(device:status, data)时用on(device:status, handler)来精确订阅也可以新增on(device:*, handler)来订阅所有设备相关的主题。实现思路是把事件名按分隔符拆成数组然后用深度匹配判断当前事件是否命中某个订阅规则。这套设计适合事件比较多、有层级关系的系统能显著减少业务方重复订阅。注意发布订阅虽然好用但绝不是万能的。如果你的组件通信变得极其复杂事件满天飞而找不到来源那可能是架构出问题了。发布订阅适合“一对多”的通知场景不适合“一对一”的数据流管理。4. 节流与防抖不一样的“限流”思路4.1 防抖等一等再说防抖的核心思想是当事件被连续触发时不立即执行回调而是等待一段“安静时间”如果在这段时间内又触发了事件就重新计时。换句话说它是“每次触发都重置定时器”。我把防抖理解为“电梯门”场景电梯门即将关闭时如果又有人按了开门键门就会重新打开并再等待一段时间。只有在一段时间内没有新人到达门才会真正关闭。在代码里很自然地就用到了闭包和setTimeout。但一个成熟可用的防抖函数除了基础debounce之外还需要考虑“立即执行版本”和“取消执行”能力。立即执行版是指第一次点击就执行后续连续点击不执行等停止操作一段时间后再点击才会再次执行。这个对“提交按钮”场景特别有用用户连续点击付款按钮不希望第一次点击延迟后才生效更不希望每次都生效。下面这个版本我集成了immediate参数同时提供cancel方法function debounce(fn, wait 300, immediate false) { let timer null; let isInvoked false; function debounced(...args) { if (timer) clearTimeout(timer); if (immediate !isInvoked) { fn.apply(this, args); isInvoked true; timer setTimeout(() { isInvoked false; timer null; }, wait); } else { timer setTimeout(() { fn.apply(this, args); isInvoked false; timer null; }, wait); } } debounced.cancel function () { if (timer) clearTimeout(timer); timer null; isInvoked false; }; return debounced; }注意这里在定时器回调里修复了this指向通过fn.apply(this, args)保证回调函数内部能拿到正确的调用上下文。调试时如果发现防抖函数里拿不到当前组件的this多半是这个细节没做好。4.2 节流按节奏执行节流的核心思想则是不管事件触发多频繁只在固定的时间间隔内执行一次。我习惯用“红绿灯”来类比不管路口堵了多少车红绿灯按自己的节奏切换单位时间内通过的车辆数是有上限的。节流有两种最常见的实现方式时间戳版和定时器版。时间戳版会立即执行第一次后续每次触发都会与上次执行时间比较如果时间差大于interval则执行。其缺点在于最后一次触发如果没满一个时间间隔会被丢弃。定时器版则正好相反它不会立即执行第一次事件要等interval时间后才执行但它能捕获最后一次触发。实际使用中经常需要两个能力结合第一次要立即执行最后一次也不能丢。我把两种方式合并成一个带leading和trailing配置的实现function throttle(fn, interval 300, options { leading: true, trailing: true }) { let lastTime 0; let timer null; const { leading, trailing } options; function throttled(...args) { const now Date.now(); if (!lastTime leading false) { lastTime now; } const remaining interval - (now - lastTime); if (remaining 0 || remaining interval) { if (timer) { clearTimeout(timer); timer null; } lastTime now; fn.apply(this, args); } else if (!timer trailing ! false) { timer setTimeout(() { lastTime leading false ? 0 : Date.now(); timer null; fn.apply(this, args); }, remaining); } } throttled.cancel function () { if (timer) clearTimeout(timer); timer null; lastTime 0; }; return throttled; }解释一下这里的逻辑remaining 0表示距离上次执行已经超过了设定的时间间隔可以立即执行。remaining interval这个判断针对的是“手动调整系统时间后导致now比lastTime还小”的边界场景属于防御性判断。如果时间未到且需要trailing最后一次也要执行就设置一个定时器等待剩余时间结束后再执行。trailing定时器执行后如果leading false把lastTime重置为 0否则重置为当前时间。这样能保证下一次触发能满足remaining interval的条件进入立即执行分支。4.3 选择场景与扩展能力防抖和节流经常出现在同一个页面里但用途完全不同。整理一下最典型的场景对比场景推荐方案原因搜索输入框实时请求建议词防抖300ms等待用户停止输入再请求减少无意义请求提交表单按钮重复点击防抖immediate第一次点击立即生效后续点击被打断滚动监听计算位置或加载更多节流需要按固定频率响应滚动保证流畅度窗口 resize 重新布局节流避免频繁重排但也要持续响应按钮点击保存草稿防抖只在停止操作后保存一次除此之外业务里还会用到“防抖和节流的组合”——比如用户在输入关键词时期望“一边输入一边搜索”但又不希望每敲一个字符就请求一次这时可以用节流控制请求频率而用户停顿后的搜索又可以用防抖来处理。两种技术配合使用效果才是最好的。5. 懒加载图片加载的“按需分配”5.1 图片懒加载的业务痛点页面里如果有成百上千张图片直接src赋值会导致两个问题一是首屏请求数量太多浏览器并发连接数有限会阻塞关键资源的加载二是会消耗大量带宽尤其移动端弱网环境下体验极差。懒加载的核心思想就是图片不在视口范围内时不加载进入视口时才发起真正的请求。最经典的方案是先把真实地址放在>function lazyLoadImages(images, rootMargin 0px 0px 200px 0px) { if (!(IntersectionObserver in window)) { // 不支持时退回一次性加载全部 images.forEach(img { const realSrc img.dataset.src; if (realSrc) img.src realSrc; }); return; } const observer new IntersectionObserver( (entries, self) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; const realSrc img.dataset.src; if (realSrc) { img.src realSrc; // 加载完成后移除data-src避免重复 img.removeAttribute(data-src); } // 完成加载后停止观察 self.unobserve(img); } }); }, { rootMargin } ); images.forEach(img observer.observe(img)); return observer; }这段代码有几个地方值得说明。rootMargin可以控制“提前量”比如200px表示图片距离视口底边还有200px时就开始加载给用户更平滑的体验。回调里使用self.unobserve(img)是因为图片加载后就不需要再观察避免浪费性能。返回observer很重要因为组件卸载时需要调observer.disconnect()否则在 SPA 里会持续监听引起内存泄漏。最近我在实际项目里还会配合loadinglazy属性一起用。原生loadinglazy在 Chrome、Firefox 等主流浏览器里已得到较好支持对于普通的页面图片直接加这个属性就够了IntersectionObserver更适合需要对加载时机进行精细控制的场景比如占位图切换、加载失败重试、上报统计等。注意懒加载要注意给图片设置合适的尺寸宽高否则图片从占位图变成真实图片时页面高度会突然变化导致滚动条跳动。这是懒加载优化时最容易忽略的体验问题。6. 常见问题与排查技巧6.1 深拷贝的Bug排查清单深拷贝最容易出现的几类问题我整理成了排查清单现象可能原因解决方向拷贝后修改数据原数据仍然变化拷贝不彻底内层对象还是引用确认是否递归处理每一个嵌套属性拷贝时出现栈溢出数据存在循环引用用WeakMap记录已拷贝对象拷贝后Date变成字符串undefined丢失用了JSON.parse(JSON.stringify(obj))使用完整版深拷贝实现拷贝后无法访问非枚举属性使用for...in遍历改用Reflect.ownKeys带有getter的对象拷贝后值变成了执行结果直接obj[key]读取值使用getOwnPropertyDescriptors配合defineProperty拷贝后的对象原型丢失用Object.assign或展开符用Object.create(Object.getPrototypeOf(source))6.2 防抖节流失效的根因分析防抖函数最常见的坑是“防抖没生效”——明明调用了debounce函数还是被高频执行。排查时先确认是否每次事件触发时都重新调用了debounce工厂函数而不是复用一个已经创建的防抖实例。这个错误非常隐蔽// 错误示例每次触发都创建一个新的防抖函数 el.addEventListener(click, () { debounce(submit, 300)(); }); // 正确示例只创建一次复用到事件回调里 const debouncedSubmit debounce(submit, 300); el.addEventListener(click, debouncedSubmit);节流也是类似情况。如果在事件监听器回调内部定义节流函数那么每次触发都重建了闭包节流完全失效。这类问题在 React 组件里尤其常见如果在一个函数里调用throttle(fn, 300)而不是把节流实例放在useRef或模块级变量里那每次 render 都会产生新函数。另一个容易被忽视的坑是this丢失。比如在类组件里给事件绑定节流函数节流函数内部执行fn时如果不用apply(this, args)回调里的this就会指向window或undefined在严格模式下。检查这一点最快的办法就是在回调函数里打印this看看它到底是谁。6.3 发布订阅的内存泄漏排查发布订阅最典型的内存泄漏场景是订阅方在组件销毁时没有取消订阅。特别是 SPA 应用路由切换后旧的组件实例仍然持有事件回调导致组件永远无法被垃圾回收。解决办法其实很简单组件在destroy或useEffect的清理函数里调用off。但如果你的项目里事件非常分散一个个off容易遗漏。更好的做法是在组件挂载时记录所有通过on/once注册的回调在销毁时统一调用offAll或遍历取消。我在自己的项目里做了这样一个增强给EventEmitter增加了一个namespace和dispose方法在组件销毁时调用eventBus.dispose(namespace)内部会把该命名空间下注册的事件全部清掉。这就很像 Node.js 里EventEmitter的removeAllListeners按事件名清理的能力只是粒度换成了业务模块。6.4 懒加载不生效的排查顺序如果你的懒加载没有生效可以先按这个顺序检查查看浏览器控制台是否报错尤其是>
返回列表