深入理解 JavaScript 的运行机制:单线程、异步与事件循环

221 阅读5分钟

深入理解 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)
);

执行流程如下:

  1. getCurrentPosition 被压入调用栈;

  2. 浏览器注册回调,并开始异步处理(例如请求地理权限);

  3. 调用栈完成后,继续执行后续代码;

  4. 用户授权后,回调函数被送入任务队列,等待执行。

关键点是:异步任务不会阻塞调用栈,而是通过回调的方式“延迟执行”。


任务队列(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

微任务的执行顺序为:

  1. 当前调用栈执行完毕后;
  2. 在任何宏任务(如 setTimeout)之前;
  3. 一次性清空微任务队列。

示例: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

解释如下:

  1. Promise.then → 微任务(放入 microtask queue)
  2. setTimeout → 宏任务(放入 task queue)
  3. queueMicrotask → 微任务
  4. console.log(3) 立即执行
  5. 执行微任务队列中的 32 → 11 → 再入一个微任务 4
  6. 最后执行宏任务 9

总结:JavaScript 幕后的并发协作机制

JavaScript 虽然是单线程语言,但通过事件循环机制、任务队列、微任务队列及浏览器 Web API 的协作,实现了非阻塞的异步执行模型

🎯 重点记忆:

  • JavaScript 本质是单线程,执行栈(Call Stack)一次只处理一件事;
  • Web API 提供异步能力,将耗时任务“外包”出去;
  • 微任务优先于宏任务执行,常见于 Promise;
  • 事件循环控制调度,按顺序协调各类异步任务。

📢 如果你觉得这篇文章对你有帮助,欢迎点赞、评论或分享给更多前端小伙伴!

原文作者:DeepIntoDev