理解 JavaScript 的闭包与事件循环
JavaScript 的闭包能让外部变量保持有效;事件循环则负责管理调用栈、微任务和宏任务。两者都会通过可运行的代码示例进行说明。
来源: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion
JavaScript常被形容为一种充满怪癖的语言,但它最容易被误解的两种特性——闭包和事件循环——其实源自同一个核心理念:函数以及那些被安排执行的任务并不会因为周围代码的继续执行而消失。要理解函数如何在父函数返回后仍能“记住”变量,以及运行时如何决定何时执行那些尚未立即可用的代码,就需要你不再将JavaScript视为简单的逐行脚本,而是将其看作一个拥有内存管理和时间调度规则的系统。本文将从闭包开始,逐步讲解这两个概念。
理解闭包
闭包究竟是什么
当一个函数保留了其创建时的作用域中的变量,并且即使外部函数已经执行完毕仍能访问这些变量时,就出现了闭包。
一个便于理解的方式是想象函数无论走到哪里都会带着一个装满变量的小背包。
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
注意,createCounter()已经运行并返回了结果,但它所返回的内部函数仍然可以访问count。在外部调用在技术上已经结束时,这种持续的访问能力就是闭包。
闭包常见的出现场景
每当一个函数需要在创建它的函数已经退出后仍保留某些数据时,闭包就显得非常重要。在许多日常场景中都会遇到这种模式:
- 私有变量——用于隐藏数据,使外部代码无法直接访问。
setTimeout或setInterval内部访问变量。它们为何重要
闭包使你无需依赖全局变量即可保留状态,从而使代码更加简洁安全。
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
},
getBalance() {
return balance;
}
};
}
const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined
在那个示例中,无法直接从函数外部访问balance。只有由createAccount返回的对象方法才能访问它。
这种结构能带来以下几大优势:
- 更好的封装性
- 真正私有的状态
- 更简洁、更易读的代码
- 减少对全局变量的依赖
经典的循环示例
展示闭包作用的最典型方式,就是比较在用于调度回调的循环中使用 var 和 let 的区别。
使用 var 时会出现以下情况:
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 4
// 4
// 4
初始输出可能与多数人的预期不同,原因在于 var 是函数作用域而非块作用域。所有三个 setTimeout 回调最终都引用同一个变量 i。等到这些回调实际执行时,循环早已结束,i 的值也已经变为 4。
现在再对比一下使用 let 的情况:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 1
// 2
// 3
由于 let 具有块级作用域,每次循环迭代都会为 i 创建独立的绑定。这意味着每个回调函数都会捕获不同的值,因此数字会按照人们预期的顺序被打印出来。
常见的面试问答
问题:JavaScript 中的闭包是什么?
闭包是一种函数,它能够记住并在其定义的作用域中访问变量,即便外部函数已经执行完毕。
追问:实际中会在哪里使用闭包?
一个好的答案应涵盖实际应用场景而不仅仅是定义:
闭包最常出现在回调函数、定时器、事件处理程序以及函数工厂中。每当有状态需要保持私有而不属于全局作用域时,闭包也非常有用。
核心要点
其核心思想可以简化为一个公式:函数加上它周围的变量等于闭包。如果把“背包”图像记在脑海中——即函数在任何被调用的地方都会携带它所捕获的变量——那么整个概念就会变得不那么神秘了。
理解事件循环
闭包解释了函数如何能够跨越时间保留数据。而事件循环则解释了JavaScript是如何决定何时运行那些尚未立即准备好的代码的——很多令人困惑的控制台输出也正是由此产生的。
几乎每位JavaScript开发者最终都会在面试或偶然情况下遇到类似代码片段:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
从上到下阅读这段代码,人们会认为控制台会先输出1,再输出2,最后输出3——因为超时时间被设置为零毫秒,所以根本不应该有延迟。
但在浏览器控制台或Node环境中运行时,实际输出却是这样:
1
3
2
那么,为何原本计划“立即”执行的代码实际上会在文件中后面编写的代码之后才执行呢?
要回答这个问题,就需要了解代码背后的运行机制:事件循环。与其陷入关于执行上下文和调度算法的复杂术语中,不如构建一个简单且直观的心理模型来理解实际发生的情况。
JavaScript核心中的矛盾
在了解图表和定义之前,有一件事你需要牢记:
JavaScript 是严格的单线程语言。
单线程意味着 JavaScript 只有一个调用栈,它只有一个处理“大脑”,在任何时刻只能执行一行代码。它无法并行运行两个函数,也无法自行将计算任务分配到多个核心上。
这一限制引出了一个显而易见的问题:想象有一段代码需要等待缓慢的 API 响应整整三秒——在只有一条线程可用且没有其他辅助手段的情况下,页面的其他部分在等待期间能做些什么?
在完全没有辅助的纯单线程环境中,整个浏览器都会卡住。你将无法点击任何按钮、滚动页面、在输入框中打字,也无法观看动画——所有操作都会停滞,直到网络响应最终到达。
但实际上并非如此。现代网页应用能够同时流畅地传输视频、发送后台网络请求、悄悄更新分析数据以及接收键盘输入,而不会出现卡顿。
那么,单线程语言是如何做到这一点的呢?
答案是:JavaScript本身是单线程的,但它所处的环境——即浏览器或Node.js——并非如此。
以餐厅厨房作为类比
理解这种架构最清晰的方式,就是想象一个忙碌的快餐厨房:
[ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
│
▼ Hands off slow jobs
[ The Kitchen Staff ] (Web APIs)
│
▼ Ready order slips
[ Order Counter ] (Queues)
▲
│ Checks constantly
[ The Floor Manager ] (The Event Loop)
各角色的职责如下:
- 主厨(调用栈)。动作迅速且技术娴熟,但一次只能处理一份订单——必须完全完成当前订单才能开始下一份。
- 厨房助手(Web API)。当某份订单需要长时间等待——比如计时器到期或送餐员响应迟缓时,主厨不会一直盯着时钟。相反,主厨会将订单交给助手,并指示其负责跟进,一旦完成就及时汇报,而主厨则继续处理手头的其他任务。
- 订单计数器(回调队列和微任务队列)。计时结束后,助手不会打断主厨的工作,将订单塞到主厨手中,那样只会造成混乱。相反,已完成处理的订单会被放在计数器上等待。
这就是整个系统的简化模型。结合餐厅的场景,现在该用JavaScript引擎中实际的名称来替换这些角色了。
引擎给这四个组件起了不同的名字,但每个名称都直接对应餐厅中的某个角色。
下图展示了运行时环境(浏览器或Node.js)如何围绕JavaScript引擎并将这些组件连接在一起。
┌───────────────────────────────────────────────────────────┐
│ BROWSER / RUNTIME │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ CALL STACK │ │ WEB APIs │ │
│ │ (JS Engine/V8) │ │ │ │
│ │ │ ──────────> │ setTimeout() │ │
│ │ fnA() │ │ fetch() │ │
│ │ console.log() │ │ DOM Events │ │
│ └────────┬────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ │ Finished │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ MICROTASK QUEUE │ │
│ │ │ (Promises, VIP) │ │
│ │ └─────────┬─────────┘ │
│ │ │ │
│ EVENT LOOP ▼ │
│ "Is Stack empty?" ┌───────────────────┐ │
│ └───────────────────── │ MACROTASK QUEUE │ │
│ │ (Timers, Clicks) │ │
│ └───────────────────┘ │
└───────────────────────────────────────────────────────────┘
调用栈——“当前正在发生的一切”
调用栈是一种普通的后进先出结构,由 JavaScript 引擎自行管理——这与 Chrome 和 Node 中的 V8 以及 Safari 中的 JavaScriptCore 所使用的机制相同。
每次调用函数时,该函数都会被添加到栈顶。一旦该函数返回或执行完毕,它就会从栈顶被移除。
function stepThree() {
console.log("Done!");
}
function stepTwo() {
stepThree();
}
function stepOne() {
stepTwo();
}
stepOne();
通过这样一段简短的调用链来追踪,可以清晰地展示其工作原理:
stepOne被推入栈中。stepOne调用stepTwo,因此stepTwo被推到栈顶。stepTwo又调用stepThree,于是stepThree被推到栈顶之上。stepThree执行console.log,输出“Done!”后便从栈中移除。
stepThree 执行完毕,因此被弹出。stepTwo 执行完毕,因此被弹出。stepOne 执行完毕,因此被弹出。如果该栈中的某个函数需要连续十秒钟进行高强度计算,那么整个运行环境都会在这十秒钟内停滞。在栈清空之前,其他任何代码都无法执行——这就是“厨师只有两只手”所带来的直接后果。
Web API —— “等待”实际发生的地方
在职业生涯初期,很多人会对此感到惊讶:setTimeout、fetch以及DOM事件监听器并非JavaScript语言本身的功能。
在 ECMAScript 规范中找不到相关内容。它们由运行你的代码的环境提供——无论是浏览器,还是在 Node.js 中,则是由 C++ 绑定与 libuv 库共同提供的。
来看这个调用:
setTimeout(callback, 2000);
当调用栈执行那次 setTimeout 调用时,它实际上只是告诉浏览器的内部计时机制开始倒计 2000 毫秒。该指令一旦被处理,setTimeout 本身就会立即从调用栈中移除,程序会继续执行代码中的后续内容。而倒计时本身则完全在宿主环境中的单线程之外进行。
宏任务队列——普通队列
根据不同人的说法,这个队列也被称为回调队列或任务队列。
一旦 Web API 完成其后台任务——计时器归零、网络响应接收完毕、用户点击了某个元素——宿主环境就会将对应的回调函数放入这个队列中。
该队列中的所有元素都会按规则执行:它遵循标准的先进先出原则,每个元素都要等待轮到自己在调用栈中运行。
典型的宏任务包括:
- 由
setTimeout()和setInterval()安排的回调函数 - 诸如
click、scroll或keydown等 DOM 事件监听器 - 仅适用于 Node.js 的
setImmediate()
微任务队列——快速通道
与普通队列并行运行的还有一个优先级更高的小型队列:微任务队列。
任何放置在这里的内容都会立即被放到处理队列的最前面。一旦调用栈为空,事件循环会首先查看微任务队列——甚至不会去考虑宏任务队列中等待处理的内容。
进入微任务队列的元素包括:
- 通过 Promise 的
.then()、.catch()或.finally()方法绑定的处理函数 - 在
async函数中的await之后执行的代码 - 使用
queueMicrotask()安排的任务 - 由
MutationObserver触发的回调函数
事件循环背后的运行规则
抛开比喻不谈,事件循环实际上只是主机环境中运行的一个无限 while 循环,它会反复执行五个严格的步骤:
- 清空调用栈。持续执行同步代码,直到栈中完全没有任何内容。
- 彻底清空微任务队列。只要还有微任务在等待,就立即执行它。如果执行该微任务又产生了新的微任务,则继续执行这个新产生的微任务。只有当微任务队列完全为空时,循环才会进入下一步。
- 如果在浏览器中,则处理渲染工作。引擎会检查是否需要重新绘制屏幕——通常为每秒60次,即大约16.6毫秒一次——如果是,则重新计算布局并再次绘制。
- 仅执行一个宏任务。查看宏任务队列,选取等待时间最长的任务,将其放入调用栈中,并让其执行完毕。
- 从第一步重新开始这个循环。
让人困惑的细节就在于这种不对称性:在循环继续之前,所有微任务都会被完全处理完毕——而宏任务则必须严格逐个处理,在每个宏任务之间还会对栈和微任务队列进行完整检查。
分析一个常见的面试题
在明确了相关机制后,值得仔细研究一下让大量开发者犯错的代码片段:
console.log("Start");
setTimeout(() => {
console.log("Timeout Callback");
}, 0);
Promise.resolve().then(() => {
console.log("Promise Callback");
});
console.log("End");
逐行分析这四个组件后,那些令人惊讶的输出结果就能理解了。
先进行同步处理
console.log("Start")会被压入栈中,输出“Start”后又被弹出。
接下来,setTimeout(...)会被推入栈中。它指示浏览器的Web API层使用给定的回调函数启动一个0毫秒的计时器。由于延迟时间为零,该计时器几乎会立即在后台到期,因此浏览器会立刻将() => console.log("Timeout Callback")放入宏任务队列中。随后,setTimeout调用本身也会从栈中弹出。
之后,Promise.resolve().then(...)会被推入栈中。该承诺会立即得到解决,其.then()处理函数——即() => console.log("Promise Callback")——会被直接放入微任务队列中。这一调用同样也会从栈中弹出。
最后,console.log("End")被推入栈中,输出“End”后便从栈中弹出。
一旦栈为空
此时调用栈上已没有任何内容。事件循环被唤醒后会检查它的两个队列:微任务队列中存放着 Promise 回调函数,而宏任务队列中则存放着超时回调函数。
微任务优先处理
循环首先检查微任务队列。它找到 Promise 回调函数,将其推入栈中,随后会输出“Promise Callback”。此时栈再次变空,微任务队列也为空了。
接着处理一个宏任务
由于没有微任务剩余,循环最终转向宏任务队列。它取出最旧的条目——即超时回调函数,将其推入栈中,输出“Timeout Callback”,然后再将其从栈中移除。
综合以上步骤,最终输出的顺序如下:
Start
End
Promise Callback
Timeout Callback
尽管超时时间被设置为0毫秒,且代码中该设置出现在Promise之前,但Promise的回调函数仍先被执行。这印证了一个规则:无论为定时器设置多短的延迟,微任务总是会优先于宏任务执行。
这导致人们对延迟值的实际作用产生常见误解。许多初学者认为setTimeout(fn, 1000)的意思是“在当前时间点恰好1000毫秒后运行此函数”。但实际上并非如此。它真正保证的是,至少在1000毫秒过去之后,回调函数才会被放入任务队列。即便进入队列后,它仍需等待,直到调用栈为空且所有待处理的微任务都执行完毕。
因此,如果在执行复杂的排序操作或处理图像时调用栈被占用五秒钟,那么setTimeout(fn, 100)设置的回调函数将在队列中闲置整整五秒钟,直到事件循环才有时间处理它。简而言之,超时延迟只是最低等待时间,并非必然会触发的时刻。
这种耗尽队列的行为还会带来一个更为危险的副作用,值得单独了解。由于事件循环会一直等待微任务队列完全清空才会继续执行,因此有可能在并未真正阻塞同步调用栈的情况下就使应用程序陷入停滞状态。这种故障模式被称为微任务饥饿,有时也称作队列饥饿。
想象这样一个函数:
function infiniteMicrotasks() {
Promise.resolve().then(() => {
infiniteMicrotasks();
});
}
infiniteMicrotasks();
逐步分析其运行过程:初始调用执行完毕后栈会被清空。事件循环随后检查微任务队列,发现其中有一个待处理的任务,便立即执行它,但执行该任务的同时又会安排另一个微任务。事件循环再次检查队列时发现它仍然有任务,于是继续执行下一个任务,而这个新任务又会再安排一个微任务。每次有微任务完成,栈就会继续清空,但事件循环始终无法跳出这个检查流程,无法进一步处理,从而导致浏览器无法重新绘制用户界面,也无法处理宏任务队列中等待处理的任务。
实际后果是页面会完全卡住:点击操作被忽略,动画停止播放,最终浏览器可能会发出“脚本无响应”的警告。
要总结运行时中各类任务的调度与优先级分配方式,可以参考附图所示的对比分析,了解同步代码、微任务、宏任务以及渲染操作在整体周期中的位置。
掌握事件循环的运作原理不仅对面试有帮助——它还直接决定了实际应用程序的设计方式,由此可形成三种实用的习惯。
首先,拆分那些耗时的同步循环。如果需要在主线程上处理包含50万条数据的数组,应避免使用一个连续不断的for循环来完成,因为这会导致界面卡住。应将任务拆分成多个小块,并通过setTimeout(chunkHandler, 0)或requestIdleCallback()在每个小块处理之间将控制权交还给事件循环。这样浏览器就有时间在各个小块处理期间处理用户点击并重新绘制界面。
其次,要准确了解async/await在底层的具体作用。它其实是基于承诺机制的语法糖:在第一个await执行之前,所有代码都会以同步方式运行;而await之后的所有代码则会被封装到一个微任务回调中,该回调会一直处于暂停状态,直到所等待的承诺得到处理。
第三,切勿将计时器视为精确的秒表。如果你正在开发类似应用内计时器或音频序列器的功能,不要依赖 setInterval 或 setTimeout 来达到精确的毫秒级控制。系统负载、浏览器为节省电量而限制后台标签页的运行,以及队列中的拥堵都会导致时间偏差。相反,应使用 performance.now() 根据系统时钟来测量经过的时间。
在单线程上运行的 JavaScript 并非需要规避的局限,而是该语言最出色的设计选择之一。通过将快速的同步引擎与一系列后台浏览器 API 以及严格管理的事件循环相结合,JavaScript 能够营造出同时处理多项任务的体验,同时避免了其他语言中多线程所带来的竞态条件、死锁以及线程安全问题。
一旦你理解并掌握了队列的工作原理,你的代码执行方式就不再会让你感到意外。