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在内的所有自有属性键。很多业务场景里,对象的键可能是Symbol,Object.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-catch,Promise就会挂在那里,状态永远不会变。现场写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分钟做手写题,早上写一道新的,晚上把前一天写过的题目不看参考答案重新写一遍。这样既保持手感,也能检验是否真的记住了。
这个方法的优点是投入时间不重,考试前几天不会焦虑。缺点是坚持比较难,我建议可以找个前端的搭子互相出题互相评,效果比一个人埋头刷要好不少。
最后再分享一个小技巧:面试前一晚,不要再看新题目,而是把写过的经典代码从头到尾过一遍就行。你会发现,大部分“突然不会”的瞬间,都是因为太久没写生疏了,而不是知识真的缺失。保持稳定和自信,比什么都重要。