深入理解 JavaScript 的运行机制:单线程、异步与事件循环
📌 阅读完本文,你将深入理解以下内容:
- JavaScript 如何在单线程中执行代码
- 什么是调用栈(Call Stack),它如何管理代码执行
- Web API 如何协助处理异步操作
- 宏任务队列(Task Queue)与微任务队列(Microtask Queue)之间的区别
- 事件循环(Event Loop)如何协同上述组件完成任务调度
为什么说 JavaScript 是单线程的?
我们都知道,JavaScript 是一门运行在单线程上的语言,这意味着它在任意时刻只能处理一项任务。但奇怪的是,它却能同时处理网络请求、用户输入、定时器等各种操作,而不会卡顿。这到底是怎么实现的?如果你曾经有过“JavaScript 是怎么在单线程下搞定这么多事儿”的疑问,本文会带你搞清楚它背后的机制。
JavaScript 的运行环境与解释机制
JavaScript 并不是编译型语言(比如 C/C++/Go),它无法直接转成机器码,而是需要一个“运行时”来读取并解释执行 JS 代码。
在现代浏览器(如 Chrome)或 Node.js 中,这个“解释器”就是 V8 引擎。V8 可以将 JS 源码转成底层机器可读的代码并执行——这是 JavaScript 得以运行的基础。
V8 的内部主要包含两部分:
- Heap(堆) :用来存储变量、对象、数组等引用类型。
- Call Stack(调用栈) :用于管理代码的执行顺序,也是 JS 单线程的核心原因。
调用栈:JavaScript 执行的心脏
调用栈是一个“后进先出(LIFO)”的数据结构,每次我们调用一个函数,它就被压入栈中,执行完毕后再从栈中弹出。
来看一个简单的例子:
console.log("First");
console.log("Second");
-
第一个 console.log 被压入调用栈,执行完后弹出;
-
接着第二个被压入,执行完再弹出;
-
整个过程严格遵循 逐行顺序执行,一次只做一件事 的原则。
💡 每次函数调用,都会创建一个“执行上下文(Execution Context)”,这个上下文包含该函数的局部变量、参数、this 绑定、以及作用域链等。
遇到耗时操作怎么办?
假如我们写了一个非常耗时的操作:
function veryHeavyOperation() {
for (let i = 0; i < 1e10; i++) {
console.log("Processing...");
}
}
function importantTask() {
console.log("Important log");
}
veryHeavyOperation();
importantTask();
在上面代码中,importantTask 要等到 veryHeavyOperation 完全执行完,调用栈空了之后,才能被执行。这会导致页面假死,也就是大家常说的“卡顿”。
在真实应用中,像网络请求、文件读取、定时器等待这类耗时操作是非常常见的,如果全部塞进调用栈,会直接阻塞页面运行,体验极差。
Web API:浏览器的异步管家
为了解决这个问题,JavaScript 借助了浏览器提供的 Web API。
Web API 是浏览器提供的一套异步接口,支持诸如:
-
setTimeout
-
fetch
-
navigator.geolocation
-
DOM事件监听
-
console
这些 API 并不在 V8 引擎中,而是由浏览器环境提供,当你调用这些函数时,它们会把任务“外包”给浏览器处理,JS 引擎继续往下执行,不会被阻塞。
示例:使用 Geolocation API
navigator.geolocation.getCurrentPosition(
(position) => console.log(position),
(error) => console.error(error)
);
执行流程如下:
-
getCurrentPosition 被压入调用栈;
-
浏览器注册回调,并开始异步处理(例如请求地理权限);
-
调用栈完成后,继续执行后续代码;
-
用户授权后,回调函数被送入任务队列,等待执行。
关键点是:异步任务不会阻塞调用栈,而是通过回调的方式“延迟执行”。
任务队列(Task Queue)与事件循环(Event Loop)
任务队列,又叫 Callback Queue,用来存储等待执行的“异步回调函数”。
而负责检查调用栈是否空闲,并把任务队列中的回调塞进去执行的,就是著名的:
🌀 事件循环(Event Loop)
事件循环会不停地轮询:
- 如果调用栈空了,就从任务队列中取出第一个任务执行;
- 一次只取一个;
- 保证同步执行逻辑优先。
示例:
setTimeout
的真正延迟时间是?
setTimeout(() => {
console.log("1000ms");
}, 1000);
console.log("Start");
执行顺序为:
Start
1000ms // 在最早 1000ms 之后执行(但不是绝对,取决于调用栈是否空闲)
注意:
setTimeout(fn, 1000) 的 1000ms 并不是“执行时间”,而是延迟进入任务队列的时间。若当时调用栈还没空,则继续等待!
微任务队列(Microtask Queue):Promise 的秘密
除了宏任务队列,JavaScript 中还有一个优先级更高的队列:
📍 微任务队列 Microtask Queue
常见进入微任务队列的场景有:
-
.then/.catch/.finally 中的回调
-
async/await
-
queueMicrotask
微任务的执行顺序为:
- 当前调用栈执行完毕后;
- 在任何宏任务(如 setTimeout)之前;
- 一次性清空微任务队列。
示例:Promise 的执行优先级
Promise.resolve().then(() => console.log("microtask"));
setTimeout(() => console.log("task"), 0);
console.log("sync");
输出顺序为:
sync
microtask
task
综合示例:你能看懂输出顺序吗?
Promise.resolve().then(() => console.log(32));
setTimeout(() => console.log(9), 5);
queueMicrotask(() => {
console.log(11);
queueMicrotask(() => console.log(4));
});
console.log(3);
输出结果是:
3
32
11
4
9
解释如下:
- Promise.then → 微任务(放入 microtask queue)
- setTimeout → 宏任务(放入 task queue)
- queueMicrotask → 微任务
- console.log(3) 立即执行
- 执行微任务队列中的 32 → 11 → 再入一个微任务 4
- 最后执行宏任务 9
总结:JavaScript 幕后的并发协作机制
JavaScript 虽然是单线程语言,但通过事件循环机制、任务队列、微任务队列及浏览器 Web API 的协作,实现了非阻塞的异步执行模型。
🎯 重点记忆:
- JavaScript 本质是单线程,执行栈(Call Stack)一次只处理一件事;
- Web API 提供异步能力,将耗时任务“外包”出去;
- 微任务优先于宏任务执行,常见于 Promise;
- 事件循环控制调度,按顺序协调各类异步任务。
📢 如果你觉得这篇文章对你有帮助,欢迎点赞、评论或分享给更多前端小伙伴!
原文作者:DeepIntoDev