理解 JavaScript:调用栈、任务队列
2024-01-29 • ☕️☕️ 8 min read
前言
JavaScript 是一种单线程语言,单线程是指 JavaScript 引擎中负责解析执行 JavaScript 代码的线程只有一个(主线程),即每次只能做一件事。主线程承担着很多的工作,渲染页面、执行 JavaScript 都在其中运行。
JavaScript 中所有的任务可以归为两种:同步任务与异步任务。
同步
我们先看同步任务,和我们阅读代码的顺序一样,浏览器实际上是按照我们书写代码的顺序从上到下地执行程序的。下面这段代码就是一段同步代码,代码的执行流程:
代码执行中的每一个步骤都依赖与前一个步骤,这样做是很有必要的,后面声明的变量依赖于前面的变量,每一行新的代码都是建立在前面代码的基础之上的。
在 JavaScript 中,函数的调用也是同步的,函数的调用顺序是通过调用栈(Call Stack)来控制的。
调用栈是 JavaScript 引擎的一部分,这不是特定于浏览器的。它是一个堆栈,意味着它是后进先出 (LIFO) 的,其中每一帧代表一个函数调用。当进行第一个函数调用时,一个新的帧被推送到调用堆栈的顶部。当该函数返回时,其帧将从调用堆栈中弹出。
下面是一个调用栈的可交互示例,通过一段简单的代码展示了调用栈的运行过程。
这就是 JavaScript 引擎跟踪哪些函数被调用以及调用顺序的方式。通过调用栈,JavaScript 引擎可以确保函数的调用顺序,使得代码可以同步、有序地执行。
这么看来,如果代码都是同步代码,编程就会变得很简单!但是同步代码也有缺点...如果一个函数的执行非常耗时,它会长时间占用调用栈,阻塞后面的函数调用。
上述代码中,delay(3000) 是一个非常耗时的函数,它会占用调用栈 3 秒钟才能完成执行。这会导致后面的 console.log('End') 无法在 delay() 完成前调用。
为什么这会是一个问题呢?主要有以下两个原因:
- 用户体验差 : 由于 JavaScript 是单线程,浏览器会暂停响应用户输入和界面渲染,直到同步任务完成,导致页面出现“卡顿”。
- 资源利用效率低 : 在同步任务执行期间,CPU 和内存资源都被占用,其他任务无法使用这些资源。
以下面这段 Html 为例。
有一段文本和两个按钮,点击按钮 1 时会立即改变文本内容和背景颜色,点击按钮 2 会延迟 3 秒后文本和背景颜色才会变化。如果你尝试先点击按钮 2 然后立即点击按钮 1,会发现浏览器在延迟时间内无法响应按钮 1 的点击操作。
现在是 2026 年了,没有人想要一个反应迟钝的网站,我们不能让 JavaScript 主线程阻塞。为了避免这种情况发生,需要一种方案能最大限度保证 JavaScript 单线程的流畅运行,并且不会阻塞浏览器。
异步
这时就轮到异步任务上场了,异步就是解决阻塞等待的方案。异步的基本思想是:不等任务执行完毕,就立即执行后续任务。但是我们都知道 JavaScript 是单线程的,那单线程是怎么实现异步的呢?
事实上所谓的 "JavaScript 是单线程的" 只是指 JavaScript 的主线程只有一个,而不是整个运行环境都是单线程。
JavaScript 的异步是用支持多线程的运行环境为单线程的 JavaScript 提供的并发能力。
在 JavaScript 的主要运行环境中,浏览器为我们提供了一些 JavaScript 引擎本身不提供的功能:WebAPI,这包括网络请求、定时器、事件监听等,这些 API 通常由 C++ 编写,并且由浏览器执行,JavaScript 只负责调用它们,也就是通知浏览器执行任务。
![]()
JavaScript 变成了“大老板”,运行环境里的各种 C++ 线程成为了“打工仔”,专门为它服务。
例如,程序发起一次网络请求,JavaScript 主线程不必同步等待响应结果,真正处理这些异步任务由另外的线程实现,待有结果了再通知到主线程,主线程在等待任务响应的同时是会去做其它事的,不会造成主线程阻塞。
修改前面同步代码的示例,在 greet() 函数内部加上 setTimeout 定时器函数。setTimeout 允许我们延迟任务而不阻塞主线程,其第一个参数是定时器到时后执行的回调函数,第二个参数是最小延迟时间。
上述例子,主线程执行到 greet() 内部的 setTimeout 定时器,定时器是异步的,此时主线程不会等待定时器到时,而是继续往下执行,然后 hello() 函数进入调用栈中。
当定时器时间到了之后,其回调函数 timer 会被放到任务队列(Tasks)中。一旦调用栈中的所有任务执行完毕后,就会从任务队列中获取第一个可运行的任务。然后,被读取的异步任务结束等待状态,进入调用栈,开始执行。
限于主题,上面只是描述了主线程大致的执行流程,其实背后是由事件循环协调管理不同任务的执行。关于事件循环,感兴趣的读者可以看我写的另一篇《理解 JavaScript:事件循环详解》。
总结
JavaScript 通过使用异步可以大大提高网站性能,让 JavaScript 单线程得以充分发挥效率,不会因为一个阻塞任务而影响整体流畅度。