大多数JavaScript面试题并不是在考你能否说出输出结果——而是在考你是否理解产生该结果的底层模型。一位经验丰富的面试官在白板上写下 console.log(x); var x = 5; 时,早就知道输出的是 undefined。他们真正评分的,是你能否解释为什么——而不是搬出”变量提升会把声明移到顶部”这句话,因为那只是一种心智模型的描述,而非底层机制。能够晋级的候选人,是那些既能说出运行时行为的名称、预测输出结果,又能用两句简洁的话把推理过程清晰表达出来的人。
本文选取了十道题,分别对应十个核心知识点——执行模型、暂时性死区(TDZ)、闭包、词法作用域、this 绑定、事件循环以及类型强制转换——并将每道题拆解为四个部分:代码片段、精确输出及原因、面试官真正考察的内容,以及你接下来会遇到的追问。所有输出均由规范定义,可在任何当前引擎中复现。这些行为在所有当前引擎中保持一致——它们是语言语义,而非特定版本的怪癖。
核心要点
var变量提升的题目,考的不是你知不知道输出是undefined——而是你是否理解 JavaScript 在进入作用域时、任何代码执行之前,就已经创建了变量绑定。let和const同样会被提升,但它们在暂时性死区(TDZ)中保持未初始化状态,直到执行到声明语句那一行,因此提前读取会抛出ReferenceError,而不是返回undefined。- 在经典的”循环中的
setTimeout”问题里,var输出的是循环结束后的最终值(3 3 3),因为所有回调共享同一个函数作用域绑定;而let输出的是每次迭代的值(0 1 2),因为它为每次迭代创建一个全新的绑定。 this不由函数的书写位置决定——而由函数的调用方式决定;箭头函数是例外,因为它们没有自己的this。- Promise 回调(微任务)始终在
setTimeout回调(宏任务)之前执行,即使延迟设置为0毫秒也不例外。
关于变量提升与执行模型的JavaScript面试题
Discover how at OpenReplay.com.
1. 为什么 var 在赋值前打印 undefined?
console.log(x); // undefined
var x = 20;
console.log(x); // 20
第一行打印的是 undefined,而不是 ReferenceError。常见的解释是声明被”移到了顶部”,但那只是一个比喻。实际发生的是:当引擎进入一个作用域时,会在执行任何语句之前,先为该作用域实例化所有绑定,而 var 绑定在此时会被初始化为 undefined。赋值语句 = 20 保留在原来的位置,按顺序执行。这个绑定创建步骤在 ECMAScript 语言规范 的变量环境实例化章节中有明确定义——没有任何东西被物理移动。
真正考察的内容: 你是否理解 JS 在执行阶段之前存在一个创建阶段。能说出 undefined 只是常识;理解两阶段执行模型才是真正的能力体现。可参考 MDN 的变量提升词条 获取规范性表述。
可能的追问: “如果换成 let 会怎样?“——这正是第2题。
2. 为什么 let 会抛出错误而不是打印 undefined?
console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
let 和 const 同样会被提升——绑定在进入作用域时就已创建——但它们保持未初始化状态,直到执行到声明语句那一行。从进入作用域到执行到声明语句之间的这段窗口期,就是暂时性死区(Temporal Dead Zone),在此期间读取绑定会抛出 ReferenceError: Cannot access 'y' before initialization。这是关键区别:var 在创建时就被初始化为 undefined;而 let/const 在其声明语句执行之前根本不会被初始化。MDN 在 let 的暂时性死区章节 中对此有详细说明。
真正考察的内容: 你是否能将绑定创建与绑定初始化区分开来。如果候选人说”let 不会被提升”,那说明他的模型是错误的;正确答案是:它确实被提升了,只是处于未初始化状态。
可能的追问: “那么 TDZ 为什么存在?“——可以这样回答:它使 const 的强制约束得以实现,并将声明前使用的行为从静默的 undefined 变成了一个显式的错误。
以下是面试官期望你能口头重建的参考表格:
| 行为 | var | let | const |
|---|---|---|---|
| 是否提升(进入作用域时创建绑定) | 是 | 是 | 是 |
| 创建时是否初始化 | undefined | 否(TDZ) | 否(TDZ) |
| 声明前读取 | undefined | ReferenceError | ReferenceError |
| 作用域 | 函数级 | 块级 | 块级 |
| 同一作用域内可重复声明 | 是 | 否 | 否 |
| 可重新赋值 | 是 | 是 | 否 |
3. 函数声明与函数表达式的提升差异
foo(); // "I run"
bar(); // TypeError: bar is not a function
function foo() { console.log("I run"); }
var bar = function () { console.log("I don't"); };
函数声明会被整体提升——包括函数名和函数体——因此 foo() 在其定义之前就能正常调用。而赋值给 var bar 的函数表达式,只有 bar 这个绑定会被提升,且初始化为 undefined。调用 undefined 会抛出 TypeError,而不是 ReferenceError——绑定是存在的,只是它还不是一个函数。能说出正确的错误类型,才是真正理解的体现。MDN 在函数声明页面中对这一区别有详细说明。
真正考察的内容: 你是否理解声明会提升值,而表达式只提升绑定。这与第1题和第2题的本质区别相同,只是应用在了函数上。
可能的追问: “如果 bar 用 let 声明会怎样?“——那么提前调用会因 TDZ 抛出 ReferenceError,而不是 TypeError。
关于闭包与作用域的JavaScript面试题
4. 经典的”循环中的 setTimeout”
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3
}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
var 版本的循环打印 3 3 3。整个循环只有一个函数作用域的 i;当回调触发时(在同步循环结束之后),i 已经是 3,三个闭包读取的都是同一个绑定。let 版本打印 0 1 2,因为 let 为每次迭代创建一个全新的绑定,每个回调都闭包了属于自己的 i。这是网络上被误解最多的代码片段之一,它同时涉及三个概念:闭包、作用域和事件循环(回调是延迟执行的,所以循环会先完成)。MDN 的闭包指南记录了 let 的逐次迭代绑定机制。
真正考察的内容: 你是否能区分闭包何时读取变量与何时创建的问题。这不是纯粹的理论题——它与生产环境中事件处理器和 React effect 里出现的过期闭包状态属于同一类 bug。对过期闭包处理器的会话回放,往往会发现它们记录的是来自早期渲染的捕获值。
可能的追问: “不用 let,如何修复?“——用 IIFE 包裹循环体,将 i 作为参数传入,从而为每次迭代创建一个新的作用域。
5. 私有计数器
function makeCounter() {
let count = 0;
return {
increment: () => ++count,
value: () => count,
};
}
const c = makeCounter();
c.increment(); // 1
c.increment(); // 2
c.value(); // 2
// count 无法从外部访问
闭包是一个函数与其定义时所能访问的变量的组合,而不是调用时——这就是为什么返回的方法在 makeCounter 执行完毕后仍然能访问 count。由于外部没有任何途径直接暴露 count,它实际上是私有的。这是用作用域构建的封装,而不是依赖 #private 字段。
真正考察的内容: 你是否将闭包理解为一种内存管理和封装工具,而不仅仅是一道考题的答案。追问会探究你是否了解其代价。
可能的追问: “这会造成内存泄漏吗?“——只要 c 是可达的,count 变量就会一直存活,因为闭包持有对它的引用;在这个例子中这是预期行为,但对大型对象的不受控闭包确实是真实的内存泄漏来源。
6. 词法作用域:由定义位置决定,而非调用位置
const x = 10;
function outer() {
const x = 20;
return inner;
}
function inner() {
console.log(x); // 10
}
outer()(); // 10
inner 打印的是 10,而不是 20。JavaScript 通过函数在源代码中定义的位置来解析自由变量,而不是通过调用位置。inner 定义在顶层,因此它的 x 解析为顶层的 10——outer 内部的 x 与它无关。这就是词法(静态)作用域,也是闭包行为可预测的根本原因。
真正考察的内容: 你是否将调用栈与作用域链混淆。动态作用域语言会打印 20;JavaScript 不会。
可能的追问: “现在把 inner 的声明移到 outer 内部会怎样?“——那么它会解析到 20,因为其定义位置发生了变化。
关于 this 绑定的JavaScript面试题
7. 这里的 this 指向什么?
const user = {
name: "Ada",
greet() { return this.name; },
};
const fn = user.greet;
user.greet(); // "Ada"
fn(); // undefined(严格模式下会抛出错误)
this 不由函数的书写位置决定——而由函数的调用方式决定。以 user.greet() 的形式调用时,调用点的对象是 user,所以 this.name 是 "Ada"。将其赋值给 fn 并直接调用时,没有调用点对象,因此在非严格模式下 this 是全局对象(this.name 为 undefined),在严格模式下 this 是 undefined(this.name 会抛出错误)。四种绑定规则在 MDN 的 this 参考页 中有详细总结。
真正考察的内容: 你是否知道 this 是动态的、由调用点驱动的,而不是词法固定的。
可能的追问: “如何将 this 固定绑定到 user?“——使用 fn.call(user)、fn.apply(user) 或 user.greet.bind(user)。
| 调用形式 | this 绑定到 |
|---|---|
fn()(直接调用) | undefined(严格模式)/ 全局对象(非严格模式) |
obj.fn()(方法调用) | obj(点号左侧的对象) |
fn.call(o) / fn.apply(o) / fn.bind(o) | o(显式绑定) |
new Fn() | 新创建的实例 |
| 箭头函数 | 继承自外层词法作用域 |
8. 回调中的 this
const timer = {
seconds: 0,
startBroken() {
setInterval(function () { this.seconds++; }, 1000); // this 指向错误
},
startFixed() {
setInterval(() => { this.seconds++; }, 1000); // this 指向 timer
},
};
在 startBroken 中,传给 setInterval 的普通 function 被定时器机制调用时没有调用点对象,所以 this 不是 timer——this.seconds++ 修改的是错误的对象。在 startFixed 中,箭头函数没有自己的 this,它从 startFixed 的作用域中继承 this,而那里的 this 正是 timer。这是箭头函数存在的经典原因,专门用于回调场景。参见 MDN 关于箭头函数的文档。
真正考察的内容: 你是否理解词法 this 的概念,以及为什么箭头函数取代了过去 var self = this 这种变通写法。this 丢失的问题在生产环境的类组件和事件监听器中频繁出现。
可能的追问: “为什么箭头函数不能用作构造函数,或者用作需要自己 this 的对象方法?“——因为它没有可供赋值的 this 绑定,所以 new 会抛出错误;而作为对象方法时,箭头函数捕获的是外层的 this,而不是该对象。
关于运行时模型的JavaScript面试题
9. 预测控制台输出顺序(事件循环)
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// 输出:1 4 3 2
Promise 回调(微任务)始终在 setTimeout 回调(宏任务)之前执行,即使延迟设置为 0 毫秒也不例外。执行过程如下:
console.log("1")同步执行 → 输出1。setTimeout将其回调调度到宏任务队列。Promise.resolve().then(...)将其回调调度到微任务队列。console.log("4")同步执行 → 输出4。- 调用栈现在为空。引擎在处理任何宏任务之前,会先完整清空微任务队列 → 输出
3。 - 之后才取出下一个宏任务 → 输出
2。
MDN 的微任务指南描述了这一执行顺序。
真正考察的内容: 你是否理解事件循环有两个优先级层级,而不是一个单一队列。这种两层事件循环模型,正是每一个”我的状态更新晚了一个 tick”的 bug 背后的根本原因。
可能的追问: “async/await 在其中处于什么位置?“——await 之后的代码作为微任务运行,因此其优先级与 .then() 回调相同。
10. == 与 === 及类型强制转换
0 == "0"; // true
0 === "0"; // false
null == undefined; // true
NaN === NaN; // false
[] == ![]; // true
== 在比较前会进行类型强制转换,而 === 不会,这就是为什么 0 == "0" 是 true,而 0 === "0" 是 false。几个反直觉的例子:null == undefined 因一条特殊规则而为 true(且在 == 下它们不等于任何其他值);NaN 不等于任何值,包括它自身;[] == ![] 为 true,是因为 ![] 是 false,强制转换为 0,而 [] 先强制转换为 "",再转换为 0。完整的算法可参见 MDN 的相等性比较指南。
真正考察的内容: 你是否能预测类型强制转换的结果——而不仅仅是背出”始终使用 ===”这句话。面试官想要的是底层机制,然后才是经验法则。
可能的追问: “给未声明的变量赋值会怎样?“——value = 42(不带 var/let/const)是否抛出错误,取决于当前模式:严格模式和 ES 模块会抛出 ReferenceError;非严格模式的脚本则会静默创建一个隐式全局变量。同一段代码,两个正确答案——能指出这一区别本身就是高级工程师的信号。
什么决定了录用与淘汰
贯穿这十道题的共同主线是:面试官评分的是你的解释,而不仅仅是输出结果。能预测出 3 3 3 或 1 4 3 2,只证明你见过这道题;能说出绑定模型、作用域链、调用点规则和两层事件循环,才证明你对这门语言的理解深到足以在压力下进行调试。反复练习这十道题,直到你能不看笔记、用两句话说清楚为什么——而且由于这些行为都与版本无关,你可以在任何当前引擎中自行验证每个代码片段,结果都是可信的。
常见问题
暂时性死区与变量值为 undefined 有什么区别?
处于暂时性死区的变量已被提升但尚未初始化,因此读取它会抛出 ReferenceError;而值为 undefined 的 var 变量已被提升并初始化为 undefined,读取它会返回 undefined。只有 let 和 const 会产生 TDZ,TDZ 从进入作用域开始,到声明语句执行时结束。关键区别在于绑定创建与绑定初始化。
为什么箭头函数不能用作对象方法或构造函数?
箭头函数没有自己的 this 绑定,因此无法胜任需要 this 绑定的场景。用作对象方法时,箭头函数从外层词法作用域捕获 this,而不是指向该对象,导致 this.property 无法指向正确的对象。用于 new 调用时,引擎会抛出 TypeError,因为没有 this 绑定可供分配新实例。这两种情况都应使用普通函数。
循环中的 setTimeout 问题在所有 JavaScript 引擎中表现一致吗?
是的。var 版本在所有当前引擎中都打印循环的最终值,因为 var 创建的是一个被所有回调共享的函数作用域绑定;let 版本在所有引擎中都打印逐次迭代的值,因为规范规定 for 循环中的 let 为每次迭代创建一个全新的绑定。这一行为由 ECMA-262 规范定义,与引擎无关,因此 Node.js、V8、SpiderMonkey 和 JavaScriptCore 都会产生相同的输出。
async/await 会改变相对于 Promise.then 的事件循环优先级吗?
不会。await 之后的代码作为微任务运行,与 Promise.then 回调的优先级完全相同,因此它会在任何 setTimeout 宏任务之前执行。await 实际上是暂停函数的执行,并在被等待的值 settle 后,将后续代码的执行调度到微任务队列。这意味着在同一时间点排队的 await 后续代码和 then 回调,会按源码顺序依次执行,且都在任何延迟为零毫秒的定时器回调之前运行。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k