1. 从简历到现场——为什么JavaScript基础题总在前端面试的第一关
今年的前端行情大家都有体会,投简历的人多,靠谱的少,面试官也学精了。不再一张口就问"你用过Vue还是React",而是先拿JavaScript最基本的东西试底。原因很简单:框架可以速成,但语言功底骗不了人。你让他手写一个reduce,说没用过;问他Promise.all怎么实现,说背过但忘了。这两分钟基本就决定这场面试的走向了。
这份2023年前端JavaScript高频24问,是我结合近半年真实面试场景、牛客网和掘金的面经、以及身边朋友被问到的题目整理出来的,覆盖基础语法、函数作用域、原型链、异步编程、ES6+ 和运行机制六个方向。按照惯例,我先把问题清单放出来:JS数据类型有哪些、null和undefined有什么区别、==和===的区别、深拷贝和浅拷贝怎么实现、闭包是什么、this的指向规则、call/apply/bind的区别、箭头函数和普通函数有什么不同、原型链是什么、new操作符做了什么、怎么实现继承、instanceof的原理、事件循环机制是什么、Promise有哪几种状态、async/await的原理、宏任务和微任务怎么区分、怎么实现Promise.all、let/const/var的区别、ES Module和CommonJS的区别、防抖和节流怎么实现、内存泄漏有哪些常见场景、类型转换有哪些坑、怎么判断数组、怎么实现一个可用的深拷贝工具函数。
每个问题我都会给出一个"能过面试官这关"的答法,同时站在面试官的角度解释一下他到底在考察什么。有些题你表面上看是考代码,实际上考的是你有没有踩过那些坑。
2. 基础语法与数据类型——第一关就筛掉一半人
2.1 JS数据类型有哪些,怎么准确判断
凡是一上来就只回答"基本类型和引用类型"的,我基本会再追问一句"那typeof null是什么?"——这题没答好,后面的问题我也就不太想继续了。规范答案是:JavaScript共有7种基本数据类型(undefined、null、boolean、number、string、symbol、bigint)和1种引用类型(object),其中function和array是object的"特殊形态"。判断类型的方法分三档:typeof适合判断基本类型,但typeof null返回"object"这个历史bug必须知道;instanceof可以判断引用类型,但跨iframe场景下会失效;Object.prototype.toString.call()最可靠,能精确区分array、date、regexp等。
面试官问"怎么判断一个变量是不是数组",考察的不是你会不会写Array.isArray,而是你知不知道为什么要用这个方法,以及它和instanceof Array的区别在哪儿。Array.isArray是ES5就有的方法,内部走的是Object.prototype.toString的通道,不依赖原型链,所以跨iframe也不会出错。如果面试官继续追问"怎么实现一个万能的类型判断函数",我一般给这个版本:
function getType(value) { const typeStr = Object.prototype.toString.call(value); return typeStr.slice(8, -1).toLowerCase(); }这个函数的原理是利用toString方法返回的标签字符串,比如[object Array]、[object Null],用slice(8, -1)把中间的Array、Null截出来再转小写就完成了。用这套方式判断null、array、regexp、date都不会出错,算是面试里最稳妥的答案。补充一个小技巧:如果你要判断某个值是不是NaN,直接用Number.isNaN(value)而不是全局的isNaN,前者不会先把值转成number再判断,避免了isNaN("abc")返回true这种脑壳疼的结果。
2.2 null和undefined有什么区别,==和===到底差在哪儿
这两个问题我放在一起说,因为它们太容易答混了。null表示"定义了但没有对象",通常由程序员主动赋值;undefined表示"声明了但没有赋值",是引擎给的默认值。你声明一个变量不赋值,打印出来就是undefined;你把一个变量的值主动设为null,是为了告诉别人"这个变量我清空了"。函数没有return语句,默认返回undefined;访问对象上不存在的属性,返回的也是undefined。这些细节都答清楚,说明你不是背概念,而是在真的写过代码。
==和===的考点主要在==的隐式转换规则。null == undefined返回true,null === undefined返回false;"1" == 1返回true,"1" === 1返回false。实际开发中一律推荐使用===,但面试时也可能会让你说出==转换的完整规则。我一般会回答:如果两端类型不同,==会先尝试把一端转成另一端的类型,转换顺序大致是boolean先转number,然后字符串转number,再比较;对象类型先调用valueOf,如果有toString再走toString。完整规则太琐碎,有一个记忆技巧是:记住null和undefined在==下互相相等且不和任何其他值相等,其余情况优先转number即可。
注意:
NaN不等于任何值,包括它自己。在面试里问"怎么判断一个值是不是NaN",你要区分NaN === NaN是false,所以有了isNaN和Number.isNaN。
2.3 深拷贝和浅拷贝怎么实现,手写一个深拷贝
这道题出现频率极高,而且通常会要求现场手写。浅拷贝只复制第一层,用展开运算符{...obj}或者Object.assign({}, obj)就能搞定,但嵌套对象还是共享引用,修改副本会影响原对象。深拷贝要递归复制所有层级,最简单的答案是JSON.parse(JSON.stringify(obj)),但这个方法有致命缺陷:函数、undefined、symbol、循环引用都会被处理成undefined或直接报错。
手写深拷贝的正确姿势是先判断基础类型直接返回,再判断是不是数组或对象,然后递归复制。要处理循环引用,得用一个WeakMap记录已经拷贝过的对象。我面试时给出的版本大致是这样的:
function deepClone(target, map = new WeakMap()) { if (target === null || typeof target !== 'object') return target; if (map.has(target)) return map.get(target); const result = Array.isArray(target) ? [] : {}; map.set(target, result); const keys = Reflect.ownKeys(target); for (const key of keys) { result[key] = deepClone(target[key], map); } return result; }这段代码用Reflect.ownKeys是因为它能拿到symbol类型的key和不可枚举的key,比Object.keys更完整。用WeakMap存引用关系,循环引用时第二次遇到同一个对象就能直接返回,不会死循环。面试时能写出处理循环引用的版本基本就是高分答案了,如果还能提一句"函数和symbol可以作为引用直接返回,不需要深拷贝",那就更完美了。
2.4 JavaScript类型转换有哪些坑
这题最近问得也很多,因为它能看出你对JS怪异行为的理解程度。1 + "2"等于"12","3" - 1等于2,"a" * 1等于NaN,[] + []等于空字符串,[] + {}等于"[object Object]",{} + []却等于0——这些看似无聊的题目背后全是规范里的ToPrimitive规则。简单说:加号运算符只要有一端是字符串,就优先做字符串拼接;减号、乘号、除号会尝试把两边转成number。对象在做隐式转换时会先调用valueOf,如果返回值不是基本类型,再调用toString。
面试的时候我不建议你背一堆变态用例,更重要的是表达"我知道这些坑所以我在项目里都是靠显式转换来规避的"。比如从接口拿数据时,习惯性用Number()、String()、Boolean()包装一层;判断用户输入的数字时不用==而用Number.isNaN()显式校验。能把自己规避隐式转换的实践经验讲出来,比背十个诡异表达式更有说服力。
3. 函数与作用域——闭包和this是面试重灾区
3.1 闭包到底是什么,有哪些实际应用
闭包是我见过被问最多、也最容易被讲虚的一道题。很多人的回答是"函数中返回一个函数,这就是闭包",这个答案只能拿30分。更准确的说法是:当一个函数内部引用了外部作用域的变量,并且这个函数在定义它的作用域之外被调用时,就产生了闭包。JavaScript的引擎不会立刻回收被闭包引用的变量,而是把它们保存在所谓的"闭包环境"中,这就是闭包能够记住外部变量的原因。
面试官真正想听到的是应用场景。我一般会讲三个:第一个是防抖节流函数,每次调用都通过闭包保存timer变量;第二个是柯里化,通过闭包记住之前传入的参数;第三个是模拟私有变量,比如一个计数器对象,只有内部方法能修改它的值,外部直接读不到。讲应用场景的时候可以顺带提一下内存泄漏风险:闭包长期持有大对象可能导致内存无法回收,解决办法是及时把不再使用的引用置为null。这样既有理有据,又有实操经验,面试官会觉得你确实写过东西。
3.2 this的四种指向规则,箭头函数有什么区别
this指向判断几乎是必考题,我总结了一个四步判断法:默认绑定(非严格模式下this指向window或globalThis,严格模式下是undefined)、隐式绑定(方法作为对象属性调用时this指向调用它的对象)、显式绑定(用call/apply/bind指定this)、new绑定(构造函数中this指向新建的实例)。优先级从高到低是:new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。
这里有个混淆点:对象方法内的嵌套函数的this指向的是window而不是外层对象,这是因为嵌套函数是独立调用,不满足隐式绑定规则。想保留外层this,要么用箭头函数(箭头函数没有自己的this,继承定义时外层作用域的this),要么在外层先把this存成变量(var self = this)。面试时如果你能把这个嵌套函数的坑讲明白,同时说明bind只能绑定一次、多次bind不会生效,基本就稳了。
3.3 call、apply、bind的区别和手写实现
三个方法都是用来改变函数执行时this指向的。call和apply都会立即执行函数,区别只是传参方式不同:call是逐个传参,apply是传数组。bind不立即执行,而是返回一个新函数,同时把this永久绑定,新函数可以多次调用。手写实现也是高频题,我给出一个简易版:
Function.prototype.myCall = function(context, ...args) { context = context || window; const key = Symbol(); context[key] = this; const result = context[key](...args); delete context[key]; return result; };核心思路是把函数挂到指定的context对象上,然后以"obj.method()"的形式调用,这样this就自然指向context了。用Symbol作为key是为了避免覆盖context上原有的属性,调用完再删除。手写apply只是参数不同,手写bind则要结合call或者apply,同时处理好返回新函数的逻辑。能把Symbol加进去这个细节讲出来,瞬间就和只会背代码的人拉开差距。
3.4 arguments和剩余参数有什么区别
这题和箭头函数结合在一起问:箭头函数没有自己的arguments,它里面的arguments指向外层函数的arguments。ES6的剩余参数(...args)是用来替代arguments的更现代方案。arguments是类数组对象,有length但没数组方法,需要先转成数组才能用map、filter;剩余参数本身就是真数组,直接就能调用数组方法。
面试时我还会补充一个高级用法:剩余参数可以结合解构使用,比如const [first, ...rest] = arr,拿掉第一个元素后剩下的放进rest数组。这个写法在处理函数参数时非常常用。如果你发现面试官在追问"arguments怎么转数组",三个方案都要说出来:Array.from(arguments)、[...arguments]、Array.prototype.slice.call(arguments)。
4. 原型链与面向对象——new和继承必须手写
4.1 原型链是什么,instanceof的原理
原型链这个概念是JavaScript面向对象的核心。每个函数都有一个prototype属性,指向原型对象;每个对象实例都有一个__proto__属性,指向创建它的构造函数的prototype。当访问一个对象上不存在的属性时,引擎会沿着__proto__链向上找,一直找到Object.prototype的__proto__,也就是null为止。这条链就是原型链。
instanceof的原理就是判断构造函数的prototype是否出现在实例的原型链上。手写一个myInstanceof可以这样:
function myInstanceof(instance, Constructor) { let proto = Object.getPrototypeOf(instance); const prototype = Constructor.prototype; while (proto !== null) { if (proto === prototype) return true; proto = Object.getPrototypeOf(proto); } return false; }面试官一般会接着问:"instanceof能判断基本类型吗?"答案是不能,因为基本类型没有原型链。但Symbol.hasInstance这个静态属性可以改写instanceof行为,比如用Object.create(null)创建的对象没有原型链,instanceof就失效了。还有一个小坑:1 instanceof Number是false,但new Number(1) instanceof Number是true,这一对正好用来考你包装对象和字面量的区别。
4.2 new操作符到底做了什么,怎么手写
new操作符的步骤背下来不难,难的是把它和原型链、构造函数联系起来。new一共做四件事:创建一个新对象、把新对象的__proto__指向构造函数的prototype、把构造函数里的this绑定到新对象上并执行、如果构造函数显式返回了引用类型对象就返回那个对象,否则返回新对象。手写版本:
function myNew(Constructor, ...args) { const obj = Object.create(Constructor.prototype); const result = Constructor.apply(obj, args); return (result && typeof result === 'object') ? result : obj; }这段代码用Object.create(Constructor.prototype)一步完成了"创建对象"和"设置原型"两件事。注意最后一行要判断构造函数返回的是不是引用类型,如果是基础类型就直接忽略,返回新对象。如果构造函数里写了return {name: 'xxx'},那new出来的实例就是这个返回的对象。这个细节很多人会漏,面试官一提就露馅。
4.3 实现继承的几种方式和优缺点比较
继承考的是你对JavaScript对象机制的理解深度。古典答法是:原型链继承(子类的prototype指向父类的实例)、构造函数继承(子类构造函数里调用Parent.call(this))、组合继承(两者结合)、寄生组合继承。现在ES6的extends关键字已经把这些复杂写法收编了,但面试官还是会问底层原理。
寄生组合继承是目前最优解,核心思路是:子类构造函数里用Parent.call(this)继承实例属性,子类原型用Object.create(Parent.prototype)继承方法,同时修正构造函数的指向:
function Child(...args) { Parent.call(this, ...args); } Child.prototype = Object.create(Parent.prototype); Child.prototype.constructor = Child;一定要记得修正constructor指向,否则子类的实例会被误判为父类创建的。面试时你把这个版本的每一步为什么这么写讲清楚,比背十个继承版本都管用。
4.4 ES6 class和构造函数有什么区别,class本质是什么
class本质上是构造函数的语法糖,typeof class Foo {}返回的是"function",这一点很多人都不知道。但语法糖不代表它们完全等价,class和构造函数有几个关键区别:class声明不会提升,有暂时的死区;class内的所有方法都是不可枚举的;class必须用new调用,直接调用会报错;class内部默认是严格模式。class的方法定义、静态方法、getter/setter、私有字段(ES2022的#前缀)整套语法写起来干净很多。
面试官如果接着问"class能不能模拟private方法",我会说三个方案:用#私有字段(ES2022标准)、用闭包管理状态、用WeakMap存私有数据。每个方案的适用场景不同,但都能体现你对封装概念的理解。
5. 异步编程——事件循环和Promise是必考大题
5.1 事件循环机制,宏任务和微任务怎么区分
这题几乎每场面试都会出现,而且经常是压轴题。要讲清楚事件循环,记住一个核心:JavaScript是单线程的,但浏览器的执行环境是多线程的。JS主线程执行完当前同步代码后,会先清空微任务队列,然后从宏任务队列取一个任务执行,执行完再清空微任务队列,如此反复。这个"宏任务结束后清空微任务"的顺序是整个事件循环最关键的细节。
宏任务和微任务怎么区分:宏任务包括setTimeout、setInterval、I/O、UI渲染、requestAnimationFrame(有些场景算宏任务),微任务包括Promise.then、queueMicrotask、MutationObserver、process.nextTick(Node环境)。遇到输出顺序题,先用"同步代码先执行,微任务先于宏任务,每个宏任务执行完都再清一次微任务"这个口诀推一遍。常见的"setTimeout里面再包一个Promise"这类嵌套题,只要把这个循环逻辑理顺就不会错。
5.2 Promise的三种状态,then和catch怎么串联
Promise的三种状态是pending(进行中)、fulfilled(已成功)、rejected(已失败),状态只能从pending变为fulfilled或rejected,一旦改变就不可逆。then和catch都返回新的Promise,所以可以链式调用。catch本质上是.then(null, onRejected)的语法糖。这里有个常见考点:在then的第二个参数里处理rejection和在catch里处理有什么不同?第二个参数只能捕获当前Promise的错误,catch能捕获前面所有then里新抛出的错误。从这个角度讲,catch是更安全的写法。
面试还会考Promise的几个静态方法:Promise.all在任一失败时整体失败但会立即返回第一个错误;Promise.allSettled等所有任务结束,返回每个任务的状态和结果;Promise.race只返回第一个完成的结果,不管成功还是失败。有些公司会要求手写Promise.all,我后面单独讲。
5.3 async/await的原理,串行和并行的区别
async/await是Promise的语法糖,async函数返回的永远是一个Promise,await后面的表达式会被转成Promise.resolve()的形式等待。很多人不知道的是,await关键字其实可以出现多次,每次await都会中断当前async函数的执行,把它变成微任务挂到队列里。这个"中断-恢复"的机制是理解async/await性能的关键。
面试时易踩坑的是"await到底是串行还是并行":多个await依次写就是串行(第二个等待第一个完成),如果几个请求之间没有依赖关系,应该用Promise.all直接并行执行。某些人写的业务代码几百行里全是await await await,接口一个等一个,比串行瀑布还慢,这就是没理解await的本质。我会现场举一个商品详情页的例子:需要同时请求用户信息和商品信息,这两个接口没有依赖,正确的做法是const [user, product] = await Promise.all([getUser(), getProduct()]),而不是先后await两次。
5.4 手写Promise.all
手写Promise.all是一道高频题,考的是对Promise静态方法原理的掌握。核心逻辑:遍历传入的Promise数组,每个Promise的then回调里把结果存到对应索引的位置,用计数器判断是否全部完成,然后resolve结果数组。只要有一个reject,整体就reject:
function myPromiseAll(promises) { return new Promise((resolve, reject) => { const result = []; let count = 0; if (promises.length === 0) resolve(result); promises.forEach((promise, index) => { Promise.resolve(promise) .then(value => { result[index] = value; count++; if (count === promises.length) resolve(result); }) .catch(reject); }); }); }有两个细节一定要说清楚:一是用index存结果而不是用push,因为异步任务完成的顺序不一定是数组顺序,用push会把顺序搞乱;二是对传入的非Promise值要先包一层Promise.resolve(),不然基础类型值没有then方法会直接报错。
6. ES6+与运行机制——新特性加内存排查
6.1 let、const、var的区别,为什么const声明的对象还可以修改
这道题几乎是送分题,但能全说利索的人真不多。var有变量提升、没有块级作用域、可以被重复声明;let和const有块级作用域、有暂时性死区、不能重复声明。const在声明时必须赋值且基本类型的值不能修改,但对象和数组可以修改,这是因为const锁定的只是变量的绑定关系(指针),对象内部的属性不受影响。如果想冻结对象,要用Object.freeze,但它只冻结一层,深层对象需要递归处理。
面试官还常追问"暂时性死区是什么":在块级作用域内,let和const声明的变量在声明语句之前是不可以访问的,这个区间就是暂时性死区。有意思的是typeof这个操作符在TDZ内也会抛ReferenceError,这一点和typeof未声明的变量返回undefined是完全不同的行为。
6.2 ES Module和CommonJS有什么区别,循环依赖怎么处理
这个问题考察的是你对模块化演进的理解深度。CommonJS是Node.js的模块规范,是同步加载,模块导出的是值的拷贝,运行时才能确定模块结构;ES Module是浏览器原生支持的模块规范,是异步加载,导出的是值的引用(live binding),静态分析阶段就能确定依赖关系。经典面试题"两个模块循环引用会发生什么"通常会伴随一个示例代码让你推结果。
处理循环依赖的策略:CommonJS用的是缓存加"先返回已加载部分的导出对象",ES Module用实时绑定,"谁先被加载就先解析谁,依赖等真正用到时再从模块里取"。回答的关键是:循环依赖本身不是问题,问题是你是否在模块顶层立即使用了对方模块的值,如果是,就会拿到undefined或者不完整对象。工程上的规避方案是尽量往更细的粒度拆模块,避免顶层加载时产生副作用。能把这个逻辑讲清楚,面试官会认可你的架构意识。
6.3 防抖和节流怎么实现,适用场景是什么
这题我基本都会让候选人现场手写。防抖(debounce)是在事件触发后等待一定时间再执行,如果过程中又触发了就重新计时;节流(throttle)是固定时间间隔内只执行一次。防抖适合输入框搜索、窗口resize结束后的计算;节流适合滚动事件、按钮点击频率控流。
手写要点有两个:一是要保留this指向,回调函数里的this应该指向触发事件的元素;二是要有首尾执行的控制。带立即执行功能的防抖写法比较加分:
function debounce(fn, delay, immediate = false) { let timer = null; return function(...args) { const callNow = immediate && !timer; clearTimeout(timer); timer = setTimeout(() => { timer = null; if (!immediate) fn.apply(this, args); }, delay); if (callNow) fn.apply(this, args); }; }这段代码里callNow的作用是判断是否立即执行一次,timer置null是为了在结束后允许下一次立即执行。面试官如果有追问:"防抖和节流的区别能不能用生活场景比喻",我会说:防抖像电梯门,有人进就重新计时关门时间;节流像地铁发车,不管多少人到站,固定时间就开走一班。
6.4 内存泄漏有哪些常见场景,怎么排查
内存泄漏这几年问得越来越多,因为大家开始关注长页面和移动端的性能问题。常见场景包括:全局变量挂载东西(未用var/let/const声明的变量会变成全局变量)、被遗忘的定时器和回调函数、脱离DOM的引用(已经是null的对象还存着它的引用)、闭包长期持有大对象、事件监听器未解绑。
排查方法要讲得具体一些:打开Chrome DevTools的Memory面板,录制heap snapshot,对比前后快照找出持续增长的对象;或者用Performance面板录制一段时间,观察JS heap的变化曲线。如果曲线只升不降,基本就是有泄漏。实战中我遇到过这样一个案例:一个列表页轮询接口,定时器每分钟拉取一次数据,但页面卸载时没有clearInterval,导致组件被不断重新挂载时创建了新的定时器,旧定时器又没清,最后页面每秒钟会发几十个请求。这种问题光靠写代码是防不住漏的,需要形成"凡是setInterval必须有对应的clearInterval"的肌肉记忆。
7. 最后再分享一点面试心态层面的经验
以上24个问题全部过一遍,其实只完成了50%的功课。我在面试别人的时候,发现真正拉开差距的从来不是答案背得有多熟,而是你在回答过程中展现出来的"被问题引导"的能力。比如面试官问闭包,你不仅能说出定义,还能主动讲到内存泄漏的规避;问Promise,你主动提到allSettled的应用场景;问原型链,你延伸到class的语法糖本质。这种主动延伸而不是被动挤答案的状态,面试官是能感受到的。剩下50%,是大量真项目经验的积累——手写代码练完之后,把手上的项目仔仔细细过一遍,想一想每个API调用背后的原理,比死刷一百道题更有用。祝各位准备面试的朋友都能拿到心仪的offer。