前几年团队招人,我面试过不少前端候选人,聊到“手写工具函数”这一环,十个人里有七八个都能把防抖、节流背得滚瓜烂熟,代码也写得像模像样。但一问到“防抖和节流分别解决什么场景问题”“深拷贝遇到循环引用怎么处理”“发布订阅如何避免内存泄漏”这类问题时,很多人就开始含糊了。这其实是前端工具函数学习里最典型的现象:会背,但不理解;能写,但不扎实。
这篇内容,我打算把自己在项目里沉淀下来的常用工具函数实现(深拷贝、发布订阅、节流、防抖、懒加载)完整拆一遍。这些函数都来自真实业务场景:表单编辑器的深拷贝、跨组件通信的发布订阅、搜索框和滚动事件的性能优化、图片列表的懒加载。我会把每个函数的实现思路、关键代码、边界情况和踩过的坑都一一交代清楚。不管你是刚入行的前端新人,还是准备面试的中级工程师,或者单纯想把代码写得更稳一点的业务开发,都能从这里拿走一些可以直接用的东西。
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和flags,Map和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 里会持续监听,引起内存泄漏。
最近我在实际项目里还会配合loading="lazy"属性一起用。原生loading="lazy"在 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 懒加载不生效的排查顺序
如果你的懒加载没有生效,可以先按这个顺序检查:
- 查看浏览器控制台是否报错,尤其是
>