news 2026/8/29 22:48:04

前端面试高频JS手写题全解析:Promise、防抖、深拷贝一网打尽

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端面试高频JS手写题全解析:Promise、防抖、深拷贝一网打尽

3月是前端跳槽的传统旺季,从2月底开始,我陆续面了几家中大型公司,前后经历了十几轮技术面试。对比去年同期的面试题,一个明显感受是:纯概念背诵题的比例在下降,手写JS编程题的占比在上升,而且题目越来越贴近实际业务场景。这篇文章没有废话,直接把我这轮面试季遇到的高频JS手写题、原理解析、现场作答思路整理出来,给正在准备或即将参加面试的同学一个“避坑指南”。

文章内容适合两类人:一是正在准备前端面试、需要集中突破手写题的候选人,二是想检验自己JS基础是否扎实的开发者。全文不涉及具体公司名称,只谈考点本身。

1. 为什么面试官热衷手写JS编程题

1.1 编程题背后考察的三层能力

面试本质上是判断一个人能不能干活、怎么干活。JS编程题之所以高频出现,是因为它在短时间内能同时考察三层能力:

第一层是基本功。闭包、原型链、事件循环、异步处理这些知识点,背概念谁都会,但落到代码里是否真的理解,一写便知。比如让写一个防抖函数,很多人能说出“n秒后执行”,但参数透传、this指向、立即执行选项这些细节,只有真写过才拿得稳。

第二层是边界意识。生产环境的代码和练习题的差异,就体现在对边界条件的处理上。面试官通常会在你写完基础版本后追加问题,比如“如果输入是空数组怎么办”“如果需要取消上一次请求怎么处理”。这些追问其实是在模拟真实开发中的需求变更和异常处理。

第三层是代码习惯。变量命名是否清晰、函数拆分是否合理、有没有写注释的习惯、代码风格统不统一,这些在二十分钟的手写过程中都会自然暴露。我见过有人在白板上写代码完全不缩进,也见过有人把一段只做一件事的代码拆成三个小函数,后者给人的信心是完全不同的。

1.2 我的高频命题观察

根据我这轮面试的统计,手写题出现频率从高到低大致是:

  1. Promise相关:手写Promise、Promise.all、Promise.race、串行控制
  2. 防抖和节流:以及它们的使用场景辨析
  3. 深拷贝:从浅拷贝一路追问到可处理循环引用的深拷贝
  4. 数组相关:手写map、reduce、flat、去重、乱序
  5. 字符串相关:判断包含关系、反转、格式化
  6. 其他:事件总线、对象扁平化、函数组合等

这些题目表面上看是“八股”,但每一道都能和实际业务挂钩。比如防抖就是搜索框输入场景,事件总线就是组件通信场景,深拷贝就是复杂数据存储场景。面试官问的不是题目本身,而是你面对这类问题时的思路。

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分钟做手写题,早上写一道新的,晚上把前一天写过的题目不看参考答案重新写一遍。这样既保持手感,也能检验是否真的记住了。

这个方法的优点是投入时间不重,考试前几天不会焦虑。缺点是坚持比较难,我建议可以找个前端的搭子互相出题互相评,效果比一个人埋头刷要好不少。

最后再分享一个小技巧:面试前一晚,不要再看新题目,而是把写过的经典代码从头到尾过一遍就行。你会发现,大部分“突然不会”的瞬间,都是因为太久没写生疏了,而不是知识真的缺失。保持稳定和自信,比什么都重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/29 22:47:40

Android转Flutter混合开发面试指南:简历技巧与核心考点精讲

这话题我太熟了。从Android转Flutter&#xff0c;再到以混合开发工程师的身份出去面试&#xff0c;这条路我完整走过。整理这份面经的时候&#xff0c;刚帮三个朋友改完简历&#xff0c;他们都拿到了面试机会&#xff0c;其中一个已经过了技术面。所以这篇文章不是复制粘贴的面…

作者头像 李华
网站建设 2026/8/29 22:46:58

前端面试手写题全解析:从防抖节流到响应式原理

不废话&#xff0c;直接进入正题。前端面试中的手写题&#xff0c;本质上不是考你背了多少 API&#xff0c;而是通过一小段代码&#xff0c;快速判断你平时写代码时有没有想过“底层发生了什么”。面试官看你手写代码&#xff0c;看的不是代码能不能跑&#xff0c;而是你的思路…

作者头像 李华
网站建设 2026/8/29 22:45:54

防蒸馏机制失效?黑盒条件下小模型如何“套出”大模型思维链

近年来&#xff0c;大模型行业一直在打两场仗&#xff1a;一场是明面上的性能军备竞赛&#xff0c;另一场是暗面上的模型攻防战。最近被社区反复讨论的一项研究工作&#xff0c;把第二场仗推到了新的高度——它绕过当前主流闭源模型的防蒸馏机制&#xff0c;让一个参数量小得多…

作者头像 李华
网站建设 2026/8/29 22:44:40

AI Agent 开发框架全景对比:LangChain、AutoGen 与 CrewAI 怎么选

这里写自定义目录标题欢迎使用Markdown编辑器一、先看本质&#xff1a;任何框架都是"五件套"的组装方式二、LangChain / LangGraph&#xff1a;生态最全的"脚手架"三、AutoGen&#xff1a;以"对话"为核心的 Agent 协作框架四、CrewAI&#xff1a…

作者头像 李华
网站建设 2026/8/29 22:43:10

被不熟的库卡住一整晚?OpenHands 这条命令装完就能上手

被不熟的库卡住一整晚&#xff1f;OpenHands 这条命令装完就能上手 【免费下载链接】OpenHands &#x1f64c; OpenHands: AI-Driven Development 项目地址: https://gitcode.com/GitHub_Trending/ope/OpenHands 深夜十一点&#xff0c;你被一个不熟的库卡住了&#xff…

作者头像 李华