
常见JS手撕题及算法总结前端面试与日常开发的硬核实战手册做前端这几年我面试过不少人也被面试官按在地上摩擦过不少次。要说前端面试里最让人心里没底的环节“手撕代码”绝对排第一——不是背八股文那种背完就忘的题而是要求你当场在白板或在线编辑器里写出一段能跑的代码。JS手撕题涵盖的范围其实很固定高频考点就那些数组方法的手写实现、Promise相关的异步处理、深拷贝、防抖节流、常见的排序和查找算法以及几道经典的贪心、动态规划题目。这篇文章会把我在面试和学习中反复遇到的高频手撕题全部整理一遍每道题都给出可以直接抄的代码实现、复杂度分析以及一些容易被忽略的细节。无论你是准备面试的候选人还是想巩固JS基础的开发者这份总结都能帮你少走很多弯路。手撕题这东西表面上考的是“会不会写某段代码”实际上考的是你对语言特性、数据结构、算法复杂度的理解深度。同样是写一个debounce有人两行糊弄过去有人能把this指向、参数透传、立即执行选项都处理到位——这就是差距。所以我写的每道题都不会只甩一个答案而是会拆开讲讲“为什么要这么写”这样你下次遇到变体题也能举一反三。1. 数组与对象操作类手写题看似简单实则全是细节数组和对象可以说是JS里最常用的数据结构了所以面试官特别爱从这里出题。这些题表面上看起来人畜无害但恰恰是考察基本功的最佳试金石。我见过不少工作三五年的前端在数组去重和深拷贝上翻车——不是不会写而是写出来的实现有各种隐蔽的bug。1.1 数组去重从双层循环到一行Set你要掌握几种思路数组去重是老牌手撕题了几乎每个面试官都会问。最笨的双层循环写法我就不多说了直接从面试官期待的几个层次来讲。第一种是用Set这是ES6之后最推荐的方案代码最简洁const unique (arr) [...new Set(arr)];一行代码搞定而且时间复杂度是O(n)。但这里有个坑——如果你直接回答这一种面试官往往会追问“如果数组里是对象呢”Set的去重用的是SameValueZero算法对于引用类型它比对的是引用地址而不是内容所以两个内容相同但引用不同的对象无法去重。这时候就需要JSON.stringify配合Map或者用reduce加findIndex来做深层次去重const uniqueObjects (arr, key) { const map new Map(); return arr.filter(item { if (!map.has(item[key])) { map.set(item[key], true); return true; } return false; }); };这个写法能按指定字段去重实用性更强。我还被问过“如果数组中包含NaN怎么去重”Set能正确处理NaN因为SameValueZero把NaN视为相同但indexOf做不到——arr.indexOf(NaN)永远返回-1。这个细节能答上来面试官会觉得你真的懂JS的底层机制。1.2 数组扁平化考验递归思维和迭代能力的经典题数组扁平化就是把嵌套数组展开成一维数组。这个题看着简单但选对方法很重要。使用ES6的flat方法一行搞定const flat (arr, depth Infinity) arr.flat(depth);当然面试官不会让你这么轻松更常考的是手动实现。递归版本最容易想到const flattenRecursive (arr) { let result []; arr.forEach(item { if (Array.isArray(item)) { result result.concat(flattenRecursive(item)); } else { result.push(item); } }); return result; };递归版本的代码很直观但存在一个性能隐患——如果嵌套层级特别深超过调用栈限制会爆栈。更稳妥的做法是用栈来模拟const flattenStack (arr) { const stack [...arr]; const result []; while (stack.length) { const item stack.pop(); if (Array.isArray(item)) { stack.push(...item); } else { result.push(item); } } return result.reverse(); };这里有个容易出错的地方最后要reverse()一下因为栈是后进先出不反转顺序就反了。这个题考察的核心是“递归有没有真正理解以及是否意识到递归的局限”如果你能主动提出栈迭代方案绝对是加分项。1.3 手写深拷贝九成面试者会踩坑的隐藏考点深拷贝几乎是必考题因为它能考察你对JS数据类型的掌握程度。先给一个最基础的版本const deepClone (obj) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj); const clone Array.isArray(obj) ? [] : {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { clone[key] deepClone(obj[key]); } } return clone; };这个版本已经能覆盖大部分场景但真正面试的时候面试官会不断加条件。比如“对象里有循环引用怎么办”这时候就需要用到WeakMap来记录已经拷贝过的对象const deepCloneWithCycle (obj, map new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (map.has(obj)) return map.get(obj); const clone Array.isArray(obj) ? [] : {}; map.set(obj, clone); for (const key in obj) { if (obj.hasOwnProperty(key)) { clone[key] deepCloneWithCycle(obj[key], map); } } return clone; };还有一点很多人会漏掉——for...in只能遍历可枚举属性Symbol类型的键不会被for...in遍历到。如果对象里有Symbol属性用Reflect.ownKeys会更安全。真正的生产环境我建议直接用lodash的cloneDeep但面试时能写出上面这个带循环引用处理的版本就已经超过九成候选人了。1.4 数组方法的原生实现map、filter、reduce一个都别放过手写map、filter、reduce是面试官很爱考的题因为它们能考察你对回调函数、this绑定和数组遍历机制的理解。先看map的实现Array.prototype.myMap function(callback, thisArg) { const result []; for (let i 0; i this.length; i) { if (i in this) { result.push(callback.call(thisArg, this[i], i, this)); } } return result; };注意这里有个细节if (i in this)这个判断是为了跳过稀疏数组中的空洞保证实现和原生map行为一致。filter的实现类似只是要加个条件判断Array.prototype.myFilter function(callback, thisArg) { const result []; for (let i 0; i this.length; i) { if (callback.call(thisArg, this[i], i, this)) { result.push(this[i]); } } return result; };reduce的实现稍微复杂一点要处理初始值的判断Array.prototype.myReduce function(callback, initialValue) { let accumulator initialValue; let startIndex 0; if (arguments.length 2) { accumulator this[0]; startIndex 1; } for (let i startIndex; i this.length; i) { accumulator callback(accumulator, this[i], i, this); } return accumulator; };这里有个隐藏考点如果没有传初始值需要把数组第一个元素作为累加器的初始值并且从索引1开始遍历。要是数组为空且没传初始值原生reduce会抛TypeError这个边界条件最好也处理上。2. 进阶函数与异步核心手写Promise、防抖节流、call/apply/bind数组和对象的手写题只是暖场真正拉开差距的是函数进阶和异步相关的题目。这些题不仅在面试中出现日常开发中我也会手写因为原生方法在某些场景下有兼容性或行为不一致的问题。2.1 防抖和节流不只是面试题更是性能优化的基本功防抖和节流我在实际项目中用得太多了——搜索框输入、窗口resize、滚动加载、按钮点击防重复提交全是它们的应用场景。这两个概念面试时是必问的我建议你不仅要会写还要能说清楚“为什么需要它们”。先写防抖debounce它的核心思想是“每次触发都重置计时器只等最后一次”const debounce (fn, delay 300, immediate false) { let timer null; let isInvoked false; return function(...args) { const context this; if (timer) clearTimeout(timer); if (immediate !isInvoked) { fn.apply(context, args); isInvoked true; } else { timer setTimeout(() { fn.apply(context, args); isInvoked false; timer null; }, delay); } }; };这里的immediate参数是控制“立即执行”的比如搜索框的联想功能用户输入第一个字符时我们希望立刻响应而不是等300毫秒。这个参数经常被面试官作为追问点能主动实现出来会加分。节流throttle的核心思想是“固定时间间隔内只执行一次”。我用时间戳实现一个版本const throttle (fn, interval 300) { let lastTime 0; return function(...args) { const context this; const now Date.now(); if (now - lastTime interval) { fn.apply(context, args); lastTime now; } }; };时间戳版本的特点是“每段间隔开始就执行”但存在一个问题是最后一次触发无法执行。如果想要“最后一次也执行”可以结合定时器实现一个带尾部调用的版本这里我贴一个在实际项目中更常用的组合实现const throttleTrailing (fn, interval 300) { let lastTime 0; let timer null; return function(...args) { const context this; const now Date.now(); const remaining interval - (now - lastTime); if (remaining 0) { if (timer) { clearTimeout(timer); timer null; } fn.apply(context, args); lastTime now; } else if (!timer) { timer setTimeout(() { fn.apply(context, args); lastTime Date.now(); timer null; }, remaining); } }; };这个版本保证了第一次触发时立即执行同时最后一次触发也能通过定时器补上用起来更符合直觉。我实际项目里很多滚动加载场景用的就是这个版本。2.2 手写call、apply、bind理解this指向的关键钥匙call、apply、bind这三个方法的手写实现是面试必考的因为它们直接考察你对this绑定的理解。核心思路其实是一样的把函数挂到目标对象的属性上通过对象调用函数来改变this指向。先看call的实现Function.prototype.myCall function(context, ...args) { context context || window; const uniqueKey Symbol(key); context[uniqueKey] this; const result context[uniqueKey](...args); delete context[uniqueKey]; return result; };这里有几个细节值得注意用Symbol作为key是为了避免覆盖对象原有的属性这是很多人容易忽略的点。apply和call的区别只是参数传递方式不同Function.prototype.myApply function(context, args) { context context || window; const uniqueKey Symbol(key); context[uniqueKey] this; const result context[uniqueKey](...args); delete context[uniqueKey]; return result; };bind稍微复杂一点因为它返回的是一个新函数且支持函数柯里化的参数预置Function.prototype.myBind function(context, ...args) { const fn this; return function(...restArgs) { return fn.apply(context, args.concat(restArgs)); }; };这里还要考虑一个边界情况如果用new关键字调用bind返回的函数this应该指向新创建的对象而不是绑定的context。完整版还需要用instanceof判断new的情况但这在面试中属于加分项能主动提出来就会让人眼前一亮。2.3 手写Promise新手劝退题却是真正理解异步的必经之路Promise的手写实现可以说是前端手撕题里的“天花板”了。很多面试者一听到“手写一个Promise”就慌了但其实面试官并不会要求你实现和原生Promise完全一致的完整规范核心考察点是三块状态机的管理、then链式的调用、异步回调的执行顺序。一个基础版的Promise这样写const PENDING pending; const FULFILLED fulfilled; const REJECTED rejected; 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()); } }; const reject (reason) { if (this.state PENDING) { this.state REJECTED; this.reason reason; this.onRejectedCallbacks.forEach(cb cb()); } }; 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)); } } }这个版本能跑但还缺少最关键的链式调用返回新Promise的能力以及值的穿透、错误捕获等细节。完整的Promise/A规范实现有一百多行我在面试时通常先写这个精简版然后逐步补充。面试官想看的是你有没有真正理解异步的状态流转只要能把状态机、回调注册和执行顺序讲明白这个题就拿下了。2.4 手写new、instanceof细节里藏着对原型链的理解new的手写实现也是高频题。它做的事情主要有四步创建新对象、让新对象的原型指向构造函数的prototype属性、执行构造函数并绑定this、如果构造函数返回对象则返回该对象否则返回新对象。const myNew (fn, ...args) { const obj Object.create(fn.prototype); const result fn.apply(obj, args); return (typeof result object result ! null) || typeof result function ? result : obj; };这里有两个细节容易被忽略一是如果构造函数返回的是原始值比如数字、字符串new仍然返回新对象二是Object.create就是为了确保新对象的原型链正确。instanceof的手写实现考的是对原型链的遍历const myInstanceof (left, right) { let proto Object.getPrototypeOf(left); const prototype right.prototype; while (proto) { if (proto prototype) return true; proto Object.getPrototypeOf(proto); } return false; };站在面试官的角度这个题考察的是你有没有真正理解原型链的查找机制而不是只会背“instanceof是用来判断引用类型的”。3. 高频算法手撕题排序、查找、字符串匹配一次讲透前端面试的算法题难度通常集中在LeetCode的中等偏下水平但有一个特点就是特别爱考“你能否用JS写出来并说清楚复杂度”。这一节我把最常考的几类算法题集中梳理一下。3.1 排序算法从冒泡到快排再到堆排序的复杂度演化冒泡排序是很多人的算法入门题虽然时间复杂度是O(n²)但代码实现简单面试时能快速上手const bubbleSort (arr) { const len arr.length; for (let i 0; i len - 1; i) { let swapped false; for (let j 0; j len - 1 - i; j) { if (arr[j] arr[j 1]) { [arr[j], arr[j 1]] [arr[j 1], arr[j]]; swapped true; } } if (!swapped) break; } return arr; };这里加了一个swapped标记来优化如果一轮循环下来没有发生任何交换说明数组已经有序直接退出。这个优化在面试中是加分项。快速排序是前端面试最常考的排序算法它的平均时间复杂度是O(n log n)其分治思想在很多算法题中都有应用const quickSort (arr) { if (arr.length 1) return arr; const pivot arr[0]; const left []; const right []; for (let i 1; i arr.length; i) { if (arr[i] pivot) { left.push(arr[i]); } else { right.push(arr[i]); } } return [...quickSort(left), pivot, ...quickSort(right)]; };这里选的pivot是第一个元素最坏情况下数组已经有序时间复杂度会退化为O(n²)。更好的做法是随机选pivot或是三数取中法。这个版本的好处是代码最清晰好记面试时不容易写错。堆排序在前端面试中出现频率略低但偶尔也会遇到。它的核心是“建堆”和“调整堆”两个过程const heapSort (arr) { const len arr.length; const heapify (i, size) { let largest i; const left 2 * i 1; const right 2 * i 2; if (left size arr[left] arr[largest]) largest left; if (right size arr[right] arr[largest]) largest right; if (largest ! i) { [arr[i], arr[largest]] [arr[largest], arr[i]]; heapify(largest, size); } }; for (let i Math.floor(len / 2) - 1; i 0; i--) { heapify(i, len); } for (let i len - 1; i 0; i--) { [arr[0], arr[i]] [arr[i], arr[0]]; heapify(0, i); } return arr; };堆排序的时间复杂度稳定在O(n log n)但实际运行速度不如快排因为常数项更大而且对缓存不友好。面试时我说完这个思路面试官通常就会转到下一个题了。3.2 二分查找与它的边界地狱二分查找看似简单但“边界条件”的坑特别多while (left right)还是while (left right)middle怎么更新用Math.floor还是Math.ceil稍有疏忽就死循环或者漏掉边界值。const binarySearch (arr, target) { let left 0; let right arr.length - 1; while (left right) { const mid Math.floor((left right) / 2); if (arr[mid] target) { return mid; } else if (arr[mid] target) { left mid 1; } else { right mid - 1; } } return -1; };这个版本用left right所以找到后可以立即返回。如果不用等号left和right指向同一个位置时循环会退出导致漏判这一点需要特别留意。二分查找的变体题也常考比如“寻找数组中第一个大于等于target的位置”其实就是lower_bound。这套边界逻辑搞明白了很多变体题都能迎刃而解。3.3 字符串匹配KMP算法用JS写出来是什么样子KMP算法在字符串匹配题目中是高端局了很多前端开发者看到KMP就直接跳过但它在面试中真出现时能写出来的人少之又少答上了就是非常大的加分项。KMP的核心思想是“利用部分匹配表next数组在匹配失败时尽量多跳过一些字符”。先构建next数组const getNext (pattern) { const next [0]; let prefix 0; let i 1; while (i pattern.length) { if (pattern[i] pattern[prefix]) { prefix; next[i] prefix; i; } else if (prefix 0) { prefix next[prefix - 1]; } else { next[i] 0; i; } } return next; };然后是主匹配逻辑const kmpSearch (text, pattern) { if (pattern.length 0) return 0; const next getNext(pattern); let i 0; let j 0; while (i text.length) { if (text[i] pattern[j]) { i; j; if (j pattern.length) { return i - j; } } else if (j 0) { j next[j - 1]; } else { i; } } return -1; };KMP的难点在于理解next数组的构建过程面试时如果能画个图把匹配过程演示一遍会非常加分。实际业务中其实很少手动写KMP直接用indexOf或正则就够了但“会写”本身就是竞争力的体现。3.4 常用字符串方法substring、indexOf、includes用太多次了但你真的理解吗关于字符串还有一个经常以“手撕题”形式出现的问题——判断一个字符串是否包含另一个字符串。ES6提供了includes方法但要手动实现一个判断逻辑也很常见const contains (str, subStr) { if (subStr.length 0) return true; for (let i 0; i str.length - subStr.length; i) { let flag true; for (let j 0; j subStr.length; j) { if (str[i j] ! subStr[j]) { flag false; break; } } if (flag) return true; } return false; };这个朴素匹配的时间复杂度是O(m*n)和KMP相比差了不少但胜在简单易懂。面试时先写朴素版再提到可以优化到KMP节奏就很好。4. 经典算法题实战解析从LeetCode到面试现场除了手写语言特性面试中最常见的还有一类题——直接给你一道LeetCode原题。据我观察前端面试最爱考的算法题集中在“贪心排序”“双指针”“动态规划入门”这几类。我把最高频的几道题挑出来讲讲思路和JS实现。4.1 跳跃游戏 II一道典型的贪心算法题跳跃游戏II是LeetCode中等难度里非常经典的贪心题目。题目是给定一个非负整数数组你最初位于数组的第一个位置数组中的每个元素代表你在该位置可以跳跃的最大长度目标是到达数组的最后一个位置求最少跳跃次数。贪心的思路是每次记录“当前这一步能跳到的最远位置”当遍历到这个位置时步数加一同时更新下一次能到达的最远位置const jump (nums) { let steps 0; let curEnd 0; let furthest 0; for (let i 0; i nums.length - 1; i) { furthest Math.max(furthest, i nums[i]); if (i curEnd) { steps; curEnd furthest; } } return steps; };这段代码的关键在于理解“每一步覆盖的范围”。我见过不少人用DFS去解这个题但在这个问题上贪心就能做到O(n)时间、O(1)空间DFS是指数级复杂度完全不是一个量级。这个题很好地展示了“选择合适算法比会写代码更重要”这个道理。4.2 组合总和DFS回溯的经典模板组合总和这道题考察的是回溯算法它在面试中出现的频率极高因为它的代码结构特别适合用来考察候选人对递归和剪枝的理解。题目描述通常是给定一个无重复元素的数组candidates和目标值target找出所有可以使数字和为目标值的组合。数组中的数字可以无限制重复被选取。const combinationSum (candidates, target) { const result []; const dfs (start, current, sum) { if (sum target) { result.push([...current]); return; } if (sum target) return; for (let i start; i candidates.length; i) { current.push(candidates[i]); dfs(i, current, sum candidates[i]); current.pop(); } }; dfs(0, [], 0); return result; };这个题的模板是固定的理解了三要素路径、选择列表、结束条件后面遇到全排列、子集、组合总和II等题目都能套用。这里有个小技巧用sum candidates[i]作为参数传递而不要在递归前修改sum这样可以避免回溯时忘记恢复状态的尴尬。4.3 全排列与子集回溯算法的两个标准变体全排列和子集是回溯算法的另外两个经典应用。全排列的核心差异在于每层递归可以选择的元素范围不同const permute (nums) { const result []; const used new Array(nums.length).fill(false); const dfs (current) { if (current.length nums.length) { result.push([...current]); return; } for (let i 0; i nums.length; i) { if (used[i]) continue; used[i] true; current.push(nums[i]); dfs(current); current.pop(); used[i] false; } }; dfs([]); return result; };子集问题则是一个“选或不选”的决策树也可以用回溯模板来解const subsets (nums) { const result []; const dfs (start, current) { result.push([...current]); for (let i start; i nums.length; i) { current.push(nums[i]); dfs(i 1, current); current.pop(); } }; dfs(0, []); return result; };子集问题的代码相对简洁核心在于每次递归后从i 1开始避免使用重复元素。这几道题都是同一种套路建议一起练习。4.4 手撕题答题策略时间有限如何按优先级取舍写到最后分享一套我自己的手撕题答题策略按优先级排列第一优先级是把“边界条件”处理好。无论是数组为空、参数不是预期类型、还是输入超大都要先想清楚再动笔。我会在写代码前先和面试官确认条件比如“数组里有没有负数元素是整数吗”这本身就是思考和沟通能力的体现。第二优先级是代码的可读性。手撕题不是竞赛面试官看重的是你的代码是否易读、有良好的变量命名和结构。写一个a、b、c这样命名的人和写leftIndex、rightIndex的人专业度一眼就能看出来。第三优先级是主动说出时间和空间复杂度。写完代码后不急着说“写完了”而是主动分析一下复杂度甚至提出一种更优的解法。这个习惯非常加分因为它表明你不只满足于“能跑”而是在思考“跑得好不好”。最后才是追求代码本身的简洁和优化。很多复杂技巧在面试的短时间里容易写错用朴素写法把题目解出来再提一句优化方向远远好于憋一个复杂写法最后写崩。写在最后我参与过不少面试也带过不少新人。我自己的体会是手撕题的真正价值不在于那道题本身的答案有没有写对而在于它暴露出来的思维过程。有人能在一道简单的数组去重题里展示出对Set、Map、Symbol这些ES6特性的熟练运用有人却连引用类型和基本类型都分不清——差距不是一道题的距离而是日常积累的距离。如果你正在准备面试我的建议是把这篇文章里出现的每一段代码都亲手敲一遍不要照抄而是合上代码自己从空白文件开始写卡住了就回头看一眼思路提示再继续写。过几天再重新写一遍直到每道题都能五分钟内在编辑器里完成。这个过程很枯燥但回报是实打实的。如果你已经工作了一段时间我也建议你抽空把这些基础再过一遍。前端技术迭代很快框架年年换但JS的语言本质和底层算法逻辑一直没有变过。根基稳固的人学什么新框架都快因为万变不离其宗。希望这份总结能帮到你。