九、十月份是秋招和跳槽的黄金期,圈子里叫“铜九铁十”。这段时间我身边不少同学都在准备前端面试,问来问去,绕不开的永远是JavaScript。面试官不管考框架多深,最后都会落到语言本身:闭包、this、事件循环、手写题、运行时报错排查,这些是前端面试的“八股文”,也是最容易拉开差距的地方。很多候选人项目经历写得漂亮,一到JavaScript基础就露馅,非常可惜。这篇内容我按自己面试别人和被别人面试的经验,把JavaScript这部分的高频考点和答题思路整理出来,覆盖基础、手写、异步、实战几个维度,适合正在备战面试的朋友直接拿去背、拿去练。
1. 核心基础考点:数据类型、作用域与闭包
1.1 数据类型判断:从typeof到Object.prototype.toString
面试官很喜欢从“JavaScript有哪些数据类型”开始切入。这个问题看似简单,但回答的深度直接决定后面问题的难度。标准答案分成两种:原始类型(Primitive Types)和引用类型(Reference Types)。原始类型包括string、number、boolean、null、undefined、symbol、bigint,引用类型主要是object,数组、函数、日期都是object的细分。
紧接着会被追问“如何判断数据类型”。这里有几个层次:
- typeof:适合判断原始类型,但有两个著名坑点,
typeof null返回"object",typeof function(){}返回"function"而不是"object"。前者是历史遗留bug,后者是特意设计的。 - instanceof:用于判断引用类型,但它的原理是沿着原型链查找,所以
[] instanceof Object也是true,无法精确判断具体类型。 - Object.prototype.toString.call():这个才是终极方案。
Object.prototype.toString.call([])返回"[object Array]",call(/a/)返回"[object RegExp]",可以精确区分所有内置类型。
我在面试别人时,最喜欢让候选人手写一个getType函数。大部分人能写出来,但会忽略null这个边界情况。这里把我的标准版本贴出来:
function getType(value) { if (value === null) return 'null'; if (typeof value !== 'object') return typeof value; return Object.prototype.toString.call(value).slice(8, -1).toLowerCase(); }这个版本的逻辑是先处理null和原始类型,再对引用类型做精细判断,实际项目中完全够用。注意不要在typeof value !== 'object'这里把函数丢掉,函数应该由typeof直接返回。
1.2 作用域与闭包:面试官真正想听什么
闭包是JavaScript面试的“必考题”,但很多人的理解停留在“函数嵌套函数,内层函数可以访问外层函数的变量”。这个说法没错,但太浅了。面试官真正想听的是:闭包的产生机制、解决了什么问题、带来了什么副作用。
要理解闭包,先要理解作用域链。JavaScript在创建函数时,会保存一个[[Environment]]引用,指向函数定义时的词法环境。函数执行时,会把这个词法环境作为作用域链的最前端。当内层函数引用了外层函数的变量,外层函数执行完毕后,本应销毁的词法环境因为被内层函数引用而保留在内存中,这就是闭包。
我一般建议候选人用一个计数器来串讲:
function createCounter() { let count = 0; return function () { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2这里count变量没有被回收,就是闭包在起作用。回答时要主动提到闭包的三个特点:变量私有化、状态保持、内存驻留。后面两点是双刃剑,尤其要主动提内存问题,展示你有性能意识。我在面试中只要听到候选人主动讲出“闭包可能导致内存泄漏,因为词法环境不会释放”,第一印象就会很好。
1.3 this指向:四种绑定规则与箭头函数例外
this指向问题是JavaScript八股文里翻车率最高的点之一。很多候选人刷了题,但换道题又错。核心原因是没有理解this的绑定规则。
- 默认绑定:非严格模式下,独立函数调用
fn(),this指向window/global;严格模式下,this是undefined。 - 隐式绑定:
obj.fn()这种形式,this指向对象obj。 - 显式绑定:call、apply、bind可以强制指定this。
- new绑定:构造调用时,this指向新创建的对象。
- 箭头函数:没有自己的this,this继承自外层词法作用域。
面试里最难的是“隐式绑定丢失”问题。比如const fn = obj.fn; fn();,这里this指向window而不是obj,因为函数被赋值后,调用位置已经没有obj前缀了。同理,setTimeout(obj.fn, 100)也会丢失绑定,因为setTimeout里的回调是独立调用。
我推荐一个答题模板:见到this题目,先看函数是不是箭头函数,如果是,直接找外层作用域;如果不是,看调用方式,是函数名()还是对象.方法()还是new调用,再套对应规则。这一套流程走完,正确率能到90%以上。
1.4 原型链与继承:别只背“寄生组合式继承”
原型链考的是对JavaScript对象系统的理解。面试官问“new的时候发生了什么”,不只是让你背四步流程。四步是:创建空对象、将空对象的__proto__指向构造函数的prototype、将this指向空对象并执行构造函数、如果函数没返回对象则返回这个空对象。
然后会考“手写寄生组合式继承”。这里不要直接甩代码,先讲思路:子类的prototype要指向父类prototype的副本,同时修复constructor指向。标准写法:
function inherit(child, parent) { child.prototype = Object.create(parent.prototype); child.prototype.constructor = child; } function Parent(name) { this.name = name; } Parent.prototype.say = function () { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age = age; } inherit(Child, Parent); const child = new Child('张三', 18); child.say(); // 张三注意Object.create(parent.prototype)不能直接写成child.prototype = parent.prototype,否则修改子类原型会污染父类。也不能用new parent(),会多执行一次父类构造函数。这个细节面试官一定会追问,提前准备好。
2. 高频手写题:防抖、节流、深拷贝与数组方法
2.1 防抖与节流:场景决定方案
防抖和节流是手写题里出现频率最高的题目。我统计过自己参与过的几十场前端面试,几乎每两场就有一场考这个。两者的核心区别要一句话说清:防抖是“最后一次执行”,节流是“固定频率执行”。
防抖的经典场景是搜索框输入。用户连续输入时,只在停止输入后一段时间内发起请求。节流的经典场景是滚动监听、按钮防连点,保证一定时间内只执行一次。
防抖手写要分成“立即执行版”和“非立即执行版”,我面试时如果候选人只写出一种,会追问另一种,能写出来的不多。这里给出完整版:
function debounce(fn, delay, immediate = false) { let timer = null; return function (...args) { const callNow = immediate && !timer; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) fn.apply(this, args); }, delay); if (callNow) fn.apply(this, args); }; }注意保存this,因为事件监听里this指向当前DOM元素,直接用fn(args)会让this丢失。节流的实现有两种:时间戳版和定时器版。时间戳版先执行,定时器版后执行。回答时主动说出两者的区别,会显得你有思考深度。
2.2 数组去重:从双层循环到Set的演进
数组去重也是经典手写题,但我更建议大家把它当作“一题多解”的展示机会。从最简单的Set去重,到filter去重,再到考虑对象属性、NaN、Symbol等边界情况的版本,层层递进,展示知识广度和边界意识。
const arr = [1, 2, 2, 3, NaN, NaN]; const unique = [...new Set(arr)]; console.log(unique); // [1, 2, 3, NaN]上面这个版本能处理NaN,因为Set使用SameValueZero比较,NaN等于NaN。但如果是数组里的对象,Set去重是无效的,因为每个对象引用不同。这时候可以用Map按唯一标识去重。回答到这一层,面试官通常会点头进入下一题。
除了去重,数组遍历也是高频考法。forEach、map、filter、reduce、some、every都要能说出区别:forEach不返回新数组、不可中断;map返回等长新数组;filter返回筛选后的新数组;reduce可以做累积操作。这里有个细节很多人不知道:forEach和map都不能用break中断,只能用some返回true或者用for...of。能主动提到这个,很加分。
2.3 深拷贝:从乞丐版到面试版
深拷贝是手写题里的“分水岭”。简单的递归拷贝谁都会写,但完整处理各种边界情况的版本能过滤掉大部分候选人。
基础版:
function deepClone(obj) { if (obj === null || typeof obj !== 'object') return obj; const result = Array.isArray(obj) ? [] : {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { result[key] = deepClone(obj[key]); } } return result; }这个版本有哪些问题?一是循环引用会栈溢出,二是Date、RegExp、Map、Set等特殊对象会被拷贝成普通对象,三是函数的处理。面试版至少要解决循环引用:用WeakMap缓存已经拷贝过的对象。
function deepClone(obj, cache = new WeakMap()) { if (obj === null || typeof obj !== 'object') return obj; if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj); if (cache.has(obj)) return cache.get(obj); const result = Array.isArray(obj) ? [] : {}; cache.set(obj, result); for (const key of Object.keys(obj)) { result[key] = deepClone(obj[key], cache); } return result; }这里用Object.keys而不是for...in,是为了不遍历原型链上的属性。这个版本题型覆盖了“为什么用WeakMap”“为什么v8引擎的structuredClone能处理更多类型”这些追问题,值得好好研究。
2.4 手写Promise.all与reduce:掌握异步与数组的交叉题
手写Promise.all是近几年面试的新宠,它综合考察了Promise原理、异步并发控制、错误处理。回答时要先说清楚Promise.all的行为:接收一个可迭代对象,全部resolve后返回结果数组,任何一个reject就立即reject。
function promiseAll(promises) { return new Promise((resolve, reject) => { const results = []; let count = 0; if (promises.length === 0) { resolve(results); return; } promises.forEach((promise, index) => { Promise.resolve(promise).then( (value) => { results[index] = value; count++; if (count === promises.length) resolve(results); }, (reason) => reject(reason) ); }); }); }注意这里不能用results.length === promises.length判断完成,因为异步任务完成顺序不一定和索引顺序一致,如果最后一个先完成,results.length已经等于数组长度,但前面的还没填充,会导致提前resolve。这是一个非常隐蔽的bug,我在面试中专门用这道题“钓鱼”,能发现这个坑的候选人凤毛麟角。
3. 事件循环与异步编程:输出顺序题的正确打开方式
3.1 宏任务、微任务与async/await的执行顺序
JavaScript的事件循环是面试必考,且几乎每场都会出“打印顺序”的题目。很多候选人死记硬背“先微任务后宏任务”这句话,一遇到async/await就出错。实际上要理清楚的是:
- 每次执行宏任务之前,都要把当前微任务队列清空。
- Promise.then里的回调是微任务,async函数里await后面的代码也是微任务。
- setTimeout、setInterval、I/O、UI渲染这些是宏任务。
- 微任务优先于宏任务执行,但不是说所有微任务执行完才执行宏任务,而是“每一个宏任务之后都要把微任务清空”。
经典打印题:
console.log('script start'); setTimeout(() => console.log('timeout'), 0); Promise.resolve().then(() => console.log('promise')); async function foo() { console.log('async start'); await bar(); console.log('async end'); } async function bar() { console.log('bar'); } foo(); console.log('script end');正确答案是:script start -> async start -> bar -> script end -> promise -> async end -> timeout。这里最容易被忽略的是async end。await bar()这行,会先执行bar()同步代码,输出bar,然后await后面的代码相当于放进了微任务队列。所以虽然Promise.resolve().then先注册,但async end要等bar()执行完后才注册。理解这个执行顺序的关键是:await不是全部立即让出,而是先执行右侧表达式,再把后续代码塞进微任务队列。
3.2 手写一个简易Promise:理解then链的机制
如果面试官看了你的输出顺序题觉得基础不错,很可能会进一步问“能不能手写一个Promise的核心逻辑”。这道题很大,但核心其实只有两点:状态机和then链。Promise有三个状态:pending、fulfilled、rejected,状态只能从pending流转一次。then方法要支持链式调用,因此需要返回一个新的Promise,并且在回调里处理返回值的resolve或reject。
核心骨架:
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()); } }; executor(resolve, reject); } then(onFulfilled, onRejected) { // 处理链式调用,返回新的promise } }写到这里已经能说明对Promise的理解了,但要注意:回调数组的设计不是为了支持同一个promise多次then,而是为了处理resolve在executor内部是异步调用的情况,即先注册回调后状态才改变。很多候选人漏掉这个点,我认为这是最值得背下来的“底层细节”。
3.3 定时器延迟与事件循环的“排队”问题
setTimeout(fn, 0)真的会立即执行吗?不会。它只是把回调放进宏任务队列,真正执行时间取决于当前调用栈和之前的微任务队列是否为空。这也是面试中常考察的“setTimeout不准时”问题。回调会等当前代码执行完毕、微任务清空后才执行,如果前面有大量同步计算或微任务,实际延迟远大于设置的0毫秒。
更隐蔽的坑是嵌套setTimeout的最小延迟限制。HTML规范要求嵌套层级超过5层后,每次延迟最小为4ms。这就是为什么有些高精度任务用setTimeout会“变慢”的原因。面试中如果被问到“如何实现一个高精度的定时器”,可以用requestAnimationFrame或Web Worker的方案回答,多数候选人不会,能讲到这层基本就锁定胜局了。
3.4 内存泄漏:闭包、定时器与监听器的回收
前面提到闭包可能导致内存泄漏,面试官通常会顺着追问“还有哪些常见的内存泄漏场景”。这个问题考察的是代码实践经验,不是背诵能力。典型场景有:
- 全局变量意外泄漏:未声明的变量直接赋值会在非严格模式下挂到window上。
- 定时器未清理:setInterval创建后没有clearInterval,回调引用的对象无法释放。
- 事件监听器未移除:DOM元素被移除时,监听器还挂在它身上,或者监听器引用了外部变量。
- 闭包持有大量数据:闭包内部引用了大对象,即使外层函数执行完毕,该对象仍被闭包引用。
我在实际项目中排查内存泄漏的经验是:先用Chrome DevTools的Performance面板录制一段页面操作,观察内存曲线是否持续上升不回落;然后利用Memory面板的Heap Snapshot对比两次快照,过滤出增长较大的对象;再通过Retainers面板分析这个对象被谁引用着。这一步只能挨个看引用链,定位到具体代码。这个排查思路如果面试时能讲出来,会比单纯背概念有效得多。
4. 运行时报错与工程化实战:从void(0)到微前端
4.1 javascript:void(0)和javascript:void(o)报错:一个古老的坑
近期很多人搜“javascript:void(0)”和“javascript:void(o)报错”,这两个问题其实是同一个根源。javascript:是一个URL协议,浏览器会把它后面的内容当作JavaScript代码执行。void(0)表示执行0,然后返回undefined。在a标签的href里写javascript:void(0),是为了让点击链接时不跳转、不刷新页面,而交由onclick事件处理。
为什么有人写成javascript:void(o)会报错?因为o不是已定义的变量,引擎会抛出ReferenceError: o is not defined。这个报错通常发生在有人手误,把数字0打成了字母o,或者复制代码时被混淆过。解决办法很简单:要么改回void(0),要么干脆不用javascript协议,用href="#"配合event.preventDefault()。
实际面试中,这个问题常被包装成“a标签href="javascript:void(0)"和href="#"有什么区别”来问。答案是:void(0)不会改变URL和滚动位置,而href="#"会跳到页面顶部,需要在点击事件里return false才能阻止。这个细节理解到位,说明候选人真的处理过实际页面问题。
4.2 运行时报错的类型与排查思路
前端JavaScript运行时报错是每个开发者都绕不开的问题。面试中会被问到“你遇到过哪些运行时报错,是怎么排查的”,这里要能说出具体错误类型和排查路径。
- ReferenceError:变量未定义。排查思路是用浏览器的Sources面板,在报错位置打断点,看作用域链里到底缺了哪个变量。
- TypeError:读取了undefined/null的属性。这是最频繁的报错,排查时先看报错信息里“Cannot read property 'xxx' of undefined”,然后去数据源排查字段是否为空。
- RangeError:递归太深或数组越界。常见于无限递归,排查时看调用栈。
- SyntaxError:语法错误,通常在构建阶段就会暴露,浏览器里出现的概率低。
- URIError、EvalError:比较少遇到,知道存在即可。
我自己的排查习惯是:先看错误信息的堆栈(Stack Trace),定位到具体文件和行号;然后再看作用域(Scope)面板里变量的当前值;如果数据是从接口来的,还要Network面板确认返回内容是否符合预期。最忌讳的是不看堆栈,直接猜原因。七成以上的js报错,堆栈已经告诉你了答案。
4.3 严格模式与模块化:ESM和CommonJS的区别
严格模式'use strict'在面试中出现的频率也不低。要记住几个关键差异:严格模式下this默认指向undefined而不是全局对象;禁止给未声明的变量赋值;禁止删除不可删除的属性;函数的参数名不能重复;八进制字面量被禁止。其中“this指向undefined”是最高频的考点,因为它直接对应了前面讲的this默认绑定规则。
模块化方面,ESM和CommonJS的区别是必考:
- ESM的import/export是静态分析,可以在编译期确定依赖关系,支持tree-shaking;CommonJS的require/module.exports是运行时加载。
- ESM在浏览器端原生支持,CommonJS是Node.js的规范,需要构建工具转换才能在浏览器用。
- ESM的this是undefined,CommonJS模块顶层this指向module.exports。
- ESM是引用拷贝,CommonJS是值拷贝。这句话展开讲:CommonJS输出的是值的拷贝,模块内部变化不影响外部已拿到的值;ESM输出的是值的引用,模块内部变化外部能感知到。
能把这四点讲清楚,面试官基本上就会跳过工程化切换下一题了。
4.4 Worker与微前端:JavaScript在复杂场景下的表现
热词里出现了“前端使用worker上传大文件”和“微前端”,这两个点其实是JavaScript进阶面试的方向。Web Worker可以让JavaScript在后台线程执行,避免阻塞主线程。上传大文件时,可以在Worker里做文件分片、计算哈希(比如用SparkMD5计算文件指纹),主线程只负责UI交互。这样即使文件有好几个GB,页面也不会卡死。这个思路在面试中属于“加分项”,能主动讲出来,说明你有大规模文件处理的实战经验。
微前端考察的是JavaScript的隔离能力。多个子应用同时运行在页面上,怎么避免变量冲突、样式冲突?方案有:JS沙箱(用with语句配合Proxy拦截全局变量的读写)、样式隔离(CSS Modules、Shadow DOM)、通信机制(自定义事件、全局状态管理)。面试官会重点问“如何实现一个简易的JS沙箱”,核心是用Proxy监听window上的属性访问,只允许子应用访问属于自己的命名空间。这个问题很综合,但答好了会非常出彩。
5. 面试答题经验:从背答案到讲思路
5.1 答题节奏:先给结论,再展开原理
我面试过不少候选人,发现最大的通病是:回答问题时想到哪儿说到哪儿,没有结构。比如被问到“什么是闭包”,直接开始背定义,背完就结束了,完全没有展开。我建议的回答模板是:先一句话给结论,比如“闭包是函数与其词法环境的组合,它让函数可以访问定义时的作用域变量”;然后给一个最简单的例子;最后讲它的应用场景(私有变量、函数柯里化)和副作用(内存驻留)。这个节奏控制在1分半到2分钟之间最合适。
如果面试官没有喊停,就继续往下延伸:可以主动讲“闭包在react函数组件中的表现”,因为函数组件每次render都会生成新的闭包,这就联系到了useCallback、useRef的作用。从八股文延伸到框架原理,是区分“背题型选手”和“实战型选手”的关键一步。
5.2 面试官追问的深水区:如何展示知识纵深
八股文背熟了只能到及格线,想拿高分必须能接住追问。面试官最喜欢追着某个点往下深挖。比如你提到“深拷贝用WeakMap解决循环引用”,他会问“为什么是WeakMap而不是Map”。答案是:WeakMap的键是弱引用,不会阻止垃圾回收。如果用Map,缓存的对象永远不会被回收,本身就是内存泄漏。这个追问如果你能答上来,直接证明了你是“理解了”而不是“记住了”。
另一个常见追问是“Set的NaN去重是怎么实现的”。这里要提到SameValueZero算法,它和严格等于===的区别在于,SameValueZero认为NaN等于自身,同时认为+0和-0相等。这套比较算法是ES规范里的核心,很多候选人完全没听过,能答出来就是亮点。
5.3 面试前最后一周:手写题和概念题的复习清单
根据自己的面试经验,我整理了一份JavaScript八股文的复习优先级清单:
- 必背手写题:防抖节流、深拷贝、Promise.all、数组去重、函数柯里化、new的实现、call/apply/bind的实现。
- 必背概念题:事件循环与打印顺序、原型链、闭包、this绑定、模块化差异、垃圾回收机制。
- 高频场景题:大文件上传、前端并发请求控制、大量数据渲染优化、前端错误监控。
这里我特别想强调“并发请求控制”。它通常会包装成“有100个请求,但浏览器最多同时发10个,怎么控制并发”,解法用递归或队列,本质是生产者和消费者模型。这道题既能考察异步编程又能考察设计能力,出现频率不低,值得提前练一练。
最后再分享一个小技巧。面试前一天,不要继续刷新题,把已经写好的手写题代码重新默写一遍。你会发现很多“我以为会了”的代码,真的动笔写的时候会卡壳。八股文这种东西,临时记忆不会管用,肌肉记忆才是关键。把每一道手写题练到不需要思考就能写出来的程度,面试时才能留出脑力应对追问。希望这份整理能帮到正在准备铜九铁十的你,JavaScript这块稳住,面试就稳了一半。