
面试这事儿一旦聊到“手撕代码”很多人的第一反应就是紧张。明明平时项目写得挺顺一到白板阶段脑子就空白Promise.all 怎么写来着防抖是立即执行还是延迟执行其实面试官问你手撕题真不是想看你背了多少API而是想通过几道高频题快速判断你的编程基本功、边界思维和沟通习惯。这份“面试官最爱问的高频手撕清单”我按照近几年一线大厂的面试反馈和身边朋友的面经整理了一遍把最常出现的题目和底层逻辑都拆开揉碎讲清楚。这份清单主要面向准备前端中高级岗位面试的同学也适合刚转行、想系统补基础的新人。里面每道题我都给了破题思路、参考实现和面试官真正想看到的加分点照着练比你盲目刷100道题有用得多。1. 为什么面试官都爱让候选人手撕代码1.1 手撕题到底在考察什么很多人以为手撕题考的是“API背得熟不熟”这是最大的误解。面试官在短短四五十分钟里能考察的维度其实很有限。项目经历可以包装系统设计可以背八股但手撕代码这个环节最能真实暴露一个工程师的代码习惯、思维方式和解决问题的路径。我做过几次模拟面试官也跟不少面试官聊过。大家普遍一致的看法是手撕题主要看四点第一基础功底扎不扎实比如原型链、闭包、事件循环这些底层概念是不是真理解而不是只会背概念第二边界思维够不够强参数传空、类型不对、超大输入这些情况有没有考虑到第三代码风格好不好变量命名是否清晰、是否过度设计、有没有写无用逻辑第四沟通协作能力遇到题目不会做是闷头硬想还是会主动跟面试官交流思路、提出假设。1.2 高频手撕题的核心分类“高频手撕清单”不是一道题一道题去背而是要掌握类型。我观察下来面试官最爱问的手撕题大致能分三类第一类是工具函数实现比如防抖节流、深拷贝、柯里化、数组去重、对象扁平化这类题源于日常开发考察基本功第二类是框架底层原理简化版比如手写 Promise、实现一个简易的 Vue 响应式或 Redux考察对框架的理解深度第三类是经典算法题比如快排、二分查找、LRU 缓存等考察数据结构和算法的基本功。这三类题目都有一个共性就是“题面短、解法多、边界多”。题面越短越能考察你的思维广度解法越多越能考察你会不会选型边界越多越能考察你的工程经验。这也是为什么面试官青睐这类题——不是难题但特别能看出水平。1.3 从一道手撕题看面试官的思维给你模拟一个场景。面试官说“手写一个防抖函数。”看似简单但里面的门道特别多this 指向要不要处理参数能不能透传是立即执行还是延迟执行返回值怎么办取消功能要不要做我见过不少候选人很快写出一个 basic 版本function debounce(fn, delay) { let timer null; return function() { clearTimeout(timer); timer setTimeout(() fn(), delay); }; }写完就结束了。代码是对的但面试官内心毫无波澜因为这跟背课文没区别。但如果你能在这个基础上继续聊“this 绑定需要保留”“参数要透传”“如果需要立即执行版本怎么办”“怎么支持 cancel 取消”这题的分数就完全不一样了。面试官看的是你有没有把这些问题想清楚而不是那句“let timer null”。2. 高频手撕清单全景图按题目类型拆解2.1 必须能默写的8道必考题根据近几年面经和一线的反馈下面这8道题的出现频率非常高可以说是“面试官最爱问”的第一梯队。第一梯队工具函数类防抖、节流、深拷贝、柯里化。为什么是这四个因为它们覆盖了闭包、递归、引用类型、高阶函数这些 JS 最核心的概念。一道题能考察多个知识点面试官自然爱用。第二梯队手写实现类Promise尤其是 Promise.all 和 Promise.race、new 操作符模拟、call/apply/bind 实现。这四个涉及原型链、this 绑定、异步流程控制是前端进阶的关键分水岭。第三梯队数据结构类LRU 缓存、快排 / 二分查找变体。这两类题目能考察你是否具备基本的算法思维。我给的排序建议是先练工具函数类再练手写实现类最后专门攻数据结构类。原因是工具函数类最容易建立信心和手感手写实现类能帮你打通 JS 的底层逻辑有了前面两类积累数据结构类才能真正理解而不是死记。2.2 这些题为什么出现频率这么高细心的人会发现这些题都是平时写业务代码时频繁用的东西。防抖节流是搜索框、滚动监听的基本操作深拷贝是处理接口返回数据的常规操作Promise 更是异步编程的核心。面试官本质上是在问你每天都在用这些 API那你能不能自己造一个出来能造出来说明你“to understand how it works under the hood”。不能造出来说明你只是“会用”至于内部的实现机制你其实没有真正搞懂。大厂看重的恰恰是前者。2.3 从清单反推面试准备策略之前有个学弟问我说刷了 300 道 LeetCode手撕题还是被挂。原因是方向错了。前端面试手撕题的算法难度不要求你做到竞赛水平除了少数明确考算法的岗位之外通常维持在 LeetCode easy 到 medium 前半段的水平。真正的重点是“实现类题目”也就面试官非常关注“你能不能理解原理并实现出来”。所以正确策略应该是用20%时间刷常规算法保持手感用80%时间围绕上面三类高频题反复打磨每一步都要能讲清楚为什么要这么做。我见过太多人刷题只刷不总结刷了 100 道还是老样子。按照这份清单来每道题吃透了比盲目刷 100 道都管用。3. 必考手写实现每道都要能默写3.1 防抖与节流高频中的高频这道题几乎每三场面试就会出现一次因为它在实际开发里的使用频率实在太高。防抖和节流都用于限制函数的执行频率但适用场景不同防抖debounce在事件被触发 N 秒后再执行回调如果在这 N 秒内又被触发则重新计时。典型场景搜索框输入、窗口 resize 后等用户停止操作再执行。节流throttle规定一个单位时间内只能触发一次函数。典型场景滚动加载、拖拽移动、按钮频繁点击。手写防抖的完整版本应该把 this、参数、立即执行、取消防抖都考虑进来function debounce(fn, wait 500, immediate false) { let timer null; let isImmediateCalled false; // 区别于 lodash 的 immediate 处理方式 return function(...args) { const context this; if (timer) clearTimeout(timer); if (immediate) { if (!isImmediateCalled) { fn.apply(context, args); isImmediateCalled true; } timer setTimeout(() { isImmediateCalled false; }, wait); } else { timer setTimeout(() { fn.apply(context, args); }, wait); } }; }这里用了一个isImmediateCalled标志位来处理“立即执行”的场景同时确保在等待窗口结束后可以再次立即执行。这种写法比单纯用callNow布尔值更清晰也比 lodash 里复杂的lastCallTime方案更好理解。节流的实现比较经典的时间戳版本和定时器版本各有优劣。时间戳版本能保证首次立即执行但停止触发后不会再执行定时器版本保证停止后最后再执行一次但首次有延迟。实际使用中我推荐把两者结合起来function throttle(fn, wait 500) { let previous 0; let timer null; return function(...args) { const now Date.now(); const remaining wait - (now - previous); if (remaining 0) { // 距上次执行间隔已超过 wait立即执行 if (timer) { clearTimeout(timer); timer null; } previous now; fn.apply(this, args); } else if (!timer) { // 保证停止触发后还能再执行一次 timer setTimeout(() { previous Date.now(); timer null; fn.apply(this, args); }, remaining); } }; }面试的时候写出来 timeflag 和 timerflag 的版本之后面试官往往会追问一句如果希望第一次点击不触发最后一次触发后还能执行一次你怎么处理这就是上面这个“双保险”版本解决的问题把两个版本的优势都合进来。3.2 深拷贝递归是基础边界才是分水岭深拷贝几乎是前端面试必考题它考察的是递归、引用类型、循环引用、特殊对象处理等知识点。一个大而全的深拷贝函数可以覆盖非常多的考察点function deepClone(target, map new WeakMap()) { // 基本类型直接返回 if (target null || typeof target ! object) { return target; } // 处理循环引用 if (map.has(target)) { return map.get(target); } // 处理 Date、RegExp if (target instanceof Date) { return new Date(target.getTime()); } if (target instanceof RegExp) { return new RegExp(target.source, target.flags); } const result Array.isArray(target) ? [] : {}; map.set(target, result); // 处理 Symbol 属性 const symbolKeys Object.getOwnPropertySymbols(target); symbolKeys.forEach(symKey { result[symKey] deepClone(target[symKey], map); }); // 处理普通可枚举属性 Reflect.ownKeys(target).forEach(key { result[key] deepClone(target[key], map); }); return result; }很多候选人能写出递归版本但会在循环引用这里卡住——比如源对象里有一个属性指向自身不处理就会无限递归导致栈溢出。用 WeakMap 记录已经克隆过的对象遇到同一个引用直接返回这个细节一写出来面试官心里就会点头。还有个容易忽略的点是 Symbol 属性的克隆。很多人用Object.keys()遍历但Object.keys()拿不到 Symbol 属性。如果你用Reflect.ownKeys()一次性拿所有键或者在遍历普通键之后再单独处理 Symbol说明你对 ES6 的掌握是扎实的。另外Object.create(null)创建的对象、Map、Set 这类特殊结构如果能主动提到并说明怎么处理都是加分项。3.3 手写 Promise 与 Promise.all面试官最爱追问的异步题手写 Promise 是前端面试的一个分水岭。能把 Promise 基本结构写出来的人不少能把 Promise.all 写对的人不到一半。先看 Promise 核心结构class MyPromise { constructor(executor) { this.state pending; 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(cb cb(value)); } }; const reject (reason) { if (this.state pending) { this.state rejected; this.reason reason; this.onRejectedCallbacks.forEach(cb cb(reason)); } }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { onFulfilled typeof onFulfilled function ? onFulfilled : value value; onRejected typeof onRejected function ? onRejected : reason { throw reason }; const promise2 new MyPromise((resolve, reject) { if (this.state fulfilled) { setTimeout(() { try { const result onFulfilled(this.value); this.resolvePromise(promise2, result, resolve, reject); } catch (e) { reject(e); } }); } // ... 处理 rejected 和 pending 状态 }); return promise2; } resolvePromise(promise2, result, resolve, reject) { if (promise2 result) { reject(new TypeError(Chaining cycle detected)); return; } // 处理 result 是 Promise 或 thenable 的情况 if (result instanceof MyPromise) { result.then(resolve, reject); } else { resolve(result); } } static all(promises) { return new MyPromise((resolve, reject) { if (!Array.isArray(promises)) { reject(new TypeError(arguments must be an array)); return; } const results []; let count 0; if (promises.length 0) { resolve(results); return; } promises.forEach((p, index) { MyPromise.resolve(p).then(value { results[index] value; count; if (count promises.length) { resolve(results); } }, reject); }); }); } }面试时如果没有让你完整实现 Promise A 规范手写all已经能应付 90% 的情况。这里的关键点是用 count 计数并按下标存储结果而不是往数组里 push。很多人习惯用results.push(value)这会导致返回结果的顺序和传入顺序不一致——先完成的放前面但Promise.all必须保持顺序。这一点面试官特别爱考细节决定成败。还有一个加分点是promises.length 0的情况直接resolve([])。能主动补上这个边界判断说明你确实理解了这个 API 的语义而不是背了个模板。3.4 call / apply / bindthis 指向的底层逻辑这道题考察的是对 this 机制的理解。核心思路都是把函数挂到目标对象上通过对象调用函数让 this 自然指向目标对象。Function.prototype.myCall function(context globalThis, ...args) { const fn Symbol(); // 用 Symbol 避免覆盖对象上的已有属性 context[fn] this; const result context[fn](...args); delete context[fn]; return result; }; Function.prototype.myApply function(context globalThis, args []) { const fn Symbol(); context[fn] this; const result context[fn](...args); delete context[fn]; return result; }; Function.prototype.myBind function(context globalThis, ...bindArgs) { const originalFn this; return function(...callArgs) { return originalFn.myApply(context, [...bindArgs, ...callArgs]); }; };这里有两个值得关注的细节一是用Symbol()作为临时属性名避免可能与目标对象原属性冲突的问题二是用globalThis代替window或global兼容 Node 环境和浏览器环境这个细节不少面试官会在心里默默加分。bind 的实现还有一种更严谨的写法需要考虑new调用的情况new 出来的实例不能继承绑定上下文。但前端中高级岗位面试考到这个深度的不多能写出上面这版基本就够用了。3.5 new 操作符模拟经典但不能只会背手写 new 也是高频题它考察的是对构造函数、原型链、返回值处理的理解function myNew(Fn, ...args) { // 1. 创建一个新对象原型指向构造函数的 prototype const obj Object.create(Fn.prototype); // 2. 执行构造函数this 绑定到新对象上 const result Fn.apply(obj, args); // 3. 如果构造函数返回了对象返回那个对象否则返回新对象 if (result ! null (typeof result object || typeof result function)) { return result; } return obj; }最常见的坑是忘了处理第三步——构造函数显式返回一个对象的情况。比如构造函数里写了return { a: 1 }那么 new 出来的一定是那个{ a: 1 }而不是实例本身。凡是能说出这个细节的人面试官基本可以确定你理解了 new 的完整语义。4. 数据结构与算法型手撕不是背题是理解4.1 手写 LRU 缓存LRULeast Recently Used最近最少使用这道题在一些高频手撕清单里常年霸榜。它考察的是双向链表加哈希表Map的数据结构组合能力以及你对“时间复杂度”的敏感度。JavaScript 里的 Map 天然维护了插入顺序可以巧妙利用这一点实现 LRUclass LRUCache { constructor(capacity) { this.capacity capacity; this.map new Map(); } get(key) { if (!this.map.has(key)) return -1; const value this.map.get(key); // 删除重新插入让该 key 排到最新位置 this.map.delete(key); this.map.set(key, value); return value; } put(key, value) { if (this.map.has(key)) { this.map.delete(key); } else if (this.map.size this.capacity) { // 删除最久未使用的 keyMap 的第一个 key const oldestKey this.map.keys().next().value; this.map.delete(oldestKey); } this.map.set(key, value); } }这里的核心技巧是“删掉再插入”利用 Map 的插入顺序模拟访问时间的刷新。this.map.keys().next().value取到第一个 key也就是最久没有被访问的那个直接删除。这个方案在思路和实现上都比手写双向链表更简洁面试时先写出这个版本再跟面试官聊“如果要求严格 O(1) 的 get / put 呢”再引申到双向链表版本既有梯度又有深度。4.2 手写快速排序和二分查找快排在算法手撕里的出现频率很高。面试官通常会加一个前提不用 API手写排序算法。对于中小规模数据快排的效率确实很能打。function quickSort(arr) { if (arr.length 1) return arr; const pivot arr[0]; const left []; const right []; for (let i 1; i arr.length; i) { arr[i] pivot ? left.push(arr[i]) : right.push(arr[i]); } return [...quickSort(left), pivot, ...quickSort(right)]; }但必须提醒一点这是最直观的写法空间复杂度偏高每次递归都新建数组。如果你能在写完这个版本之后主动说“这是最容易理解的版本但空间复杂度是 O(n log n)面试中如果要求原地排序可以改成荷兰国旗问题的双指针分区方式”面试官会认为你不仅会写还真的对比过不同实现。二分查找的模板也比较固定但边界条件一定要记牢function binarySearch(nums, target) { let left 0; let right nums.length - 1; while (left right) { const mid Math.floor((left right) / 2); if (nums[mid] target) return mid; if (nums[mid] target) { left mid 1; } else { right mid - 1; } } return -1; }边界是left right还是left rightmid加一还是减一这里错了就直接死循环。我建议你把这个模板背下来然后用二分查找的变体题比如查找第一个大于等于 target 的位置、旋转数组最小值去巩固就能形成条件反射。4.3 数组去重与扁平化看似简单实则多解数组去重是一道特别适合“引导式面试”的题。面试官会不断追问去重还能怎么写时间复杂度是多少能不能不改变顺序如果是对象呢基础版本用 Setconst unique arr [...new Set(arr)];进阶版本用 reduce 加 includes / indexOf保持原数组顺序const unique arr arr.reduce((acc, cur) acc.includes(cur) ? acc : [...acc, cur], []);数组扁平化也一样从flat(Infinity)到递归实现再到用栈预处理function flatten(arr) { return arr.reduce((acc, cur) Array.isArray(cur) ? [...acc, ...flatten(cur)] : [...acc, cur], [] ); }这类题目考的不是能不能写出来而是你能写出几种解法能不能对比它们的复杂度。每多一层思考在面试官那里的评分就会高一档。5. 面试现场从思考到敲完的完整答题套路5.1 先想清楚再动手先说思路再敲代码很多候选人看题之后直接上手写写了一半发现方向不对又删了重来。面试官并不会因为你“答得快”就给高分相反他们更欣赏那种先花一两分钟把问题和边界想清楚的人。我推荐的现场答题流程是第一步口述你要怎么实现比如“我打算用 Map 来存get 的时候删掉再重插put 的时候先查有没有有就删没有就判断容量”第二步确认关键边界条件比如“传入的是空数组怎么办”“负数怎么处理”第三步动手写代码写的时候边写边解释第四步写完主动梳理一遍指出哪些地方可能有边界问题。这套流程打下来面试官会明显感觉到你是有方法论的人而不是背题的考生。5.2 三道最常见的追问方式“还有没有更优的解法”——考察你掌握多少种方案以及你是否会主动做复杂度分析。“如果输入变了这段代码还成立吗”——考察你的边界思维和防御性编程意识。“能不能把这段代码里没考虑到的内存问题指出来”——考察你对工程化质量的敏感度。面对这些追问别慌。追问答得不好并不全是坏事很多面试官故意用追问来看你面对压力时的反应。你可以诚实说“这里我没有想到特别好的优化方案”比强行编一个错的方案要好得多。5.3 不同基础的候选人怎么用这份清单如果你是刚准备面试的新人先用两天把工具函数类全部默写一遍再花三天把 Promise 和 call/apply/bind 搞清楚最后用周末把 LRU 和快排练熟。先求完整再求深入。如果你的时间有限优先保防抖节流、深拷贝、Promise 这三道出现概率真的太高了。如果你有基础想冲高分把重点放在“每个题的最优解”和“边界问题”上再配合 LeetCode 的 medium 难度题练手感。6. 我的刷题顺序与避坑心得6.1 刷题顺序千万别乱来我最开始准备面试时犯过一个严重错误上来就啃 Promise A 规范结果看了几天连 resolvePromise 的递归逻辑都没吃透自信心差点被打崩。后来调整策略按照“工具函数类 → 手写实现类 → 数据结构类”的顺序来刷效果立竿见影。为什么这个顺序有效因为工具函数类是最好上手的防抖节流深拷贝写个两三遍就熟了能积累“我能行”的正反馈。有了这个底子再去看 Promise 这种复杂逻辑就能理解为什么要用回调数组、为什么 then 要返回新的 Promise。等前面两类都顺了再去碰 LRU、快排这些算法题思路会开阔很多因为你已经建立了“先想边界、再想解法”的习惯。6.2 记住我踩过的三个坑第一个坑是只写不练。看答案觉得自己会了真要自己从头写就卡住。后来我定了规矩看完一道题合上手机空手写出来写不出来就是不会重新学。这个方法很笨但效果是真的好。第二个坑是不总结共性。我很长一段时间是“今日学防抖明日学深拷贝后日学 Promise”学完就忘。后来我给自己做了张表把每道题考察的 JS 核心知识点标出来发现它们高度重叠闭包、this 绑定、递归、事件循环。从那以后我理解了手撕题刷的不是题而是对 JS 核心机制的理解程度。第三个坑是忽视了“说”的练习。手撕题不但要会写还要会讲。面试时一边写一边说思路面试官才能跟上你的节奏。我建议你练题的时候念出声来模拟面试场景既能帮自己理清思路又能缓解真实面试时的紧张感。6.3 后续怎么扩展这份高频清单覆盖的是基础盘如果你还想往上冲可以继续往下扩展手写一个简版 redux-thunk、实现 Promise.retry重试机制、手写一个简易的模板引擎、实现一个支持并发限制的调度器。这些都是“进阶手撕题”面试官问出来往往能区分候选人水平。但我还是想强调基础盘没打牢别急着追新题。把这份清单吃透了再往外扩你的面试稳定性会远超大多数人。最后再分享一个我复盘面试时的小习惯。每次面试结束我会第一时间把被问到的题目和当时自己的思考过程记录下来特别是那些答得不好的题过两天再重新独立写一遍。面试不只是找工作的手段更是暴露自己漏洞的最快方式。上面这份“高频手撕清单”你可以直接照着练但真正的功夫在练的过程里——理解每个实现背后的为什么再反复答题直到形成肌肉记忆。前几次面试翻车太正常了多复盘多补漏你的手感会一次比一次好。