☰
JS手撕题与算法全总结:前端面试与开发实战必备
2026/10/5 5:20:01 网站建设 项目流程

常见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的语言本质和底层算法逻辑一直没有变过。根基稳固的人,学什么新框架都快,因为万变不离其宗。希望这份总结能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询