☰
前端面试手撕JS与算法题全拆解:高频手写代码与避坑指南
2026/10/5 6:04:32 网站建设 项目流程

如果你正在准备前端面试,js手撕题和算法应该已经占据了你刷题列表的大半。过去一年多我前端招聘面试了上百位候选人,发现一个很扎心的规律:简历写得越漂亮,越容易在手撕环节暴露基本功——new的原型链讲不清、防抖节流的this绑定一写就错、快排背过但分析不了时间复杂度。这篇文章就把我在真实面试中高频遇到的手写题和算法题完整拆解一遍,不光给代码,还会讲清楚每个关键步骤为什么这么写、哪些细节最容易翻车,以及面试官到底想从手撕题里看到什么。如果你是准备校招或者社招跳槽的前端,这篇文章应该能帮你省下不少自己盲目刷题的时间。

1. 为什么前端面试总爱考手撕JS?先看懂出题人的心思

1.1 手撕题到底在考察什么

很多人以为手撕题考的是"背代码",这是最大的误解。面试官让你手写new或者Promise,真正想看的是这三层东西:第一,你对 JavaScript 语言核心机制的理解深度,比如原型链、this绑定、事件循环、闭包,这些不是背概念能糊弄过去的;第二,你在边界条件下的处理能力,比如深拷贝遇到循环引用怎么办、二分查找数组为空怎么办,代码跑不跑得通一看便知;第三,你的工程习惯和复杂度意识,写排序算法时有没有分析过时间复杂度和空间复杂度,写完后会不会主动考虑测试用例。

另一个认知误区是"手撕题只考死代码"。实际上面试官更愿意看到你的思考过程,哪怕最后没写完,只要思路清晰、能说出当前方案的缺陷和优化方向,印象分会远高于一个默默敲完但不解释的人。我面试时经常会追问"如果数据量变成十万条这个写法还成立吗",其实就是在考察你是否有意识地从工程角度审视代码。

1.2 面试官是怎么给你打分的

我内部复盘面试情况时,对手撕题的打分一般分成五个维度:正确性、边界处理、复杂度分析、代码风格、沟通表达。一个常见误区是候选人只顾着憋代码,全程不讲话,甚至写完后也不自测。面试不是笔试考试,手撕环节本质上是一次技术沟通。

比较理想的答题节奏是:先花 30 秒到 1 分钟确认题目要求和边界条件,再花 1 分钟左右说思路和复杂度预期,然后动笔写,写完再用 1-2 个测试用例快速走读一遍代码,最后主动总结这道题可以怎么优化。整个过程 5-10 分钟完成是比较合适的。如果 15 分钟还没理出头绪,通常说明这题准备不足或者思路偏了,面试官会开始提示,这时候一定要接住提示,硬着头皮写不仅浪费时间还会扣分。

2. 高频工具函数手写题逐个拆解

2.1 手写 new:原型链理解的试金石

new几乎是前端面试必考题,它考察的是 JavaScript 面向对象最底层的逻辑。new之所以能"凭空"创建一个对象,是因为它内部做了四件事:新建一个空对象、把这个对象的原型指向构造函数的prototype、把构造函数内部的this绑定到这个新对象上并执行构造函数、根据构造函数返回值决定最终返回什么。

function myNew(Constructor, ...args) { // 1. 创建新对象,继承构造函数的原型 const obj = Object.create(Constructor.prototype); // 2. 执行构造函数,将 this 绑定到新对象上 const result = Constructor.apply(obj, args); // 3. 构造函数显式返回对象类型则返回该对象,否则返回新建的 obj const isObject = result !== null && (typeof result === 'object' || typeof result === 'function'); return isObject ? result : obj; }

这里最容易翻车的是第三步。很多人会忘记判断构造函数显式返回值的情况:如果构造函数里return { name: 'xxx' }这样一个对象,new之后拿到的其实是这个显式返回的对象,而不是内部新建的obj。只有返回基本类型值或者null的时候,new才会忽略它并返回obj。这个细节我面试时几乎每次都会问到。

用的时候可以验证一下:function Person(name) { this.name = name; },myNew(Person, '张三')之后实例的__proto__能访问到Person.prototype上的方法,instanceof Person也返回true。理解了手写 new,原型链、构造函数、Object.create这几个概念就被串起来了。

2.2 手写 call、apply、bind:this 绑定全家桶

call、apply、bind是面试官最喜欢组合打包考察的手写题,因为它们考察的是this绑定的本质。核心思路其实一句话:this本质上是谁调用就指向谁,所以可以把函数挂到目标对象上再调用。

Function.prototype.myCall = function(context, ...args) { context = context ?? window; const fn = Symbol('fn'); context[fn] = this; const result = context[fn](...args); delete context[fn]; return result; }; Function.prototype.myApply = function(context, args) { context = context ?? window; const fn = Symbol('fn'); context[fn] = this; const result = context[fn](...(args || [])); delete context[fn]; return result; }; Function.prototype.myBind = function(context, ...bindArgs) { const original = this; return function(...callArgs) { return original.apply(context, [...bindArgs, ...callArgs]); }; };

手写这套题的坑不少。第一个坑是用Symbol作为挂载到目标对象上的键名,而不是随便用一个普通字符串,避免把目标对象上原本存在的属性覆盖掉。第二个坑是执行完要delete掉临时挂载的属性,不然会给对象留下永久污染。第三个坑是myBind返回的函数如果被new调用,this应该被忽略指向新对象,完整版还要处理"箭头函数不能绑定 this"之类的争议,这个先不展开。

还有一种写法是用Object.defineProperty定义不可枚举的属性,比直接赋值更严谨,因为直接赋值后这个属性会在for...in中被遍历到。面试能讲到这一层基本是加分表现。

2.3 手写深拷贝:进阶功能的试炼场

深拷贝这道题很经典,因为它可以从简单的JSON.parse(JSON.stringify())一路进阶到处理各种边界情况的完整版。先明确一点:JSON序列化的方式有硬伤,undefined、function、Symbol、循环引用、Date对象、正则都会出问题,所以面试官想看的通常是完整版本。

function deepClone(obj, hash = new WeakMap()) { if (obj === null || typeof obj !== 'object') return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); if (hash.has(obj)) return hash.get(obj); const clone = Array.isArray(obj) ? [] : {}; hash.set(obj, clone); for (let key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { clone[key] = deepClone(obj[key], hash); } } return clone; }

这里最关键的是用WeakMap解决循环引用问题。如果不做处理,const a = {}; a.self = a; deepClone(a)这个测试用例会直接栈溢出。WeakMap的好处是键是弱引用,不会影响对象被垃圾回收,不会造成内存泄漏,同时它就是专门为这种"恢复对象-副本对应关系"的场景设计的。

还有两个常见的优化点:Map和Set类型的拷贝,完整版会分别处理;Object.getOwnPropertySymbols()可以用来拷贝 Symbol 键。不过面试中能把WeakMap循环引用、Date、RegExp、数组这四类处理好已经是个不错的答案了,不用强求每一行都完美。

2.4 手写 instanceof 与类型判断

instanceof的核心逻辑是沿着原型链向上查找,看看左边对象的原型链上能不能找到右边构造函数的prototype。

function 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; }

与之配套的是Object.prototype.toString.call()用于核验精确类型。为什么不用typeof?因为typeof []返回的是'object',typeof null返回的也是'object',这种结果是没法区分真实类型的。用Object.prototype.toString能拿到'[object Array]'、'[object Date]'这类精确字符串,再配合slice就能实现一个通用的类型判断函数。

3. 异步难题:Promise、防抖节流与事件总线

3.1 从零手写一个 Promise

手写Promise是很多人的噩梦,因为状态流转、微任务时序、链式调用堆在一起就乱了。其实把它拆开看,核心就是状态机加发布订阅:pending、fulfilled、rejected三种状态,一旦状态改变就不能再变,then注册的回调等到状态确定后再执行。

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(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) { onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value; onRejected = typeof onRejected === 'function' ? onRejected : reason => { throw reason; }; const promise2 = new MyPromise((resolve, reject) => { const handleFulfilled = () => { setTimeout(() => { try { const x = onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }; const handleRejected = () => { setTimeout(() => { try { const x = onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }; if (this.state === 'FULFILLED') handleFulfilled(); else if (this.state === 'REJECTED') handleRejected(); else { this.onFulfilledCallbacks.push(handleFulfilled); this.onRejectedCallbacks.push(handleRejected); } }); return promise2; } catch(onRejected) { return this.then(null, onRejected); } }

上面代码里的resolvePromise用来处理then回调返回的值以及promise2之间的依赖关系,完整实现要考虑:回调返回值是promise2本身会形成死循环要报错、返回值可能是带then方法的thenable对象、返回值本身又是Promise要递归解析。这块代码我是在项目里踩过坑的,当时手动实现了一个简化版,结果在嵌套Promise.resolve的场景下直接卡死,后来才意识到必须循环判断返回值类型。

如果觉得完整版太难,可以先写一个状态机加then链式调用的半成品,再逐步补齐。面试官更看重的是你能清状态一旦改变不可逆和then 返回新 Promise 支持链式调用这两个设计要点。

3.2 手写 Promise.all / Promise.race

Promise.all的核心语义是"所有成功才成功,一个失败就失败"。实现时注意两点:结果数组的按序填充、空数组直接返回。

Promise.myAll = function(promises) { return new Promise((resolve, reject) => { const result = []; let count = 0; if (promises.length === 0) { resolve(result); return; } promises.forEach((p, index) => { Promise.resolve(p).then((value) => { result[index] = value; count++; if (count === promises.length) resolve(result); }).catch(reject); }); }); }; Promise.myRace = function(promises) { return new Promise((resolve, reject) => { promises.forEach((p) => { Promise.resolve(p).then(resolve, reject); }); }); };

Promise.race就简单一些,谁先改变状态就用谁的结果。面试中常见的追问是"Promise.all中的失败会不会影响其他请求"——会,因为.catch(reject)已经注册了,任何一个请求失败都会让整体的Promise进入rejected状态,但其他的异步操作并不会被中断。

3.3 手写防抖 debounce 与节流 throttle

防抖和节流是前端高频题,也是实际项目里用得最多的工具函数。防抖的核心是"触发后等待一段时间,期间再次触发则重新计时",节流的核心是"一段时间内最多执行一次"。

function debounce(fn, wait = 300) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, wait); }; } 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); } }; }

这里有两个常见坑。第一个是this绑定问题,如果内部直接写fn(...args),那么当返回的函数作为对象方法调用时,this会丢失。第二个是防抖的clearTimeout要置空timer,不然可能导致闭包变量混乱;节流则要搞清楚是"时间戳版"还是"定时器版",时间戳版在第一次触发时会立即执行,定时器版在结束后还会再执行一次,各有适用场景。

我实际处理搜索框输入的场景时,用的是"防抖 + 立即执行一次"的变体,防止用户输入第一个字符后要等很久才有反馈,体验不好。这个变体实现起来也很简单,用一个immediate参数判断是否立即执行即可。

3.4 手写事件总线 EventEmitter

事件总线在异步编程、模块通信里很常见,本质是一个发布订阅模式。Vue里的$bus和Node.js里的EventEmitter都是这个思路。

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); } emit(event, ...args) { if (!this.events.has(event)) return; this.events.get(event).forEach(cb => { cb(...args); }); } off(event, callback) { if (!this.events.has(event)) return; const index = this.events.get(event).indexOf(callback); if (index > -1) { this.events.get(event).splice(index, 1); } } once(event, callback) { const wrapper = (...args) => { callback(...args); this.off(event, wrapper); }; this.on(event, wrapper); } }

once的实现是一个常见考点:不能直接把原始回调存进去,因为off的时候要能找到它,所以要包一层wrapper,在触发时先执行再取消订阅。这个设计我在实际开发中用过很多次,比如页面埋点只上报一次、WebSocket 只监听首次连接消息等。

4. 排序算法:面试里绕不开的基本功

4.1 必会三兄弟:冒泡、选择、插入

排序算法是算法题的基础,很多人觉得考试不考就跳过,但实际上手写排序能很好地反映你对循环和交换的熟练度。冒泡排序是最直观的:每一轮让相邻元素两两比较,大的往后移,经过 n-1 轮之后数组有序。

function 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; }

选择排序每轮找最小值和当前轮次的位置交换,插入排序则是把每个元素向前插入到已经有序的部分。这三个基础排序的时间里复杂度最坏都是 O(n²),但插入排序在近乎有序的情况下可以达到 O(n),所以实际工程中有些排序框架在数据量小时会用插入排序作为兜底。

4.2 高频双雄:快速排序与归并排序

快排是面试最高频的排序算法,核心是分治加原地分区。一个常见问题是很多人背了递归写法但说不清楚为什么最坏情况下会退化到 O(n²),以及如何避免——当每次基准都选到最大或最小值时,分区极度不均衡,递归深度就变成 n。通常的优化策略是取三数取中法,或者随机选基准。

function quickSort(arr) { if (arr.length <= 1) return arr; const pivot = arr[Math.floor(arr.length / 2)]; const left = []; const right = []; const middle = []; for (let num of arr) { if (num < pivot) left.push(num); else if (num > pivot) right.push(num); else middle.push(num); } return [...quickSort(left), ...middle, ...quickSort(right)]; }

这个写法不是原地排序,空间复杂度高一些,但最容易理解和记忆。如果面试中追求原地快排,需要额外实现partition函数:选基准、双指针从两端向中间移动、交换元素。归并排序则是稳定的排序算法,时间复杂度稳定为 O(n log n),适合需要稳定排序的场景。

4.3 堆排序:构建与调整的魔法

堆排序在算法题中出现频率高,手撕代码量也算中等偏难。核心是两步:先构建一个最大堆,再把堆顶元素和末尾元素交换,缩小堆范围后重新调整堆。

function heapSort(arr) { const n = arr.length; for (let i = Math.floor(n / 2) - 1; i >= 0; i--) { siftDown(arr, i, n); } for (let i = n - 1; i > 0; i--) { [arr[0], arr[i]] = [arr[i], arr[0]]; siftDown(arr, 0, i); } return arr; } function siftDown(arr, i, len) { while (true) { let maxIndex = i; const left = 2 * i + 1; const right = 2 * i + 2; if (left < len && arr[left] > arr[maxIndex]) maxIndex = left; if (right < len && arr[right] > arr[maxIndex]) maxIndex = right; if (maxIndex === i) break; [arr[i], arr[maxIndex]] = [arr[maxIndex], arr[i]]; i = maxIndex; } }

堆排序最重要的点是理解siftDown的下沉调整:从某个节点出发,把它和左右孩子中的较大者比较,如果父节点小就交换,然后继续向下调整。面试时建议先画一个数组的下标二叉树映射图,把自己讲明白再写,不然很容易在2 * i + 1、2 * i + 2这些边界上栽跟头。

4.4 排序算法对比与选型

算法平均时间复杂度最坏时间复杂度空间复杂度稳定性
冒泡排序O(n²)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n²)O(1)稳定
快速排序O(n log n)O(n²)O(log n)不稳定
归并排序O(n log n)O(n log n)O(n)稳定
堆排序O(n log n)O(n log n)O(1)不稳定

面试中被问到"两个排序都稳定且都是 O(n log n),怎么选",本质是让你比较归并和堆排序。归并排序稳定但空间占用高,堆排序不稳定但空间 O(1),实际工程中会结合数据规模、稳定性需求和内存限制综合决定。

5. 高频算法题套路拆解

5.1 二分查找:边界条件千万别写错

二分查找本身不难,难的是边界条件。我用的是左右闭区间[left, right]的写法,配合mid = left + ((right - left) >> 1),这样计算中间值时不容易整数溢出,位运算也比除法更快。

function binarySearch(nums, target) { let left = 0; let right = nums.length - 1; while (left <= right) { const mid = left + ((right - left) >> 1); if (nums[mid] === target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }

写二分最容易踩的坑是循环条件到底写left <= right还是left < right,以及更新边界时mid要不要加一减一。两种写法都能对,但要保持一致:left <= right配合left = mid + 1、right = mid - 1;left < right时则要注意最后返回的是left还是right。进阶题型是找左侧边界、右侧边界,以及在旋转数组中查找,核心都是在理解普通二分的基础上去套模板。

5.2 双指针:处理数组和字符串的利器

双指针算法在面试中出现概率极高,尤其是以下几个方向:有序数组两数之和用左右指针逼近,链表找环用快慢指针,去重和移除元素用快慢指针覆盖写,最长无重复子串用滑动窗口。

以"最长无重复字符的子串"为例,滑动窗口加哈希表记录字符最后出现的位置是标准解法。这类题每次看到都会觉得思路不难,但真正动手写时经常写错更新 left 边界的时机。核心是:每次扩张右边界时,如果新字符之前在窗口内出现过,那么left应该跳到Math.max(left, map.get(char) + 1),然后更新字符位置并计算窗口长度。

双指针还有一个特殊场景就是跳跃游戏2这类贪心题:维护当前可到达范围的右边界和下一步能到达的最远距离,遍历时更新farthest,当走到当前边界时就步数加一并把边界提升到farthest。这个解法不涉及双指针,但我建议你把贪心加在双指针那一类里一起练,因为它们都强调"用最少的状态变量记录关键信息"。

var jump = function(nums) { let jumps = 0; let curEnd = 0; let farthest = 0; for (let i = 0; i < nums.length - 1; i++) { farthest = Math.max(farthest, i + nums[i]); if (i === curEnd) { jumps++; curEnd = farthest; } } return jumps; };

5.3 深度优先DFS与广度优先BFS

DFS 和 BFS 是算法题里最常见的两种遍历方式。DFS 一般用递归加回溯,适合求路径总数、排列组合、岛屿数量这类问题;BFS 用队列逐层扩展,适合求最短路径、二叉树的层序遍历这类问题。

DFS 的典型模板:

function dfs(node, visited) { if (visited.has(node)) return; visited.add(node); for (let neighbor of getNeighbors(node)) { dfs(neighbor, visited); } }

BFS 的典型模板:

function bfs(root) { if (!root) return []; const queue = [root]; const result = []; while (queue.length) { const levelSize = queue.length; const level = []; for (let i = 0; i < levelSize; i++) { const node = queue.shift(); level.push(node.val); if (node.left) queue.push(node.left); if (node.right) queue.push(node.right); } result.push(level); } return result; }

注意 BFS 这里如果频繁用到shift,性能上会有问题,更严谨的做法是用双端队列模拟或者维护一个头指针。不过在面试手撕场景,能用数组写出来并跑通测试用例已经是合格水平,可以先不纠结性能细节。

5.4 动态规划:从暴力递归到递推填表

动态规划在前端面试里没那么高频,但像背包问题、爬楼梯、最长递增子序列、买卖股票这类经典题还是值得花时间准备的。核心就一句:把大问题拆成子问题,用数组或者哈希表存子问题的解,避免重复计算。

以经典爬楼梯为例,dp[i]表示爬到第 i 阶有几种方法,状态转移方程是dp[i] = dp[i - 1] + dp[i - 2],因为最后一次可以跨一阶也可以跨两阶。初始化时dp[0] = 1,dp[1] = 1,然后从 2 推到 n 即可。

面试中遇到动态规划题,我建议按这个顺序推进:先写暴力递归,再分析有没有重复子问题,然后加上记忆化数组,最后改写成迭代填表。这样每一步都有逻辑依据,也方便面试官看到你的思考路径。如果一上来就写状态转移方程,很容易卡壳。

6. 数据结构实现与刷题方法论

6.1 手写 LRU 缓存:真实业务的常见考点

LRU 缓存是面试中的高频手写题,因为它既是算法题又是实际工程里 Redis、浏览器缓存都会用到的策略。用Map实现 LRU 是最简洁的写法,因为Map天然保留了插入顺序,迭代时按键值对的插入顺序输出。

class 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); this.map.delete(key); this.map.set(key, value); return value; } put(key, value) { if (this.map.has(key)) { this.map.delete(key); } this.map.set(key, value); if (this.map.size > this.capacity) { this.map.delete(this.map.keys().next().value); } } }

这里的关键操作是get时先把键删除再重新插入,让键变成最新插入的,从而调整它在Map中的迭代顺序。清理时取this.map.keys().next().value得到的就是最早的键。用双向链表加哈希表实现 LRU 是更进阶的版本,面试官提出来时你要知道它的优势是删除节点是 O(1),但Map的写法在面试中已经足够惊艳,而且代码量少得多。

6.2 数组去重与扁平化等工具题

工具函数是整个手撕题家族里最基础也最容易得分的一类。数组去重的最高级写法是利用Set,一行搞定:const unique = arr => [...new Set(arr)]。如果面试官追问"如果元素是对象呢",就要说明浅去重和深去重的区别,对象是否完全相等要看JSON.stringify或者递归比较。

数组扁平化可以用reduce配合concat实现:

const flatten = (arr) => arr.reduce((acc, cur) => acc.concat(Array.isArray(cur) ? flatten(cur) : cur), [] );

字符串相关的工具题里,"判断字符串是否包含某个字符"可以直接用includes,也可以用indexOf、正则test。这类题虽然简单,但值得把不同方法的差异说清楚:includes是 ES6 的语法,indexOf能找到下标,正则能匹配模式,实际使用中按场景选择即可。

6.3 基础数据结构实现要点

前端手撕题虽不常要求你完整实现链表、栈、队列,但对应的高频题型还是值得过一遍。栈常用数组模拟,队列可以用数组加头指针,链表则要掌握反转、环形检测、合并两个有序链表。哈希表在 JS 里直接就是Map和Object,面试中遇到"判断两个字符串是否为异位词"、"两数之和"这类题,核心就是用哈希表把查找从 O(n) 降到 O(1)。

以"手写一个简易的栈"为例,用数组就能实现,但要注意pop和top的区别。很多算法题比如"有效的括号"、"逆波兰表达式求值",本质上都是在考栈的运用。如果你刷过 LeetCode 前 100 题,会发现栈和队列的变形题特别多,做熟了以后面试遇到基本秒过。

6.4 刷题顺序与实践建议

如果你时间有限,给一个我推荐的刷题优先级:先刷工具函数手写题,把new、call/apply/bind、深拷贝、防抖节流、Promise 系列搞透;再刷高频算法思想题,重点是二分、双指针、哈希表、DFS/BFS、排序;最后如果有余力再看动态规划和冷门算法。

还有一点想提醒你:KMP、Prim、EM 算法、DBSCAN 这类名字在热搜里很显眼,但除非你投的是算法岗或者面试官明确要求,否则前端手撕题基本不会考这么偏门的东西。我曾经花了一个周末研究 KMP 的 next 数组,后来发现面试根本没考过,性价比很低。把时间投入到高频题型上更划算。

7. 手撕题常见问题排查与避坑指南

7.1 常见边界错误速查

常见问题原因排查方法
深拷贝栈溢出循环引用未处理用 WeakMap 记录已拷贝对象
debounce 中 this 丢失内部直接调用 fn,没用 apply在返回函数里用 fn.apply(this, args)
二分查找死循环边界更新写错检查 left/right 更新公式,写完后跑空数组与单元素用例
Promise.then 不执行状态已变但回调数组没被触发确认 resolve 后切换状态是否执行回调遍历
快排结果错误分区逻辑处理相同元素出错引入 middle 数组或在 partition 中让相等元素正确分布
数组扁平化溢出递归层数过深面试中说明可以用迭代加栈替代递归

这张表是我复盘面试答题时整理的,哪怕你在现场没写对,如果能准确说出"这个问题我不太确定,但我知道应该用 XX 去排查",也会比闷头改半天强得多。面试官看重的不是你一次写对,而是你面对 bug 时的调试能力。

7.2 一道看似简单但很容易翻车的案例

我经常在面试中使用的一个题是"实现一个函数,输入一个数组和一个整数 k,输出数组中第 k 大的元素"。这题看起来简单,但考察点很多:直接排序取下标是最容易写出的方案,但时间复杂度是 O(n log n);进阶方案是使用快速选择算法,平均时间复杂度 O(n);再进阶是用堆,只维护一个大小为 k 的小顶堆,空间复杂度 O(k)。

很多候选人卡在了一个细节上:第 k 大是从大到小排序后的第 k 个,不是从小到大。如果你直接对升序数组取arr[len - k]其实是正确的,但如果你写成arr[k - 1]就错了。这种题目难吗?不难,但它能测试出你有没有先明确题干定义、有没有主动和面试官确认边界条件的意识。

7.3 勤用测试用例自检,别写"一次性代码"

手撕题最怕的不是思路错,而是写完就交付,不检查。我建议每写完一个函数,至少在脑海里跑两个用例:一个正常用例,一个边界用例。比如写了排序,就跑一个[3, 1, 2]和一个[];写了二分,就跑一个空数组和长度为 1 的数组。这一两分钟的自检时间,能帮你避免大量低级错误,也会让面试官对你的工程素养印象深刻。

8. 写在最后:作为面试者我的几点实在建议

我个人在实际招聘中发现,手撕题准备得扎实的候选人,工程能力通常也不会差。因为手写题本质上是对语言细节、逻辑思维和代码习惯的综合检验,你没真正理解某个概念是写不出来的,背代码只能应付一时,面试官稍微换个角度追问就露馅了。

最后分享一个小技巧:刷题的时候不要只看自己写出来的代码,要把每一道题的复杂度分析和优化方向写在旁边,形成自己的解题模板。比如看到"两数之和"就想到哈希表,看到"有序数组"就想到二分或双指针,看到"最短路径"就想到 BFS,这些条件反射靠的就是大量重复训练。

这篇内容后续你还可以这样扩展:把手写 Promise 完整版补上Promise.resolve、finally、allSettled;把 LRU 用双向链表加哈希表实现一遍;再把 LeetCode 高频一百题按我给的优先级刷两轮。只要掌握好前面这些基础,其实前端面试手撕题这一关没什么可怕的。祝你面试顺利。

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

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

立即咨询