尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

前端面试手写代码题全解析:从防抖节流到Promise与深拷贝

前端面试手写代码题全解析:从防抖节流到Promise与深拷贝 “铜九铁十”这个词这两年在前端圈子里越来越流行。以前是“金三银四”现在是“铜九铁十”大环境不好招聘名额缩水但该面的试一场没少。有一类题几乎每场面试都躲不掉那就是手写代码题。你可能会想现在开发都用框架、组件库谁还手写这些底层方法但面试官让你手写考察的从来不是你能不能在生产环境里用这个实现而是看你对 JS 语言特性的理解深度、代码的严谨性以及在压力下能不能快速写出无 bug 的代码。这背后反映的是你的基本功和工程素养。这篇博文我结合最近这几个月帮朋友模拟面试、以及自己复盘的真实面经把前端面试里出现频率最高、最值得反复打磨的手写代码题做了个系统性梳理。不是简单丢一堆答案而是带着你一步步拆解面试官为什么考这个、核心难点在哪、有哪些隐藏的坑、怎么写才能加分。无论你是准备跳槽还是刚入行想打牢基础这都能当一份备考手册。1. 内容整体设计与思路拆解面试里的手写代码题风格差异特别大。有的面试官喜欢考“实现一个防抖节流”有的喜欢考“手写一个 Promise.all”还有的喜欢考“深拷贝”。但如果你深入拆解会发现这些题背后有一个统一的底层逻辑通过一个看起来简单、但内部细节极多的题目快速判断你平时写代码是“能用就行”还是“真正理解原理”。1.1 为什么是“手写”而不是“原理问答”很多候选人能把“闭包”“原型链”讲得头头是道但一让他现场写代码就卡壳。原因很简单口述原理是记忆层面的东西手写代码是逻辑综合层面的东西。面试官要的不仅是“你知道”更是“你能用它解决问题”。比如说面试官问“你了解防抖吗”你背一段定义就能过关。但面试官说“手写一个防抖函数并且要支持立即执行”的时候你瞬间就要考虑this指向怎么处理参数怎么透传立即执行参数immediate怎么实现定时器如何清理这些细节一旦揉在一起很多人的思路就乱了。这就是手写题的价值所在——它像一面照妖镜基础扎不扎实一目了然。另外手写题还能考察你的代码洁癖。一个函数写完后有没有考虑边界情况是不是写了几行就开始堆 if变量命名是否清晰在真实项目中这些都是 code review 的核心关注点面试官顺手就一起考察了。1.2 手写题的两大考察方向我把前端手写题归纳成两大方向理解了这两个方向你在准备时就能抓住主线而不是盲目背题。方向一语言 API 的复现这类题目要求你用原生 JS 重新实现某个内置方法或常见库函数。比如Array.prototype.map、Promise.all、JSON.stringify、new操作符、Object.create等。这类题目考察的是你对语言本身的理解比如方法的参数有哪些返回值是什么空数组/空对象/边界值的表现是否和内置方法一致是否处理了异常和非法输入方向二业务场景的抽象这类题目给你一个具体的交互场景比如“搜索框输入节流”“大数据列表分批渲染”“表单校验联动”“上传文件分片”。你需要通过代码实现一个可复用的功能模块。这类题考察的是你的工程抽象能力面试官希望看到你能把业务需求转化成清晰的代码结构。两类题目各有各的复习方法。方向一需要多背多写形成肌肉记忆方向二需要多思考场景的边界把思维能力练上去。后续的具体拆解我会把这两类方向穿插着讲帮你把知识网络织起来。2. 高频必写题一数组、对象与深拷贝数组和对象是前端开发每天打交道最多的数据类型。面试官最喜欢从这两个方向出题因为几乎不存在候选人没写过数组方法的场景但能把边界情况写明白的候选人真的不多。2.1 数组去重、扁平化的多种解法“数组去重”听起来特别基础但面试官会像剥洋葱一样一层层追问看你到底会几种写法每种写法的适用场景是什么。我记得有一次面试官问我“实现一个数组去重。”我很快就写出了[...new Set(arr)]。面试官点了点头接着问“那如果数组里是对象呢两个内容相同的对象算不算重复”这就是在考察你对Set去重原理的理解了。Set去重用的是SameValueZero比较规则两个不同引用的对象哪怕内容一样也不会被视为重复。再追问一层“如果要求保留首次出现的元素并且不能用Set怎么写”这就是经典的 indexOf 解法function unique(arr) { return arr.filter((item, index) arr.indexOf(item) index); }核心逻辑是利用indexOf返回的是第一个匹配项的索引如果当前元素的索引和indexOf返回的索引一致说明是首次出现予以保留。这个方法能过但时间复杂度是 O(n²)面试官如果问性能优化你可以接着答“用对象做哈希表空间换时间”。一层层剥下去这道基础题能聊十分钟。数组扁平化也是同样的套路。基础版是递归实现function flatten(arr) { let result []; for (let item of arr) { if (Array.isArray(item)) { result result.concat(flatten(item)); } else { result.push(item); } } return result; }进阶版是控制扁平化层数深度再进阶就是利用reduce改写最后面试官可能会问“数组扁平化和手写flat方法有什么区别”这就要你注意到Array.prototype.flat在遇到空位时的特殊处理逻辑。每一层追问背后都是在考察你有没有真正写过、研究过而不是背了一段答案就完事。2.2 深拷贝从 JSON.stringify 到递归实现深拷贝是手写题里当之无愧的“压轴常客”几乎每一轮技术面都可能遇到。很多候选人的第一反应是const clone JSON.parse(JSON.stringify(obj));这样写不是不行但面试官一定会追问“JSON 方式的局限是什么”如果答不上来这道题基本就挂了一半。JSON.stringify深拷贝的局限我列一下你对照着检查自己能不能说全无法处理undefined、Symbol、function这些值会被直接省略或转为null无法处理循环引用会直接报错Converting circular structure to JSONDate对象会被转成字符串而不是保留原类型RegExp、Map、Set等特殊对象无法正确处理NaN和Infinity会被转成null。所以面试的时候我会建议你不要主动提 JSON 方案直接手写一个递归深拷贝效果会好很多。一个能打的分层深拷贝长这样function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } // 处理循环引用 if (map.has(target)) { return map.get(target); } // 处理 Date、RegExp if (target instanceof Date) { return new Date(target); } if (target instanceof RegExp) { return new RegExp(target.source, target.flags); } // 处理 Map if (target instanceof Map) { const result new Map(); map.set(target, result); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } // 处理 Set if (target instanceof Set) { const result new Set(); map.set(target, result); target.forEach((value) { result.add(deepClone(value, map)); }); return result; } // 处理数组和普通对象 const result Array.isArray(target) ? [] : {}; map.set(target, result); // Reflect.ownKeys 可以拿到 Symbol 类型的 key Reflect.ownKeys(target).forEach((key) { result[key] deepClone(target[key], map); }); return result; }这里有两个容易遗漏的点我重点说一下。第一是循环引用必须用 WeakMap 来解决。为什么不用 Map因为 WeakMap 的 key 是弱引用当原对象被垃圾回收后WeakMap 里的记录也会被自动回收不会造成内存泄漏。虽然对于一次深拷贝来说差别不大但这个细节能看出来你是“背代码”还是“理解原理”。第二是遍历 key 时要用Reflect.ownKeys。它能拿到字符串 key 和 Symbol key而Object.keys拿不到 Symbol 属性。这一步加上去深拷贝的完整性就上了一个档次。面试官看到你写Reflect.ownKeys大概率会眼前一亮。2.3 手写 call、apply、bindthis 指向的本质如果说深拷贝是“压轴常客”那么手写call、apply、bind就是“入门必考”。这三兄弟在前端面试中的出现频率极高因为this指向本来就是 JS 最让人头疼的知识点之一而这三方法恰恰是控制this指向的核心工具。当年我准备面试的时候把这三个方法手写了好几遍写到后来闭着眼都能默出来。但现在回顾真正让我“开窍”的是理解了它们的本质所有 JS 函数内的this都是在函数被调用的那一刻决定的谁能拿到对象的方法并调用它this就指向谁。顺着这个思路实现call就特别简单了Function.prototype.myCall function(context, ...args) { // 处理边界情况没传 context 时默认指向 window或 globalThis context context ?? globalThis; // 用 Symbol 创建一个唯一 key避免覆盖原对象属性 const fnKey Symbol(fn); context[fnKey] this; // 通过对象调用方法this 就指向了 context const result context[fnKey](...args); // 删掉临时挂载的属性 delete context[fnKey]; return result; };实现apply只差一个参数形式的区别Function.prototype.myApply function(context, args []) { context context ?? globalThis; const fnKey Symbol(fn); context[fnKey] this; const result context[fnKey](...args); delete context[fnKey]; return result; };而bind的不同点在于它返回一个新函数而不是立即执行并且支持参数柯里化也就是第一次传一部分参数调用返回的新函数时再传另一部分参数。Function.prototype.myBind function(context, ...bindArgs) { const fn this; return function(...callArgs) { return fn.apply(context, [...bindArgs, ...callArgs]); }; };这个版本能解决 90% 的面试场景。但如果面试官是“细节控”可能会追问“如果用new调用绑定后的函数this应该指向实例而不是 context”。这时候就要考虑兼容new的写法了属于加分项。我的建议是先写出基础版本跑通再拓展new场景。3. 高频必写题二Promise 与异步控制如果你是最近两年准备前端面试异步编程这部分几乎是绕不开的大山。从setTimeout模拟Promise到手写Promise.all再到async/await的实现原理面试官对异步的考察是层层递进的。3.1 手写 Promise从 A 规范到基础实现“手写一个 Promise” 可能是前端手写题里的天花板之一。这道题代码量大、状态流转复杂、边界情况多。很多候选人看到这道题就直接放弃了但事实上面试官很少要求你完整写出一个符合 Promise/A 规范的实现。更常见的是面试官让你“简单实现一个支持链式调用的 Promise”或者在手写Promise.all的过程中顺带考察你对 Promise 原理的理解。不过我还是建议你把 Promise 的核心实现过一遍。不是因为面试一定考而是当你亲手实现过一次你对.then、.catch、微任务、状态不可变这些概念的理解会从“纸面认识”变成“肌肉记忆”。一个简化版的核心 Promise 实现逻辑是这样的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) { onFulfilled typeof onFulfilled function ? onFulfilled : (value) value; onRejected typeof onRejected function ? onRejected : (reason) { throw reason; }; const nextPromise new MyPromise((resolve, reject) { if (this.state fulfilled) { setTimeout(() { try { const result onFulfilled(this.value); resolve(result); } catch (err) { reject(err); } }, 0); } // pending / rejected 分支类似这里省略 }); return nextPromise; } static resolve(value) { return new MyPromise((resolve) resolve(value)); } static reject(reason) { return new MyPromise((_, reject) reject(reason)); } }这里用setTimeout模拟微任务并不是严格意义上的 Promise 行为只是为了在解释实现原理时清晰易懂。如果你要深入研究可以再去查一查queueMicrotask或者用一个MutationObserver来模拟微任务的执行时机。我个人认为手写 Promise 的核心价值不在代码能不能跑通而在于你写完以后能回答出下面几个问题为什么 Promise 的状态一旦从pending变为fulfilled或rejected后就不可再变.then返回的为什么是一个新的 Promise而不是this微任务的引入是为了解决什么问题它和宏任务有什么关系Promise.resolve传入一个 Promise 对象时会直接返回该对象吗如果这四个问题你都能脱口而出手写 Promise 这一题你就算真正拿下了。3.2 Promise.all、Promise.race 等静态方法的实现细节相比手写完整 Promise手写Promise.all和Promise.race的性价比要高得多。代码量小逻辑清晰面试官既能考察你的异步编程思维又不会让你陷入 Promise/A 规范的海量细节。Promise.all的行为大家应该不陌生接收一个 Promise 数组也可以是可迭代对象等所有 Promise 都成功时返回结果数组任何一个失败立即返回失败。面试中写Promise.all我会用这个版本作为基础Promise.myAll function(promises) { return new Promise((resolve, reject) { const results []; let count 0; const total promises.length; if (total 0) { resolve([]); return; } promises.forEach((promise, index) { // 注意用 Promise.resolve 包一层 // 让 myAll 可以接收非 Promise 值 Promise.resolve(promise).then( (value) { results[index] value; count; if (count total) { resolve(results); } }, (reason) { reject(reason); } ); }); }); };写这个函数有几个关键点几乎也是面试官盯着看的点一是必须用Promise.resolve(promise)包一层。因为调用方可能传入的不是 Promise 而是普通值Promise.resolve会把普通值直接转成 resolved 状态的 Promise保证代码兼容性。如果漏掉这层传入普通值时.then就会报错。二是结果数组需要用index赋值。如果直接用results.push(value)一旦前面的 Promise 比较慢后完成的 Promise 结果会顶到前面最终结果顺序就错乱了。三是空数组的处理要放在最前面。Promise.all([])应该直接 resolve 一个空数组避免因为total 0永远无法触发count total的条件。Promise.race的实现则更短核心逻辑就是“谁先完成就听谁的”Promise.myRace function(promises) { return new Promise((resolve, reject) { promises.forEach((promise) { Promise.resolve(promise).then(resolve, reject); }); }); };这里所有 Promise 共用同一个resolve和rejectJS 是单线程的第一个调用的resolve/reject生效后续调用直接失效天然实现了 race 的效果。面试官如果问“为什么不需要像myAll那样记录状态”这就是答案。3.3 手写事件总线 EventEmitter发布订阅的经典场景如果你面的公司业务里有大量复杂交互面试官就会喜欢问“手写一个 EventEmitter”。它可以说是发布订阅模式在前端里最典型的应用。为什么面试官爱考这个因为在 React 或 Vue 的项目里跨组件通信、兄弟组件通信、模块解耦很多场景本质上都是事件机制。你如果能手写一个 EventEmitter说明你不只会用框架的 API还能理解背后的设计思想。一个核心实现如下class EventEmitter { constructor() { // 存储事件名到回调函数数组的映射 this.events {}; } on(event, callback) { if (!this.events[event]) { this.events[event] []; } this.events[event].push(callback); return this; // 返回 this 支持链式调用 } once(event, callback) { const wrapper (...args) { callback(...args); this.off(event, wrapper); }; this.on(event, wrapper); return this; } off(event, callback) { if (!this.events[event]) return this; this.events[event] this.events[event].filter((fn) fn ! callback); return this; } emit(event, ...args) { if (!this.events[event]) return; // 遍历副本防止回调中修改原数组导致遍历异常 [...this.events[event]].forEach((callback) { callback(...args); }); } }这里有个细节容易被忽略emit遍历时要拷贝一份回调数组。想象一下如果某个回调内部执行了off把自己移除原数组在遍历中发生了变化会导致后面的回调丢失或者索引错乱。拷贝一份副本再遍历就能规避这类问题。另外once的实现也不难。它包了一层wrapper先执行原回调再主动触发off移除。但如果你直接写this.off(event, callback)是无效的因为注册进事件数组的是wrapper而不是原始callback。我在面试中见过不少候选人在这个点上翻车所以专门提醒一下。4. 高频必写题三防抖节流与函数柯里化防抖和节流可能是我见过的在手写题里“区分度”最高的一对兄弟。你说它难吧网上的答案一大把你说它简单吧能把this指向、参数透传、取消防抖这些细节全部写对的人真不多。4.1 防抖基础版到带立即执行版防抖的场景大家很熟输入框搜索、窗口 resize、按钮提交。原理就是在事件被连续触发时只有最后一次触发后的等待时间结束才执行目标函数。基础版实现function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() { fn.apply(this, args); timer null; }, delay); }; }注意我用了fn.apply(this, args)这个this是返回的包装函数被调用时的this通常对应触发事件的 DOM 元素。很多候选人在这个位置直接写fn(...args)虽然功能上没问题但this指向就丢了。如果目标函数内部依赖this获取元素的信息就会出错。如果要做一个带立即执行版本的防抖就是“第一次触发时立即执行后续连续触发只重置定时器”实现会再复杂一点function debounce(fn, delay 300, immediate false) { let timer null; return function(...args) { const callNow immediate !timer; if (timer) { clearTimeout(timer); } timer setTimeout(() { timer null; }, delay); if (callNow) { fn.apply(this, args); } }; }这个版本的思路是immediate为真且定时器不存在时说明是第一次触发立即执行。后面每次触发都重置定时器但不清空callNow的判断结果因为定时器一直存在。直到最后一次触发后等了delay毫秒timer被置为null下次触发又会被当成“第一次”。我在实际项目里还封装过一个带“取消防抖”版本的函数因为用户点完搜索后如果中途切换了条件需要取消上一次的“未执行搜索”避免返回过期结果。实现也很简单返回的函数上挂一个cancel方法就行function debounce(fn, delay 300, immediate false) { let timer null; const debounced function(...args) { if (timer) clearTimeout(timer); if (immediate !timer) { fn.apply(this, args); } timer setTimeout(() { timer null; }, delay); }; debounced.cancel function() { if (timer) { clearTimeout(timer); timer null; } }; return debounced; }面试时如果你能主动说出“实际项目中经常还要加一个 cancel 方法”会是一个不小的加分项。它说明你不是背了一个 demo而是真的在项目里写过、踩过坑。4.2 节流时间戳版和定时器版的取舍节流和防抖经常被放在一起问。节流的语义是在一定时间间隔内无论事件触发多少次只执行一次。典型场景是滚动加载、拖拽、游戏中的射击冷却。节流通常有两种实现思路时间戳版和定时器版。时间戳版function throttle(fn, interval 300) { let last 0; return function(...args) { const now Date.now(); if (now - last interval) { last now; fn.apply(this, args); } }; }定时器版function throttle(fn, interval 300) { let timer null; return function(...args) { if (timer) return; timer setTimeout(() { fn.apply(this, args); timer null; }, interval); }; }这两种版本的行为不太一样。时间戳版是“立即执行”但停止触发后不会再执行定时器版是“延迟执行”停止触发后还会再补一次执行。如果你用过lodash的throttle你会发现它可以配置leading和trailing来控制是否在头尾各执行一次。面试的时候最好能把这个区别说明白。面试官如果问“项目中渲染商品列表用哪种”我会回答时间是低频操作用时间戳版保证即时反馈如果希望回调稳定用定时器版。或者直接封装一个支持leading和trailing的完整版这样既展示了你的能力也展示了工程思维。4.3 函数柯里化与偏函数实现一个 add(1)(2)(3)柯里化这道题说难不难说简单也容易翻车。最常见的是function add(a) { return function(b) { return function(c) { return a b c; }; }; }但面试官通常不会满足于这种写死层数的版本而是让你实现一个通用的curry函数能把任意多参数的函数转成可链式调用的柯里化函数。一个经典实现如下function curry(fn, ...args) { return fn.length args.length ? fn(...args) : (...rest) curry(fn, ...args, ...rest); } function sum(a, b, c, d) { return a b c d; } const curriedSum curry(sum); curriedSum(1)(2)(3)(4); // 10 curriedSum(1, 2)(3)(4); // 10核心原理是利用fn.length拿到函数声明时的参数个数当累计传入的参数个数达到或超过预期时直接调用原函数否则继续返回一个新函数等待更多参数。如果面试官接着问“能不能让add(1)(2)(3)这种无限调用也能拿到结果”那就涉及到一个更刁钻的技巧——利用函数的toString或Symbol.toPrimitivefunction add(x) { const fn (y) add(x y); fn[Symbol.toPrimitive] () x; return fn; } add(1)(2)(3); // 6这个实现的核心思想是每次调用返回一个新函数但通过闭包累积计算结果当 JS 引擎需要把函数转成原始值时会调用Symbol.toPrimitive返回累积的值。这样做的好处是支持任意长度的链式调用。这种“看似函数、实则可求值”的技巧在面试中是妥妥的加分项。5. 高频必写题四new、instanceof 与发布订阅谈完异步和性能优化再聊几个语言层面的高频手写题。这些题目往往短小精悍但背后涉及的 JS 核心概念非常密集。5.1 手写 new深入理解原型链的流向很多候选人把new背成了“创建一个新对象”但面试官一问“new 的过程中到底发生了什么”就说不清楚了。手写new的实现并不长但每一行都对应一个关键步骤function myNew(Constructor, ...args) { // 1. 创建一个新对象将新对象的原型指向构造函数的 prototype const obj Object.create(Constructor.prototype); // 2. 执行构造函数将 this 绑定到新对象上 const result Constructor.apply(obj, args); // 3. 如果构造函数返回了对象则返回该对象否则返回新对象 return (typeof result object result ! null) || typeof result function ? result : obj; }这里有个容易漏掉的细节是第三步如果构造函数内部显式return了一个对象那么new表达式的返回值就是这个对象而不是新创建的对象比如return {}。但显式return基本类型number、string则不会生效仍然返回obj。这个规则就是new的“对象优先”原则。Object.create(Constructor.prototype)这一行是整个new的灵魂。它把新对象的原型链指向了构造函数的prototype对象这样实例就能通过原型链访问到构造函数原型上定义的方法。理解了这行代码你就同时理解了new、原型、原型链三者的关系。面试官接下来大概率会追问“Object.create如果让你手写该怎么写”这相当于把冷门 API 也纳入考察范围。一个简化版的Object.create是function myObjectCreate(proto) { function F() {} F.prototype proto; return new F(); }用空函数当桥梁把函数的prototype指向传入的原型对象然后new一个实例。这个实例的原型链就正确指向了proto。你看new的原理在和Object.create互相印证后理解会更深一层。5.2 手写 instanceof沿着原型链找祖先instanceof的实现思路和new有异曲同工之妙。它的核心是判断right.prototype是否出现在left的原型链上。基础实现function myInstanceof(left, right) { let proto Object.getPrototypeOf(left); const prototype right.prototype; while (proto) { if (proto prototype) { return true; } proto Object.getPrototypeOf(proto); } return false; }这里有个知识点值得展开Object.getPrototypeOf(left)是标准的方法有些候选人会写left.__proto__。虽然两者很多时候能互换但__proto__是一个已经被废弃的、存在兼容性问题的访问器属性在标准环境里建议优先使用Object.getPrototypeOf。面试的时候写对 API也是一种基本功的体现。再往深了说Symbol.hasInstance可以自定义instanceof的行为。如果你想让人为构造的假对象在instanceof判断时返回特定结果可以实现静态方法[Symbol.hasInstance]。这个比较冷门但能展现出你对语言规范的了解程度。5.3 手写 Object.create 与继承方案组合继承到寄生组合继承“手写继承”也是面试里经久不衰的题型。从原型链继承、借用构造函数继承到组合继承、寄生组合继承每一种方案背后都对应着不同的坑。组合继承是比较常见的初级答案function Parent(name) { this.name name; } function Child(name, age) { Parent.call(this, name); this.age age; } Child.prototype new Parent(); Child.prototype.constructor Child;但组合继承有两个缺点一是调用了两次Parent构造函数一次在Parent.call一次在new Parent()会导致子类实例上存在多余的属性二是原型上挂载了多余属性不够干净。更优的解法是寄生组合继承实现方式是这样function inherit(child, parent) { child.prototype Object.create(parent.prototype); child.prototype.constructor child; } function Child(name, age) { Parent.call(this, name); this.age age; } inherit(Child, Parent); Child.prototype.sayAge function() { console.log(this.age); };思考路径是利用Object.create(parent.prototype)生成一个新对象作为子类原型这样既不需要调用Parent构造函数又保留了父类原型上的方法。最后把constructor指回Child避免原型链混乱。面试官如果接着问 ES6 的class继承和寄生组合继承的关系你可以说class的extends底层机制和寄生组合继承非常接近只是在细节上处理得更加完善。能把这个演进脉络讲清楚面试官对你的评价一定不低。6. 手写代码常见问题与排查技巧实录写手写题最怕的不是不会写而是写完了一堆细节错误自己还没发现。这一节我把自己当面试官时最爱问的问题以及候选人最容易掉的坑整理成速查表。6.1 高频翻车点从 this 指向到边界条件this 指向丢失我见过太多候选人写防抖节流时在定时器回调里直接调用fn(...args)完全忽略了this。虽然不报错但在真实 DOM 事件场景里回调函数里的this指向目标元素丢了会引发连锁 bug。写手写题时不确定this指向就一律用fn.apply(this, args)来保留上下文这是一个不会出错的原则。边界条件是加分项还是减分项同一个函数考虑不考虑边界条件在面试官眼里的区别非常大。比如写数组去重时会不会处理空数组写深拷贝时会不会处理null写Promise.myAll时会不会处理空数组这些边界条件可能只有一两行代码但写出来会让面试官觉得你平时写代码就是“防御式”的而不是“能跑就行”。过度封装反而失分有些候选人来面试前背了一个特别复杂的深拷贝版本面试时直接默写代码量很大。面试官其实只想看你是否能理解“递归 循环引用检测”这两个核心点。如果你先快速写出核心版本再主动说明哪些地方还可以加强效果往往更好。毕竟面试官看的是你的思维方式和沟通能力不是看你的代码能不能上生产。6.2 面试现场遇到不会的手写题怎么办我在几次模拟面试中反复向候选人传达一个观点面试手写题不会写不是末日沉默才是。如果遇到完全没思路的题一个好的应对策略是“先聊后写”。你可以先和面试官确认“这道题是要实现完整功能还是核心逻辑就行”同时尝试把大问题拆成小问题。比如让你手写一个Promise.retry你一开始可能不知道这 API 长什么样但你可以先说“Promise.retry应该是在失败了之后自动重试指定次数如果重试耗尽才 reject”把需求用自己的话复述一遍。这样做有两个好处一是确认自己理解正确避免方向跑偏二是在拆解过程中思路会逐渐清晰往往拆到一半就有灵感了。如果写了半天实在写不出来也千万不要假装自己能跑更不要试图用花哨的语法糊弄。在面试现场坦诚地告诉面试官“这块我掌握得不够深入但我理解它的核心思路是……”然后把你懂的部分讲出来展示逻辑思考能力。这种应对方式反而比硬憋出大量错代码要好。6.3 从手写题看真实项目优化一个实战案例最后讲一个手写题映射到真实项目的例子。前段时间我在处理一个上传模块时遇到一个大文件上传场景。传统做法是直接把文件POST给后端但如果文件快 2GB网络一抖整个上传就失败了只能重新来。很不巧这类需求的本质就是面试题里常见的“异步控制 并发控制”。我当时的做法是把大文件切片然后每一片独立上传最后通知后端合并。这个方案就涉及并发控制不能一股脑把 100 个切片同时发出去否则后端和带宽都扛不住。所以我手写了一个通用并发控制器把核心逻辑抽象出来async function asyncPool(poolLimit, tasks, iteratorFn) { const ret []; const executing new Set(); for (const task of tasks) { const promise Promise.resolve().then(() iteratorFn(task)); ret.push(promise); executing.add(promise); const clean () executing.delete(promise); promise.then(clean, clean); if (executing.size poolLimit) { await Promise.race(executing); } } return Promise.all(ret); }这段代码其实就是面试里“手写 Promise 并发控制”的实战版。你能手写Promise.all、能理解Promise.race看到这类需求时才能快速形成方案。面试官考你手写题真正想看的就是你有没有这种能力迁移的储备。7. 备考策略与现场发挥心得准备手写题的阶段很容易陷入一个误区天天背代码把题库里的题目都默写一遍就觉得万事大吉。但面试一碰到变种题脑子就卡壳了。针对这个问题我分享一下自己的备考方法和现场心得。7.1 按“主题簇”复习不按“题目名单”死记我建议把你的手写题题库按主题簇来划分而不是一个个孤立地背。比如异步控制簇手写 Promise、Promise.all、Promise.race、asyncPool、事件总线函数增强簇防抖、节流、柯里化、偏函数、组合函数对象与原型簇深拷贝、Object.create、new、instanceof、继承类型判断簇typeof 的陷阱、Object.prototype.toString、instanceof 原理为什么要这样分组因为这些题目内部的底层逻辑是互相打通的。比如你掌握了Object.create的原理再看new的实现和寄生组合继承就会觉得特别顺。比如你掌握了 Promise 的.then返回新 Promise 的特性再写asyncPool并发控制就很容易理解。7.2 用“讲给自己听”的方式做复盘有一个很笨但非常有效的学习方法每写完一个手写题关上电脑用自己的话把它讲一遍。不是背代码而是讲思路“这个防抖函数做的事情是维护一个定时器每次调用时清除上次的定时器再开启一个新定时器。定时器到期才执行目标函数。如果传了 immediate还要判断是不是第一次调用……”如果你能清晰地把这段话讲出来说明你是真正理解了这个函数而不只是在默写代码。面试官在现场追问题的时候你也能应对自如。如果你讲的时候卡壳了那就是你的知识盲区回头再补。7.3 现场发挥的三个实用技巧第一先写主体逻辑再补边界情况。不要在第一个 API 的参数校验上纠结太久先把核心逻辑写出来证明你思路清晰然后再慢慢补细节。面试官一般不会打断你补边界的动作。第二写代码的同时保持“出声思考”。很多候选人一紧张就安静地闷头写面试官根本不知道你在想什么。正确的姿势是边写边说“我先在这里处理空数组的情况因为 Promise.all 传入空数组时应该直接 resolve。”这样即使代码没写完面试官也能看到你的思考过程。第三写完以后主动检查一遍并口述测试用例。面试官让你手写代码你写完以后一定要自己过一遍。我一般会挑两三个测试例子口头跑一下“比如传一个[1, 2, 3]进去输出应该是[1, 2, 3]去重后还是[1, 2, 3]传[1, 1, 2]这里indexOf返回的是第一个 1 的索引所以只保留第一个……”这个动作在面试官眼中是“代码正确性检查”的体现非常加分。结合我自己的体会手写代码题刷起来确实是个体力活但它带来的收益并不只是面试通过。就算你不跳槽把手写题的逻辑吃透平时在项目里写代码时也会发现自己的思路清晰很多——遇到一个复杂需求你能更快地想出实现方案评估不同方案的性能差异。这份积累会在你不知不觉中渗透到日常开发的每一个细节里。
返回列表