最近集中面了几轮前端,发现一个规律:候选人简历上写的东西五花八门,框架用过不少,但面试官第一关永远先拿JavaScript 核心知识试水。这个环节的表现,直接决定了后面技术面的深度和整体印象分。很多人挂在第一关,不是败在某个偏门 API 上,而是基础知识在关键时刻出现了盲区。
这篇文章就来搭一个面试向的 JS 核心知识索引,把高频考点按照面试官习惯的追问链路重新拆一遍。从数据类型、闭包、this,到事件循环、原型链、异步编程、手写题,每一块都会说清楚:面试官到底想问什么、什么样的回答算合格、有哪些常见的坑和加分项。
内容偏实战,准备跳槽、刚学完基础想查漏补缺,或者开始带新人做复习规划的,都可以直接用这套索引来对照自检。
1. 数据类型考核:从 typeof 到深浅拷贝的连环追问
数据类型这块是前端面试的默认开场。几乎每一次面试的第一个问题都是“JS 里有几种数据类型”,但很多人答完这个就停住了,以为问题结束了。实际上真正的考核在后面的追问链上:怎么判断、怎么转换、怎么拷贝。
1.1 先分清基础类型与引用类型
按 ECMAScript 标准,目前有 7 种基础类型和 1 种引用类型。基础类型是number、string、boolean、null、undefined、symbol、bigint;引用类型就是object,function、array、date、regexp这些本质上都是对象。
面试时建议这么回答:基础类型存的是值本身,复制的时候是值的拷贝;引用类型存的是引用地址,复制的时候只是把地址复制了一份,所以改指向的数据,原对象会跟着变。最好边说边在白板上画一下,画一个栈里存值、堆里存对象的示意,面试官立刻就知道你理解到位了。
接着面试官一般会问一个经典陷阱:typeof null为什么是object。底层原因是早期引擎用低几位表示类型标记,null 的表示恰好是 0,和对象类型标记撞了,这个 bug 一直保留到今天。不用解释太深,但要说清楚这是历史遗留问题,并且它在实践中会导致某些边界判断需要单独处理。
1.2 类型判断:typeof、instanceof 与 toString 的边界
判断类型有三大工具,每个都有适配场景,也都有坑。
typeof适合判断基础类型和function,但遇到null返回object,遇到数组也返回object,遇到正则也未必准确。instanceof适合判断引用类型之间的继承关系,但它依赖原型链,而且Object.create(null)创建出来的对象没有原型,instanceof Object直接返回 false。Object.prototype.toString.call()是最稳定的判断方式,几乎所有内置类型都能返回正确的标签,比如[object Array]、[object Null]。
我把它们的对比整理在这里:
| 判断方式 | 适合场景 | 典型坑 |
|---|---|---|
| typeof | 基础类型、function | null、Array 都会被判定为 object |
| instanceof | 对象与构造器的关系 | 跨 iframe / 修改原型链时失效 |
| Object.prototype.toString.call() | 精确判断内置类型 | 自定义 class 需要额外处理 |
回答时有一个加分技巧:如果自己封装类型判断函数,统一用Object.prototype.toString.call(),并对null、undefined做前置判断。这一步说出来,面试官就知道你写过工具代码。
1.3 隐式转换:面试官最爱的陷阱区
隐式转换基本必考,经典题是[] == ![]为什么等于 true。这道题很多人背过答案但不知道推理链路,我来拆一遍。
右边![]先把数组转布尔值,空数组转为 true,取反后是 false。于是表达式变成[] == false。比较时如果两边类型不同,优先转成数字,空数组转数字是 0,false 转数字也是 0,所以 0 == 0 成立。完整链路是:取反 → 转布尔 → 转数字 → 比较。
类似的高频题还有'0' == false(true)、null == undefined(true)、NaN == NaN(false)。这几个题表面考等号比较,实际考的是对 ToPrimitive、ToNumber 这些内部转换规则的理解。面试的时候不用背结论,把转换链路完整说出来,比单纯回答 true/false 好得多。
1.4 深浅拷贝与循环引用
类型判断之后,面试官接下来大概率问拷贝。浅拷贝方式有展开运算符、Object.assign,深拷贝要提到JSON.parse(JSON.stringify(obj)),但必须同时指出它的局限:函数、undefined、symbol作为属性值会被丢弃;Date会变成字符串;正则变成空对象;循环引用直接报错。
手写深拷贝在这个环节是高频要求。核心框架是递归:基础类型直接返回,数组和对象分别处理,关键点有两个,一是用Object.create(Object.getPrototypeOf(target))保留原型,二是用WeakMap记录已拷贝对象来打破循环引用。
function deepClone(target, map = new WeakMap()) { if (target === null || typeof target !== 'object') { return target; } if (map.has(target)) { return map.get(target); } const clone = Array.isArray(target) ? [] : {}; map.set(target, clone); for (const key of Object.keys(target)) { clone[key] = deepClone(target[key], map); } return clone; }面试时写到这里,主动解释为什么用WeakMap(键不会阻止垃圾回收)以及为什么不用JSON.stringify方案,基本就过关了。
2. 闭包的真考点:作用域链与内存回收的完整链路
闭包是最被滥用也最被低估的知识点。很多候选人能说出来“函数里面返回函数就是闭包”,但问到闭包和垃圾回收的关系就卡住了。实际上,面试官对闭包的考察集中在两个维度:作用域链的延长机制,以及闭包带来的内存影响。
2.1 什么是闭包,什么不是
闭包的本质是:一个函数在定义时捕获了外层作用域中的变量,使得该变量在函数执行完毕后仍然被内部函数引用而无法被回收。用一句话概括就是“函数 + 其定义时所在的作用域链”。
常见的生产场景比如防抖节流、私有变量模拟、函数柯里化、单例模式,都用到了闭包。但面试时不要只举例子,要把“定义时捕获”和“调用时查找”区分开。看这段代码:
function outer() { let count = 0; return function inner() { count++; return count; }; } const fn = outer(); fn(); // 1 fn(); // 2count之所以不会重置,不是因为inner每次都重新定义,而是因为inner的闭包里长期持有了对count所在作用域的引用。面试官想看的就是这个解释,而不是念一遍代码。
2.2 闭包、作用域链与垃圾回收
紧接着可以预判下一个问题:闭包会造成内存泄漏吗?
这个问题的标准答法是:闭包不必然导致内存泄漏,它只是让某个变量生命周期变长;真正的问题在于一个长生命周期的对象被闭包引用,而这个闭包又挂在全局环境中不被释放,内存就回不来了。常见场景是全局事件监听器里引用了一个大数组,或者定时器回调里引用 DOM 节点,这种情况下即使页面不需要了,回调依然持有引用。
能说出“V8 在优化闭包时会把需要的变量封装到一个 Context 中,不需要的变量如果未被内部函数引用则不会被捕获”这个层次,已经是加分项了。面试官更想听的是:你知道闭包的生命周期可以控制,可以通过把引用置为 null 来完成主动释放。
2.3 高频闭包题:定时器循环与 var 的坑
经典题大家应该都遇到过:
for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); }答案是打印五次 5,原因是var没有块级作用域,五个回调共享同一个i。解决方案有三条:用let声明、用立即执行函数包一层、用Function.prototype.bind传入参数。面试官接下来还会问:改用let后,forEach回调里为什么每个回调拿到的i是不同的?这又回到了块级作用域和每次迭代创建独立词法环境的机制上。
这三种方案都提到,顺便说一句“本质上都是为每个回调创建独立的作用域”,比只回答用let给人的印象深得多。
3. this 指向:四种绑定规则之外的追问
this 是我面试中最常见的淘汰点。很多人死记硬背“谁调用就指向谁”,但遇到赋值、回调、箭头函数三者混在一起时还是容易错。这个考点的核心不是背规则,而是能根据一段代码完整推导出输出。
3.1 四种绑定规则
this 的绑定规则从左到右的优先级是:new 绑定 > 显式绑定(call / apply / bind) > 隐式绑定(对象方法调用) > 默认绑定(独立函数调用)。
默认绑定是最容易中招的。非严格模式下,独立函数调用里的 this 指向全局对象;严格模式下指向undefined。很多人以为函数内部一定指向当前对象,这就是典型误解。举个例子:
var name = 'global'; const obj = { name: 'obj', fn: function () { console.log(this.name); }, }; const fn = obj.fn; fn(); // 输出 global,而不是 obj这段的关键在于fn()是独立调用,.fn前面的上下文没有跟着传递,隐式绑定丢失了。面试题通常会把这种情况包装成 setTimeout 回调或者数组方法回调,所以看到回调函数第一反应是“调用点在哪里”,而不是“函数定义在哪个对象里”。
3.2 箭头函数的特殊性
箭头函数会捕获定义时的外层 this,并且后续无法通过 call、apply、bind 改变。这不是语法偏好,而是因为它本身没有自己的 this,它在词法上记住了父级作用域的 this。
一个常见的追问组合是:
const obj = { name: 'obj', fn: function () { setTimeout(() => { console.log(this.name); }, 0); }, }; obj.fn();这里输出obj。因为外层fn的 this 由隐式绑定指向 obj,箭头函数捕获了它。如果setTimeout里换成普通函数,输出就会变成全局的name。把这种对比讲清楚,面试官就不需要再追一句“那箭头函数里的 this 能变吗”。
3.3 手写 bind 与 new 优先级
手写 bind 也是高频题,而且写完往往还要回答“new 出来的 this 该怎么处理”。标准实现是这样:
Function.prototype.myBind = function (ctx, ...args) { const fn = this; return function (...innerArgs) { return fn.apply(ctx, args.concat(innerArgs)); }; };但实际上有一个隐藏问题:如果用new调用 bind 返回的函数,ctx应该被忽略,this 应该指向 new 创建出来的新对象。所以健壮版需要判断this instanceof当前返回函数的情况。面试时即使不写完整版,也要主动提到这个优先级问题,这能直接体现你对 new 和 bind 的关系有深入理解。
4. 事件循环与异步顺序:一道题摸清并发模型
这一块是很多自学出身的前端最头疼的部分。原因在于它不是一个独立知识点,而是把运行时机制、任务队列、渲染时机、Promise 调度全部串起来的链路。面试官通常会从一道输出顺序题开始,再逐步加深。
4.1 为什么需要事件循环
JS 是单线程的,但不代表它能同时只做一件事。浏览器运行时维护了一个调用栈和一个任务队列,同步代码先执行,遇到异步操作时交给其他线程处理,等结果就绪后把回调放进任务队列,当主线程空闲时再取出来执行。这个“取出来执行”的过程就是事件循环。
注意,这里不要只是背诵“微任务宏任务”,更好的回答是:把同步代码理解为最高优先级,微任务队列在每次宏任务结束之后清空,渲染操作在宏任务之间穿插。这样才有“为什么 setTimeout 不一定精确延迟”的解释基础。
4.2 宏任务与微任务的执行顺序
执行顺序的规律可以概括为:执行一个宏任务 → 清空所有微任务 → 需要渲染则渲染 → 继续取下一个宏任务。
常见的宏任务包括setTimeout、setInterval、I/O 事件、messageChannel;常见的微任务包括Promise.then、MutationObserver、queueMicrotask。很多人会忘记的一点是,Promise构造函数里的代码是同步执行的,只有.then回调是微任务。
4.3 从经典题目推导完整流程
来看这道流传很广的输出顺序题:
console.log('script start'); setTimeout(() => { console.log('setTimeout'); }, 0); Promise.resolve() .then(() => { console.log('promise1'); }) .then(() => { console.log('promise2'); }); console.log('script end');输出结果是script start→script end→promise1→promise2→setTimeout。
推导逻辑是:同步代码先全部执行,所以两个console.log先打印;然后当前宏任务结束,开始清空微任务队列,promise1和promise2依次执行;最后才进入下一次事件循环取出setTimeout的回调。这里要注意.then链式调用中第二个.then是等第一个.then返回后才加入微任务队列的,不是一开始就全部排好队。
如果把Promise.resolve().then改成await,比如写成:
async function foo() { console.log('foo start'); await bar(); console.log('foo end'); } async function bar() { console.log('bar'); } foo();输出是foo start→bar→foo end。await右边的同步代码立即执行,但await之后的代码被放进了微任务队列,等价于.then回调。很多人在这个变体上翻车,是因为没意识到await并不是“等着但不执行其他代码”,而是把后面的代码延后了。
4.4 Node 事件循环与浏览器的差异
高级一点的面试官会问:这段代码在 Node 环境会不会一样。这时要提到 Node 的事件循环有阶段概念的,比如timers、poll、check等,setTimeout归 timers 阶段,setImmediate归 check 阶段,而process.nextTick的优先级高于 Promise 微任务,是 Node 独有的。
我面试时的建议是:先答完整浏览器事件循环,再补充说 Node 在较新版本里也基本遵循宏任务、微任务的大框架,但阶段调度和nextTick会让输出顺序在不同环境下有细微差异。这种回答既不露怯,又显示出你对运行时有区分能力。
5. 原型链与继承:从手写 new 到 class 的底层逻辑
原型链在前端圈子里的讨论热度一直很高,但面试问题的种类其实有限:基于原型链的输出题、手写 new、手写继承方式、绝大多数时候就这三种。
5.1 原型链的本质
原型链的模型不复杂:每个构造函数都有一个prototype属性,指向它的原型对象;每个实例都有一个隐藏的__proto__指向构造函数的prototype;原型对象本身也有自己的__proto__,一层层向上找就形成了原型链,最终终点是Object.prototype,再往上是null。
常考的一道输出题是:
function Person() {} const p = new Person(); console.log(p instanceof Person); // true console.log(p instanceof Object); // true console.log(p.constructor === Person); // true这段代码能分析清楚,说明你已经理解线索转在了哪里。注意p.constructor并不是 p 自己的属性,它是沿着__proto__找到了Person.prototype.constructor。
5.2 手写 new 与 instanceof
手写 new 是必须会的程度,标准实现:
function myNew(Constructor, ...args) { const obj = Object.create(Constructor.prototype); const result = Constructor.apply(obj, args); return result && (typeof result === 'object' || typeof result === 'function') ? result : obj; }这里的关键点是:创建新对象、绑定原型、调用构造函数、如果构造函数返回了对象则替换返回值。讲明白最后一条,面试官就知道你踩过对象返回值的坑。
手写 instanceof 也很常见:
function myInstanceof(left, right) { let proto = Object.getPrototypeOf(left); while (proto) { if (proto === right.prototype) return true; proto = Object.getPrototypeOf(proto); } return false; }5.3 继承方式的演进与面试答题顺序
面试问“JS 如何实现继承”,最好的回答顺序不是直接背代码,而是先讲演进脉络:原型链继承 → 构造函数继承 → 组合继承 → 寄生组合继承 → class extends。
每种方式的问题都值得说明。原型链继承的问题是两个子类实例会共享同一个父类实例属性,改一个另一个也变;构造函数继承解决不了父类方法复用;组合继承会调用两次父类构造函数。最后给出寄生组合继承,再用class extends做收尾对比。
这里有人会忽略一个细节:class的继承和普通构造函数继承在内部机制上有区别,比如class必须先调用super()才能访问this,而普通函数继承没有这个强制约束。这个点答出来,说明你研究过 ES6 class 的实现本质。
6. 异步编程从回调到 async/await:理解这一条演进线
异步是 JS 的底色,面试里考察的不仅是用 Promise 写代码,而是你是否理解从回调到 Promise、再到 async/await 的演进原因。很多候选人直接用 async/await 写得很溜,但问“它和 Generator 有什么关系”就沉默了,这是基本功不扎实的信号。
6.1 回调地狱的根源
回调地狱表面问题是嵌套丑陋,深层问题是三个:可读性差、错误处理不一致、难以并行控制。你去看一段老代码就能直观感受到,五个回调嵌套在一起,想在上面加一个超时控制,几乎要重写整块逻辑。
所以 Promise 的价值不是消灭嵌套,而是把异步结果从“回调参数”变成“状态对象”,然后允许链式调度。
6.2 Promise 的状态机
Promise 有三种状态:pending、fulfilled、rejected。状态只能从 pending 迁移到 fulfilled 或 rejected,一旦变化不可逆。.then之所以能无限链下去,是因为每次.then都会返回一个新的 Promise。
面试里容易出的一道追问:.then回调里返回一个 Promise 会发生什么?答案是后一个.then会等待这个 Promise resolve 后再执行,它实现了 Promise 之间的拼接,也是链式控制的核心机制。手写 Promise 时最难处理的就是这一块,需要递归地处理 then 的返回值。
6.3 async/await 的原理
async/await 可以理解为 Promise 的语法糖,但严格说它底层是基于生成器的。await会暂停当前 async 函数的执行,把控制权交回事件循环,等 Promise 结束后再恢复函数栈。这意味着await后面的代码不是同步的,而是会被放入微任务队列。
面试时一个常见问题是:“async 函数里遇到 throw error,返回的 Promise 是什么状态?”答案是 rejected,并且调用方能用.catch捕获。另一个常见问题是:“两个 await 能否并行?”如果不能,就要用Promise.all。把这两个问题答好,已经覆盖了 async/await 的绝大多数用法。
6.4 常见手写 Promise 方法
高频的手写题包括Promise.resolve、Promise.reject、Promise.all、Promise.race、Promise.allSettled。Promise.all的关键是:传入的数组不一定是 Promise,要先 Promise.resolve 包装;结果必须保持顺序;任何一个 rejected 则整体失败。
Promise.myAll = function (promises) { return new Promise((resolve, reject) => { const result = []; let count = 0; promises.forEach((p, index) => { Promise.resolve(p).then( (value) => { result[index] = value; count++; if (count === promises.length) resolve(result); }, reject ); }); }); };面试时能把Promise.all手写出来,基本就能过这一关了。allSettled则要求无论成功失败都返回带status的对象,注意不要和all混淆。
7. 手写题的优先级排序与应对策略
很多前端对“手写题”有一种抵触情绪,觉得是在背题。实际上手写题考的是你对 API 实现层的理解,它会直接暴露你平时是在“用工具”还是在“懂原理”。合理的策略不是大量刷题,而是按优先级覆盖核心题,并且坚持“先讲原理,再动笔”。
7.1 高频题目清单
根据我这几年面试的经验,前端面试中手写题的出现频率大致可以分为几个梯队。
第一梯队是几乎必考的:防抖、节流、深拷贝、Promise.all、call/apply/bind。这类题覆盖了闭包、递归、函数式编程、异步控制四个核心能力。
第二梯队是出现频率较高的:函数柯里化、instanceof、new、数组去重与扁平化、发布订阅事件总线。
第三梯队是加分题,出现概率不高但答上很亮眼:手写 Promise A+、JSON.stringify的底层实现、数组 reduce 的原生实现。
我建议先死磕第一梯队,再根据目标面试官的风格补第二梯队。这个顺序比盲目刷一百道题高效得多。
7.2 先讲原理再写代码
面试中常见的一个错误是:题目刚读完就低头写代码,写错了又反复改。更好的方式是先花三十秒把原理列出来,让面试官知道你的思路链路,再开始写。比如写到防抖,先说“防抖的本质是清除上次的定时器并重新设置,保证事件在停止触发后 delay 毫秒才执行”;写到节流,先说“节流是保证一个时间段内只允许执行一次”。原理讲清楚之后代码写错一点,面试官也更愿意给提示。
7.3 防抖与节流的代码差异
防抖和节流是两个非常容易搞混的工具,用一张对比表可以看得更清楚:
| 维度 | 防抖 | 节流 |
|---|---|---|
| 核心思想 | 重置计时器 | 限制执行频率 |
| 触发时机 | 停止操作后执行 | 固定间隔内最多执行一次 |
| 适用场景 | 搜索框联想输入、resize 重算 | 滚动加载、拖拽坐标更新 |
一个经典写法是:
function debounce(fn, delay = 200) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); }; } function throttle(fn, interval = 200) { let last = 0; return function (...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; }注意两个实现里的this:因为返回的函数可能作为对象方法被调用,所以必须用apply(this, args)把上下文传回去,否则this会意外丢失。这是一个很容易被忽略的加分细节。
8. 我的面试建议:核心知识的复习路线
最后分享一点我个人的体会。面过不少候选人,我发现真正稳定的那批人,不是背了多少野题,而是有一张完整的知识地图,知道每个知识点在整个语言体系里的位置。面试官随便从一个点切入,他们都能向上挂联到相邻知识点,这种“能迁回”的能力,比单点记忆重要得多。
建议你在准备面试时,把这份索引当成自测清单,而不是速查表。每天挑一个专题,放下编辑器,拿一张白纸,靠记忆画出它的完整链路图。比如今天画事件循环的宏任务、微任务流转链路,明天画原型链上各对象的__proto__指向。画不出来、卡住的地方,就是你需要补课的地方。
越到后面你越会发现,JS 面试的终点不是某个 API 能背多少,而是你能否用这门语言的核心机制,有条理地解释真实世界里的代码行为。这份索引只是一个起点,顺着它往深挖,你收获的不只是面试通过率,还有对这门语言更踏实的掌控感。