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

资讯详情

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

前端面试手写题全解析:从防抖节流到响应式原理

前端面试手写题全解析:从防抖节流到响应式原理 不废话直接进入正题。前端面试中的手写题本质上不是考你背了多少 API而是通过一小段代码快速判断你平时写代码时有没有想过“底层发生了什么”。面试官看你手写代码看的不是代码能不能跑而是你的思路、边界意识、对 JavaScript 语言特性的掌握程度以及你在压力下写代码的习惯。这篇文章我结合自己面过别人和被别人面的经验把高频手写题分成几个梯队逐个拆解考察点、实现思路、常见翻车点最后给出一套应试策略。内容偏实战直接可复用。1. 手写题背后的真实考察意图面试官到底想看什么先说一个很多人没想明白的问题为什么现在前端面试几乎必考手写题尤其是大厂一轮技术面里至少有一两道手写题有的甚至全程都在手写。这背后其实有一套非常清晰的筛选逻辑。1.1 手写题是“低成本的底层能力探测仪”面试官在短短四五十分钟内很难通过问答判断出一个人真实的代码水平。八股文可以背项目经验可以包装但手写题很难装。你拿起笔或者打开在线编辑器从第一行代码开始你脑子里对 API 的熟悉程度、对边界情况的预判、对代码组织的习惯全都暴露了。比如让你手写一个debounce有的人写完就结束了有的人会主动追问“要不要处理 cancel”“immediate 参数需不需要支持”——后者在面试官心里的分数完全不一样。手写题另一个隐形作用是筛掉那些只是“会用框架、但是不懂语言本身”的人。现在前端开发的门槛确实低了会 Vue 会 React 就能找工作但很多人对 JavaScript 本身的机制理解是模糊的。手写题恰好把这个模糊地带照了出来。1.2 手写题的难度梯度和考察维度我面过不少候选人也总结过自己的面试题库前端手写题大致分为三档第一档基础工具函数。比如debounce、throttle、deepClone、curry、new、call/apply/bind、instanceof。这类题考察的是对语言基础 API 的理解以及边界情况处理。难度不大但坑很多。第二档JS 核心机制实现。比如手写Promise至少实现resolve、reject、then、catch、finally、EventEmitter发布订阅、Array.prototype.reduce、JSON.stringify。这类题考察的是对异步机制、原型链、事件循环的理解深度比第一档难不少。第三档框架原理相关。比如 Vue 2 的Object.defineProperty响应式、Vue 3 的Proxy响应式、简化版 Vuex/Redux、v-model的实现原理、React 的useState简化实现。这类题已经不限于是 JS 语法层面了而是考察你有没有读框架源码的习惯遇到问题时是“百度改一下”还是“能定位到框架底层”。知道这三档之后我们逐个来拆。1.3 手写题准备的核心方法论别背推演很多人准备手写题就是背代码背一个deepClone的模板面试时默写出来。这样做有两个问题第一面试官稍微变个场景你就不会了第二背出来的代码基本没有注释逻辑说不清楚面试官一追问就露馅。正确的方法就两个字推演。拿到一个题目先想这个函数是干什么的输入是什么输出是什么有哪些边界情况如果代码运行中报错了最可能错在哪一行把这几个问题想清楚了代码自然就写出来了。我比较推荐的一个方法把每个手写题都当作一个真实的小项目来做。写之前先画输入输出写完之后自己出几个测试用例跑一遍再想想“如果生产环境用这个函数会不会出问题”。用这个思路走一遍你对这个题的理解深度绝对远超背十遍。2. 高频基础手写题防抖、节流、深拷贝最容易“答得上来却写不对”这一档是面试出现频率最高的也是最容易让人“感觉良好但实际翻车”的。先说防抖和节流这两个函数看着简单但每次面试都能筛掉一批人。2.1 防抖debounce为什么你的实现一用就出问题防抖的核心思想事件触发后等待一段时间再执行回调如果等待期间又触发了则重新计时。典型场景搜索框输入联想、窗口 resize 后的计算。一个相对完整的实现function debounce(fn, wait 300, immediate false) { let timer null; let isInvoked false; const debounced function (...args) { const context this; if (timer) clearTimeout(timer); if (immediate !isInvoked) { fn.apply(context, args); isInvoked true; return; } timer setTimeout(() { fn.apply(context, args); isInvoked false; timer null; }, wait); }; debounced.cancel function () { if (timer) clearTimeout(timer); timer null; isInvoked false; }; return debounced; }这个实现里最容易被忽略的是this的指向。很多人写setTimeout(() { fn() }, wait)如果调用方是obj.method debounce(obj.method, 300)fn里的this就丢了。所以必须用fn.apply(context, args)而且context要在闭包里保存。immediate参数是加分项。面试官问“如果希望第一次点击立即执行之后等待怎么做”你能马上说出加一个immediate参数并且给出实现这就是一次明确的加分。2.2 节流throttle时间戳和定时器两种实现的差异节流的核心思想在一段时间内无论触发多少次只执行一次。典型场景滚动加载更多、拖拽、游戏中的射击。时间戳实现function throttle(fn, wait 300) { let previous 0; return function (...args) { const context this; const now Date.now(); if (now - previous wait) { fn.apply(context, args); previous now; } }; }定时器实现function throttle(fn, wait 300) { let timer null; return function (...args) { const context this; if (!timer) { timer setTimeout(() { fn.apply(context, args); timer null; }, wait); } }; }这两者的差异在于时间戳版本第一次触发立即执行最后一次触发不会执行因为冷却期内的触发被忽略了定时器版本第一次触发要等wait后才执行但最后一次触发会被记下来并执行。如果面试官问“如何实现一个首尾都执行的节流”你可以组合两者用时间戳判断是否到了执行点用定时器兜底最后一次触发。这种细节就是你和普通候选人拉开差距的地方。2.3 深拷贝这题考验的是边界意识不是递归深拷贝几乎是前端手写题里的“必考之王”但很多人对它存在严重误解——以为写个递归就完事了。浅拷贝只需要考虑一层深拷贝则要处理多种数据类型Date、RegExp、Map、Set、ArrayBuffer、循环引用、函数还有Symbol作为 key 的情况。面试官不会让你全部处理完但你能处理多少体现的是你对 JavaScript 数据类型的理解广度。一个生产可用的深拷贝实现不依赖JSON.parse(JSON.stringify())因为那个方案有严重的功能缺陷——函数会被丢掉、undefined会被丢掉、Date会变成字符串、循环引用会直接报错function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (target instanceof Date) return new Date(target); if (target instanceof RegExp) return new RegExp(target.source, target.flags); if (target instanceof Map) { const result new Map(); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } if (target instanceof Set) { const result new Set(); target.forEach((value) { result.add(deepClone(value, map)); }); return result; } if (map.has(target)) { return map.get(target); } // 处理 Symbol 作为 key 的情况 const result Array.isArray(target) ? [] : {}; map.set(target, result); Reflect.ownKeys(target).forEach((key) { result[key] deepClone(target[key], map); }); return result; }这里有个一个关键点很多人不知道Object.keys只能拿到字符串 key拿不到Symbolkey。要用Reflect.ownKeys才能同时拿到字符串 key 和Symbolkey。这一手细节能直接证明你真正读过一些源码级别的代码。WeakMap用来解决循环引用当拷贝一个对象的属性时如果属性值引用了父级对象没有WeakMap就会无限递归直到栈溢出。用WeakMap存“已经拷贝过的对象”和“拷贝后的新对象”之间的映射关系再遇到就直接返回新对象。深拷贝的真正难点不是递归本身而是“你意识到了哪些需要特殊处理的类型”。面试官看到你instanceof Date、instanceof RegExp、Reflect.ownKeys、WeakMap这几个点写出来心里基本就有底了。3. 进阶核心机制手写 Promise、EventEmitter、new 和 bind理解原理比会调用更重要第二档的手写题已经不只是语法层面了而是要求你理解 JavaScript 的核心运行机制。3.1 手写 Promise别被 then 的链式调用吓到手写 Promise 在前端面试中的出现率极高而且大部分候选人在这里翻车。原因很简单很多人日常开发只用 Promise 的 API没有想过它底层到底是怎么把异步串起来的。一个简化但核心逻辑完整的 Promise 实现要包含几个关键部分状态机pending→fulfilled/rejected、then注册回调、异步执行回调、链式调用时 value 的穿透。下面的实现不包含完整的 Promises/A 规范那是几大页的算法但足以在面试中展示你理解核心机制class MyPromise { constructor(executor) { this.state pending; // pending | fulfilled | rejected this.value undefined; this.reason undefined; this.onFulfilledCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.state pending) { this.state fulfilled; this.value value; this.onFulfilledCallbacks.forEach((fn) fn()); } }; const reject (reason) { if (this.state pending) { this.state rejected; this.reason reason; this.onRejectedCallbacks.forEach((fn) fn()); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state fulfilled) { onFulfilled(this.value); } if (this.state rejected) { onRejected(this.reason); } if (this.state pending) { this.onFulfilledCallbacks.push(() onFulfilled(this.value)); this.onRejectedCallbacks.push(() onRejected(this.reason)); } } }这个实现有几个关键点第一为什么resolve和reject要判断state pending因为一个 Promise 从 pending 变为 fulfilled 或 rejected 后状态就不能再变。第二executor 里如果抛异常要 catch 并用 reject 接收否则错误会直接冒泡。但这版实现是不完整的不支持链式调用中的 promise 穿透。最常见的追问是“如果then里返回一个新的值下一个then怎么拿到它”要实现真正的链式穿透then方法里需要返回一个新的 Promise并且把onFulfilled的返回值传进去。这就是 Promises/A 规范里最核心的一环。我建议面试前至少把链式版的手写 Promise 逻辑跑通一遍不需要背但要把状态流转和then返回新 Promise 的设计想明白。如果实在记不住完整实现把状态机和基本then写出来也比交白卷好得多。3.2 EventEmitter发布订阅所有框架消息机制的底座这个手写题常见度略低于 Promise但非常能考察代码组织能力。它的核心是一个事件名字到回调函数列表的映射提供on、emit、off、once四个方法。class EventEmitter { constructor() { this.events new Map(); } on(event, callback) { if (!this.events.has(event)) { this.events.set(event, []); } this.events.get(event).push(callback); return this; } emit(event, ...args) { const callbacks this.events.get(event); if (callbacks) { callbacks.forEach((cb) cb(...args)); } return this; } off(event, callback) { const callbacks this.events.get(event); if (callbacks) { const index callbacks.indexOf(callback); if (index ! -1) { callbacks.splice(index, 1); } } return this; } once(event, callback) { const wrap (...args) { callback(...args); this.off(event, wrap); }; this.on(event, wrap); return this; } }once的实现是这题的灵魂不能直接在off里移除原来的callback而是要包一层wrap执行完自动带着wrap做off。很多人写once时直接注册原函数结果这个函数被调用后依然留在事件列表里。有个面试中常被追问的细节off时如果回调没传应该清空该事件的所有回调。这个设计符合直觉也符合很多库的 API 语义。我在实现里加了这个判断的话通常能看出有没有真实设计过组件间通信。3.3 手写 new 和 bind这两题是原型链的“照妖镜”new的手写题主要是考察你对四步流程的理解function myNew(Constructor, ...args) { // 1. 创建一个新对象原型指向构造函数的 prototype const obj Object.create(Constructor.prototype); // 2. 以 obj 为 this执行构造函数 const result Constructor.apply(obj, args); // 3. 如果构造函数返回了对象则返回该对象否则返回 obj return result instanceof Object ? result : obj; }第二步的apply是核心。obj被创建后它的原型链已经指向了Constructor.prototype然后以obj为this执行构造函数给obj挂属性。返回值判断也很关键如果构造函数显式返回了一个对象比如return {}那么new的结果是这个对象而不是obj。bind的手写题核心是“柯里化 new 优先级”Function.prototype.myBind function (context, ...args) { const fn this; return function (...innerArgs) { return fn.apply(context, [...args, ...innerArgs]); }; };但完整的bind还要考虑如果返回的函数被当作构造函数new了this应该绑定到新创建的对象而不是传入的context。这个细节很多人不知道属于“加分中的加分项”。这两题看着简单但对原型链和 this 绑定的理解要求极高建议把每一行的原理都讲给面试官听而不是闷头写完。4. 框架相关手写Vue 2/3 响应式、双向绑定考察源码阅读习惯第三档手写题现在已经越来越常见尤其是对社招和高级岗。面试官想确认的是你不只是一个 API 调用者遇到奇怪 bug 时你是能钻进框架底层去诊断的。4.1 Vue 2 响应式核心Object.defineProperty 的拦截逻辑手写一个简化版 Vue 2 响应式系统能覆盖 data 中对象的属性读取和赋值即可。核心就是Object.defineProperty结合观察者模式。function observe(data, callback) { if (!data || typeof data ! object) return; Object.keys(data).forEach((key) { let value data[key]; observe(value, callback); // 递归监听 Object.defineProperty(data, key, { enumerable: true, configurable: true, get() { return value; }, set(newValue) { if (newValue value) return; value newValue; observe(newValue, callback); // 新值是对象则继续监听 callback(key, newValue); }, }); }); }这个实现里有一个大坑递归监听必须在defineProperty之前做吗答案是不一定但顺序会影响行为。先把子对象递归 observe 了再 defineProperty 父级属性这样初始化时子属性的 getter/setter 已经挂好。如果没有递归修改data.a.b 2就不会被检测到这正是 Vue 2 响应式的经典局限。Vue 2 的响应式还有一个臭名昭著的限制新增属性和删除属性无法被检测到所以 Vue 2 才提供了Vue.set和Vue.delete。手写实现时如果面试官问“为什么 Vue 2 检测不到新增属性”你直接从Object.keys(data)只看已有属性这个点解释就非常清楚。4.2 Vue 3 响应式核心Proxy 为什么能拍平 Vue 2 的痛点Vue 3 用Proxy重写响应式之后新增/删除属性都能被拦截了因为Proxy拦截的是对目标对象的“操作”而不是“属性”。一个最简实现function reactive(target) { return new Proxy(target, { get(obj, key) { // 这里可以做依赖收集 return Reflect.get(obj, key); }, set(obj, key, value) { const result Reflect.set(obj, key, value); // 这里可以做触发更新 return result; }, deleteProperty(obj, key) { const result Reflect.deleteProperty(obj, key); // 同样可以触发更新 return result; }, }); }面试时重点讲清楚一个对比Object.defineProperty是“数据劫持 手动递归”Proxy是“操作代理 惰性收集”。Vue 3 不需要初始化时递归遍历所有属性而是在get时按需收集依赖所以性能更好、内存占用更低。Reflect.get有个容易忽略的细节它能让get操作保持正确的this指向。如果直接return obj[key]在一些 getter 场景下this会指向obj而不是proxy可能造成依赖收集错乱。用Reflect就是为了保证 this 语义正确。4.3 手写 v-model 的场景变体面试官常从使用场景切入还有一类手写题不完全考框架原理而是考你“用框架的思维解决实际问题”。比如常见的一个“用 Vue 2 实现一个手写电子签名组件并且把它封装成 v-model 的形式”。这类题很典型热搜词里也有“vue2实现手写电子签名功能”。这类题的难点有两个一是怎么在 Canvas 上处理鼠标事件生成笔迹二是怎么把“签名绘制完成”这个事件暴露为 v-model 能接收的input事件。组件内部大概是这样template canvas refcanvas mousedownstartDraw mousemovedrawing mouseupendDraw / /template script export default { props: { value: { type: String, default: }, }, methods: { startDraw(e) { this.isDrawing true; // 设置 canvas 起始点 this.ctx.beginPath(); this.ctx.moveTo(e.offsetX, e.offsetY); }, drawing(e) { if (!this.isDrawing) return; this.ctx.lineTo(e.offsetX, e.offsetY); this.ctx.stroke(); }, endDraw() { this.isDrawing false; this.$emit(input, this.canvas.toDataURL()); }, }, }; /scriptendDraw时把 Canvas 内容导出为dataURL通过$emit(input, dataURL)传给父组件。父组件用v-modelsignaturesignature就被更新为图片数据。这就是 v-model 在自定义组件上的本质value属性 input事件。如果你还能主动提一句“签名组件需要考虑 Canvas 的 DPR 适配不然在高分屏上会模糊”面试官就知道你真的在移动端或者大屏适配上行过水平完全不一样。5. 实际面试中的手写策略从“会写”到“拿高分”的五个细节手写题能写对和能拿高分往往是两件事。我面过一些候选人代码一运行就通过但最后评分并不高也见过代码没写完但给了不错评价的。差异在哪5.1 先讲思路再动手让面试官跟上你的节奏拿到手写题不要立刻埋头写代码。先花 30 秒到 1 分钟把思路讲出来“这个函数输入是一个数组、一个回调函数输出是一个新数组我会先声明一个新数组然后遍历原数组对每个元素调用回调根据返回结果决定是否 push。”这样做有三个好处第一你自己理清了思路写代码更快第二面试官知道你在想什么即使没写完他也知道你是会做的第三如果思路错了面试官可能会中途纠正你避免你写完一大段才发现方向错了。5.2 用注释标注你的关键思考和边界处理在代码里写注释尤其是边界情况的注释是给面试官的重要信号。比如写深拷贝时在if (map.has(target)) return map.get(target)旁边写一句“解决循环引用”面试官一眼就看到了你的边界意识。写防抖时在if (timer) clearTimeout(timer)旁写“重新计时”也可以。不要让面试官去猜你的代码意图注释就是你和面试官之间的低成本沟通渠道。5.3 主动补充优化方案和追问场景写完一个版本后不要停下来等面试官问。主动说“这个实现还有一个缺陷比如 XXX如果需要我可以再补一下。”这个行为传递的信息是我有完整的技术方案不只是会写这一个函数。比如防抖写完主动提“目前immediate参数没有加如果要求第一次立即执行需要额外判断”深拷贝写完主动提“目前没有处理WeakMap递归函数本身有内存压力不过因为只用一次问题不大”。5.4 时间分配宁可写核心不要卡在边角手写题通常限时 10-15 分钟。如果你的实现在某个边界处理上卡住了比如想不起Reflect.ownKeys或者记不住WeakMap的用法别死磕先把核心逻辑写出来。面试官看的是主体架构边角可以口述补全。我见过很多候选人为了“写全”导致主体逻辑都没写完这是舍本逐末。5.5 复盘时把自己当面试官每次面完手写题或者自己练完手写题录个音或者回忆一下我写的时候卡在哪一行是因为什么卡住的是 API 不熟还是思路混乱把这些问题记下来逐个解决。手写题的题量是有限的高频题就那么多每一题都做到“能讲清楚为什么”的程度面试基本上就没问题了。6. 手写题清单从高频到低频的优先级排序最后给一份我认为值得花时间练的清单按优先级排第一优先级必练debounce/throttledeepClone包含循环引用、Date、RegExpnew/call/apply/bind手写 Promise至少一版状态机完整版EventEmitterVue 2 / Vue 3 响应式核心Array.prototype.map/reduce第二优先级高频instanceofJSON.stringify简化版数组去重至少三种方案字符串相关翻转、截断、模板字符串解析函数柯里化curry防抖和节流在 React/Vue 组件中的使用场景实现第三优先级场景大题手写电子签名结合 Canvas 和 v-model手写一个简化版 Vuexstatecommitdispatch手写一个简化版懒加载IntersectionObserver手写一个大文件上传的分片逻辑结合 Blob、File.slice、并发控制每一道题都要给到自己追问扩展的空间比如“如果数组有 10 万个元素reduce的实现会有什么性能问题”“如果深拷贝的对象有SymbolkeyObject.keys能拿到吗”把这些扩展问题想透了面试时你就不是“背题的”你是真的懂。手写题不是目的它只是一个认知过滤器。真正重要的是你写代码时是否具备“输入是什么、输出是什么、边界在哪、会不会爆栈、会不会丢 this”这套系统性的思维习惯。把这道题练扎实了收益不止是面试通过而是你日常开发写出的代码也会更稳。
返回列表