JavaScript运行机制Event Loop浏览器与Node差异
同一段代码,在浏览器里跑输出 timer1, promise1, timer2, promise2,搬到 Node 里跑却可能变成 timer1, timer2, promise1, promise2。第一次遇到这个的时候我以为是自己看错了,反复跑了几次发现结果确实会变。
事件循环这个概念大部分人都能背两句,但一到「为什么这两个环境下不一样」「为什么我的页面点了没反应但也没报错」这类具体问题上就卡住了。这篇把浏览器和 Node 两套循环并排放在一起讲,重点在它们的差别,以及这些差别在实际排查中怎么用得上。
想看更完整的规范级拆解和面试题串讲,可以配合 JavaScript事件循环机制 一起读,这篇侧重双环境对比和排查手法。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- JS 为什么是单线程,Web Worker 有没有改变这一点
- 任务队列、执行栈、回调函数三者的关系
- 宏任务和微任务的准确分类(以及原文里过时的那几项)
- 浏览器一轮事件循环的完整顺序,渲染插在哪
- Node 六个阶段各自在干什么
- 两个环境下同一段代码为什么会输出不同顺序
process.nextTick和微任务的优先级差别- 页面卡死、定时器不准这类问题怎么排查
# 一、JavaScript是单线程
JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。
假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?为了避免这种复杂性,从一诞生 JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以这个标准并没有改变 JavaScript 单线程的本质特征。
这里要分清一件事。单线程说的是「执行 JS 代码的线程只有一个」,不是「浏览器只有一个线程」。渲染、网络请求、定时器计时这些都跑在别的线程上,它们干完活之后把回调塞进队列,等 JS 主线程有空了再来取。这是后面所有内容的前提。
# 二、任务队列
单线程就意味着,所有任务需要排队,前一个任务结束才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大 CPU 忙不过来,倒也算了。但是很多时候 CPU 是闲着的,因为 IO 设备很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来再往下执行。这种干等着实在太浪费。
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有任务队列通知主线程某个异步任务可以执行了,该任务才会进入主线程执行。
异步执行的运行机制可以拆成四步:
- 所有同步任务都在主线程上执行,形成一个执行栈
- 主线程之外还存在一个任务队列(task queue)。只要异步任务有了运行结果,就在任务队列之中放置一个事件
- 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件。那些对应的异步任务于是结束等待状态,进入执行栈开始执行
- 主线程不断重复上面的第三步

只要主线程空了,就会去读取任务队列,这就是 JavaScript 的运行机制。这个过程会不断重复。
# 三、事件和回调函数
任务队列是一个事件的队列(也可以理解成消息的队列)。IO 设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入执行栈了。主线程读取任务队列,就是读取里面有哪些事件。
任务队列中的事件除了 IO 设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取。
所谓回调函数(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
任务队列是一个先进先出的数据结构,排在前面的事件优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。但是由于存在定时器功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间才能返回主线程。
# 四、JS中的event loop
# 4.1 原理分析
主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。

上图中,主线程运行的时候产生堆(heap)和栈(stack),栈中的代码调用各种外部 API,它们在任务队列中加入各种事件(click、load、done)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。
JS 在执行的过程中会产生执行环境,这些执行环境会被顺序地加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task)队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行。
所以 JS 里所谓的异步,最后还是要回到这条单线程上排队执行,只是排队的时机被推迟了而已。
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
console.log('script end');
不同的任务源会被分配到不同的 Task 队列中,任务源可以分为微任务(microtask)和宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 task。
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise((resolve) => {
console.log('Promise')
resolve()
}).then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout
以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务,微任务在本轮循环末尾就被清空,宏任务要等下一轮。
有一处容易被漏掉,new Promise 里传的那个执行器函数是同步执行的,所以 Promise 这行会跟着 script start 一起先打出来,只有 .then 里的回调才是微任务。
# 4.2 微任务
process.nextTick(Node 专属)promise的then/catch/finallyqueueMicrotaskMutationObserver
原文这里列了 Object.observe。那个 API 早就被从规范里撤掉了,各家浏览器也都移除了实现,现在写它只会报未定义,我把它换成了 queueMicrotask。
queueMicrotask 是标准化之后专门用来手动往微任务队列里塞回调的入口。在它之前大家用 Promise.resolve().then(fn) 凑合,那个写法会多创建一个 Promise 对象,而且回调里抛错会被 Promise 吞成 rejection,用 queueMicrotask 抛出来的错误则会正常走到全局错误处理。
process.nextTick 严格说也不属于标准的微任务队列,它在 Node 里有一条独立的队列,优先级比 Promise 的微任务还要高,后面会单独说。
# 4.3 宏任务
scriptsetTimeoutsetIntervalsetImmediate(只有 Node 和老 IE 有,其他浏览器没有)I/OUI rendering
宏任务中包括了 script,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务。
# 4.4 一轮完整的事件循环
浏览器里一轮循环的顺序是这样的:
- 执行同步代码,这属于宏任务
- 执行栈为空,查询是否有微任务需要执行
- 执行所有微任务
- 必要的话渲染 UI
- 然后开始下一轮 Event loop,执行宏任务中的异步代码
「执行所有微任务」这几个字要抠一下。微任务队列是要被彻底清空的,包括在执行微任务过程中新产生的微任务,也要在本轮一起处理掉。这就带来一个后果,如果你在微任务里递归地生成新微任务,这个循环永远不会结束,浏览器会彻底冻死,连页面都不会重绘。
而同样的死循环换成宏任务(比如 setTimeout 里再 setTimeout),页面还是能响应的,因为每两个宏任务之间浏览器有机会插入渲染。这个差别在排查「页面完全没反应」的问题时非常有用。
通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的界面响应,我们可以把操作 DOM 放入微任务中。
# 五、Node 中的 Event loop
Node.js 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境。Node 的 Event loop 分为 6 个阶段,它们会按照顺序反复运行。

# 5.1 Node.js的运行机制
- V8 引擎解析 JavaScript 脚本
- 解析后的代码调用 Node API
libuv库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎- V8 引擎再将结果返回给用户
除了 setTimeout 和 setInterval 这两个方法,Node.js 还提供了另外两个与任务队列有关的方法,process.nextTick 和 setImmediate。它们可以帮助我们加深对任务队列的理解。
这里最关键的差别就出来了。浏览器只有一条宏任务队列(不同任务源有各自的队列,但取的时候一次取一个),而 Node 有六个阶段,每个阶段有自己的回调队列。所以在 Node 里问「这个回调什么时候执行」,得先问「它属于哪个阶段」。