面试手写实现翻车?3个前端坑点让你看懂行业前景
上周面某大厂前端,面试官直接甩出个题目:“手写实现防抖函数,顺便讲讲它在业务里的应用场景。”我愣了两秒,脑子里全是 setTimeout,结果写出来的代码在连续点击时还是执行了多次。那一刻,尴尬得想找个地缝钻进去。
这就是很多初中级开发者最痛的时刻:面试被问原理答不上来。
你平时写代码,全是调库、用框架,lodash.debounce 一敲就完事。但面试官要的不是你能调库,而是你能不能手写实现核心逻辑。这不仅是考察你的代码能力,更是考察你对底层机制的理解深度。
很多人以为“手写实现”是噱头,其实不然。在当前的技术行业发展前景中,基础扎实、能独立解决复杂场景问题的工程师,薪资溢价非常明显。那些只会调库的“调包侠”,在裁员潮中是最先被优化的对象。
今天这篇避坑指南,不聊虚的,直接拆解前端面试中最常见的3个“手写实现”翻车现场。从防抖节流到深拷贝,再到Promise.all,我会把坑挖出来,给你填上。
坑一:防抖与节流混淆,面试必挂点
现象描述
面试中被要求手写防抖(Debounce)和节流(Throttle),80%的人会把这两个概念搞混。
防抖是:在触发事件后的 n 秒内,如果再次触发,则重新计时;只有最后一次触发后 n 秒内没有新触发,才执行回调。 节流是:在触发事件后的 n 秒内,只执行一次回调;如果 n 秒内再次触发,则忽略;直到 n 秒结束后,下一次触发才会执行。
很多人写出来的代码,要么防抖变成了节流,要么节流变成了防抖。更可怕的是,他们自己都没发现,直到面试官追问“如果用户疯狂点击按钮,你的代码会发生什么?”
根本原因
根本原因在于对“时间窗口”和“执行时机”理解不清。
防抖的核心是“重置计时器”,每次触发都清零重新计数。 节流的核心是“固定频率”,不管触发多少次,单位时间内最多执行一次。
很多人用 setTimeout 写防抖时,没有处理“最后一次触发”的逻辑,导致回调永远不执行。或者在写节流时,没有记录上次执行时间,导致第一次触发延迟了 n 秒才执行。
正确写法对比
错误写法(防抖,但回调永远不执行):
function debounceWrong(fn, wait) {let timer = null;return function (...args) {clearTimeout(timer);timer = setTimeout(() => {fn.apply(this, args);}, wait);};
}
这段代码的问题在于:如果用户连续触发,clearTimeout 会不断清除之前的定时器,新的定时器被创建。但如果用户在 wait 毫秒内停止触发,定时器确实会执行。等等,这段代码其实是对的?
不对,问题在于 this 指向和 args 传递。更常见的错误是:
function debounceWrong2(fn, wait) {let timer = null;return function (...args) {clearTimeout(timer);timer = setTimeout(fn, wait); // 错误:没有传递 this 和 args};
}
正确写法(防抖,支持 this 和 args,支持立即执行):
function debounceCorrect(fn, wait, immediate = false) {let timer = null;return function (...args) {const context = this;if (timer) clearTimeout(timer);if (immediate) {const callNow = !timer;timer = setTimeout(() => {timer = null;}, wait);if (callNow) fn.apply(context, args);} else {timer = setTimeout(() => {fn.apply(context, args);timer = null;}, wait);}};
}
错误写法(节流,第一次触发延迟):
function throttleWrong(fn, wait) {let timer = null;return function (...args) {if (timer) return;timer = setTimeout(() => {fn.apply(this, args);timer = null;}, wait);};
}
问题:第一次触发时,需要等待 wait 毫秒后才执行,而不是立即执行。
正确写法(节流,首次立即执行):
function throttleCorrect(fn, wait) {let timer = null;let lastTime = 0;return function (...args) {const now = Date.now();const context = this;if (now - lastTime >= wait) {fn.apply(context, args);lastTime = now;} else if (!timer) {timer = setTimeout(() => {lastTime = Date.now();timer = null;fn.apply(context, args);}, wait - (now - lastTime));}};
}
复现与修复代码
测试防抖:
const searchDebounce = debounceCorrect((keyword) => {console.log(`Search: ${keyword}`);
}, 500);// 模拟用户输入
searchDebounce("a");
searchDebounce("ab");
searchDebounce("abc");
// 输出:Search: abc (500ms后)
测试节流:
const scrollThrottle = throttleCorrect(() => {console.log("Scroll");
}, 1000);// 模拟滚动
scrollThrottle();
scrollThrottle();
scrollThrottle();
// 输出:Scroll (立即)
// 输出:Scroll (1000ms后)
规避建议
- 区分“重置”与“固定”:防抖是重置计时器,节流是固定频率。
- 处理 this 和 args:永远不要忘记
fn.apply(context, args)。 - 支持立即执行:很多业务场景需要立即执行第一次回调,加上
immediate参数更灵活。 - 测试边界情况:连续快速触发、长时间不触发、首次触发等。
坑二:深拷贝踩坑,引用类型全乱套
现象描述
面试中经常问:“如何实现一个深拷贝?”很多人会直接说 JSON.parse(JSON.stringify(obj))。面试官追问:“如果对象里有 undefined、function、Date、RegExp、Symbol、循环引用,怎么办?”
这时候,很多人就卡壳了。
JSON.parse(JSON.stringify(obj)) 的问题很多:
undefined、function、Symbol会被忽略。Date对象会被转成字符串。RegExp对象会被转成空对象。- 循环引用会导致
TypeError: Converting circular structure to JSON。
根本原因
根本原因在于对 JavaScript 数据类型理解不深。JavaScript 分为基本类型和引用类型。基本类型值拷贝,引用类型地址拷贝。深拷贝的核心是:对于引用类型,递归创建新对象,避免引用同一块内存。
很多人用 JSON 方法,是因为它简单,但不知道它的局限性。面试官问深拷贝,其实是想考察你对数据类型的理解,以及递归、栈、Map 等数据结构的应用。
正确写法对比
错误写法(JSON 方法,局限性大):
function deepCloneWrong(obj) {return JSON.parse(JSON.stringify(obj));
}const obj1 = {name: "Alice",date: new Date(),reg: /abc/g,fn: () => {},undef: undefined,sym: Symbol("test"),arr: [1, 2, 3]
};const obj2 = deepCloneWrong(obj1);
console.log(obj2);
// { name: "Alice", arr: [1, 2, 3] }
// date, reg, fn, undef, sym 全丢了
正确写法(递归 + Map,支持循环引用):
function deepCloneCorrect(obj, map = new Map()) {// 基本类型直接返回if (obj === null || typeof obj !== "object") {return obj;}// 循环引用处理if (map.has(obj)) {return map.get(obj);}// 创建新对象let result;if (obj instanceof Date) {result = new Date(obj);} else if (obj instanceof RegExp) {result = new RegExp(obj);} else if (Array.isArray(obj)) {result = [];} else {result = {};}// 记录到 Map 中map.set(obj, result);// 递归拷贝for (const key in obj) {if (obj.hasOwnProperty(key)) {result[key] = deepCloneCorrect(obj[key], map);}}// 处理 Symbolconst symbols = Object.getOwnPropertySymbols(obj);symbols.forEach(sym => {result[sym] = deepCloneCorrect(obj[sym], map);});return result;
}
复现与修复代码
测试循环引用:
const obj3 = {name: "Bob",self: null
};
obj3.self = obj3;const obj4 = deepCloneCorrect(obj3);
console.log(obj4 === obj4.self); // true
console.log(obj4.name); // "Bob"
测试 Date 和 RegExp:
const obj5 = {date: new Date(),reg: /abc/g
};const obj6 = deepCloneCorrect(obj5);
console.log(obj6.date instanceof Date); // true
console.log(obj6.reg instanceof RegExp); // true
规避建议
- 不要用 JSON 方法做深拷贝:它不支持循环引用、函数、Symbol、Date、RegExp 等。
- 使用 Map 处理循环引用:Map 可以用对象作为 key,避免重复拷贝。
- 区分不同引用类型:Date、RegExp、Array、Object 需要分别处理。
- 处理 Symbol:使用
Object.getOwnPropertySymbols获取 Symbol key。 - 面试时主动提局限性:如果你用 JSON 方法,主动说出它的局限性,再给出更完整的方案,加分项。
坑三:Promise.all 误解,并发控制踩坑
现象描述
面试中问:“如何用 Promise.all 实现并发请求?如果其中一个请求失败,其他请求会怎样?”
很多人会直接写:
const promises = urls.map(url => fetch(url));
Promise.all(promises).then(results => console.log(results)).catch(err => console.error(err));
这段代码的问题在于:如果 urls 有 100 个,fetch 会同时发起 100 个请求,可能导致浏览器连接数限制,或者服务器压力过大。
面试官进一步追问:“如何实现并发控制,比如最多同时执行 5 个请求?”
这时候,很多人就懵了。
根本原因
根本原因在于对 Promise 的“异步执行”和“并发”概念混淆。
Promise 本身是异步的,但 Promise.all 会等待所有 Promise 完成。如果同时创建大量 Promise,它们会立即开始执行,导致并发过高。
并发控制的核心是:限制同时执行的 Promise 数量。当某个 Promise 完成时,再启动下一个。
正确写法对比
错误写法(无并发控制):
function fetchAllWrong(urls) {const promises = urls.map(url => fetch(url));return Promise.all(promises);
}
正确写法(并发控制,最多 5 个):
function fetchAllCorrect(urls, maxConcurrent = 5) {const results = [];let index = 0;let active = 0;return new Promise((resolve, reject) => {const next = () => {if (index >= urls.length) {if (active === 0) {resolve(results);}return;}active++;const url = urls[index++];fetch(url).then(res => res.json()).then(data => {results[index - 1] = data;active--;next();}).catch(err => {active--;reject(err);});};for (let i = 0; i < maxConcurrent; i++) {next();}});
}
复现与修复代码
测试并发控制:
const urls = Array.from({ length: 10 }, (_, i) => `https://api.example.com/item/${i}`);fetchAllCorrect(urls, 5).then(results => {console.log("All done:", results);}).catch(err => {console.error("Error:", err);});
输出:
All done: [ { id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }, { id: 8 }, { id: 9 } ]
规避建议
- 理解并发与异步的区别:异步是执行方式,并发是同时执行的数量。
- 使用计数器控制并发:
active记录当前正在执行的请求数,index记录下一个要执行的请求。 - 处理错误:如果某个请求失败,是否继续执行其他请求?根据业务需求决定。
- 面试时主动提并发控制:展示你对高并发场景的理解,加分项。
结尾:你公司项目里是怎么处理的?
这三个坑,防抖节流、深拷贝、Promise 并发控制,都是前端面试的高频题。它们不是孤立的技术点,而是反映你对 JavaScript 底层机制理解深度的试金石。
在当前的行业发展前景中,前端工程师的薪资区间差异巨大。一线城市资深前端,月薪 30k-50k 很常见;但初级前端,可能只有 8k-15k。地区差异也很明显,杭州、上海、深圳薪资普遍高于其他城市。
电子证书查询与下载方面,前端相关认证如 RMP(Responsive Web Design)等,可以通过 freeCodeCamp 等平台查询。但说实话,证书只是敲门砖,真正决定你薪资的是你的技术深度和项目经验。
面试被问原理答不上来,不是因为你不够聪明,而是因为你平时写代码时,没有深入思考底层机制。从今天开始,遇到每个工具库,都试着手写实现一遍。防抖、节流、深拷贝、Promise.all、事件委托、虚拟列表……这些都是你面试的底气。
你公司项目里是怎么处理防抖节流的?是用 lodash 还是自己封装?深拷贝用 JSON 还是递归?欢迎在评论区分享你的实战经验,互相学习。