12k
All articles

10道JS面试题及其真正考察的内容

用提升、闭包、this、事件循环和类型转换解析10道 JavaScript 面试题,并说明输出结果与考点。

OpenReplay Team
OpenReplay Team
10道JS面试题及其真正考察的内容

大多数JavaScript面试题并不是在考你能否说出输出结果——而是在考你是否理解产生该结果的底层模型。一位经验丰富的面试官在白板上写下 console.log(x); var x = 5; 时,早就知道输出的是 undefined。他们真正评分的,是你能否解释为什么——而不是搬出”变量提升会把声明移到顶部”这句话,因为那只是一种心智模型的描述,而非底层机制。能够晋级的候选人,是那些既能说出运行时行为的名称、预测输出结果,又能用两句简洁的话把推理过程清晰表达出来的人。

本文选取了十道题,分别对应十个核心知识点——执行模型、暂时性死区(TDZ)、闭包、词法作用域、this 绑定、事件循环以及类型强制转换——并将每道题拆解为四个部分:代码片段、精确输出及原因、面试官真正考察的内容,以及你接下来会遇到的追问。所有输出均由规范定义,可在任何当前引擎中复现。这些行为在所有当前引擎中保持一致——它们是语言语义,而非特定版本的怪癖。

核心要点

  • var 变量提升的题目,考的不是你知不知道输出是 undefined——而是你是否理解 JavaScript 在进入作用域时、任何代码执行之前,就已经创建了变量绑定。
  • letconst 同样会被提升,但它们在暂时性死区(TDZ)中保持未初始化状态,直到执行到声明语句那一行,因此提前读取会抛出 ReferenceError,而不是返回 undefined
  • 在经典的”循环中的 setTimeout”问题里,var 输出的是循环结束后的最终值(3 3 3),因为所有回调共享同一个函数作用域绑定;而 let 输出的是每次迭代的值(0 1 2),因为它为每次迭代创建一个全新的绑定。
  • this 不由函数的书写位置决定——而由函数的调用方式决定;箭头函数是例外,因为它们没有自己的 this
  • Promise 回调(微任务)始终在 setTimeout 回调(宏任务)之前执行,即使延迟设置为 0 毫秒也不例外。

关于变量提升与执行模型的JavaScript面试题

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;

letconst 同样会被提升——绑定在进入作用域时就已创建——但它们保持未初始化状态,直到执行到声明语句那一行。从进入作用域到执行到声明语句之间的这段窗口期,就是暂时性死区(Temporal Dead Zone),在此期间读取绑定会抛出 ReferenceError: Cannot access 'y' before initialization。这是关键区别:var 在创建时就被初始化为 undefined;而 let/const 在其声明语句执行之前根本不会被初始化。MDN 在 let 的暂时性死区章节 中对此有详细说明。

真正考察的内容: 你是否能将绑定创建绑定初始化区分开来。如果候选人说”let 不会被提升”,那说明他的模型是错误的;正确答案是:它确实被提升了,只是处于未初始化状态。

可能的追问: “那么 TDZ 为什么存在?“——可以这样回答:它使 const 的强制约束得以实现,并将声明前使用的行为从静默的 undefined 变成了一个显式的错误。

以下是面试官期望你能口头重建的参考表格:

行为varletconst
是否提升(进入作用域时创建绑定)
创建时是否初始化undefined否(TDZ)否(TDZ)
声明前读取undefinedReferenceErrorReferenceError
作用域函数级块级块级
同一作用域内可重复声明
可重新赋值

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题的本质区别相同,只是应用在了函数上。

可能的追问: “如果 barlet 声明会怎样?“——那么提前调用会因 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.nameundefined),在严格模式下 thisundefinedthis.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 毫秒也不例外。执行过程如下:

  1. console.log("1") 同步执行 → 输出 1
  2. setTimeout 将其回调调度到宏任务队列。
  3. Promise.resolve().then(...) 将其回调调度到微任务队列。
  4. console.log("4") 同步执行 → 输出 4
  5. 调用栈现在为空。引擎在处理任何宏任务之前,会先完整清空微任务队列 → 输出 3
  6. 之后才取出下一个宏任务 → 输出 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 31 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 回调,会按源码顺序依次执行,且都在任何延迟为零毫秒的定时器回调之前运行。

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.