不废话,直接进入正题。前端面试中的手写题,本质上不是考你背了多少 API,而是通过一小段代码,快速判断你平时写代码时有没有想过“底层发生了什么”。面试官看你手写代码,看的不是代码能不能跑,而是你的思路、边界意识、对 JavaScript 语言特性的掌握程度,以及你在压力下写代码的习惯。这篇文章我结合自己面过别人和被别人面的经验,把高频手写题分成几个梯队,逐个拆解考察点、实现思路、常见翻车点,最后给出一套应试策略。内容偏实战,直接可复用。
1. 手写题背后的真实考察意图:面试官到底想看什么
先说一个很多人没想明白的问题:为什么现在前端面试几乎必考手写题?尤其是大厂,一轮技术面里至少有一两道手写题,有的甚至全程都在手写。这背后其实有一套非常清晰的筛选逻辑。
1.1 手写题是“低成本的底层能力探测仪”
面试官在短短四五十分钟内,很难通过问答判断出一个人真实的代码水平。八股文可以背,项目经验可以包装,但手写题很难装。你拿起笔(或者打开在线编辑器),从第一行代码开始,你脑子里对 API 的熟悉程度、对边界情况的预判、对代码组织的习惯,全都暴露了。比如让你手写一个debounce,有的人写完就结束了,有的人会主动追问“要不要处理 cancel?”“immediate 参数需不需要支持?”——后者在面试官心里的分数完全不一样。
手写题另一个隐形作用,是筛掉那些只是“会用框架、但是不懂语言本身”的人。现在前端开发的门槛确实低了,会 Vue 会 React 就能找工作,但很多人对 JavaScript 本身的机制理解是模糊的。手写题恰好把这个模糊地带照了出来。
1.2 手写题的难度梯度和考察维度
我面过不少候选人,也总结过自己的面试题库,前端手写题大致分为三档:
第一档:基础工具函数。比如debounce、throttle、deepClone、curry、new、call/apply/bind、instanceof。这类题考察的是对语言基础 API 的理解,以及边界情况处理。难度不大,但坑很多。
第二档:JS 核心机制实现。比如手写Promise(至少实现resolve、reject、then、catch、finally)、EventEmitter(发布订阅)、Array.prototype.reduce、JSON.stringify。这类题考察的是对异步机制、原型链、事件循环的理解深度,比第一档难不少。
第三档:框架原理相关。比如 Vue 2 的Object.defineProperty响应式、Vue 3 的Proxy响应式、简化版 Vuex/Redux、v-model的实现原理、React 的useState简化实现。这类题已经不限于是 JS 语法层面了,而是考察你有没有读框架源码的习惯,遇到问题时是“百度改一下”还是“能定位到框架底层”。
知道这三档之后,我们逐个来拆。
1.3 手写题准备的核心方法论:别背,推演
很多人准备手写题就是背代码,背一个deepClone的模板,面试时默写出来。这样做有两个问题:第一,面试官稍微变个场景你就不会了;第二,背出来的代码基本没有注释,逻辑说不清楚,面试官一追问就露馅。
正确的方法就两个字:推演。拿到一个题目,先想:这个函数是干什么的?输入是什么?输出是什么?有哪些边界情况?如果代码运行中报错了,最可能错在哪一行?把这几个问题想清楚了,代码自然就写出来了。
我比较推荐的一个方法:把每个手写题都当作一个真实的小项目来做。写之前先画输入输出,写完之后自己出几个测试用例跑一遍,再想想“如果生产环境用这个函数,会不会出问题”。用这个思路走一遍,你对这个题的理解深度绝对远超背十遍。
2. 高频基础手写题:防抖、节流、深拷贝,最容易“答得上来却写不对”
这一档是面试出现频率最高的,也是最容易让人“感觉良好但实际翻车”的。先说防抖和节流,这两个函数看着简单,但每次面试都能筛掉一批人。
2.1 防抖(debounce):为什么你的实现一用就出问题
防抖的核心思想:事件触发后,等待一段时间再执行回调;如果等待期间又触发了,则重新计时。典型场景:搜索框输入联想、窗口 resize 后的计算。
一个相对完整的实现:
function debounce(fn, wait = 300, immediate = false) { let timer = null; let isInvoked = false; const debounced = function (...args) { const context = this; if (timer) clearTimeout(timer); if (immediate && !isInvoked) { fn.apply(context, args); isInvoked = true; return; } timer = setTimeout(() => { fn.apply(context, args); isInvoked = false; timer = null; }, wait); }; debounced.cancel = function () { if (timer) clearTimeout(timer); timer = null; isInvoked = false; }; return debounced; }这个实现里最容易被忽略的是this的指向。很多人写setTimeout(() => { fn() }, wait),如果调用方是obj.method = debounce(obj.method, 300),fn里的this就丢了。所以必须用fn.apply(context, args),而且context要在闭包里保存。
immediate参数是加分项。面试官问“如果希望第一次点击立即执行,之后等待,怎么做?”你能马上说出加一个immediate参数,并且给出实现,这就是一次明确的加分。
2.2 节流(throttle):时间戳和定时器两种实现的差异
节流的核心思想:在一段时间内,无论触发多少次,只执行一次。典型场景:滚动加载更多、拖拽、游戏中的射击。
时间戳实现:
function throttle(fn, wait = 300) { let previous = 0; return function (...args) { const context = this; const now = Date.now(); if (now - previous >= wait) { fn.apply(context, args); previous = now; } }; }定时器实现:
function throttle(fn, wait = 300) { let timer = null; return function (...args) { const context = this; if (!timer) { timer = setTimeout(() => { fn.apply(context, args); timer = null; }, wait); } }; }这两者的差异在于:时间戳版本第一次触发立即执行,最后一次触发不会执行(因为冷却期内的触发被忽略了);定时器版本第一次触发要等wait后才执行,但最后一次触发会被记下来并执行。
如果面试官问“如何实现一个首尾都执行的节流”,你可以组合两者:用时间戳判断是否到了执行点,用定时器兜底最后一次触发。这种细节就是你和普通候选人拉开差距的地方。
2.3 深拷贝:这题考验的是边界意识,不是递归
深拷贝几乎是前端手写题里的“必考之王”,但很多人对它存在严重误解——以为写个递归就完事了。
浅拷贝只需要考虑一层,深拷贝则要处理多种数据类型:Date、RegExp、Map、Set、ArrayBuffer、循环引用、函数,还有Symbol作为 key 的情况。面试官不会让你全部处理完,但你能处理多少,体现的是你对 JavaScript 数据类型的理解广度。
一个生产可用的深拷贝实现(不依赖JSON.parse(JSON.stringify()),因为那个方案有严重的功能缺陷——函数会被丢掉、undefined会被丢掉、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(deepClone(key, map), 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); } // 处理 Symbol 作为 key 的情况 const result = Array.isArray(target) ? [] : {}; map.set(target, result); Reflect.ownKeys(target).forEach((key) => { result[key] = deepClone(target[key], map); }); return result; }这里有个一个关键点很多人不知道:Object.keys只能拿到字符串 key,拿不到Symbolkey。要用Reflect.ownKeys才能同时拿到字符串 key 和Symbolkey。这一手细节能直接证明你真正读过一些源码级别的代码。
WeakMap用来解决循环引用:当拷贝一个对象的属性时,如果属性值引用了父级对象,没有WeakMap就会无限递归,直到栈溢出。用WeakMap存“已经拷贝过的对象”和“拷贝后的新对象”之间的映射关系,再遇到就直接返回新对象。
深拷贝的真正难点不是递归本身,而是“你意识到了哪些需要特殊处理的类型”。面试官看到你instanceof Date、instanceof RegExp、Reflect.ownKeys、WeakMap这几个点写出来,心里基本就有底了。
3. 进阶核心机制:手写 Promise、EventEmitter、new 和 bind,理解原理比会调用更重要
第二档的手写题,已经不只是语法层面了,而是要求你理解 JavaScript 的核心运行机制。
3.1 手写 Promise:别被 then 的链式调用吓到
手写 Promise 在前端面试中的出现率极高,而且大部分候选人在这里翻车。原因很简单:很多人日常开发只用 Promise 的 API,没有想过它底层到底是怎么把异步串起来的。
一个简化但核心逻辑完整的 Promise 实现,要包含几个关键部分:状态机(pending→fulfilled/rejected)、then注册回调、异步执行回调、链式调用时 value 的穿透。
下面的实现不包含完整的 Promises/A+ 规范(那是几大页的算法),但足以在面试中展示你理解核心机制:
class MyPromise { constructor(executor) { this.state = 'pending'; // pending | fulfilled | rejected 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) { if (this.state === 'fulfilled') { onFulfilled(this.value); } if (this.state === 'rejected') { onRejected(this.reason); } if (this.state === 'pending') { this.onFulfilledCallbacks.push(() => onFulfilled(this.value)); this.onRejectedCallbacks.push(() => onRejected(this.reason)); } } }这个实现有几个关键点:第一,为什么resolve和reject要判断state === 'pending'?因为一个 Promise 从 pending 变为 fulfilled 或 rejected 后,状态就不能再变。第二,executor 里如果抛异常,要 catch 并用 reject 接收,否则错误会直接冒泡。
但这版实现是不完整的,不支持链式调用中的 promise 穿透。最常见的追问是:“如果then里返回一个新的值,下一个then怎么拿到它?”要实现真正的链式穿透,then方法里需要返回一个新的 Promise,并且把onFulfilled的返回值传进去。这就是 Promises/A+ 规范里最核心的一环。
我建议面试前至少把链式版的手写 Promise 逻辑跑通一遍,不需要背,但要把状态流转和then返回新 Promise 的设计想明白。如果实在记不住完整实现,把状态机和基本then写出来,也比交白卷好得多。
3.2 EventEmitter(发布订阅):所有框架消息机制的底座
这个手写题常见度略低于 Promise,但非常能考察代码组织能力。它的核心是一个事件名字到回调函数列表的映射,提供on、emit、off、once四个方法。
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); return this; } emit(event, ...args) { const callbacks = this.events.get(event); if (callbacks) { callbacks.forEach((cb) => cb(...args)); } return this; } off(event, callback) { const callbacks = this.events.get(event); if (callbacks) { const index = callbacks.indexOf(callback); if (index !== -1) { callbacks.splice(index, 1); } } return this; } once(event, callback) { const wrap = (...args) => { callback(...args); this.off(event, wrap); }; this.on(event, wrap); return this; } }once的实现是这题的灵魂:不能直接在off里移除原来的callback,而是要包一层wrap,执行完自动带着wrap做off。很多人写once时直接注册原函数,结果这个函数被调用后依然留在事件列表里。
有个面试中常被追问的细节:off时如果回调没传,应该清空该事件的所有回调。这个设计符合直觉,也符合很多库的 API 语义。我在实现里加了这个判断的话,通常能看出有没有真实设计过组件间通信。
3.3 手写 new 和 bind:这两题是原型链的“照妖镜”
new的手写题主要是考察你对四步流程的理解:
function myNew(Constructor, ...args) { // 1. 创建一个新对象,原型指向构造函数的 prototype const obj = Object.create(Constructor.prototype); // 2. 以 obj 为 this,执行构造函数 const result = Constructor.apply(obj, args); // 3. 如果构造函数返回了对象,则返回该对象;否则返回 obj return result instanceof Object ? result : obj; }第二步的apply是核心。obj被创建后,它的原型链已经指向了Constructor.prototype,然后以obj为this执行构造函数,给obj挂属性。返回值判断也很关键:如果构造函数显式返回了一个对象(比如return {}),那么new的结果是这个对象而不是obj。
bind的手写题核心是“柯里化 + new 优先级”:
Function.prototype.myBind = function (context, ...args) { const fn = this; return function (...innerArgs) { return fn.apply(context, [...args, ...innerArgs]); }; };但完整的bind还要考虑:如果返回的函数被当作构造函数new了,this应该绑定到新创建的对象,而不是传入的context。这个细节很多人不知道,属于“加分中的加分项”。
这两题看着简单,但对原型链和 this 绑定的理解要求极高,建议把每一行的原理都讲给面试官听,而不是闷头写完。
4. 框架相关手写:Vue 2/3 响应式、双向绑定,考察源码阅读习惯
第三档手写题现在已经越来越常见,尤其是对社招和高级岗。面试官想确认的是:你不只是一个 API 调用者,遇到奇怪 bug 时,你是能钻进框架底层去诊断的。
4.1 Vue 2 响应式核心:Object.defineProperty 的拦截逻辑
手写一个简化版 Vue 2 响应式系统,能覆盖 data 中对象的属性读取和赋值即可。核心就是Object.defineProperty结合观察者模式。
function observe(data, callback) { if (!data || typeof data !== 'object') return; Object.keys(data).forEach((key) => { let value = data[key]; observe(value, callback); // 递归监听 Object.defineProperty(data, key, { enumerable: true, configurable: true, get() { return value; }, set(newValue) { if (newValue === value) return; value = newValue; observe(newValue, callback); // 新值是对象则继续监听 callback(key, newValue); }, }); }); }这个实现里有一个大坑:递归监听必须在defineProperty之前做吗?答案是不一定,但顺序会影响行为。先把子对象递归 observe 了,再 defineProperty 父级属性,这样初始化时子属性的 getter/setter 已经挂好。如果没有递归,修改data.a.b = 2就不会被检测到,这正是 Vue 2 响应式的经典局限。
Vue 2 的响应式还有一个臭名昭著的限制:新增属性和删除属性无法被检测到,所以 Vue 2 才提供了Vue.set和Vue.delete。手写实现时如果面试官问“为什么 Vue 2 检测不到新增属性”,你直接从Object.keys(data)只看已有属性这个点解释,就非常清楚。
4.2 Vue 3 响应式核心:Proxy 为什么能拍平 Vue 2 的痛点
Vue 3 用Proxy重写响应式之后,新增/删除属性都能被拦截了,因为Proxy拦截的是对目标对象的“操作”,而不是“属性”。一个最简实现:
function reactive(target) { return new Proxy(target, { get(obj, key) { // 这里可以做依赖收集 return Reflect.get(obj, key); }, set(obj, key, value) { const result = Reflect.set(obj, key, value); // 这里可以做触发更新 return result; }, deleteProperty(obj, key) { const result = Reflect.deleteProperty(obj, key); // 同样可以触发更新 return result; }, }); }面试时重点讲清楚一个对比:Object.defineProperty是“数据劫持 + 手动递归”,Proxy是“操作代理 + 惰性收集”。Vue 3 不需要初始化时递归遍历所有属性,而是在get时按需收集依赖,所以性能更好、内存占用更低。
Reflect.get有个容易忽略的细节:它能让get操作保持正确的this指向。如果直接return obj[key],在一些 getter 场景下this会指向obj而不是proxy,可能造成依赖收集错乱。用Reflect就是为了保证 this 语义正确。
4.3 手写 v-model 的场景变体:面试官常从使用场景切入
还有一类手写题不完全考框架原理,而是考你“用框架的思维解决实际问题”。比如常见的一个:“用 Vue 2 实现一个手写电子签名组件,并且把它封装成 v-model 的形式”。这类题很典型,热搜词里也有“vue2实现手写电子签名功能”。
这类题的难点有两个:一是怎么在 Canvas 上处理鼠标事件生成笔迹,二是怎么把“签名绘制完成”这个事件暴露为 v-model 能接收的input事件。组件内部大概是这样:
<template> <canvas ref="canvas" @mousedown="startDraw" @mousemove="drawing" @mouseup="endDraw" /> </template> <script> export default { props: { value: { type: String, default: '' }, }, methods: { startDraw(e) { this.isDrawing = true; // 设置 canvas 起始点 this.ctx.beginPath(); this.ctx.moveTo(e.offsetX, e.offsetY); }, drawing(e) { if (!this.isDrawing) return; this.ctx.lineTo(e.offsetX, e.offsetY); this.ctx.stroke(); }, endDraw() { this.isDrawing = false; this.$emit('input', this.canvas.toDataURL()); }, }, }; </script>endDraw时把 Canvas 内容导出为dataURL,通过$emit('input', dataURL)传给父组件。父组件用v-model="signature",signature就被更新为图片数据。这就是 v-model 在自定义组件上的本质:value属性 +input事件。
如果你还能主动提一句“签名组件需要考虑 Canvas 的 DPR 适配,不然在高分屏上会模糊”,面试官就知道你真的在移动端或者大屏适配上行过,水平完全不一样。
5. 实际面试中的手写策略:从“会写”到“拿高分”的五个细节
手写题能写对,和能拿高分,往往是两件事。我面过一些候选人,代码一运行就通过,但最后评分并不高;也见过代码没写完,但给了不错评价的。差异在哪?
5.1 先讲思路再动手,让面试官跟上你的节奏
拿到手写题,不要立刻埋头写代码。先花 30 秒到 1 分钟,把思路讲出来:“这个函数输入是一个数组、一个回调函数,输出是一个新数组;我会先声明一个新数组,然后遍历原数组,对每个元素调用回调,根据返回结果决定是否 push。”
这样做有三个好处:第一,你自己理清了思路,写代码更快;第二,面试官知道你在想什么,即使没写完,他也知道你是会做的;第三,如果思路错了,面试官可能会中途纠正你,避免你写完一大段才发现方向错了。
5.2 用注释标注你的关键思考和边界处理
在代码里写注释,尤其是边界情况的注释,是给面试官的重要信号。比如写深拷贝时,在if (map.has(target)) return map.get(target)旁边写一句“解决循环引用”,面试官一眼就看到了你的边界意识。写防抖时在if (timer) clearTimeout(timer)旁写“重新计时”,也可以。不要让面试官去猜你的代码意图,注释就是你和面试官之间的低成本沟通渠道。
5.3 主动补充优化方案和追问场景
写完一个版本后,不要停下来等面试官问。主动说:“这个实现还有一个缺陷,比如 XXX,如果需要,我可以再补一下。”这个行为传递的信息是:我有完整的技术方案,不只是会写这一个函数。比如防抖写完,主动提“目前immediate参数没有加,如果要求第一次立即执行,需要额外判断”;深拷贝写完,主动提“目前没有处理WeakMap,递归函数本身有内存压力,不过因为只用一次,问题不大”。
5.4 时间分配:宁可写核心,不要卡在边角
手写题通常限时 10-15 分钟。如果你的实现在某个边界处理上卡住了(比如想不起Reflect.ownKeys,或者记不住WeakMap的用法),别死磕,先把核心逻辑写出来。面试官看的是主体架构,边角可以口述补全。我见过很多候选人为了“写全”导致主体逻辑都没写完,这是舍本逐末。
5.5 复盘时把自己当面试官
每次面完手写题(或者自己练完手写题),录个音或者回忆一下:我写的时候卡在哪一行?是因为什么卡住的?是 API 不熟,还是思路混乱?把这些问题记下来,逐个解决。手写题的题量是有限的,高频题就那么多,每一题都做到“能讲清楚为什么”的程度,面试基本上就没问题了。
6. 手写题清单:从高频到低频的优先级排序
最后给一份我认为值得花时间练的清单,按优先级排:
第一优先级(必练)
debounce/throttledeepClone(包含循环引用、Date、RegExp)new/call/apply/bind- 手写 Promise(至少一版状态机完整版)
EventEmitter- Vue 2 / Vue 3 响应式核心
Array.prototype.map/reduce
第二优先级(高频)
instanceofJSON.stringify简化版- 数组去重(至少三种方案)
- 字符串相关(翻转、截断、模板字符串解析)
- 函数柯里化
curry - 防抖和节流在 React/Vue 组件中的使用场景实现
第三优先级(场景大题)
- 手写电子签名(结合 Canvas 和 v-model)
- 手写一个简化版 Vuex(
state+commit+dispatch) - 手写一个简化版懒加载(
IntersectionObserver) - 手写一个大文件上传的分片逻辑(结合 Blob、
File.slice、并发控制)
每一道题都要给到自己追问扩展的空间,比如“如果数组有 10 万个元素,reduce的实现会有什么性能问题?”“如果深拷贝的对象有Symbolkey,Object.keys能拿到吗?”把这些扩展问题想透了,面试时你就不是“背题的”,你是真的懂。
手写题不是目的,它只是一个认知过滤器。真正重要的是你写代码时是否具备“输入是什么、输出是什么、边界在哪、会不会爆栈、会不会丢 this”这套系统性的思维习惯。把这道题练扎实了,收益不止是面试通过,而是你日常开发写出的代码也会更稳。