
最近两周陆续帮几个朋友做了模拟面试有个现象挺扎心的简历上写着“熟练掌握 JavaScript”的候选人基础题答起来反而最容易翻车。问事件循环能背出宏任务微任务的定义换一道带 async/await 的输出排序题就乱问闭包嘴上说着“函数里返回函数”再追问内存回收和真实应用就沉默。我自己当年面试也有过这种尴尬所以干脆把平时积累和实践验证过的高频小题整理成一个系列这是第二期。这期覆盖数据类型、闭包、this 指向、事件循环、深拷贝和手写题六个方向。每道题我会拆成四个层面题目本身、出题意图、标准答法、面试官可能的追问。适合正在准备前端面试的人也适合工作几年后想自查基本功的开发者。标题里写着“八股”但看完你会发现这些题其实全都能在真实开发里找到对应场景。1. 这期选题逻辑高频小题不是背诵题是思维题先解释一下为什么偏偏选这六个方向。JavaScript 的面试题五花八门但你把招聘 JD 翻一遍会发现基础题翻来覆去就那几个核心类型系统、闭包、this、异步、拷贝、手写 API。这不是面试官偷懒是因为这几块恰好覆盖了语言最容易被误解、也最容易在日常开发中踩坑的区域。面试题方向主要考察能力真实开发中的对应场景数据类型与转换对语言底层表示的理解接口返回数据处理、后端字段类型异常兜底闭包与作用域对函数式编程模型的理解防抖节流、React Hooks 闭包陷阱、模块封装this 绑定对调用方式与上下文关系的敏感度事件回调、定时器、Vue/React 方法绑定事件循环对异步模型和运行机制的整体把握接口竞态、加载顺序、渲染性能优化深浅拷贝对引用类型本质的理解表单数据提交、状态管理、不可变数据更新手写 API对语言内置能力的掌握 编码规范工程中抽取公共工具函数、降级方案我在模拟面试时发现一个规律能把八股题答出层次的人往往不是背得最熟的人而是真的自己写过、调试过的人。比如“闭包是什么”这种题如果候选人能主动从闭包讲到内存回收再讲到 Vue 的 watch 或 React 的 useEffect 依赖数组面试官就算后面不追问心里也已经给了高分。所以我整理这些题目时特意把“跟开发实践的关联”放在重要位置你背的时候也不是为了背是为了把这些知识点在脑子里跟真实场景挂上钩。另外说句实在话面试阶段问八股本质是在筛选“有没有建立过自己的知识体系”。没有体系的人答完一个点就停住了有体系的人会自动往上下游延伸。后面每道题的“追问方向”就是在帮你模拟这种延伸。2. 数据类型与类型转换一道题能问出十种答法开头先来最基本的JavaScript 的数据类型分两大类。原始类型包括 string、number、boolean、undefined、null、symbol、bigint引用类型统称 object往下细分有普通对象、数组、函数、Date、RegExp、Map、Set 等。面试官问第一题往往就是“JavaScript 有哪些数据类型”或“typeof 能返回哪些值”。2.1 typeof 和 instanceof基础问题里的暗坑typeof 看起来简单真往深了问有不少坑。它能返回的值有undefined、boolean、string、number、object、function、symbol、bigint。注意没有null这一项typeof null object这是语言设计早期的遗留 bug现在不能修是因为修了会导致大量已上线的代码行为变化。typeof undefined // undefined typeof true // boolean typeof hello // string typeof 42 // number typeof null // object - 历史遗留 typeof {} // object typeof [] // object - 数组也是 object typeof function(){} // function typeof Symbol() // symbol typeof 10n // bigint这个表是面试官很爱深挖的点。如果只背到这一步顶多算及格。真正拉开差距的是后面这一串追问怎么准确判断一个变量是不是数组答案是Array.isArray(arr)或者Object.prototype.toString.call(arr) [object Array]。为什么不用arr instanceof Array因为 instanceof 的原理是沿着原型链找构造函数的 prototype一旦涉及 iframe 或多 realm 环境跨全局对象的数组判断会失败而Array.isArray是内置的、跨 realm 也可靠。Object.prototype.toString.call()能返回什么几乎能区分所有内置类型[object Object]、[object Array]、[object Date]、[object RegExp]、[object Map]、[object Set]等等这是判断类型的最稳方案。面试官问这块本意是看你会不会在真实开发里优雅地处理“类型不靠谱”的问题。比如后端某天把userId从 number 改成了 string你代码里到处写比较线上立刻出问题但如果你在数据入口做了类型归一化就没这事了。2.2 的隐式转换一套规则走天下再看和的区别以及隐式类型转换的规则。很多人知道“会做类型转换不会”但被问到具体怎么转就答不上来了。核心规则其实就几条如果两边类型相同和行为一致。如果一边是 number、一边是 stringstring 会转换成 number。如果一边是 booleanboolean 会先转换成 number也就是true变1、false变0。如果一边是对象、一边是原始类型对象会先转原始值通常先执行valueOf()没得到原始值再执行toString()。null undefined结果为true但它俩和其他任何值都为false。1 1 // true1 转成 1 0 false // truefalse 转成 0 false // true 转成 0false 转成 0 null undefined // true null 0 // false undefined // false [] false // true最后那个[] false是经典中的经典推导过程是false先转成0然后[]转原始值[].valueOf()返回[]不是原始值继续调[].toString()得到再转成0所以0 0结果为true。这个推导过程本身就是一个很好的面试加分项因为面试官能看到你掌握的是“规则推演”而不是“背结论”。实际开发中我的建议是新代码一律用不要依赖的隐式转换。但你要能看懂老代码里的因为线上可能有一堆历史代码还在这么写。隐式转换是语言特性不是洪水猛兽理解它反而能帮你快速排查怪问题。2.3 0.1 0.2必考但很多人讲不清0.1 0.2 ! 0.3这题几乎场场必问。答案是浮点数精度问题JavaScript 里的 number 采用 IEEE 754 双精度标准即用 64 位二进制来存储数字。十进制小数的二进制表示可能是无限循环的比如0.1在二进制里就是无限循环小数计算机只能存储一个近似值。两个近似值相加结果自然也是近似值于是出现0.30000000000000004。这个问题的汇报口径有三个层次第一层说出原因是浮点数的二进制近似存储这是绝大多数人能答到的。第二层给出解决方案比如parseFloat((0.1 0.2).toFixed(10))或者先乘 100 变成整数再除回来。第三层提到Number.EPSILON判断两个小数是否相等时用Math.abs(a - b) Number.EPSILON来比较。面试官追到第三层基本就满意了。这题在开发里对应的是金额计算、百分比展示、图表坐标轴刻度这类场景。我的经验是涉及金额千万别用浮点数直接算要么用整数分单位要么上专门的库普通场景里用toFixed做一个展示位的裁剪就已经足够。3. 闭包背会“函数里 return 函数”只算入门闭包这个大题几乎所有前端面试都会出现。定义层面闭包就是“函数能够访问其外部作用域中变量的能力”更严谨点说当一个函数引用了外部变量并且这个函数在外部变量所在的作用域之外被执行就构成了闭包。3.1 闭包形成的底层条件面试官会问“为什么函数已经返回了变量还没被回收”。这得从执行上下文和词法作用域讲起。JavaScript 的函数在创建时会保存一份定义位置的词法环境引用这个引用跟着函数走。当外层函数执行完按常规逻辑它的局部变量应该被销毁但因为内层函数还持有这个环境的引用垃圾回收器发现这个环境仍然可达就不会回收它。所以闭包形成的两个条件缺一不可一是有函数嵌套二是内层函数引用了外层函数的变量。只嵌套不引用不产生闭包只引用不嵌套本来就访问不到外部变量也不存在闭包。一个最小例子function outer() { let count 0; return function inner() { count 1; return count; }; } const counter outer(); counter(); // 1 counter(); // 2这里count就长期驻留在内存中counter每次调用都能访问到并修改它。这种“持久化局部变量”的能力在真实开发里非常有用但要注意它同时意味着变量不会被自动回收如果滥用就会造成内存泄漏。3.2 真实开发里闭包在哪里出现背闭包定义只算第一步面试官更愿意听你讲实际场景。闭包在工程里最常见的几个出现位置防抖和节流用闭包持久化 timer 或 lastTime 变量不污染全局。这是手写题和项目经验都能聊到的交叉点。函数柯里化add(1)(2)(3)这种连续调用每一步都通过闭包记住之前的参数。模块封装通过 IIFE 把变量藏在函数内部只暴露有限的接口实现类似私有变量的效果。React Hooks 的闭包陷阱useEffect回调里读到旧的 state本质上就是因为回调函数闭包了旧渲染周期的变量。聊 React Hook 闭包陷阱这个点很容易让面试官眼前一亮。比如经典的 Counter 在useEffect里依赖count却只跑一次导致count永远是初始值的问题这背后就是闭包捕获了旧值。能够从八股跳到框架源码行为是区分“背题”和“理解”的分水岭。3.3 循环输出题的典型解法再来看那道经典的循环题for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 100); }直接结果是输出 5 个 5。原因是var声明的i是函数级作用域5 个 setTimeout 回调共享同一个i等到 100ms 后回调执行时循环早已结束i停在了 5。解法有三种用let声明i每次迭代生成一个新的绑定这是最简洁的解法也是 ES6 之后的推荐写法。用 IIFE 包一层把每轮的i作为参数传入形成一个独立闭包。把打印逻辑抽到单独函数里接收参数本质上和 IIFE 一样。// 解法一let for (let i 0; i 5; i) { setTimeout(() console.log(i), 100); } // 解法二IIFE for (var i 0; i 5; i) { (function (j) { setTimeout(() console.log(j), 100); })(i); }面试官追问“解法一的原理是什么”要能答到let 在 for 循环的每个迭代里创建了独立的词法环境回调闭包的是当次迭代的绑定所以拿到的分别是 0 到 4。这题背后其实就是对“块级作用域 闭包捕获时机”两个概念的综合检验。4. this 指向四类绑定规则一题定胜负this 是 JavaScript 里被误解最深的机制之一。面试问 this最终考的是你有没有真正理解“this 的值在调用时决定而不是在定义时决定”这件事。4.1 四种绑定规则与优先级默认绑定函数直接调用非严格模式下 this 指向全局对象浏览器里是 window严格模式下是 undefined。隐式绑定通过对象方法调用this 指向该对象即obj.fn()里 this 指向 obj。显式绑定通过call、apply、bind手动指定 this。new 绑定通过new调用时this 指向新创建的对象。优先级从低到高是默认绑定 隐式绑定 显式绑定 new 绑定。其中bind绑定过的函数再用call不会覆盖 this因为 bind 的绑定优先级更高new 能覆盖 bind 绑定的 this不过实践里很少这么写。// 默认绑定 function fn() { console.log(this); } fn(); // window非严格模式 // 隐式绑定 const obj { name: obj, fn }; obj.fn(); // obj // 显式绑定 fn.call(obj); // obj // 隐式绑定丢失 const f obj.fn; f(); // window因为 f 调用时没有对象上下文4.2 箭头函数如何绕过 this箭头函数没有自己的 this它的 this 是定义时外层词法作用域的 this而且call、apply、bind都无法改变它。这句话背起来容易用起来要小心因为如果定义箭头函数时外层 this 本来就飘了那箭头函数也跟着飘。const obj { name: obj, fn: function () { setTimeout(() { console.log(this.name); }, 100); } }; obj.fn(); // obj如果 setTimeout 里换成普通函数this.name就是 undefined 了。这就是为什么很多代码里会在定时器、异步回调内部用箭头函数“锁住”外层的 this。面试官问这个其实是想看你在实际封装组件或 SDK 时有没有踩过 this 丢失的坑。4.3 高频场景题与常见丢 this 坑场景一定时器里调用对象方法。const user { name: Alice, greet() { console.log(this.name); } }; setTimeout(user.greet, 100); // undefined setTimeout(() user.greet(), 100); // Alice setTimeout(user.greet.bind(user), 100); // Alice第一种写法把函数引用传给 setTimeout执行时已经脱离对象变成了默认绑定。第二种用箭头函数包一层保留对象调用方式。第三种直接 bind 绑定。场景二的追问点是call、apply、bind三者的区别call和apply都是立即执行区别在于传参方式call用逗号分隔参数apply用数组bind返回一个新函数不立即执行且支持预置参数。能把三者在底层实现上的关系讲清楚比如手写一个简易 bind 时用到 apply就是加分项。另外解构赋值和回调传参会丢 this比如const { greet } user; greet();这种本质和前面const f obj.fn一样都是隐式绑定丢失。5. 事件循环宏任务与微任务的顺序题事件循环这题是 JavaScript 面试的“压轴常客”。原因很简单前端整天跟异步打交道请求、定时器、渲染、事件回调全都依赖这套机制不懂事件循环监控上报的时机、加载顺序、加载性能就都说不清楚。5.1 队列是怎么排的先建立一个整体认知JavaScript 是单线程的一次只能执行一段代码。V8 引擎执行完同步代码后会把异步回调按照类型放进不同的队列。宏任务队列包括 script 整体代码、setTimeout、setInterval、I/O 操作、UI 渲染微任务队列包括 Promise 的 then/catch/finally、queueMicrotask、MutationObserver。Node 环境里还有 process.nextTick它比 Promise 的微任务优先级更高。执行规则简单归纳就三句话先执行当前宏任务第一次就是整段 script里的同步代码。同步代码执行过程中产生的微任务放到微任务队列。当前宏任务执行完毕一次性清空微任务队列清空过程中如果又产生了新的微任务继续执行直到队列为空然后再取出下一个宏任务。这也是为什么 setTimeout 的 0 毫秒不是真正的“立即执行”它至少要等当前执行栈和微任务队列全部清空。5.2 两道经典输出题的完整推导第一道基础题console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve().then(() { console.log(3); }); console.log(4);推导过程同步先执行 1 和 4产生一个宏任务setTimeout和一个微任务Promise.then同步执行完毕微任务队列先输出 3之后宏任务输出 2。最终顺序是1, 4, 3, 2。第二道进阶题带 async/awaitasync function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); async1(); new Promise((resolve) { console.log(promise1); resolve(); }).then(() { console.log(promise2); }); console.log(script end);我来一步步推执行 async1 函数体同步打印async1 start。执行await async2()async2 函数体同步执行打印async2然后返回值给 await。await 后面的代码相当于.then也就是“暂停”把async1 end挂到微任务队列。继续往下走new Promise 里的执行器是同步的打印promise1resolve()后它的 then 回调进入微任务队列。继续走同步代码打印script end。同步代码结束清空微任务队列先有async1 end后有promise2按进入顺序输出。最后执行宏任务 setTimeout打印setTimeout。最终输出是script start async1 start async2 promise1 script end async1 end promise2 setTimeout这里有个细节要注意await 右侧的函数会同步执行完只有 await 之后的代码才是异步的。很多人在async2这里出错以为整个 async2 都是异步的实际 async2 的函数体是同步打印的。5.3 async/await 在队列中的真实位置追问的深水区来了await到底把“后续代码”放进了什么队列标准解释是await会隐式地把后续代码包成一个 Promise 回调放入微任务队列。但是 V8 后续版本做了优化如果 await 的右侧本身就是一个 Promise会减少一次微任务的包装层数。面试时你不需要给出过于精细的分版本行为掌握“await 后面的代码会被推迟到微任务阶段执行”这个大原则就够了。为什么前端工程师必须吃透这件事因为实际开发里有大量场景依赖它接口请求完成后要更新状态、多个请求之间的竞态控制、首屏渲染时尽早执行关键脚本、错误上报不能阻塞主流程。凡是涉及“我这段代码到底什么时候执行”的问题最终都要回到事件循环来解释。6. 深拷贝从 JSON.stringify 到递归实现深拷贝在 JavaScript 面试里的出场率极高因为它是“讲原理 手写代码 边界处理”三位一体的好题目。6.1 JSON 方案写起来爽坑也最多最省事的深拷贝写法是JSON.parse(JSON.stringify(obj))。一行代码完成拷贝但坑非常多对象的函数、undefined、Symbol类型的属性会被直接跳过结果里丢失这些字段。Date会被转成 ISO 字符串而不是 Date 对象。RegExp会变成空对象。NaN、Infinity会被转成null。循环引用会直接抛错TypeError: Converting circular structure to JSON。原型链上的属性和不可枚举属性不会被拷贝。如果只是拷贝一个纯数据的配置对象JSON 方案足够但一旦数据结构里混了函数或者出现循环引用就得换方案。我建议把“JSON 深拷贝的缺陷”作为一道独立的常识题来记因为它直接反映你对引用类型的理解深度。6.2 手写递归深拷贝的边界清单手写实现要兼顾的边界很多从简单到完整一般分三档。第一档能递归拷贝普通对象和数组function deepClone(target) { if (typeof target ! object || target null) { return target; } const clone Array.isArray(target) ? [] : {}; for (const key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { clone[key] deepClone(target[key]); } } return clone; }第二档处理循环引用用 WeakMap 记录已拷贝的源对象遇到重复引用时直接返回缓存function deepClone(target, map new WeakMap()) { if (typeof target ! object || target null) { return target; } if (map.has(target)) { return map.get(target); } if (target instanceof Date) { return new Date(target); } if (target instanceof RegExp) { return new RegExp(target.source, target.flags); } const clone Array.isArray(target) ? [] : {}; map.set(target, clone); for (const key of Object.keys(target)) { clone[key] deepClone(target[key], map); } return clone; }第三档用Reflect.ownKeys替代Object.keys把 Symbol 属性也考虑进去再处理 Map、Set 等类型。一般面试答到第二档已经超过大部分候选人了第三档可以在追问时展示深度。WeakMap 的选型也值得说一句用它做缓存不会阻止垃圾回收所以比普通 Map 更适合这种场景。6.3 和现代 API structuredClone 的对比structuredClone是浏览器后来提供的原生深拷贝 API支持循环引用、Date、RegExp、Map、Set、TypedArray 等类型拷贝行为比 JSON 方案健壮得多。但它有两个限制一是函数仍然是不可克隆的会抛DataCloneError二是兼容性要求相对较新的浏览器环境。面试时提到它能体现“我在跟进新特性而不是只会背旧方案”。不过要让面试官知道手写递归方案的价值在于理解原理工程上能用原生 API 就用原生 API就像Array.isArray和手写类型判断的关系一样。7. 手写题冲刺防抖、节流与 Promise.all手写题是八股面试里最能拉开分差的部分因为背不背得出来一写便知。面试官并不指望你写出完美无缺的库级源码他要看的是代码结构是否清晰、有没有处理 this 绑定、会不会处理返回值、步进式思考是否严密。7.1 防抖节流从代码到场景一次讲透先理清概念。防抖 debounce事件触发后延迟执行如果延迟期间再次触发就重新计时。核心用途是“只看最后一次动作”比如输入框搜索、窗口 resize 结束后再计算。节流 throttle固定时间间隔内只执行一次即使触发频率再高也严格限频。核心用途是“控制执行频率”比如滚动事件中加载更多、拖动时更新位置。防抖实现function debounce(fn, delay 300) { let timer null; return function (...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }节流实现时间戳版本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); } }; }两个点要跟面试官讲明白第一返回函数里必须用fn.apply(this, args)这样外面对象调用包装函数时 this 能正确传递第二防抖和节流都是靠闭包在“记住”上次的状态一个记 timer一个记 last 时间戳。把这两点和前面闭包章节呼应起来整个答案的连贯性会非常强。进阶追问“防抖和节流能否结合”比如输入框既需要延迟防抖又需要保证一定时间内必然执行一次可以手写一个“防抖 节流”的混合版本用 timer 做防抖、用 last 做节流双重判断。这种题没有标准答案但考察的就是你灵活运用基础能力解决实际问题的水平。7.2 Promise.all 的实现与常见坑Promise.all 也是高频手写题。它的特性是接收一个可迭代对象返回一个新的 Promise所有输入 Promise 都成功时返回按原始顺序排列的结果数组任何一个失败直接走 reject而且是“快速失败”。function promiseAll(promises) { return new Promise((resolve, reject) { if (!Array.isArray(promises)) { throw new TypeError(promises must be an array); } const results []; let count 0; if (promises.length 0) { resolve([]); return; } promises.forEach((item, index) { Promise.resolve(item).then((value) { results[index] value; count 1; if (count promises.length) { resolve(results); } }).catch(reject); }); }); }写这个题有几个容易忽略的细节必须用Promise.resolve(item)包一层因为输入可能是普通值不一定是 Promise。结果用results[index] value而非push保证输出顺序与输入顺序一致避免某个 Promise 先完成导致顺序错乱。用count计数判断是否全部完成不能用results.length因为数组中间可能存在空槽length 不可靠。空数组直接resolve([])这是一个需要显式处理的边界。追问还可能包括Promise.allSettled、Promise.race的区别。allSettled等所有请求结束并返回每个结果的状态适合批量操作里不能因为一个失败就整体的场景race是竞速取第一个完成的结果。这三个 API 背熟后手写题和项目场景题都能接住。7.3 附带一道数组扁平化的送分题有时面试官会顺手加一道数组扁平化function flatten(arr, depth Infinity) { return arr.flat(depth); }这是 API 直接调用版。手写递归版function flatten(arr, depth 1) { return arr.reduce((acc, item) { return acc.concat( Array.isArray(item) depth 0 ? flatten(item, depth - 1) : item ); }, []); }手写版考察的是 reduce、递归和 depth 控制。工程上直接用flat就好但知道底层实现能让你在遇到“别用 flat、实现一个扁平化”这种限制题时稳住。8. 除了答案面试官还想听到什么最后这部分不是八股是我自己面别人和被人面之后的复盘。很多候选人知识点都会但分不高原因往往不在技术上而在表达方式上。8.1 怎么答才不像“背八股”第一用“先结论、再展开、后举例”的结构。面试官问“闭包是什么”不要上来就背定义可以这样说“闭包是函数捕获外部变量的机制核心是函数定义时的词法环境会被保留。我举个实际例子写防抖函数时我们就是通过闭包保存 timer 的。”三句话覆盖定义、原理、场景这比背诵官方式定义质感好得多。第二答完一个点主动往外延展。讲完 it 循环经典题可以主动说“这道题其实考察的是 var 和 let 的作用域差异跟实际开发里用 let 替代 var 的规范也是一致的”。这种关联能力比多背十个知识点更有用。第三允许自己说“我不太确定”但要补一句“我准备的验证方式是这样的”。面试官反感的是不懂装懂不反感诚实加思路。比如不确定数组扁平化的边界条件可以说“我记得 flat 还有 depth 参数可以控制深度具体默认值我需要查一下文档但递归实现的核心逻辑我清楚”。这种回答会让人觉得工程素养在线。8.2 我复盘时发现的三个高性价比知识点第一个是高阶类型的判断组合拳typeof、instanceof、Object.prototype.toString三者的适用边界。这题能串起类型系统一整个专题性价比极高强烈建议优先复习。第二个是 this 和闭包的联动题。实际面试里它们经常被放在同一道题里考察比如“这个 setTimeout 回调为什么访问不到 this.xxx”和“闭包里怎么缓存 this”。把这两块放一起练比单独背概念效率高得多。第三个是手写题里的 this 还原。很多人在手写 debounce 或 Promise.all 时会用箭头函数或者普通函数连 this 怎么传给原函数都没考虑。一个很简单的判断标准凡是你写的工具函数内部调用回调时都应该用fn.apply(this, args)把上下文传回去因为调用方很可能是在对象方法里使用你的工具函数。根据我个人带人和面试的经验前端八股看起来零散但背后其实有一张网类型相关的问题连到判断和转换函数相关的问题连到作用域和闭包异步相关的问题连到事件循环和 Promise 体系。你如果能把每一道小题都跟这张网里的两三个节点挂上钩面试时就会明显感觉“有话说”而不是“背了忘忘了背”。这期整理里大部分题我都给了追问方向建议你把这些追问都自己写一遍代码验证一遍费不了多少时间但效果比干看强很多。