前端面试刷题这件事,我一直有个观点:面试官问一道题,表面在考你记没记住某个API,实际在考你有没有在真实项目里踩过它、绕过它、优化过它。所以这3道题,我刻意没有选那种“背了就会”的题,三道题都有真实场景兜底,每道题都给了完整可跑的代码。如果你最近在准备2026年的前端面试,或者已经在面试战场上了,这篇应该对你有用。
1.Cannot read properties of undefined:90%的前端都只会打补丁,不会根治
1.1 面试题原题与出题人意图
先看题。面试官在屏幕上贴出这段代码:
const user = { profile: { name: '张三' } }; console.log(user.profile.address.city);运行直接报错,错误信息是:
TypeError: Cannot read properties of undefined (reading 'city')面试官会紧接着问三个问题,层层递进:
- 这段代码为什么报错?错误信息里的
undefined指的是谁? - 让你修复它,你会怎么做?
- 如果
user、user.profile、user.profile.address这三层都有可能是undefined,你的修复方案还能不能打?
这道题每年能挂掉一大批人,不是因为大家不知道答案,而是因为绝大多数人的修复方案只停留在“把这一行改对”的层面,没有想过:为什么这个数据这么深?为什么这里会缺一层?
实际上,出题人想考察的是三件事:
- 你对 JavaScript 运行时错误本质的理解,也就是说,你拿到一个 TypeError 之后,能不能快速定位是哪一层的哪个属性出了问题;
- 你对“数据不可控”这件事的敏感度,后端返回的数据、用户输入的数据、缓存的旧数据,都可能是脏数据;
- 你有没有在真实项目里封装过一套通用的安全取值方案,而不是到处写
if (xxx && xxx.yyy)。
大多数人的第一反应是写user.profile.address && user.profile.address.city,这种修法就像墙上有个洞,你用海报把它遮住——表面看是好了,实际上洞还在,而且每个需要取 city 的地方都要贴一张“海报”。
1.2 错误本质:可选链背后的执行机制
要根治,先要理解 JS 在读取属性时到底做了什么。
user.profile.address.city这行代码,在执行引擎内部大致经历了这样的过程:
- 从
user变量里取出当前值; - 访问这个值的
profile属性; - 访问
profile的address属性; - 访问
address的city属性。
这一步一步的“属性访问”,每一环都要求前一步的结果不是null或undefined。一旦某一步拿到的是undefined,下一步再对它访问属性,引擎就只能抛TypeError。
打个比方,这就像你在机场转机,一共有四段行程:你本人先到 A 登机口,再从 A 飞到 B,再从 B 飞到 C,最后从 C 飞到目的地。结果你到了 B 发现下一班航班压根不存在,你人只能在 B 机场原地发愣。这个“原地发愣”的过程,就是引擎抛出的 TypeError。
所以错误信息里写的Cannot read properties of undefined (reading 'city'),翻译成人话就是:在取city这个属性的时候,前面那一层是undefined。具体哪一层,需要自己从代码里顺藤摸瓜。address是undefined,所以访问address.city报错;如果profile是undefined,报错就变成 reading'address';如果user本身是undefined,报错就变成 reading'profile'。
理解了这一点,再看现代 JS 给出的标准解法——可选链(Optional Chaining):
const city = user?.profile?.address?.city; console.log(city); // undefined,不再抛错?.的作用是:当它前面的值不是null或undefined时,才继续访问后面的属性;否则整个表达式直接短路返回undefined。
很多教程把可选链讲得太浅,只告诉你“用了就不报错”,但面试官如果追问一句“为什么加了?.就不报错”,你得能说出上面那段引擎执行逻辑。?.本质上是在每一环属性访问之前,加了一层“判空闸门”,只有闸门放行,才会继续走下去。
1.3 从面试到实战:封装一个链式安全取值函数
如果面试只停留在?.,这道题只能算热身。真正的分水岭在第三个问题:如果每一层都可能缺失,而且你需要在缺失时给出默认值,怎么写才能让整个项目受益?
function get(obj, path, defaultValue) { const keys = path.split('.'); let result = obj; for (const key of keys) { if (result == null) return defaultValue; result = result[key]; } return result === undefined ? defaultValue : result; } const user = { profile: { name: '张三' } }; const city = get(user, 'profile.address.city', '未知城市'); console.log(city); // 未知城市这段代码的思路很简单:把'profile.address.city'按.拆成三段,从obj出发一层一层往下找,只要发现某一层已经是null或undefined,立刻返回兜底值。
不过只支持.路径还不够,真实项目里数组很常见。公司项目里从后端拿到的数据经常是data.list[0].userInfo.name这种形态,单纯按.拆分会把list[0]拆成list[0和],完全取不到。所以进阶版要兼容数组下标:
function get(obj, path, defaultValue) { const keys = path .replace(/\[(\w+)\]/g, '.$1') // 把 list[0] 转成 list.0 .replace(/^\./, '') // 去掉开头的点 .split('.'); let result = obj; for (const key of keys) { if (result == null) return defaultValue; result = result[key]; } return result === undefined ? defaultValue : result; } const data = { list: [{ userInfo: { name: '李四' } }] }; const name = get(data, 'list[0].userInfo.name', '匿名'); console.log(name); // 李四这两个版本在面试时写出来,已经比大多数候选人强了。但如果你是去面试高级前端岗,我建议你再往下想一层:lodash 里的_.get为什么比我们手写的稳?
原因在于它内部处理了几种边界情况:
obj本身是null或undefined时不报错;path是数组时也能处理,比如_.get(obj, ['profile', 'name']);- 对非法路径字符做了过滤,避免恶意字符串干扰;
- 性能上做了缓存,同一个 path 不会反复拆分。
手写版能覆盖前两点已经足够通过面试,但如果生产环境允许引 lodash,直接用_.get是性价比更高的选择。
1.4 高频追问:如何区分“属性不存在”和“属性值为 undefined”
这题我很喜欢拿来追问,因为它极其贴近实际,但网上的题解很少提到。
JavaScript 里有一个隐蔽的陷阱:user.profile.name取出来是undefined,有可能是profile对象上根本没有name这个属性,也有可能是name属性的值就是undefined。两者用=== undefined判断结果一模一样,但业务含义完全不同。
比如:
const user1 = { profile: {} }; const user2 = { profile: { name: undefined } }; console.log(user1.profile.name); // undefined console.log(user2.profile.name); // undefined第一行是“属性不存在”,第二行是“属性存在但值为 undefined”。如果业务上需要区分这两种情况,得用hasOwnProperty或in:
console.log(user1.profile.hasOwnProperty('name')); // false console.log(user2.profile.hasOwnProperty('name')); // true console.log('name' in user1.profile); // false console.log('name' in user2.profile); // true这个知识点在数据校验、表单默认值、接口兼容场景里非常实用。比如后端老接口没有返回name字段,新接口开始返回name: null,前端如果不区分,直接把?? '匿名'加上去,会把“后端明确告诉你是空”和“后端根本没这个字段”混为一谈。生产环境里,这俩往往意味着不同的处理策略。
所以这道题完整的高分回答链路是:先解释报错原因,再给出现代语法?.,再升级到通用get函数,最后补充“属性不存在 vs 值为 undefined”的区分。每一层都比前一层更深,面试官想听的就是这个递进。
注意:
??(空值合并运算符)只在值为null或undefined时才取默认值,而||会在值为0、''、false时也触发默认值。如果默认值逻辑只针对“空”,用??;如果针对“假值”,用||。这个细节在面试和项目里都容易翻车。
2. 手写 Promise.all:会写不等于懂,重点全在“竞态”和“批量失败”
2.1 为什么面试官偏爱手写 Promise.all
十个面试官里有八个会让你手写 Promise.all,剩下两个会问你allSettled和race的区别。这道题看起来很“八股”,但它能一次性考察四层能力:
- 基础语法:
new Promise、then、catch,写不出来说明基本工不过关; - 异步协调:多个并行请求要等全部完成,一个失败就整体失败,怎么设计状态管理;
- 细节敏感度:空数组、非 Promise 元素、结果顺序、索引错位,任何一处考虑不到都会出 bug;
- 原理理解:只用过
Promise.all的人,写不出来它的引擎级行为。
所以我们先明确Promise.all的完整语义,再逐层实现。
Promise.all的语义:
- 接收一个可迭代对象(通常是数组),返回一个新的 Promise;
- 如果数组里的元素不是 Promise,会先被
Promise.resolve包装; - 所有 Promise 都成功时,返回的 Promise 进入 fulfilled,结果是一个数组,顺序和输入数组一一对应;
- 只要有一个 Promise 失败,返回的 Promise 立刻进入 rejected,失败原因是第一个失败的那个错误;
- 传入空数组时,直接 fulfilled,结果是
[]; - 失败之后,其他未完成的 Promise 不会被取消,但结果不会再影响整体结果。
前五条是网上一搜一大把的结论,第六条才是实战里最容易栽跟头的地方。
2.2 从零手写:需要考虑细节的完整版
先给出一份能跑、且能覆盖绝大多数面试追问的实现:
function myPromiseAll(promises) { return new Promise((resolve, reject) => { if (!Array.isArray(promises)) { return reject(new TypeError('promises must be an array')); } const length = promises.length; if (length === 0) { return resolve([]); } const results = new Array(length); let completedCount = 0; let isSettled = false; // 防止重复 resolve/reject promises.forEach((item, index) => { Promise.resolve(item).then((value) => { if (isSettled) return; results[index] = value; completedCount += 1; if (completedCount === length) { isSettled = true; resolve(results); } }).catch((error) => { if (isSettled) return; isSettled = true; reject(error); }); }); }); } // 验证 const p1 = Promise.resolve(1); const p2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000)); const p3 = 3; // 非 Promise 元素 myPromiseAll([p1, p2, p3]).then((res) => { console.log(res); // [1, 2, 3] });这份代码每一个细节都值得说一遍,因为它们对应着真实项目里的各种 bug。
results = new Array(length)这一步很关键。很多人图省事写成const results = [],然后在then里results.push(value)。这样写有两个问题:结果数组的顺序可能和输入不一致(快的先 push,慢的后 push);数组长度在某个瞬间会小于输入长度。用固定长度的数组,按索引赋值,顺序就永远是对的。面试官如果不追问,你自己主动提出来,印象分会拉满。
Promise.resolve(item)的包装解决的是“数组里混入普通值”的情况。这一步不写,遇到myPromiseAll([1, 2, 3])这种输入,直接item.then就报错了。真实项目里的数据往往来自 Map 遍历、Filter 遍历,混入null或普通对象极其常见,这个包装相当于给你的 Promise.all 加了一层“兼容层”。
isSettled标记解决的是两个问题:一是同一个 Promise 微任务队列里resolve和reject都触发时,只认第一个;二是第一个失败已经触发整体reject之后,后面成功的 Promise 不应该再去碰resolve。这是“单次决议”语义的关键。
completedCount === length是整体成功条件。只有全部完成,整体才进入 fulfilled。如果你用results.length === length判断也会踩坑,因为固定长度数组results的长度从创建那一刻起就是length,永远不会变。
2.3 真实场景:批量请求接口时如何用 Promise.all 控制并发与兜底
面试考手写,项目里真正用到的其实是Promise.all的策略。
场景一:批量拉取多个详情接口。比如一个订单列表,每条订单需要拉取单独的物流信息:
const orderIds = ['1001', '1002', '1003']; const tasks = orderIds.map((id) => fetch(`/api/order/${id}/logistics`).then((res) => res.json()) ); Promise.all(tasks) .then((logisticsList) => { // logisticsList 与 orderIds 顺序一致 renderLogistics(logisticsList); }) .catch((err) => { // 只要有一个订单的物流接口挂了,这里就会触发 Toast.error('部分物流信息加载失败'); });这里有个体验问题:物流接口偶尔挂一个,整页这个模块就全挂了,用户感受很差。所以更稳的写法是先用.catch给每个任务加上“保底数据”:
const tasks = orderIds.map((id) => fetch(`/api/order/${id}/logistics`) .then((res) => res.json()) .catch(() => ({ orderId: id, status: '未知' })) // 单点失败兜底 ); Promise.all(tasks).then((logisticsList) => { renderLogistics(logisticsList); });这样Promise.all永远走成功分支,因为每个任务内部已经把异常“吞”掉了。它和Promise.allSettled的区别在于:allSettled会把每个任务的“成功/失败”状态原样返回给你,由你来决定要不要兜底;而这里是自己先兜底,再交给all统一处理。
面试时如果聊到这里,可以主动引出Promise.allSettled的语义:它不会因为某一个失败而整体 reject,而是等所有任务结束后,返回一个包含{ status: 'fulfilled', value }或{ status: 'rejected', reason }的数组。适合“多个独立任务,逐个展示状态”的场景。你在简历里写过“用 Promise.all 做并行请求优化”,却不了解 allSettled,面试官一眼就能看出你没在真实项目里处理过局部失败。
场景二:并发控制。Promise.all本身不限制并发数,所有任务都是同时发起。如果业务上有“最多同时 5 个请求”的限制,Promise.all就管不住了,需要自己封装一个并发池,或者用p-limit这类库。这个属于另一道题,但面试官很可能顺着“并发”这个词追问,提前准备一个简单的池实现是一种很好的加分策略:
async function runWithLimit(tasks, limit) { const results = []; const pool = new Set(); for (const task of tasks) { const p = Promise.resolve().then(() => task()); results.push(p); pool.add(p); const clean = () => pool.delete(p); p.then(clean, clean); if (pool.size >= limit) { await Promise.race(pool); } } return Promise.all(results); }这段代码的思路是用一个 Set 保存进行中的任务,超过 limit 就Promise.race等一个先结束,腾出坑位再继续塞。实际项目里做上传组件、数据大屏轮询、批量导出,都能用上。
2.4 高频追问:手写 Promise.allSettled 和 Promise.race 的变体
面试时间够长的话,面试官大概率会加问两个变体。
Promise.allSettled的手写版,核心区别是把“有失败就整体 reject”改成“等所有任务结束后,把成功和失败都记录下来”:
function myPromiseAllSettled(promises) { return new Promise((resolve) => { const results = []; let completed = 0; const length = promises.length; if (length === 0) return resolve([]); promises.forEach((item, index) => { Promise.resolve(item) .then((value) => { results[index] = { status: 'fulfilled', value }; }) .catch((reason) => { results[index] = { status: 'rejected', reason }; }) .finally(() => { completed += 1; if (completed === length) resolve(results); }); }); }); }Promise.race的手写版更简单,谁先到就听谁的:
function myPromiseRace(promises) { return new Promise((resolve, reject) => { promises.forEach((item) => { Promise.resolve(item).then(resolve, reject); }); }); }注意then(resolve, reject)这个写法,一个 then 同时绑定了成功和失败回调,等价于then(resolve).catch(reject),但整体看起来更简洁。
这三个手写实现放一起比较,能看出来 Promise 的核心思想:一个 Promise 只能决议一次,谁先改变状态,后续的变更全部无效。理解了这一点,手写任何 Promise 组合方法都不再是背代码,而是顺着语义自然推出来的。
我在实际面试中见过很多候选人把Promise.resolve(item).then写成item.then,追问一句“数组里如果有普通对象怎么办”,就答不上来。这其实就是对“Promise 包装”这一步理解不到位。你只要在面试里主动说出这一步的作用,面试官基本能确认你对异步是有真实体感的。
3. “闭包导致的内存泄漏”:这题为什么要用 DevTools 证明,而不是背结论
3.1 题目原貌与常见误解
第三道题是概念题,但它比前两道更容易暴露水平。原题如下:
function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); counter(); counter();面试官问:这个函数有没有内存泄漏?如果继续调用counter一万次,count会不会越来越大?怎么优化?
网上的“标准答案”很混乱,有的说会内存泄漏,有的说不会。事实是:这段代码本身不会内存泄漏,但它在某些使用方式下会变成内存泄漏的源头。
为什么不会泄漏?因为count变量被闭包引用,形成一个完整的“闭包环境”,这个环境跟着counter函数的生命周期走。只要counter还被外部引用,这个环境就应该存在,这是正常的闭包持有,不是泄漏。count的值每次调用后累加,但这不影响它所占的内存大小——一个Number不管是 1 还是 1000000,占的内存都是一样的。所以你调用一万次,内存不会因此显著增长。
那什么时候会变成泄漏?常见场景是把闭包函数挂在全局变量上,或者挂在一个长期存在的 DOM 节点事件回调里,而且永远不再需要它了:
const handlers = []; function addHandler(node) { let privateData = { largeArray: new Array(1000000).fill('x') }; const handler = () => { console.log(node.id, privateData); }; node.addEventListener('click', handler); handlers.push({ node, handler, privateData }); }privateData被handler闭包引用,handler被事件监听器引用,事件监听器被node引用,而node如果一直挂在 DOM 上,这整条链就断不了。等到你要销毁这个节点时,如果只执行node.remove()而不移除事件监听器,handlers数组还存着引用,privateData里的巨大数组就永远无法被 GC 回收。这就是教科书里说的“闭包导致内存泄漏”的真实面貌。
3.2 用 Chrome DevTools 亲手证明一次
概念说不清楚,直接用工具证明。这是我强烈建议每一位去面试前都自己做一遍的实验。
- 打开 Chrome,按 F12 进入 DevTools;
- 切到 Performance 面板,勾选 Memory;
- 在控制台执行下面这段代码:
function createHeavyClosure() { const bigData = new Array(500000).fill('memory-leak-demo'); return function () { return bigData.length; }; } window.leakedClosures = []; for (let i = 0; i < 100; i++) { window.leakedClosures.push(createHeavyClosure()); }- 点击开始录制,等几秒钟,点击 Stop,观察 JS Heap 曲线。
正常情况下,曲线会先上升,然后回落到一个平稳基线。如果执行完上面的代码后,堆内存出现了持续向上的台阶,并且 GC 之后仍然不下降,说明这些闭包数据一直被引用着没法回收。
然后你再执行:
window.leakedClosures = null; // 或者 window.leakedClosures.length = 0;再录制一次,会看到之前那个稳定高台开始回落,这正是 GC 把原来的闭包环境回收后的效果。
这个实验的直观结论是:闭包不是问题,被不该存活的引用链才是问题。DevTools 的 Memory 面板里还有一个 Heap Snapshot 工具,可以抓取堆快照,然后搜索bigData或detail来定位具体的保留树(Retainers),这在排查线上问题时会非常有用。
如果面试时间允许,你可以主动演示这套操作。面试官看到你熟练地用 DevTools 取证,而不是背结论,基本就能判断你真的定位过线上内存问题。
3.3 内存泄漏的三大真实来源与修复思路
顺着刚才的实验往下说,前端内存泄漏最常见的三个来源,每一个都应该能举出项目和修复方案。
第一个来源:事件监听器没有解绑。比如单页应用里,进入页面时给 window 绑定了resize监听,离开页面时忘记移除。页面组件销毁了,监听器还留在 window 上,闭包捕获的 DOM 节点和业务数据全都没法释放。修复思路是在组件卸载生命周期里调用removeEventListener,或者用 AbortController 统一管理:
useEffect(() => { function handleResize() { // ... } window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []);第二个来源:定时器没有被清理。setInterval的回调里如果引用了外部变量,在不需要定时器的时候必须clearInterval。很多新手只记得创建,不记得清理,导致页面卡顿、CPU 占用高,还不一定立刻崩。修复思路和事件解绑一样,在卸载或取消时清理。
第三个来源:全局变量和缓存无限制增长。前端经常会做“内存缓存”:把接口结果塞进一个 Map 或对象里,避免重复请求。但如果不做上限控制,这个 Map 会随着用户操作不断膨胀,最后把页面拖垮。修复思路是使用 LRU 淘汰策略,或者定期清理过期数据。
每个来源都能对应到“为什么闭包本身无罪,但闭包经常与泄漏一起出现”——因为闭包是最常见的“捕获数据的载体”,只要引用链断不掉,被捕获的数据就永远活着。
3.4 从概念题到考察点:WeakMap 的价值与场景
聊到这里,面试官很可能会加问:有没有什么数据结构能主动避免这种问题?这时候WeakMap/WeakSet就该登场了。
WeakMap的 key 必须是对象,而且它持有的是“弱引用”,意思是:如果在别处已经没有对 key 对象的引用了,这个 key 连同WeakMap里对应的 value,都可以被 GC 回收,不需要手动删除。
举个例子,统计一个按钮被点击的次数:
const clickCountMap = new WeakMap(); function recordClick(domNode) { const count = clickCountMap.get(domNode) || 0; clickCountMap.set(domNode, count + 1); } const btn = document.getElementById('btn'); recordClick(btn);如果哪一天这个按钮从 DOM 树中被移除,且外部不再引用btn,那么clickCountMap里的这条记录也会跟着被回收。换成普通的Map,就必须手动delete,漏删就是一次泄漏。
类似的场景还可以用WeakSet:
const processed = new WeakSet(); function processNode(node) { if (processed.has(node)) return; processed.add(node); // do something }这个“弱引用”特性,面试官几乎一定会追问“WeakMap 为什么不支持基本类型 key”“为什么不能遍历”。答案是因为弱引用本身不可预测,引擎无法保证某个弱引用对象什么时候被回收;如果支持遍历,就会出现“你还没遍历完,其中某个 item 已经被回收了”的尴尬局面。
注意:不要在生产环境里为了用而用 WeakMap。如果你的业务数据本来就要求长期存活,或者 key 不是对象,WeakMap 就不合适。它适合的恰恰是“生命周期跟随对象走”的场景。
4. 面试礼仪与临场表达:这三道题怎么答才能拿高分
4.1 “先说结论,再展开”的表达框架
前面三道题,每一道都可以拆成“结论 → 项目佐证 → 方法论”三层来答。但很多人一开口就是“闭包会导致内存泄漏,因为闭包会保留变量巴拉巴拉”,直接把概念讲成了八股,没有结合自己的项目经验,面试官很难判断你到底有没有实际处理过问题。
如果面试官问“讲一下闭包导致的内存泄漏”,你的回答框架应该是:
- 先纠正概念:闭包本身不泄漏,泄漏发生在引用链无法释放的场景;
- 给出一个真实项目里踩过的泄漏场景,比如单页应用里 window 绑定了 resize 回调,组件销毁时没有移除;
- 说明自己是怎么定位的:用 Chrome DevTools 的 Performance 录制堆曲线,或者 Memory 面板抓快照看 Retainers;
- 最后才总结出“解绑、清理定时器、控制缓存”这三大类修复方案。
这种“先亮结论,再用项目佐证,最后归纳方法”的顺序,会让面试官觉得你是在讲自己的经验,而不是在背网上的面经帖。
4.2 被追问“还有吗”时,怎么扩展广度
“还有吗”是面试官常用的压力测试,本质是想看你在这个点上的知识边界。
以第一题为例,如果你已经回答了?.、空值合并、默认值,面试官还追问“还有吗”,你可以往两个方向扩展:
- 往工程化方向说:可以提 TypeScript 里如何用类型系统约束可选属性,如何用
??配合严格空值检查把“潜在 undefined”消灭在编译期; - 往边界场景说:可以提
JSON.stringify遇到undefined会直接丢字段、Object.keys不会列出值为undefined的键,这两个坑在前端做“数据上报”和“表单重置”时非常常见。
再比如第二题,问到“还有吗”,可以说并发控制、请求竞态、超时处理。如果项目里真的做过上传组件,可以把那套“分片上传 + 任务队列 + 错误重试”的经验讲出来,面试官很容易从中获取你真实项目规模和信息。
这里有个很受用的经验:宁可在自己的经验范围内讲得深,也不要去背那些“热点冷知识”。一旦你抛出一个自己都理解不深的新名词,面试官顺着追问两三层,很容易看出水分,那就不只是丢分,而是直接扣印象分。
4.3 要不要写完整代码给面试官看
能写则写,但要把代码写在“讲完思路之后”。一上来就先写代码,面试官还没进入状态,你的代码也容易被挑刺;讲完思路再写代码,代码只是在验证你的思路,双方都能聚焦在“为什么这样设计”上。
写代码时注意几个细节:
- 先声明复杂度,特别是时间复杂度和空间复杂度;
- 命名要清晰,能用
results就不要用arr,能用completedCount就不要用n; - 边界条件写在最前面:空数组、非法参数、非 Promise 元素;
- 写完顺手跑一两个测试用例,哪怕只是在脑子里跑。
面试官看候选人手写代码,重点看的其实不是代码本身多完美,而是你的思考过程是否完整、边界意识是否到位、表达是否清晰。哪怕最后写出来的不是最优解,只要过程中让面试官看到你在系统性地分析和取舍,分数一样不低。
我在带团队面人时有个习惯:候选人写代码时如果会主动说“这里我加个边界判断”“这里用 Set 是为了去重”“这里其实还可以用 xx 优化,但可读性会变差”,我会直接在心里给他加一分——因为这些细节体现的是真实工程思维,不是在背题。
5. 这三道题背后的共同底层能力:定位、边界、取证
三道题看起来各不相关,但往下挖,它们考的是同三件事。
定位能力:第一道题拿到报错,能不能一眼看出是哪一层出了 undefined;第三道题遇到页面卡顿,能不能判断是闭包引用链还是事件泄漏。不会定位,就只会把代码抄来抄去。
边界意识:第二道题里空数组、非 Promise 元素、结果顺序,每一处都是边界;第一道题的“属性不存在 vs 值为 undefined”也是边界。实际项目里接近一半的 bug 都出在“正常路径没跑通”之外。
取证能力:第三道题的 DevTools 证明过程,本质上是在用工具验证自己的假设。这也是一种排障习惯:先假设,再验证,最后修复,而不是一上来就改代码碰运气。
前端面试题目千变万化,题库越刷越长,但如果你是带着这三层能力去应对的,会发现大部分题都可以拆成“现象 → 原理 → 边界 → 工程化”四段式去回答。2026 年的前端面试更注重候选人能不能把“知道”转化为“解决过”,这三道题恰恰都是很好的试金石。
我个人的体会是:面试前的突击刷题只能帮你保住下限,真正拉开差距的,是你有没有在真实项目里亲手踩过、修过、总结过这些坑。如果有,哪怕是再“八股”的题目,你也能讲出让面试官眼前一亮的细节;如果没有,背再多题也经不起一句“你项目里遇到过吗”。
提示:文中所有代码示例都为完整可运行版本,建议直接粘贴到浏览器控制台或本地项目中试跑一遍。自己跑通过一遍的代码,才真正属于你。