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

资讯详情

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

前端面试手写题高频考点全解析:防抖、深拷贝与 Promise 实现原理

前端面试手写题高频考点全解析:防抖、深拷贝与 Promise 实现原理 这两年面试前端绕不开的一个环节就是手写题。不管你是面大厂还是中小团队面试官总喜欢在聊完项目之后突然来一句“来手写一个防抖”、“写个深拷贝”、“实现一个 Promise.all”。很多人一听到手写题就头皮发麻觉得这是在刁难人。但以我带团队和参加面试官培训的经验来看手写题真正的考察点从来不是你默写代码的能力而是你对 JavaScript 核心机制的掌握程度、在压力下的思维反应以及写代码的习惯和工程意识。这篇文章我就把前端面试里最高频的手写题做一个系统梳理结合我这些年当面试官时看到的典型答案和踩坑案例讲清楚每道题背后的原理、考法和最优解。不管你是准备跳槽还是刚开始找工作把这篇吃透面到手写题不说手到擒来至少心里有底不会再慌。1. 为什么面试官都爱考手写题1.1 手写题到底在考察什么能力很多候选人把手写题等同于“背代码”这是最大的误区。面试官让你手写代码重点看的从来不是结果对不对而是你思考的过程。我在面试时最经常做的事情就是候选人写完之后追问一句“为什么这里要用闭包”、“如果入参是数组你怎么办”、“这个实现有什么缺陷”。如果你只是背了模板这几个问题一问就露馅。手写题第一层考察的是语言基本功。防抖节流考的是闭包和 this 指向Promise 考的是异步编程和状态机思想深拷贝考的是递归和多种数据类型处理。这些能力不是背出来的是平时写业务代码时一点一滴积累的。说白了面试官是想通过一道手写题判断你平时写代码的时候是只关心功能跑通还是会去琢磨底层的运行机制。第二层考察的是工程意识。写出来的代码边界处理是否完整、参数校验是否严谨、命名是否清晰、有没有考虑性能优化这些都是一个前端工程师日常开发素养的直接体现。面试官见的人多了一套代码写出来你的水平大致在哪个段位基本就能判断个七七八八。第三层考察的是沟通和应变能力。面试不是笔试面试官会在你写的过程中随时打断提问甚至故意给出一些扩展需求。比如你写完 Promise.all他马上问你“如果其中一个 Promise 抛错怎么办”“怎么让错误信息带上是哪个任务失败的”。这时候你能不能接住反应出的是你在真实工作中面对需求变更的应对能力。1.2 高频手写题的分布规律以我近几年收集的面试题库来看手写题虽然看起来五花八门实际上是有明确梯度的。第一梯队是防抖、节流、深拷贝、数组扁平化这是出现频率最高的基本逢面必考。第二梯队是 Promise 系列、call/apply/bind、柯里化、EventEmitter这类题考察功底出现的概率也不低。第三梯队是 instanceof、new、Object.create、发布订阅、LazyMan 等需要结合场景来出题。有人会问既然网上到处都能搜到答案面试官为什么还乐此不疲地考答案很简单手写题是最难准备的题目类型因为真正的理解无法伪装。你背得再熟面试官换一个题目变体或者深挖一个细节真实水平立刻现形。所以与其焦虑怎么“押题”不如把这些高频题背后的原理彻底弄懂以不变应万变。2. 第一梯队防抖节流与数组方法入门必拿分2.1 最经典的防抖函数详解防抖应该是前端面试手写题里出场率最高的一道没有之一。它的核心思想是事件被连续触发时只有在最后一次触发后等待一段时间才执行目标函数。如果等待时间内又被再次触发那么重新计时。生活化的理解就是电梯关门——只要还有人往里走电梯门就一直等等人走完、门快关上了又有一个人进来门又重新打开等一轮。先看一段最基础的防抖实现function debounce(fn, wait 300) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, wait); }; }这段代码里有几个关键细节面试官一定会深挖。第一个是fn.apply(this, args)为什么要用 apply 并且把 this 传进去因为事件处理函数里的 this 指向的是绑定事件的 DOM 元素如果不保存 this防抖后的函数内部 this 会指向 undefined严格模式下或者全局对象。面试官问我为什么这里要这样写本质上考的就是 this 指向和闭包。第二个细节是clearTimeout之后旧的定时器是不是就没用了实际上 JavaScript 的定时器有个特点clearTimeout 清除后定时器回调依然会被压入事件循环只是不会被执行了。这里涉及内存释放的问题频繁创建和清除定时器时理论上会累积任务队列中的任务虽然实际影响不大但严谨的面试官会注意你有没有意识到这一点。如果面试官继续追问“如何让防抖函数支持立即执行一次”那就需要在上面的基础上增加一个参数function debounce(fn, wait 300, immediate false) { let timer null; return function (...args) { const callNow immediate !timer; if (timer) clearTimeout(timer); if (callNow) { fn.apply(this, args); timer setTimeout(() { timer null; }, wait); } else { timer setTimeout(() { timer null; fn.apply(this, args); }, wait); } }; }这里有个知识点需要说清楚为什么取消函数执行时要先把timer置为 null因为timer这个变量在闭包里同时承担了两层职责一是记录定时器 ID 用于清除二是作为标识位判断当前是否处于等待状态。如果不在定时器执行时把 timer 置空immediate取反判断!timer永远为 false立即执行就永远不会触发了。这个细节我在面试中反复考察答对的人不到三成。2.2 节流函数与防抖的区别和实现节流字面意思是控制流量核心是函数在指定时间内只执行一次即使事件被高频触发。生活中常见的例子是游戏里的技能冷却——技能按下去之后进入冷却时间CD 没转完之前你再怎么按都没有反应CD 转完了才允许下一次释放。一个简单的节流实现function throttle(fn, delay 500) { let prev 0; return function (...args) { const now Date.now(); if (now - prev delay) { prev now; fn.apply(this, args); } }; }这个实现叫做时间戳版节流它的特点是第一次触发会立即执行但如果最后一次触发距离下一次满足时间间隔还有很久最后一次触发会被丢掉。比如用户一直在滚动滚动停止时正好处于 CD 冷却期那最后一次滚轮事件对应的更新就永远不会执行了页面可能停留在比实际内容靠前的位置。另一种实现是定时器版节流function throttle(fn, delay 500) { let timer null; return function (...args) { if (timer) return; fn.apply(this, args); timer setTimeout(() { timer null; }, delay); }; }定时器版的特点是第一次执行前需要等一个 delay而最后一次触发一定会补上执行。比较理想的做法是把两种方式的优点结合起来既能立即执行一次也保证最后一次触发的操作不丢失面试时如果能写出这种进阶版本会很加分。function throttle(fn, delay 500) { let timer null; let prev 0; return function (...args) { const now Date.now(); const remaining delay - (now - prev); if (timer) clearTimeout(timer); if (remaining 0) { prev now; fn.apply(this, args); } else { timer setTimeout(() { prev Date.now(); timer null; fn.apply(this, args); }, remaining); } }; }2.3 数组方法实现map、filter、reduce数组方法的手写题在面试中出现的频率也很高常考的是自己实现map、filter、reduce。这类题比较简单关键是要把回调函数的参数含义和 thisArg 的绑定搞透。先看 map 的手写实现Array.prototype.myMap function (callback, thisArg) { if (typeof callback ! function) { throw new TypeError(${callback} is not a function); } const result new Array(this.length); for (let i 0; i this.length; i) { if (i in this) { result[i] callback.call(thisArg, this[i], i, this); } } return result; };有一个细节要注意为什么遍历时要判断i in this因为在真正的 map 实现中稀疏数组的空元素会被跳过结果数组在对应位置也保持空缺而不是填一个 undefined。这个细节绝大多数面试者都不会主动处理但一旦被追问能答上来就是加分项。手写 reduce 是这三道里最有含金量的因为它涉及累加器、索引偏移和空数组报错三种边界情况Array.prototype.myReduce function (callback, initialValue) { if (typeof callback ! function) { throw new TypeError(${callback} is not a function); } let index 0; let accumulator initialValue; if (arguments.length 2) { if (this.length 0) { throw new TypeError(Reduce of empty array with no initial value); } while (index this.length !(index in this)) { index; } accumulator this[index]; } for (; index this.length; index) { if (index in this) { accumulator callback.call(undefined, accumulator, this[index], index, this); } } return accumulator; };这里的核心难点是当初始值缺省时要把数组中第一个存在的元素作为累加器的初始值然后从它的下一位开始遍历。面试官经常会问你“如果数组是空的且没有传初始值会怎样”标准行为就是抛 TypeError。在处理稀疏数组时跳过空缺元素这一层如果能答出来说明你是真正查看过规范的人。2.4 数组扁平化的多种解法思路数组扁平化也是一道高频题通常会有几种不同层级的解法。最基础的是递归实现function flatten(arr) { const result []; for (const item of arr) { if (Array.isArray(item)) { result.push(...flatten(item)); } else { result.push(item); } } return result; }如果面试官问“能控制扁平化层数吗”可以在参数中增加 depthfunction flattenWithDepth(arr, depth Infinity) { const result []; for (const item of arr) { if (Array.isArray(item) depth 0) { result.push(...flattenWithDepth(item, depth - 1)); } else { result.push(item); } } return result; }另外还有基于栈的迭代写法不依赖递归在大数据量下更安全避免了递归深度过大导致的调用栈溢出function flattenByStack(arr) { const stack [...arr]; const result []; while (stack.length) { const item stack.pop(); if (Array.isArray(item)) { stack.push(...item); } else { result.unshift(item); } } return result; }在这上面再扩展一层面试官还可能问“reduce 可以扁平化吗”这也是一道变体function flattenByReduce(arr) { return arr.reduce((acc, item) { return acc.concat(Array.isArray(item) ? flattenByReduce(item) : item); }, []); }3. 第二梯队Promise 家族进阶分水岭3.1 手写 Promise.all 与错误处理Promise 相关的手写题是面试的分水岭会者不难难者不会。最常出现的题是让你实现Promise.all。function promiseAll(promises) { return new Promise((resolve, reject) { if (!Array.isArray(promises)) { return reject(new TypeError(promises must be an array)); } const results []; let count 0; if (promises.length 0) { return resolve(results); } for (let i 0; i promises.length; i) { Promise.resolve(promises[i]).then((value) { results[i] value; count; if (count promises.length) { resolve(results); } }, reject); } }); }这个实现里有几个有趣的细节。首先是为什么用Promise.resolve(promises[i])包一层因为入参不一定全是 Promise 实例也可能传入的是普通值Promise.resolve 会把普通值包装成已经 resolve 的 Promise消除类型差异。其次是为什么结果用下标results[i]而不是results.push(value)因为 Promise 任务的完成顺序是不确定的如果某个较晚的任务先完成了使用 push 就会导致结果顺序错乱必须按照传入时的下标来存放结果。面试官很喜欢追问的一个点是Promise.all 的失败策略是“谁先失败就立刻 reject并丢弃其他 Promise 的结果”。如果你只是背了代码这个问题可能答不上来深层含义所以平时写并发请求时要有意识地体会这个机制。还有一道高频变体是Promise.allSettled它不关心某个任务是否失败而是等待所有任务完成后再统一返回结果function allSettled(promises) { return Promise.all( promises.map((p) Promise.resolve(p).then( (value) ({ status: fulfilled, value }), (reason) ({ status: rejected, reason }) ) ) ); }看得出 allSettled 是建立在 all 的基础上的把每个 Promise 都映射成不可能 reject 的新 Promise。这种“利用已有能力组合出新能力”的思路正是面试官想看到的工程思维。3.2 手写 Promise.race 与 Promise.anyPromise.race 的实现思路是只要有一个任务先落定就用它的结果来 resolve 或 reject 外层 Promisefunction promiseRace(promises) { return new Promise((resolve, reject) { for (const p of promises) { Promise.resolve(p).then(resolve, reject); } }); }Promise.any则是只要有一个任务成功就使用成功结果如果所有任务都失败就抛出一个 AggregateError。实现它的关键在于统计失败次数全部失败后才能 rejectfunction promiseAny(promises) { return new Promise((resolve, reject) { let count 0; const errors []; if (promises.length 0) { return reject(new AggregateError([], All promises were rejected)); } for (let i 0; i promises.length; i) { Promise.resolve(promises[i]).then(resolve, (reason) { count; errors[i] reason; if (count promises.length) { reject(new AggregateError(errors, All promises were rejected)); } }); } }); }需要注意的是传入空数组的场景race 会永远保持 pending 状态而 any 会直接 reject。这种边界行为的差异面试官偶尔会拿出来考察候选人对 API 语义的理解。3.3 从零手写一个完整的 Promise如果你的面试进行得比较深入面试官可能会让你手写一个完整的 Promise。这题是最有分量的因为它同时考察了状态管理、微任务调度、链式调用和值穿透。先写一个最简版本使用 setTimeout 模拟异步class MyPromise { static PENDING pending; static FULFILLED fulfilled; static REJECTED rejected; constructor(executor) { this.status MyPromise.PENDING; this.value undefined; this.reason undefined; this.onFulfilledCallbacks []; this.onRejectedCallbacks []; const resolve (value) { if (this.status MyPromise.PENDING) { this.status MyPromise.FULFILLED; this.value value; this.onFulfilledCallbacks.forEach((fn) fn()); } }; const reject (reason) { if (this.status MyPromise.PENDING) { this.status MyPromise.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.status MyPromise.FULFILLED) { setTimeout(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); } if (this.status MyPromise.REJECTED) { setTimeout(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); } if (this.status MyPromise.PENDING) { this.onFulfilledCallbacks.push(() { setTimeout(() { try { const x onFulfilled(this.value); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }); this.onRejectedCallbacks.push(() { setTimeout(() { try { const x onRejected(this.reason); resolvePromise(promise2, x, resolve, reject); } catch (err) { reject(err); } }); }); } }); return promise2; } } function resolvePromise(promise2, x, resolve, reject) { if (promise2 x) { return reject(new TypeError(Chaining cycle detected)); } if (x instanceof MyPromise) { x.then((y) resolvePromise(promise2, y, resolve, reject), reject); } else { resolve(x); } }写完整 Promise 的难点在于resolvePromise里对返回值 x 的类型判断。按 Promise/A 规范如果 x 是一个 thenable 对象需要调用它的 then 方法递归处理。上面这个版本为了降低理解难度做了简化在实际面试时如果你能写出正确处理 thenable 的版本那基本就是一个满分答案了。这里需要特别强调的是callback 数组和 setTimeout 配合使用的原因。.then可能被调用多次每次调用都返回一个全新的 Promise而回调数组恰好能支持这种链式多分支的场景。我在面试时还喜欢追问“为什么 then 要包一层 setTimeout”这就要讲清楚微任务与宏任务的差异以及为什么真实 Promise 不会用 setTimeout 来模拟。3.4 链式调用与值穿透原理Promise 最难理解的是链式调用。promise.then(...).then(...)之所以能一直串下去是因为then方法返回了一个新的 Promise 对象这个 Promise 的状态由then回调的返回值决定。如果返回普通值新 Promise 立即 resolve如果返回 Promise新 Promise 会“跟随”返回的这个 Promise如果回调抛异常新 Promise 直接 reject。值穿透是另一个面试高频考点Promise.resolve(1).then().then(console.log)输出什么答案依然是 1。因为then()不传参数时相当于让值直接“穿透”到下一个 then。要实现这个行为只需要在then内部对非函数参数做一次默认值处理then(onFulfilled, onRejected) { onFulfilled typeof onFulfilled function ? onFulfilled : (value) value; onRejected typeof onRejected function ? onRejected : (reason) { throw reason; }; // ... }实际面试中能清晰解释值穿透原理的人不多如果你能顺手答出默认值处理的方式会在面试官心里留下一个很好的印象。4. 第三梯队函数式编程与深拷贝拉开差距4.1 call、apply、bind 的本质与实现面试官让你手写 call 和 apply其实是在变相考察你对 this 指向和对象属性赋值机制的理解。call 的实现思路非常巧妙把函数挂载到目标对象的一个临时属性上然后通过对象去调用它这样函数内部的 this 就自然指向了目标对象。Function.prototype.myCall function (context, ...args) { context context || globalThis; const fn Symbol(fn); context[fn] this; const result context[fn](...args); delete context[fn]; return result; };两个关键细节值得展开说。为什么要用 Symbol 作为临时属性名因为如果使用一个普通的字符串比如context[fn] this会污染目标对象更严重的是如果目标对象已经存在同名属性会被我们覆盖掉执行时就会出问题。Session 挂载后还要delete掉临时属性目的是不污染原对象。apply 和 call 的区别仅在于参数传递方式apply 接收的是数组。bind 跟 call/apply 最大的不同是它不会立即调起函数而是返回一个新函数并且支持参数分两次传入。同时要注意 bind 返回的函数被 new 调用的场景行为要退回到正常构造函数逻辑。一个带注释的常用 bind 简易实现Function.prototype.myBind function (context, ...args) { const fn this; return function (...innerArgs) { return fn.apply(context, [...args, ...innerArgs]); }; };如果你想写一个足够完善的 bind还得把 new 的情况考虑进去这时会复杂不少。不过面试中能把基础的写对同时讲清楚“为什么 a.bind(b) 之后返回的函数再用 call 绑到别的对象上也不生效”的原理就已经超出大多数候选人的水平了。4.2 函数柯里化与组合函数实现柯里化是一道比较有趣的题它的目标是让一个接受多参数的函数转化为一系列接受单个参数的函数并且在参数足量时才真正执行function curry(fn, arity fn.length, ...args) { return arity args.length ? fn(...args) : (...rest) curry(fn, arity, ...args, ...rest); }这段代码的核心是借助闭包保存已经收集到的参数每调用一次新函数就把参数积累起来当收集数量达到原函数所需的参数个数时再执行真正的函数逻辑。面试官常问的是如果原函数有默认参数怎么办fn.length拿到的长度就不准确此时需要手动传 arity 参数来指定函数期望的参数个数。4.3 深拷贝的完整实现与循环引用处理深拷贝是手写题里出镜率最高的一道也有很多变体。很多候选人能写出最简单版本function deepClone(obj) { if (obj null || typeof obj ! object) return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj); const clone Array.isArray(obj) ? [] : {}; for (const key of Object.keys(obj)) { clone[key] deepClone(obj[key]); } return clone; }但面试官真正想听到的是如何处理下面几个问题循环引用问题。如果对象内部存在obj.self obj这样的引用上面的递归就会死循环最终爆栈。解决方法是使用 WeakMap 缓存已经拷贝过的对象function deepClone(obj, cache new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (cache.has(obj)) return cache.get(obj); if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); let clone Array.isArray(obj) ? [] : {}; cache.set(obj, clone); for (const key of Object.keys(obj)) { clone[key] deepClone(obj[key], cache); } return clone; }使用 WeakMap 而不是 Map 的原因是 WeakMap 的键是弱引用在深拷贝完成后可以被垃圾回收不会造成内存泄漏。如果面试官追问到这一层能答上来的候选人真的不多。Symbol 属性的处理。使用Object.keys或for...in无法遍历到 Symbol 类型的键对于 Symbol 属性需要用Reflect.ownKeys来遍历function deepClone(obj, cache new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (cache.has(obj)) return cache.get(obj); if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); let clone Array.isArray(obj) ? [] : {}; cache.set(obj, clone); Reflect.ownKeys(obj).forEach((key) { clone[key] deepClone(obj[key], cache); }); return clone; }函数和原型链的处理。严格来说结构化克隆规范对函数是不做克隆的通常直接复用函数引用即可而原型链的处理要看实际需要工作中的深拷贝往往用 JSON.parse(JSON.stringify()) 就够了虽然它无法处理函数、undefined、Symbol、循环引用但在大部分纯数据场景下性能很好且实现简单。4.4 EventEmitter 事件总线的设计EventEmitter 是 Node.js 中一个非常常用的核心类浏览器端的很多框架也借鉴了它的设计。手写 EventEmitter 的考点在于对发布订阅模式的理解。class EventEmitter { constructor() { this.events new Map(); } on(type, handler) { if (!this.events.has(type)) { this.events.set(type, []); } this.events.get(type).push(handler); } off(type, handler) { if (!this.events.has(type)) return; const handlers this.events.get(type); const index handlers.indexOf(handler); if (index ! -1) { handlers.splice(index, 1); } } once(type, handler) { const wrapper (...args) { handler(...args); this.off(type, wrapper); }; this.on(type, wrapper); } emit(type, ...args) { if (!this.events.has(type)) return false; this.events.get(type).forEach((handler) { handler(...args); }); return true; } }once 的实现充分利用了闭包和包装函数把用户传入的 handler 包一层在调用完成之后自动移除自己。这里要注意的是如果在 emit 遍历时删除了某个 handler需要小心数组下标错位的问题面试时可以主动提出来并给出安全遍历的方案。5. 实战技巧从会说到会写5.1 手写现场的 5 个沟通技巧手写题不只是一个技术问题更是一个沟通问题。面试官给你题目后不要急着写代码先花 20 秒思考并开口确认需求。比如面试官说“写一个防抖”你可以反问一句“支持 immediately 参数吗需要处理 this 指向吗”这种问题显示你在业务开发中会主动澄清需求而不是拿到需求就闷头做。写的过程中边写边轻声讲思路。比如写到闭包时可以说“这里我用一个变量来保存状态它会随着每次调用不断更新”。这样面试官能实时了解你的思考过程即使中间写错了也能及时纠正。最怕的是闷头写完了面试官一看结果不对但完全不知道你是怎么想的这种情况就很难给分了。写完不代表结束主动检查边界条件是一个优秀的加分动作。我见过很多候选人在写完核心逻辑后会主动说“我检查一下传 null 会怎样、传空数组会怎样、如果回调抛错会怎样”然后当场补上边界处理。这是工程师素养的最佳展示时刻。5.2 面试官视角怎样作答更容易拿高分站在面试官角度手写题的评分维度通常有三个层次。第一层是正确性代码能不能跑通核心逻辑。第二层是完备性边界条件和异常处理是否周到。第三层是优雅性代码写得好不好看有没有多余变量命名是否清晰结构是否合理。从我的面试经验看很多候选人死在了第一个层次基础概念都没搞清楚就开写。比如让写节流结果写出来一个防抖。这种错误不是不熟练的问题而是对“连续触发时行为差异”缺乏概念层面的理解。建议复习时先不看代码用自己的话把每个手写题的适用场景和行为特点先说清楚再开始写代码。还有一个高频问题面试官让你写手写题时会说“你可以查文档”或者“你可以用伪代码”。这时候千万不要真去查或者写一堆看不懂的伪代码这通常是陷阱。能写完整代码就写完整代码即使不完全正确也比依赖提示要强因为考察的本质是你独立完成编码的能力。5.3 如何在日常工作中积累手写能力手写题的能力不是靠面试前突击背题能提高的它和日常开发习惯密切挂钩。我在团队带新人时经常强调一个原则每用到一个 API 或语法都要在脑子里问一句“如果让我自己实现我该怎么写”。用Array.prototype.reduce时想一下它的累加器机制用Object.create时想一想它和new的区别用setTimeout时想一想事件循环的调度顺序。还有一个很有效的练习方法就是把工程代码里常用的工具函数手写一遍。比如请求接口时封装的超时控制函数本质就是 Promise.race表单输入时的实时搜索本质就是防抖滚动加载下一页本质就是节流。把这些日常场景和手写题建立映射复习就不是死记硬背了而是真正在改善编码能力。5.4 面试结尾的扩展延伸方向手写题答完之后面试官通常还会问两三个延伸问题范围围绕刚写过的代码展开。比如写完防抖会问“防抖和节流里定时器被频繁清掉有没有性能问题”。这种问题没有标准答案面试官想听你分析权衡和取舍。另一个常见延伸是让手写题和实际项目结合。比如你写完了深拷贝面试官可能会问“你项目里哪些场景用到了深拷贝哪些场景其实不需要深拷贝”。这个问题考察的是业务敏感度。一个成熟的前端工程师应该知道在 React 里修改深层状态时需要保持不可变性也要知道不是所有对象都需要深拷贝有时浅拷贝配合 immutability 库反而是更好的方案。我的经验是回答这类开放问题时要敢于亮出自己的观点说清楚“我一般怎么做以及为什么”哪怕你的方案和面试官预期不同只要理由充分、逻辑自洽都会是一个建设性的交流。怕的是只说结果不给理由比如“我一般用 lodash 的 cloneDeep”这种回答等于没有回答。6. 高频手写题总结一套速查清单上面花了很大篇幅讲原理和实现我把这些年面试中最常考到的手写题做成一份速查清单方便各位在复习时对照检查。防抖 debounce闭包 定时器重设支持多次销毁重建immediate 版处理标识位用于搜索输入、窗口缩放等高频事件场景。节流 throttle时间戳计算冷却剩余时间定时器版保证末尾执行结合版兼顾首次和末尾用于滚动加载、按钮防重复提交等场景。深拷贝 deepClone递归 类型判断 循环引用 WeakMap Symbol 属性遍历需要了解 JSON 方案的局限性和结构化克隆的边界。数组扁平化递归、reduce、栈迭代三种实现支持深度参数需要理解稀疏数组的处理差异。Promise.all / race通过下标保存结果保证顺序统一任务类型 Promise.resolve(入参)需要在多个回调之间共享状态用闭包或循环作用域管理状态。手动实现 call/apply/bind借助 Symbol 临时属性挂载到目标对象apply 传参用数组bind 实现柯里化并处理 new 调用场景。手写 EventEmitterMap 存储事件类型与回调列表once 包一层自删除off 要处理删除期间遍历问题。手写 instanceof通过原型链查找 constructor.prototype 是否在目标对象的原型链上。手写 new创建一个新对象绑定原型改变 this 指向根据返回值类型判断返回对象还是创建的新对象。柯里化闭包保存参数参数累计到函数.length 时触发执行支持占位符的场景可以再深入扩展。这里再多说一句速查清单不是让你直接背代码。最好的使用方式是先盖住代码只看题目自己在草稿纸上写一遍再对照检查。发现自己哪里卡壳了就回到对应的原理部分去重新复习而不是看一遍答案就觉得会了。手写题的复习和健身是一个道理只有真正动手做了肌肉记忆才能建立起来。另外要提醒一点复习时不要只盯着上面这些题。前端技术一直在演进面试官也在不断更新手写题的题库。比如现在很多团队开始考察对 RequestIdleCallback 的理解或者让你实现一个带取消功能的 fetch这些都是基于经典手写题的变形延伸。掌握好基础原理遇到新题时才能举一反三而不是慌乱。
返回列表