尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

前端面试必考:深拷贝、发布订阅、节流防抖与懒加载全解析

前端面试必考:深拷贝、发布订阅、节流防抖与懒加载全解析 每到招聘季“前端八股文”就会被拉出来嘲讽一轮可真到了面试现场深拷贝、发布订阅、节流、防抖、懒加载这几个手写题还是像守门员一样刷掉一批又一批简历光鲜的候选人。我以前也觉得背背代码不就完了直到自己坐在面试官的位置上才发现大多数人背下来的实现根本经不起追问——循环引用怎么处理回调里取消订阅会不会出问题防抖能不能保证第一次立即触发问深一点就露馅。这篇内容就是冲着这些问题来的。我打算把深拷贝、发布订阅、节流、防抖、懒加载这五个高频工具函数逐个拆开从原理讲到完整实现再补上边界情况和面试追问的应对思路。不管你是在准备面试还是项目里真需要自己手写这些工具照着敲一遍、想明白每一行的存在理由基本就能在这个环节站稳了。1. 深拷贝JSON.parse(JSON.stringify()) 能走多远1.1 为什么不能只满足于浅拷贝先说一个很多新手容易忽略的点在JavaScript里对象赋值默认是传引用const b a这行代码并没有复制出新的对象只是让b和a指向了同一块内存。后面不管通过哪个变量改了属性另一个变量看到的值也跟着变。这在处理表单数据快照、状态管理、组件 props 传递等场景里非常致命——你只是想存一份副本结果却把原数据给改了。浅拷贝能解决一部分问题。Object.assign({}, obj)和展开运算符{...obj}都只能复制第一层属性如果对象的属性值本身还是一个对象那复制过来的依然是引用。数组也同理arr.slice()看起来复制了数组但数组里的对象元素并没有被真正复制。所以只要数据层级超过一层浅拷贝就不够用了。1.2 JSON 方案的两个致命伤很多第一次接触深拷贝的同学会先想到JSON.parse(JSON.stringify(obj))。这个方案确实能处理多层嵌套的普通对象代码还特别短但踩过坑的都知道它的问题比想象中要多得多undefined、函数、Symbol 类型的属性值会被直接丢弃Date 对象会被转成字符串而不是保留 Date 类型RegExp、Map、Set 等特殊对象会变成空对象{}对象存在循环引用时会直接抛出Converting circular structure to JSON错误NaN和Infinity会被转成null我在项目里就遇到过真实案例把一份包含undefined字段的配置对象传给后端前做了个 JSON 深拷贝结果某些字段悄悄消失了排查了半天才发现是这个原因。所以这个方案只适合纯数据对象、JSON-safe 的场景一旦数据里混入了特殊类型或循环引用必须手写深拷贝。1.3 手写递归深拷贝从基础到完整手写深拷贝的核心思路并不复杂遍历原对象的每个属性如果属性值是基本类型直接复制如果属性值是对象递归调用拷贝函数。下面是基础版本function deepClone(target) { if (typeof target ! object || target null) { return target; } const clone Array.isArray(target) ? [] : {}; for (let key in target) { if (target.hasOwnProperty(key)) { clone[key] deepClone(target[key]); } } return clone; }但这个版本有个严重的缺陷没有处理循环引用。当对象存在相互引用的关系时比如a.self a递归会无限进行下去最终导致调用栈溢出。解决思路是使用一个WeakMap来记录已经被拷贝过的对象每次拷贝一个对象之前先检查 WeakMap 里是否存在对应的拷贝结果如果存在就直接返回不再递归。function deepClone(target, map new WeakMap()) { if (typeof target ! object || target null) { return target; } if (map.has(target)) { return map.get(target); } const clone Array.isArray(target) ? [] : {}; map.set(target, clone); for (let key in target) { if (target.hasOwnProperty(key)) { clone[key] deepClone(target[key], map); } } return clone; }为什么用WeakMap而不是Map因为WeakMap的键是弱引用当原对象被垃圾回收时对应的键值对也会被自动清除不会造成内存泄漏。虽然单次拷贝场景下两者差别不大但这是一个值得在面试中主动讲出来的设计细节能体现出你对内存管理的敏感度。1.4 特殊类型的深拷贝处理要让深拷贝真正可用还得处理 Date、RegExp、Map、Set 这些特殊对象。处理方式是根据Object.prototype.toString.call(target)判断目标类型分别做对应处理function deepClone(target, map new WeakMap()) { if (typeof target ! object || target null) { return target; } const typeTag Object.prototype.toString.call(target); if (typeTag [object Date]) { return new Date(target.getTime()); } if (typeTag [object RegExp]) { return new RegExp(target.source, target.flags); } if (typeTag [object Map]) { const cloneMap new Map(); map.set(target, cloneMap); target.forEach((value, key) { cloneMap.set(deepClone(key, map), deepClone(value, map)); }); return cloneMap; } if (typeTag [object Set]) { const cloneSet new Set(); map.set(target, cloneSet); target.forEach(value { cloneSet.add(deepClone(value, map)); }); return cloneSet; } if (map.has(target)) { return map.get(target); } const clone Array.isArray(target) ? [] : {}; map.set(target, clone); for (let key in target) { if (target.hasOwnProperty(key)) { clone[key] deepClone(target[key], map); } } return clone; }这段代码注意几个细节Map 和 Set 的遍历回调里键和值都可能需要深拷贝所以递归处理Date 和 RegExp 在放入 WeakMap 之前先创建一个新的实例这一步比较隐蔽但能保证环引用场景下 Date 不会走通用对象分支。函数类型不需要深拷贝直接复用引用就好函数的作用域和闭包本身就不适合被“复制”。1.5 面试追问为什么不直接用 structuredClone面试官如果问你“写一个深拷贝函数”说完上面的实现之后大概率会追问一句“现在浏览器不是有原生structuredClone吗”这个问题考察的是你对前端生态的熟悉程度。structuredClone是 HTML 规范提供的原生深拷贝 API支持循环引用、Date、RegExp、Map、Set、ArrayBuffer 等绝大多数类型用起来也简单const cloned structuredClone(original);但它的局限也很明显不能复制函数和 Symbol在部分低版本浏览器里不支持而且它遵循的是结构化克隆算法与 JavaScript 运行时环境的行为并不完全一致。实际项目里如果数据都是 JSON-safe 的类型直接用structuredClone完全没问题如果数据里混了函数、Symbol或者需要兼容旧浏览器手写版本仍然是必要的。我在项目里通常会把这两个方案封装成一个工具函数优先使用原生 API不支持时降级到手写版本这样兼顾了性能和兼容性。2. 发布订阅不只是面试题它是事件总线的简化模型2.1 发布订阅模式和观察者模式的区别很多同学会把发布订阅模式和观察者模式混为一谈这两个概念确实长得很像但有一个本质区别观察者模式里观察者直接订阅被观察者被观察者状态变化时直接通知观察者两者之间是耦合的发布订阅模式则多了一个事件通道或者叫事件总线发布者和订阅者彼此不直接知道对方的存在所有消息都通过这个通道来中转。前端里最典型的发布订阅应用就是EventTarget和EventEmitter。Vue 的事件总线尽管官方已经不推荐了、各种 WebSocket 消息分发、跨组件通信底层基本都是这套逻辑。理解了这个设计模式你就理解了为什么它会在面试题里常驻它不仅仅是“叫两声”的 API而是大型前端应用解耦的基础设施。2.2 手写 EventEmitteron、off、emit、once手写一个发布订阅核心是维护一个事件名到回调函数数组的映射表。on负责注册回调emit负责触发回调off负责移除回调once负责注册一个只能触发一次的回调。下面是一个简洁的完整实现class EventEmitter { constructor() { this.events new Map(); } on(eventName, callback) { if (!this.events.has(eventName)) { this.events.set(eventName, []); } this.events.get(eventName).push(callback); return this; } off(eventName, callback) { const callbacks this.events.get(eventName); if (!callbacks) { return this; } if (!callback) { this.events.delete(eventName); return this; } const index callbacks.indexOf(callback); if (index ! -1) { callbacks.splice(index, 1); } return this; } once(eventName, callback) { const wrapper (...args) { callback(...args); this.off(eventName, wrapper); }; this.on(eventName, wrapper); return this; } emit(eventName, ...args) { const callbacks this.events.get(eventName); if (!callbacks || callbacks.length 0) { return false; } const callbacksCopy callbacks.slice(); callbacksCopy.forEach(callback { callback(...args); }); return true; } }实现里面有两个细节值得专门说一下。第一个是once的实现方式给原始回调包了一层函数触发完成后立即调用off移除自己这样既保证了只触发一次又保证off能通过wrapper正确移除。第二个是emit里为什么要用callbacks.slice()复制一份如果不复制在回调函数里如果注册了新的事件回调新回调会被追加到数组尾部同一轮 forEach 就可能把新回调也执行了反过来如果回调里把自己off掉了正在遍历的数组会发生变形可能导致跳过某些回调。复制一份可以保证当前这轮触发的事件列表是稳定的。2.3 实际场景从 MQTT 消息分发到组件通信我之前在一个物联网设备管理后台里负责过数据实时刷新这块。后端通过 MQTT 推送设备状态前端所有页面的实时数据都依赖同一个消息源。如果每个页面直接去处理 MQTT 的消息解析代码会非常散乱而且页面销毁时很容易忘记取消订阅导致回调泄漏。后来我封装了一个基于发布订阅模式的消息中心const messageCenter new EventEmitter(); // 建立 MQTT 连接后把原始消息分发到对应事件 client.on(message, (topic, payload) { messageCenter.emit(topic, parsePayload(payload)); }); // 组件里订阅自己关心的主题 messageCenter.on(device/status/123, updateStatus);这样消息生产和消息消费彻底解耦页面上任何组件想订阅哪个设备的状态就订阅哪个不需要知道消息是怎么来的。组件卸载时再调用off取消订阅就不会出现“组件已经销毁了回调还在被触发”的经典 bug。发布订阅模式在真实项目里的价值不是写一个 EventEmitter而是围绕它构建出清晰的消息流转结构。2.4 边界情况与内存管理发布订阅容易踩的坑主要集中在内存泄漏上。最常见的问题是订阅了事件但在组件销毁时忘了取消订阅导致回调一直留在事件表里持续被触发。这个问题的隐蔽之处在于回调函数往往还闭包引用了组件实例造成组件无法被垃圾回收内存占用只增不减。这也是为什么现代框架Vue 3、React都在引导开发者不要使用全局事件总线而是用官方推荐的状态管理方案。面试里谈到发布订阅时我可以顺便提一下off的健壮性处理移除不存在的回调函数不能报错移除整个事件的回调可以传null这些都是从工程角度对 API 的打磨。面试官听到这里通常会觉得你不仅是会写实现还考虑过实际使用的体验。3. 节流与防抖别再混为一谈了3.1 先用一个电梯门的比喻理解它们节流和防抖是面试里最容易混淆的一对。很多时候候选人能把代码默写出来但被问了一句“你项目里用它们分别解决什么问题”就卡壳了。我习惯用一个电梯门的比喻来区分防抖是“电梯等人”电梯门开着只要还有人往里走就继续等等人走完或者超过一定时间没人来了再关门。放在 JS 里就是事件触发了但先不执行等事件停止触发一段时间后再执行。适合搜索框输入的场景——用户还在打字的时候不需要拼命请求等停顿了再去查一次。节流是“限流通道”不管多少人涌进来每秒钟只能放行一个。放在 JS 里就是事件持续触发时每隔固定时间执行一次回调。适合滚动、拖动这类高频事件的场景——滚动过程会触发几十上百次事件但你并不需要每次都执行计算量大的回调。3.2 防抖实现从基础到带立即执行先写一个基础版防抖。原理很简单每次事件触发时先清除上一个定时器再重新创建一个定时器定时器到期后执行回调。这样连续触发时只有最后一次会成功等到定时器结束function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; }注意两点回调里用fn.apply(this, args)保证事件处理函数中的this指向和原函数一致借助闭包保存timer这样每次调用返回的函数时都操作同一个定时器变量。但基础版有个问题如果用户一直不停触发回调可能永远不执行。搜索框场景没问题但有些场景要求“第一次触发就立即执行后续触发需要等待”。比如用户点击“保存”按钮你希望第一次点击立即提交同时防止用户在短时间内重复点击。这时候需要加一个leading参数function debounce(fn, delay 300, immediate false) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } if (immediate !timer) { fn.apply(this, args); } timer setTimeout(() { timer null; if (!immediate) { fn.apply(this, args); } }, delay); }; }这个版本里immediate为真时第一次触发会立即执行一次此后在延迟时间内的所有触发都会被清理并重置定时器只有等定时器跑完timer被设为null之后的下一次触发才会再次立即执行。这样做既保证了首次响应又避免了高频重复触发。3.3 节流实现时间戳版和定时器版节流有两种经典实现方式差别主要在于“第一次触发时是否立即执行”。时间戳版记录上一次执行的时间每次触发时比较当前时间与上次时间的差值超过设定的间隔才执行。function throttle(fn, interval 300) { let lastTime 0; return function(...args) { const now Date.now(); if (now - lastTime interval) { lastTime now; fn.apply(this, args); } }; }这个版本的优点是第一次触发会立即执行因为lastTime初始为 0差值远大于interval缺点是在一个间隔的末尾触发的回调会被丢弃无法保证最后一次触发一定能执行。定时器版每次触发时如果定时器不存在就设置一个定时器到点执行并重置。function throttle(fn, interval 300) { let timer null; return function(...args) { if (!timer) { timer setTimeout(() { fn.apply(this, args); timer null; }, interval); } }; }这个版本保证了间隔内至少执行一次但同时意味着第一次触发不会立即执行要等一个间隔之后。把两个版本合并成一个支持leading和trailing配置的综合版并不复杂但面试时能把两个版本的差异说清楚并给出按需选择或合并的思路就已经很出彩了。3.4 完整配置版支持 leading 和 trailing我在项目里常用的节流实现同时支持首尾执行配置代码也不长可以直接拿来用function throttle(fn, interval 300, options { leading: true, trailing: false }) { const { leading, trailing } options; let lastTime 0; let timer null; return function(...args) { const now Date.now(); if (lastTime 0 leading false) { lastTime now; } const remaining interval - (now - lastTime); if (remaining 0) { if (timer) { clearTimeout(timer); timer null; } lastTime now; fn.apply(this, args); } else if (!timer trailing) { timer setTimeout(() { lastTime leading ? Date.now() : 0; timer null; fn.apply(this, args); }, remaining); } }; }这个版本在“时间戳 定时器”之间做了一个组合时间足够就立即执行时间不够但开启了trailing就安排一个定时器在间隔末尾执行一次。这样做的好处是既保证了高频触发下的节流效果又不会漏掉最后一次触发的动作。3.5 面试高频追问什么时候用防抖什么时候用节流面试官特别喜欢出一个场景题“窗口滚动要计算滚动位置显示返回顶部按钮用防抖还是节流”正确答案是节流。原因是滚动事件会持续高频触发如果用防抖只有用户停下来才会执行体验上会感觉按钮的出现总是慢半拍用节流可以保证滚动过程中每隔一段时间就更新一次位置交互更跟手。反过来“搜索框输入关键词实时请求联想结果”用防抖。因为输入过程中每次按键都是一个新输入防抖能保证用户停顿下来才发起请求避免每敲一个字母就发一个请求既浪费带宽又容易被后端限流。另外一个容易踩的坑是“按钮防止重复提交”。这个场景的最佳实践是防抖 立即执行也就是 3.2 里immediate: true的版本。用户点击的一瞬间就提交同时后续的重复点击会被防抖吞掉。3.6 和懒加载的配合节流是懒加载降级方案的基石这里先埋一个伏笔。第 5 节里我会写一个基于IntersectionObserver的懒加载实现它的性能很好但在兼容性不太行的老浏览器里需要降级到scroll监听方案。而滚动监听天然高频触发如果不做节流每次滚动都会执行几十次位置计算页面会明显卡顿。所以节流不仅是独立的面试题它也是懒加载降级方案的核心支柱。两者不是孤立的知识点而是同一个性能优化体系里的组件。4. 懒加载从图片到组件的延迟渲染4.1 懒加载的本质不白花没必要的请求懒加载Lazy Loading的核心思想是“延迟加载”资源不是页面打开时就全部加载而是等到它即将进入可视区域时才加载。对图片来说这意味着首屏只加载首屏内的图片滚动到哪一屏再加载哪一屏的图片。对组件来说这意味着路由切换时才去请求对应的 JavaScript 代码块而不是首屏一次性加载全部代码。这个策略能显著减少首屏请求数量和网络传输量尤其在图片很多的长页面里效果非常明显。我做过一个电商活动页原本首屏要拉 20 多张商品图优化后首屏只拉第一屏的 4 到 5 张加载速度从白屏 5 秒降到 2 秒以内。4.2 浏览器原生方案img 的 loadinglazy在动手写懒加载之前先确认一个事实现代浏览器已经原生支持图片懒加载了只需给img标签加一行属性img srcproduct.jpg loadinglazy alt商品图 /loadinglazy会告诉浏览器这张图片暂时不加载等它滚动到接近可视区域时再加载。这个方案实现成本为零性能也足够好但问题有两个一是浏览器兼容性没有到 100%老浏览器里这个属性会被直接忽略图片恢复为正常加载二是它对加载时机的控制比较黑盒没法精细地自定义触发距离。所以真实项目里主流的方案还是使用自研懒加载或者基于IntersectionObserver的第三方库这样能保持行为一致性不受浏览器版本差异影响。4.3 IntersectionObserver观察元素是否进入视口IntersectionObserver是浏览器提供的异步观察“元素与视口交叉状态”的 API。它解决了传统滚动监听方案里的两个核心痛点一是性能它不依赖滚动事件而是由浏览器在合适的时机自动触发回调二是精度它能在元素真正进入视口前的指定距离就提前通知你。基本用法如下const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { // 元素进入可视区域 } }); }, { root: null, rootMargin: 0px 0px 200px 0px, threshold: 0 }); observer.observe(targetElement);root为null表示观察区域是浏览器视口rootMargin可以扩展观察区域的范围让元素在进入视口前200px就触发回调提前加载图片threshold表示元素可见比例达到多少时触发回调0表示只要有一个像素进入视口就触发。注意rootMargin的四个值分别对应上、右、下、左只想提前加载下方图片的话把下面的边距调大就行。这个参数非常实用能让你在图片即将显示之前就开始加载而不是等图片肉眼可见了才懒加载——那会儿用户已经看到占位图了体验很差。4.4 手写一个完整的图片懒加载组件下面是一个完整的图片懒加载实现包含占位图、真实图片替换、加载失败处理和销毁清理。整体思路是先把真实图片地址放在>img classlazy>class LazyLoad { constructor(options {}) { this.rootMargin options.rootMargin || 0px 0px 200px 0px; this.observer null; this.init(); } init() { const isSupport IntersectionObserver in window; if (!isSupport) { this.fallback true; this.bindScroll(); return; } this.observer new IntersectionObserver(entries { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; this.loadImage(img); this.observer.unobserve(img); } }); }, { rootMargin: this.rootMargin, threshold: 0 }); this.observer.observeAll this.observeAll.bind(this); } observeAll(images) { images.forEach(img { if (this.observer) { this.observer.observe(img); } }); } loadImage(img) { const realSrc img.dataset.src; if (!realSrc) { return; } img.src realSrc; img.addEventListener(load, () { img.classList.add(loaded); }); img.addEventListener(error, () { img.classList.add(error); }); } bindScroll() { const throttleScroll throttle(this.checkInView.bind(this), 200); window.addEventListener(scroll, throttleScroll, { passive: true }); this.destroyScroll () { window.removeEventListener(scroll, throttleScroll); }; } checkInView() { const images document.querySelectorAll(img[data-src]:not(.loaded):not(.error)); images.forEach(img { const rect img.getBoundingClientRect(); if (rect.top window.innerHeight 200) { this.loadImage(img); img.classList.add(loaded); } }); } }这个实现里有几个工程细节值得展开第一loadImage里在真正加载完成后才添加loaded类名而降级方案里直接加了loaded两种方式的类名时机不同但都保证了“已经处理过的图片不会被重复处理”避免一个元素被反复观察、反复赋值src。第二降级方案里用了throttle(this.checkInView.bind(this), 200)——这就是我前面说的“节流和懒加载的配合”。滚动事件高频触发每次滚动都执行querySelectorAll和getBoundingClientRect计算性能压力很大。节流之后每 200 毫秒最多执行一次检查页面滚动就顺滑很多。第三rootMargin里的200px是一个经验值。设太小会导致图片临近可视区域才开始加载可能看到白屏或者占位图设太大会削弱懒加载的意义可能一次性加载了大量还未进入视口的图片。实测下来 200 到 400 像素是一个合理的提前量。4.5 组件懒加载和路由懒加载同一思想的延伸图片懒加载的思路可以无缝迁移到组件和路由的按需加载上。前端工程化里import()动态导入就是最常用的懒加载手段。// 路由懒加载 const routes [ { path: /dashboard, component: () import(./views/Dashboard.vue) } ];这里的() import(./views/Dashboard.vue)会返回一个 PromiseWebpack 或 Vite 在构建时会把 Dashboard 组件单独打成一个 chunk用户访问/dashboard时才下载这段代码。首屏只需要加载整体框架的代码页面切换时再按需拉取对应模块首屏体积能小不少。面试时如果能把图片懒加载、组件懒加载、路由懒加载放到同一个知识框架里讲会让面试官觉得你不是在背 API而是真的理解了延迟加载思想在整个前端性能优化体系里的位置。5. 手写还是背题聊聊这类题目的面试呈现和工程落地点5.1 面试官真正想考察的是什么这些工具函数之所以听起来像“八股文”是因为网上到处都有现成答案背下来并不难。但如果我是面试官我给这道题从来不是为了听你把代码背一遍。我更想通过这道题观察三个维度的能力第一边界情况意识。深拷贝里循环引用怎么处理说明你想没想过无限递归的问题发布订阅里emit时回调修改事件表怎么防变形说明你想没想过调用过程中数据结构变化的问题。第二工程取舍能力。你知道什么时候该用防抖、什么时候该用节流甚至知道现代浏览器有loadinglazy和structuredClone说明你对浏览器生态足够敏感不会盲目造轮子。第三代码表达能力。变量命名是否清晰、函数参数是否考虑扩展性、实现是否方便测试这些细节都会暴露平时的编码习惯。所以准备这类题目的正确姿势不是背而是把每一行代码都问一遍“为什么这样写”。能回答上来的东西才是你自己的只是顺进去的句子面试官多问一层就会崩。5.2 项目落地优先用成熟的库还是自研工作中如果实际需要这些工具函数我的建议是分情况看待。如果只是需要一个防抖函数或节流函数项目里已经引入了lodash或underscore直接用库里的实现就好没必要自己维护。lodash.debounce、lodash.throttle经过了大量项目的验证边界情况处理得非常完善自己写的版本大概率不如它们健壮。如果你在做一个需要控制包体积的项目或者公司有严格的技术合规要求不允许引入额外的依赖那自己维护一个精简版工具函数是合理的。这时候我推荐用 TypeScript 写并且补上完整的单元测试把这些函数的边界情况都覆盖到。测试用例本身就能起到文档的作用后面接手的人也能放心改动。发布订阅这块如果没有特别复杂的需求我自己手写的那个 EventEmitter 完全够用如果项目里需要更严谨的类型推导、可能用上通配符监听或者优先级控制可以考虑mitt或tiny-emitter这类轻量库。5.3 把这些函数串起来一个综合应用的例子最后分享一个小场景这个例子我经常在面试最后反问环节讲给候选人听因为它把前面几个知识点串在了一起一个商品列表页顶部是搜索框下面是商品图片和服务端返回的数据。搜索框输入用防抖减少请求次数商品图片用懒加载首屏不加载视口外的图片懒加载在IntersectionObserver不可用的时候降级为节流过的滚动检查搜索结果通过 WebSocket 推送实现实时更新推送消息通过发布订阅分发到各个组件组件卸载时取消订阅。这一套流程下来防抖、懒加载、节流、发布订阅全都有实际落点。面试官听到这种回答通常能看到你在“学到的知识和实际工程之间搭桥”的能力——这比单纯背五个代码片段要值钱得多。
返回列表