前端面试手写代码题全攻略:从防抖节流到Promise原理

发布时间:2026/8/29 23:01:56
前端面试手写代码题全攻略:从防抖节流到Promise原理 铜九铁十到了群里又炸锅了。不是问项目怎么包装就是问八股文怎么背。但有一类题程序员之间聊起来最没有水分也是面试官最不好糊弄你的——手写代码。这玩意儿不像背概念编两句还能蒙混过关。手写题是现场对着白板或共享屏幕敲会就是会不会就卡在那非常透明。我前后面试过几十个前端也在求职市场被面试官“盘”过很多轮累计下来手写题几乎是每一家的必考环节而且出镜率极高。从最低级的数组去重到Promise.all、再到手写一个简化版的Vue 响应式范围跨度很大但核心逻辑永远就那么几条。这篇文章我就把自己的手写题准备笔记和考场心得整理出来按“面试官到底想考什么”来分类拆解每个题都会给出可直接复现的代码实现和必要的边界条件分析。不管是正在冲秋招、准备跳槽还是单纯想把 JS 基础补扎实这份内容都值得你花时间过一遍。1. 手写代码题到底在考什么——先搞懂面试官的真实意图我不止一次在模拟面试里听到候选人吐槽这东西工作里根本用不到啊我手写干嘛Lodash 一行_.cloneDeep不就完了你要是带着这个心态去准备手写题大概率会凉。1.1 面试官不是要你造轮子而是验证你的功底手写题的核心目的从来不是让你在生产环境里裸写一个Promise代替库面试官自己也清楚现成工具更稳妥。它真正考察的有三件事语言基础是否扎实比如你写call是不是真的理解this的指向规则你写new是不是清楚原型链是怎么串起来的。边界情况是否敏感正常的deepCopy谁都能写但你考虑过Date、RegExp、Symbol、循环引用吗考虑过数组和对象的区分吗这些“陷阱”才是筛选经验的分水岭。代码组织能力手写题通常要求边写边讲。思路是否清晰、变量命名是否可读、有没有用注释表达关键逻辑这些比“背模板”更能反映一个人的工程素养。所以我建议你在准备任何一道手写题时都抱着“把它当小项目来做”的心态。先想清楚输入输出再想边界条件最后动手写而不是上来就默写。1.2 常见手写题分类和优先级排序以我刷过的几百道真实面试题来看手写题大致分为五档按频率和重要性排序如下。优先级题型分类典型题目准备建议P0工具函数基础三件套防抖、节流、深拷贝几乎必考烂熟于心P0JS 核心原理call / apply / bind、new、instanceof高频考察 this 和原型链P1异步与 Promise 家族Promise.all / race、简版 Promise、async 变体极高频率需理解微任务机制P1数组与对象操作数组去重、扁平化、柯里化、乱序简单有陷阱适合快速筛选P2框架原理简化版Vue2 响应式、EventBus、defineProperty vs Proxy加分项高级岗位常客P3场景设计题并发控制、大文件上传、LRU 缓存考察综合设计能力加分明显如果你时间紧张先把 P0 和 P1 的题吃透再去碰 P2 和 P3。但如果你目标是中大厂的资深岗位P2 和 P3 才是拉开差距的地方。2. 三个“必背”工具函数防抖、节流、深拷贝我面过的人里十个有九个都会写防抖节流但能把边界条件说清楚的一个手数得过来。这仨函数看似简单其实最能拉开区分度。2.1 防抖与节流核心不是 setTimeout而是“状态管理”防抖debounce的核心思想是事件触发后延迟执行如果在延迟时间内再次触发则重新计时。你就把它想象成电梯门——只要有人进来门就重新等几秒再关。节流throttle的核心思想是固定时间间隔内只执行一次。想象成游戏里的技能冷却——不管你按得多快技能 CD 没结束就放不出来。好多人的防抖实现长这样function debounce(fn, delay) { let timer null; return function(...args) { clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; }说实话到这一步已经能过大部分面试了但面试官马上会追问一句“这个this指向是否正确如果事件处理函数里用了event对象会不会丢”——你需要在setTimeout里通过fn.apply(this, args)传递this和参数这也是上面代码的关键所在。节流的标准实现我推荐用时间戳版加上定时器版的双模式function throttle(fn, delay) { let last 0; let timer null; return function(...args) { const now Date.now(); const remaining delay - (now - last); if (remaining 0) { clearTimeout(timer); timer null; last now; fn.apply(this, args); } else if (!timer) { timer setTimeout(() { last Date.now(); timer null; fn.apply(this, args); }, remaining); } }; }为什么用“时间戳 定时器”双模式因为纯时间戳版会丢最后一次触发纯定时器版会浪费第一次触发。双模式能保证“第一次立即执行最后一次也一定执行”这也是 lodash 里_.throttle的默认行为逻辑。2.2 深拷贝从“能跑”到“靠谱”的距离深拷贝是手写题里的经典也是最能加分的题。很多人写出来就是function deepClone(obj) { return JSON.parse(JSON.stringify(obj)); }这种写法在面试里只能得 30 分。它能处理简单的对象和数组但一旦遇到undefined、Symbol、function、Date、RegExp、循环引用就彻底崩了——JSON.stringify会把undefined和function直接丢弃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(key, 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); } const result Array.isArray(target) ? [] : {}; map.set(target, result); // 注意这里要区分可枚举的自有属性、Symbol 属性等 Reflect.ownKeys(target).forEach(key { const descriptor Object.getOwnPropertyDescriptor(target, key); if (descriptor descriptor.enumerable) { if (descriptor.value ! undefined) { result[key] deepClone(target[key], map); } else { Object.defineProperty(result, key, descriptor); } } }); return result; }这里几个关键点值得展开WeakMap 而不是 Map 来存映射关系WeakMap 的键是弱引用不会阻止垃圾回收这样深拷贝完成后这个临时映射可以被自动回收避免内存泄漏。Reflect.ownKeys而不是Object.keysObject.keys只能拿字符串可枚举属性拿不到Symbol类型的键而Reflect.ownKeys能拿到所有自有属性。为什么要处理描述符如果原对象的属性是只读或者不可枚举的直接赋值会丢失这些特性。用Object.defineProperty可以完整复制属性描述符。面试时你把这个版本写出来再解释清楚每个设计的理由面试官对你这题的评价会明显上一个档次。3. 高频手写题逐个击破从 call/apply 到 Promise这一节覆盖了最近面试出现频率最高的几个核心原理题。这些题都有一个共性考察你对 JS 运行时机制的真正理解而不是你对 API 名称的记忆。3.1 call / apply / bindthis 指向的本质是“给对象临时挂方法”手写call的思路其实特别朴素就一句话你要让某个函数里的this指向指定的对象那我就在这个对象上临时挂载这个函数调完再删掉。Function.prototype.myCall function(context, ...args) { context context || window; // 注意非严格模式下传 null/undefined 会指向全局 const fnSymbol Symbol(fn); // 用 Symbol 防止属性名冲突 context[fnSymbol] this; const result context[fnSymbol](...args); delete context[fnSymbol]; return result; }; Function.prototype.myApply function(context, argsArray) { context context || window; const fnSymbol Symbol(fn); context[fnSymbol] this; const result Array.isArray(argsArray) ? context[fnSymbol](...argsArray) : context[fnSymbol](); delete context[fnSymbol]; return result; };bind比call多两步返回一个新函数以及支持 new 调用如果通过new创建实例this指向实例而不是绑定的上下文。这一步很多人会漏Function.prototype.myBind function(context, ...bindArgs) { const fn this; return function newFn(...callArgs) { // 判断是否通过 new 调用如果 new.target 存在说明是 new 出来的 if (new.target) { return new fn(...bindArgs, ...callArgs); } return fn.apply(context, [...bindArgs, ...callArgs]); }; };这里new.target的判断特别关键。如果bind之后的函数被new了this应该指向新创建的实例对象而不是 context。3.2 new 操作符底层就四件事new的手写基本是送分题但它能顺带考察你对原型链的理解。标准实现function myNew(Constructor, ...args) { // 1. 创建空对象 const obj {}; // 2. 设置原型链obj 的原型指向构造函数.prototype Object.setPrototypeOf(obj, Constructor.prototype); // 3. 绑定 this 并执行构造函数 const result Constructor.apply(obj, args); // 4. 构造函数显式返回对象时以它为准否则返回 obj return typeof result object result ! null ? result : obj; }第四步是个常见陷阱。如果构造函数里写了return { a: 1 }那new出来的不是实例而是那个对象但如果返回的是基本类型比如return 1new依然会返回新建的实例。3.3 简版 Promise别再死背 A 规范全文了现在面试官大都聪明了很少让你完整写一个 Promise——那个工程量太大。更常见的是手写Promise.all和Promise.race手写一个支持then链式调用的“简版 Promise”先说高频的Promise.all。它的逻辑很直白等所有 promise 都成功才返回数组有一个失败就直接失败。实现时要注意两点保持结果顺序和输入一致用计数而不是数组长度判断是否全部完成因为异步任务不是按序结束的。Promise.myAll function(promises) { return new Promise((resolve, reject) { const results []; let count 0; if (promises.length 0) { resolve(results); return; } promises.forEach((promise, index) { Promise.resolve(promise).then(value { results[index] value; // 关键按下标赋值保证顺序 count 1; if (count promises.length) { resolve(results); } }, reject); }); }); };简版 Promise 的核心也不用做到 full A但至少要把“状态只能从 pending 到 fulfilled/rejected 且不可逆”和“then 返回新 Promise 从而支持链式调用”这两件事写出来class SimplePromise { 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 (e) { reject(e); } } then(onFulfilled, onRejected) { const promise2 new SimplePromise((resolve, reject) { // 这里略复杂完整版还要处理返回值是 Promise 的情况 const handle () { if (this.state fulfilled) { const result onFulfilled ? onFulfilled(this.value) : this.value; resolve(result); } if (this.state rejected) { const result onRejected ? onRejected(this.reason) : this.reason; reject(result); } }; if (this.state pending) { this.onFulfilledCallbacks.push(handle); this.onRejectedCallbacks.push(handle); } else { handle(); } }); return promise2; } }这里有个很关键的工程点为什么 resolved 状态的值不是直接调用onFulfilled而是包了一层handle因为要保证then回调注册时 promise 可能还没结束所以要在状态变化时统一通过回调函数触发。面试时能把这一层讲清楚已经能证明你理解 Promise 的异步队列机制了。4. 数组对象与函数式编程手写题里的“提分项”这类题单独看难度不大但它们有一个共同特征实现容易写优很难。面试官经常用这类题来考察候选人的算法基础和代码品味。4.1 数组去重的十种姿势与最优解数组去重算是面试热身题。我给个稳妥递进方案最无脑的Set解法先写上然后马上补充一个能处理对象的Map解法展示你的复杂度意识。// 基础版利用 Set 和展开运算符 const uniqueBySet arr [...new Set(arr)]; // 进阶版利用 Map 做条件去重可以按指定 key 去重 function uniqueByKey(arr, key) { const map new Map(); arr.forEach(item { const val typeof key function ? key(item) : item[key]; if (!map.has(val)) { map.set(val, item); } }); return [...map.values()]; }如果面试官继续说“不要用 Set 和 Map”你可以写一个indexOf/includes的双循环版本同时要指出复杂度是 O(n²)面试官其实最想听你分析复杂度而不是真的让你不用 API——你用但要能说明为什么这个方案好。4.2 柯里化与组合函数闭包的真实工业价值柯里化currying这道题面试官主要考察你对闭包的理解深度。标准的柯里化函数把接受多个参数的函数变换成接受单一参数的函数并且返回接受余下参数的新函数直到参数凑齐才执行。function curry(fn) { const arity fn.length; return function curried(...args) { if (args.length arity) { return fn.apply(this, args); } return function(...rest) { return curried.apply(this, args.concat(rest)); }; }; } // 用法示例 const add (a, b, c) a b c; const curriedAdd curry(add); curriedAdd(1)(2)(3); // 6我面试时遇到过候选人在这里卡了好几分钟后来发现是没搞清fn.length的含义——它指的是函数声明时形参个数而不是arguments的长度。这个细节容易踩坑值得特别提示。4.3 数组扁平化从递归到 reduce 的一行流数组扁平化也是高频题特别是现在后端数据嵌套特别深的情况下这种题有很强的现实意义。// 递归版逐层展开 function flatten(arr) { const result []; arr.forEach(item { if (Array.isArray(item)) { result.push(...flatten(item)); } else { result.push(item); } }); return result; } // 一行流用 reduce 递归 const flattenByReduce arr arr.reduce((acc, cur) acc.concat(Array.isArray(cur) ? flattenByReduce(cur) : cur), []);如果你再补一句“Infinity控制扁平化层数其实Array.prototype.flat(Infinity)可以直接做”面试官会知道你对 ES2019 的新特性也有关注。手写题的潜规则是你既可以写原生实现也要能说出对应的高级 API——两者兼备才是最佳答案。5. 场景题与框架原理别忽略的加分板块这一章节的高频题在 P2/P3 档位难度上去了但也更能体现工程能力。如果你面的是中高级岗位这部分表现好基本能锁定 offer。5.1 手写 EventBus发布订阅模式EventBus 在 Vue2 时代是跨组件通信的老熟人现在虽然官方不推荐了但手写它的逻辑仍然是理解“发布订阅”如何工作的好题。而且这个模式不只用于前端框架你在 Node.js、小程序里都能看到它的影子。class EventBus { constructor() { this.events Object.create(null); } on(event, callback) { if (!this.events[event]) { this.events[event] []; } this.events[event].push(callback); return this; // 支持链式调用 } off(event, callback) { if (!this.events[event]) return this; if (!callback) { delete this.events[event]; // 不传回调清空整个事件的所有监听器 } else { this.events[event] this.events[event].filter(fn fn ! callback); } return this; } once(event, callback) { const wrap (...args) { callback.apply(this, args); this.off(event, wrap); }; this.on(event, wrap); return this; } emit(event, ...args) { const cbs this.events[event]; if (cbs cbs.length) { // 复制一份再遍历防止执行过程中 off 导致数组变化出错 [...cbs].forEach(fn fn.apply(this, args)); } return this; } }这个题有一个容易忽略的工程细节emit时为什么要[...cbs]复制一份因为监听器执行过程中可能又会触发off删除自己如果不复制数组在遍历过程中长度变化会导致跳过一个回调或者报错。这种实用性细节面试官一听就知道你是写过的人。5.2 大文件上传里的并发控制考察异步任务调度的硬实力热词里出现了“前端使用 worker 上传大文件”说明这已经是从 2023 年开始的高频考点。手写题里常见的变体是“实现一个并发控制函数限制同时执行的异步任务数”。async function asyncPool(limit, tasks) { const results []; const executing []; for (const task of tasks) { const promise Promise.resolve().then(() task()); results.push(promise); if (limit tasks.length) { const whenComplete promise.then(() executing.splice(executing.indexOf(whenComplete), 1)); executing.push(whenComplete); if (executing.length limit) { await Promise.race(executing); } } } return Promise.all(results); }这里核心思路是维护一个executing数组记录正在执行的任务一旦达到并发上限就用Promise.race等最先结束的那个任务完成然后继续推进。面试官最爱追问的一句是“用Promise.race而不是Promise.all是为了什么”——race只要有一个完成就返回符合“腾出一个坑位”的需求而all会等到所有都完成那就失去了并发控制的意义。5.3 手写 Vue2 响应式核心defineProperty vs Proxy这一题属于典型的框架原理“减配版”。它考察的不是你对 Vue 源码的记忆而是你是否理解“数据驱动视图”这个核心黑魔法的本质。function observe(obj) { if (typeof obj ! object || obj null) return; Object.keys(obj).forEach(key { let value obj[key]; observe(value); // 递归处理嵌套对象 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { // 依赖收集 if (currentWatcher) { currentWatcher.addDep(key); } return value; }, set(newVal) { if (newVal ! value) { value newVal; // 触发更新 notify(key); } } }); }); }写到这里主动说一句“这个方案有三点不足新加属性无法拦截需要Vue.$set、数组索引无法拦截需要重写数组方法、递归只处理了已有属性。换成 Proxy 就没有这些问题。”——你的分数立刻不一样。很多候选人知道 Proxy 比 defineProperty 好但说不出具体好在哪三点这恰恰是面试官希望听到的差异化理解。6. 面试现场生存指南常见问题与排查技巧实录这一部分是我自己面试和被面过程中积累的实战经验市面上八股文大全里基本不会写这些。但它们往往比“会写代码”更能决定面试成败。6.1 现场写不出来怎么办——先说思路别鸦雀无声我带过的新人最容易犯的错拿到题没思路就闷头想屏幕上一片空白面试官也一言不发。这种沉默对面试是毁灭性的。正确做法是先说话“我先确认一下需求这个函数输入是什么输出什么需要考虑异步吗”——一句话就能给面试官留下逻辑清晰的印象。然后再把思路用中文说出来“我打算用一个定时器每次触发先清掉上一个然后再开新的。”说出思路之后你会发现自己反而不紧张了写代码的速度也会快很多。6.2 容易被追问的几个“坑点”提前背熟以下问题是我基于真实面试复盘整理出来的高频追问题每一个都值得单独练一练防抖如果同时要拿到event对象怎么办因为事件回调是同步先执行的这时event还在但setTimeout执行时event可能已被浏览器复用。解决方法是const e event先把事件对象同步存下来再传给防抖函数。深拷贝里原型上的属性要不要拷贝这是开放题。通常说“只拷贝自有属性不拷贝原型链上的属性”因为原型链上的方法通常由共享原型提供逐个拷贝会占用大量内存。Promise 里resolve之后还能throw吗不能。状态一旦从 pending 变为 fulfilled后续的所有操作都不会再改变状态。但try/catch里如果executor执行过程中抛错会走到reject。柯里化后函数自动执行怎么办如果arity为 0柯里化第一次调用就会执行。所以实现curry时要考虑fn.length 0的情况直接返回执行结果。6.3 手写题的准备节奏建议按这个节奏走稳一点我在准备面试时习惯按“三步法”推进这里分享给有需要的朋友第一遍按分类写一遍对照标准实现搞懂每一个设计点的理由。不理解的地方查 MDN 或源码而不是死记。第二遍只看题名默写模拟面试环境。默写时注意把注释和解题思路一起写出来——面试时你大概率也会一边写一边讲解要养成这个习惯。第三遍随机抽题给自己 10-15 分钟的限时。超过时间直接看答案标记薄弱项隔天再回来重写。这三步走完大部分常见手写题你基本能形成肌肉记忆面试时也就不会慌了。我个人在实际面试和面别人后最深的一个体会是手写题不只是考“会不会记代码”它更像一面照妖镜——你写代码时的思考方式、对边界条件的敏感度、讲解思路时的表达能力全部暴露无遗。所以不要把它当成背诵任务而是当成一次“最小化代码评审”来准备。每道题都问自己三个问题为什么这么写边界在哪能不能再优化这样练出来的手写能力才真正属于你自己。