前端面试高频JS手写题全解析:Promise、防抖、深拷贝一网打尽 3月是前端跳槽的传统旺季从2月底开始我陆续面了几家中大型公司前后经历了十几轮技术面试。对比去年同期的面试题一个明显感受是纯概念背诵题的比例在下降手写JS编程题的占比在上升而且题目越来越贴近实际业务场景。这篇文章没有废话直接把我这轮面试季遇到的高频JS手写题、原理解析、现场作答思路整理出来给正在准备或即将参加面试的同学一个“避坑指南”。文章内容适合两类人一是正在准备前端面试、需要集中突破手写题的候选人二是想检验自己JS基础是否扎实的开发者。全文不涉及具体公司名称只谈考点本身。1. 为什么面试官热衷手写JS编程题1.1 编程题背后考察的三层能力面试本质上是判断一个人能不能干活、怎么干活。JS编程题之所以高频出现是因为它在短时间内能同时考察三层能力第一层是基本功。闭包、原型链、事件循环、异步处理这些知识点背概念谁都会但落到代码里是否真的理解一写便知。比如让写一个防抖函数很多人能说出“n秒后执行”但参数透传、this指向、立即执行选项这些细节只有真写过才拿得稳。第二层是边界意识。生产环境的代码和练习题的差异就体现在对边界条件的处理上。面试官通常会在你写完基础版本后追加问题比如“如果输入是空数组怎么办”“如果需要取消上一次请求怎么处理”。这些追问其实是在模拟真实开发中的需求变更和异常处理。第三层是代码习惯。变量命名是否清晰、函数拆分是否合理、有没有写注释的习惯、代码风格统不统一这些在二十分钟的手写过程中都会自然暴露。我见过有人在白板上写代码完全不缩进也见过有人把一段只做一件事的代码拆成三个小函数后者给人的信心是完全不同的。1.2 我的高频命题观察根据我这轮面试的统计手写题出现频率从高到低大致是Promise相关手写Promise、Promise.all、Promise.race、串行控制防抖和节流以及它们的使用场景辨析深拷贝从浅拷贝一路追问到可处理循环引用的深拷贝数组相关手写map、reduce、flat、去重、乱序字符串相关判断包含关系、反转、格式化其他事件总线、对象扁平化、函数组合等这些题目表面上看是“八股”但每一道都能和实际业务挂钩。比如防抖就是搜索框输入场景事件总线就是组件通信场景深拷贝就是复杂数据存储场景。面试官问的不是题目本身而是你面对这类问题时的思路。2. 高频编程题逐一拆解与手写实现2.1 Promise相关从手写Promise到串行控制Promise相关题目几乎是我这轮面试的“必考题”十次面试里有七次都问到。最基础的版本是手写一个满足基本功能的Promise核心要解决三件事状态管理、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) { const promise2 new MyPromise((resolve, reject) { if (this.state fulfilled) { setTimeout(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (e) { reject(e); } }); } // 省略 pending / rejected 分支的类似处理 }); return promise2; } }这里有个我踩过的坑then方法里返回的是一个新的Promise这个“穿针引线”的设计是用来支持链式调用的核心。很多人写到这里就停了导致链式调用失效。另外onFulfilled和onRejected最好用setTimeout包裹一下模拟微任务的时序严格来说应该是微任务但面试中能用setTimeout说明异步思路通常也能接受。Promise.all是另一个高频追问点。它的核心逻辑是接收一个Promise数组全部成功才返回结果数组有一个失败就直接走reject。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); }); }); };关键点在于results[index] value必须用索引赋值不能用push。否则有一个Promise先返回结果数组的顺序就乱掉了。2.2 防抖与节流别只写基础版防抖和节流是面试官非常喜欢追问细节的题目因为他们想知道你究竟理解了多少而不只是背了个模板。防抖的核心思想是“在事件触发n秒后再执行回调如果n秒内再次触发则重新计时”适用于输入框搜索、窗口resize这类频繁触发但只需要最终状态的场景。function debounce(fn, wait 500, immediate false) { let timer null; return function (...args) { const callNow immediate !timer; clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) { fn.apply(this, args); } }, wait); if (callNow) { fn.apply(this, args); } }; }节流的思路是“保证在一个时间窗口内只能触发一次”适用于滚动加载、按钮重复点击这类需要控制频率的场景。function throttle(fn, wait 500) { let lastTime 0; return function (...args) { const now Date.now(); if (now - lastTime wait) { lastTime now; fn.apply(this, args); } }; }以上是基础版本但面试现场大概率会被追问两个问题第一个是是否支持首次立即执行第二个是是否支持取消操作比如频繁点击搜索按钮希望第一次立即触发连续点击之后重置同时提供一个取消方法。我在回答时会补充取消能力function debounce(fn, wait 500, immediate false) { let timer null; const debounced function (...args) { const callNow immediate !timer; clearTimeout(timer); timer setTimeout(() { timer null; if (!immediate) { fn.apply(this, args); } }, wait); if (callNow) { fn.apply(this, args); } }; debounced.cancel function () { clearTimeout(timer); timer null; }; return debounced; }看面试官的反应这个补充基本都能带来正向反馈。它能说明你考虑到了实际业务中“组件卸载后要清理定时器”的场景。2.3 深拷贝循环引用和特殊类型是分水岭深拷贝几乎是必考题。基础版容易写但真正拉开差距的是后面的追问。浅拷贝和深拷贝的区别相信准备过面试的同学都清楚这里直接看实现。常规递归实现function deepClone(obj, map new WeakMap()) { if (obj null || typeof obj ! object) { return obj; } if (map.has(obj)) { return map.get(obj); } const result Array.isArray(obj) ? [] : {}; map.set(obj, result); for (const key of Reflect.ownKeys(obj)) { result[key] deepClone(obj[key], map); } return result; }这里必须说清楚两个设计决策第一个是用WeakMap而不是Map来解决循环引用。WeakMap的键是弱引用不会阻止垃圾回收而且当对象被回收时对应的项也会自动删除。我用const obj { a: 1 }; obj.self obj;这一步来验证循环引用场景如果不用WeakMap记录递归就会无限循环下去直接爆栈。第二个是用Reflect.ownKeys而不是Object.keys。Reflect.ownKeys会返回包括Symbol在内的所有自有属性键。很多业务场景里对象的键可能是SymbolObject.keys会漏掉。面试官还会追问Date、RegExp、Map、Set这些特殊类型。如果只是做通用深拷贝这些类型会被克隆成普通对象明显不行。这里给一个增强版本的片段if (obj instanceof Date) { return new Date(obj.getTime()); } if (obj instanceof RegExp) { return new RegExp(obj.source, obj.flags); } if (obj instanceof Map) { const result new Map(); map.set(obj, result); obj.forEach((value, key) { result.set(key, deepClone(value, map)); }); return result; } if (obj instanceof Set) { const result new Set(); map.set(obj, result); obj.forEach((value) { result.add(deepClone(value, map)); }); return result; }我遇到的实际项目中需要深拷贝的数据往往就是从后端接口拿到的JSON数据基本不涉及Date、RegExp这些类型。所以判断一下场景也很重要如果面试官没有要求完整版可以先讲清楚处理思路再落到代码上。2.4 数组和字符串操作基础题里的隐藏陷阱数组和字符串相关题目看起来比Promise简单但恰恰是这些基础题最容易暴露程序员对原生方法的理解是否到位。手写map是最常见的关键是清楚“回调函数接收三个参数当前值、索引、原数组”以及“回调函数的返回值会组成新数组”Array.prototype.myMap function (callback, thisArg) { const result []; for (let i 0; i this.length; i) { if (i in this) { result[i] callback.call(thisArg, this[i], i, this); } } return result; };数组去重、扁平化这类题目我反而建议直接说明原生API的优劣再补充手写版本。比如Array.from(new Set(arr))去重最简单但没法处理对象类型的去重arr.flat(Infinity)扁平化非常方便但会去掉数组的稀疏位。字符串相关题目里有一个高频考点是“判断字符串是否包含某个子串”。最直接的是includes方法但面试官会追问如果不能用includes你还能怎么实现这时可以考虑用indexOf、正则、或者自己遍历匹配。现场写一个简单的遍历版本function contains(str, sub) { if (sub.length 0) return true; if (sub.length str.length) return false; for (let i 0; i str.length - sub.length; i) { if (str.startsWith(sub, i)) { return true; } } return false; }利用startsWith的第二个参数代码简洁且正确性有保证。这类题目本身不难但现场能快速想到边界处理和多种实现方式会给面试官留下思维灵活的印象。3. 现场写题节奏把控与常见坑3.1 拿到题目的四步思考法面试现场和平时刷题最大的区别是“有压力、有时间限制”。我总结了一套做题节奏经过多轮验证效果比较稳定。第一步确认需求。面试官说“实现一个防抖函数”时不要立刻动笔先问清楚需要支持立即执行吗需要支持取消吗作用域和参数需要透传吗这些都是面试官心里有数但不会主动说明的条件。第二步画输入输出。在心里想一个具体例子比如debounce(fn, 500)()调用两次预期结果是什么。这一步能快速验证思路避免写着写着跑偏。第三步先写核心逻辑。把最主要的骨架写出来先保证功能正确再考虑边界和优化。不要一上来就追求最完整的版本把基础版本写出来再跟面试官说明“如果要支持取消可以这样补充”这样更有条理。第四步主动测试。写完以后自己说一组测试用例口头验证输出是否符合预期。这个动作非常重要很多面试官看了你主动测边界就直接默认你有测试意识。3.2 我亲眼见过和亲身踩过的坑几个高频错误我总结一下基本都能帮你在面试中少丢分第一个坑是this指向丢失。这个在防抖、节流、事件绑定里特别常见。我见过有人写防抖函数时内部直接fn(...args)然后说“这里this应该没问题吧”。实际上在Vue或React组件里这个this已经丢了。我在写这类函数时会习惯性地用fn.apply(this, args)透传this这个细节面试官基本都会注意。第二个坑是Promise回调里漏了try-catch。在一个then回调里如果onFulfilled里抛了异常理论上应该进reject分支如果漏了try-catchPromise就会挂在那里状态永远不会变。现场写Promise时我会习惯性地把executor和then回调都包一层try-catch。第三个坑是深拷贝里遇到递归函数。深拷贝函数本身在处理对象时如果对象属性里有函数常规的递归拷贝会把函数变成普通对象属性直接失效。我在面试中遇到过这个追问当时也愣了一下。后来我养成了习惯深拷贝只处理纯数据对象遇到函数直接原样返回。第四个坑是说了不能再犯的错直接把原生方法写在Array.prototype上没有加Object.defineProperty设置为不可枚举。写手写题时面试官默认你在演示实现思路但如果你加了一句“实践里要配置enumerable为false”会显得更有工程意识。3.3 沟通比代码本身更值钱这个话题容易被忽视但实际面试中非常关键。手写题的十五分钟本质上是一场“结对编程模拟”面试官不仅看代码还看你怎么沟通问题、怎么阐述思路。我建议在动笔前用一分钟把思路说清楚“我会先用一个闭包保存状态然后返回一个新函数在新函数里处理计时逻辑。”这样面试官跟着你的思路走即便中间有小的疏漏也能看出你整体方向是对的。遇到卡壳时不要沉默。先把自己想到的方案说一半再问“您希望我继续沿这个方向还是换一种思路”。大多数面试官愿意给提示因为他们的目的不是难倒你而是观察你在卡壳时的反应能力。4. 备考路线怎么刷才不算白刷4.1 按专题刷而不是按题量刷很多人准备手写题的方式是“LeetCode刷了几百道”但面试中真正需要的不是算法强度而是对JS语言特性的熟悉程度。我建议按专题去准备每一类写熟几个典型题目比盲目追求数量更有效。我自己的专题清单是异步专题手写Promise、Promise.all、Promise.race、串行请求、并发控制函数专题防抖、节流、柯里化、组合函数对象专题深拷贝、对象扁平化、Object.create实现数组专题map/filter/reduce实现、去重、乱序、扁平化类与继承手写call/apply/bind、模拟new、寄生组合式继承每个专题写熟两三个题面试基本就够用了。4.2 复盘比新刷更重要每次模拟练习后我都会问自己三个问题我卡在哪一步说明哪个知识点理解不到位下次遇到同类问题怎么避免比如我第一次写Promise.all时压根没想过Promise.resolve(promise)这一步后来复盘才意识到入参并不保证都是Promise实例可能是普通值甚至可能是thenable对象。用Promise.resolve包装一下就能保证统一处理。这样的复盘才能提升能力。4.3 时间安排每天保持手感我备考期间每天抽出30到45分钟做手写题早上写一道新的晚上把前一天写过的题目不看参考答案重新写一遍。这样既保持手感也能检验是否真的记住了。这个方法的优点是投入时间不重考试前几天不会焦虑。缺点是坚持比较难我建议可以找个前端的搭子互相出题互相评效果比一个人埋头刷要好不少。最后再分享一个小技巧面试前一晚不要再看新题目而是把写过的经典代码从头到尾过一遍就行。你会发现大部分“突然不会”的瞬间都是因为太久没写生疏了而不是知识真的缺失。保持稳定和自信比什么都重要。