JS 中的事件循环原理以及异步执行过程
这些知识点对新手来说可能有点难,但是是必须迈过的坎,逃避是解决不了问题的,本篇文章旨在帮你彻底搞懂它们。
1. JS 是单线程的
我们都知道 JS 是单线程执行的(原因:我们不想并行地操作 DOM,DOM 树不是线程安全的,如果多线程,那会造成冲突)。
这里小说明一下:V8 是谷歌浏览器的 JS 执行引擎,在运行 JS 代码的时候,是以函数作为一个个帧(保存当前函数的执行环境)按代码的执行顺序压入执行栈(call stack)中,栈顶的函数先执行,执行完毕后弹出再执行下一个函数。其中堆是用来存放各种 JS 对象的。

假设浏览器就是上图的这种结构的话,执行同步代码是没什么问题的,如下
scss
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
console.log('baz')
}
foo()
我们定义了 foo、bar、baz 三个函数,然后调用 foo 函数,控制台输出的结果为:
baz
bar
foo
执行过程如下:
- 一个全局匿名函数最先执行(JS 的全局执行入口,之后的例子将忽略),遇到
foo函数被调用,将foo函数压入执行栈。 - 执行
foo函数,发现foo函数体中调用了bar函数,则将bar函数压入执行栈。 - 执行
bar函数,发现bar函数体中调用了baz函数,又将baz函数压入执行栈。 - 执行
baz函数,函数体中只有一条语句console.log('baz'),执行,在控制台打印:baz,然后baz函数执行完毕弹出执行栈。 - 此时的栈顶为
bar函数,bar函数体中的baz()语句已经执行完,接着执行下一条语句(console.log('bar')),在控制台打印:bar,然后bar函数执行完毕弹出执行栈。 - 此时的栈顶为
foo函数,foo函数体中的bar()语句已经执行完,接着执行下一条语句(console.log('foo')),在控制台打印:foo,然后foo函数执行完毕弹出执行栈。 - 至此,执行栈为空,这一轮执行完毕。
动图展示
还是图直观点,以上步骤对应的执行流程图如下:

非动图 
2. 事件循环(event loop)
-
事件循环:JS 处理异步任务的机制,因单线程特性,通过循环读取任务队列实现非阻塞。
-
过程:
- 执行同步代码(调用栈清空)。
- 执行所有微任务(
Promise回调等),直到微任务队列清空。 - 执行一个宏任务(
setTimeout等),然后回到步骤 2,循环往复。
我们改变一下代码 1, 如下是代码 2:
scss
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
setTimeout(() => {
console.log('setTimeout: 2s')
}, 2000)
console.log('baz')
}
foo()
根据 1 中的假设,浏览器只由一个 JS 引擎构成的话,那么所有的代码必然同步执行(因为 JS 执行是单线程的,所以当前栈顶函数不管执行时间需要多久,执行栈中该函数下面的其他函数必须等它执行完弹出后才能执行(这就是代码被阻塞的意思)),执行到 baz 函数体中的 setTimeout 时应该等 2 秒,在控制台中输出 setTimeout: 2s,然后再输出:baz。所以我们期望的输出顺序应该是:setTimeout: 2s -> baz -> bar -> foo(这是错的)。
浏览器如果真这样设计的话,肯定是有问题的!遇到 AJAX 请求、setTimeout 等比较耗时的操作时,我们页面需要长时间等待,就被阻塞住啥也干不了,出现了页面 "假死",这样绝对不是我们想要的结果。
实际当然并非我以为的那样,这里先重点提醒一下:JS 是单线程的,这一点也没错,但是浏览器中并不仅仅只是由一个 JS 引擎构成,它还包括其他的一些线程来处理别的事情。如下图 !

浏览器除了 JS 引擎(JS 执行线程,后面我们只关注 JS 引擎中的执行栈)以外,还有 Web APIs(浏览器提供的接口,这是在 JS 引擎以外的)线程、GUI 渲染线程等。JS 引擎在执行过程中,如果遇到相关的事件(DOM 操作、鼠标点击事件、滚轮事件、AJAX 请求、setTimeout 等),并不会因此阻塞,它会将这些事件移交给 Web APIs 线程处理,而自己则接着往下执行。Web APIs 则会按照一定的规则将这些事件放入一个任务队列(callback queue,也叫 task queue)中,当 JS 执行栈中的代码执行完毕以后,它就会去任务队列中获取一个事件回调放入执行栈中执行,然后如此往复,这就是所谓的事件循环机制。
| 线程名 | 作用 |
|---|---|
| JS 引擎线程 | 也称为 JS 内核,负责处理 JavaScript 脚本。(例如 V8 引擎)① 负责解析 JS 脚本,运行代码。② 一直等待着任务队列中的任务的到来,然后加以处理。③一个 Tab 页(renderer 进程)中无论什么时候都只有一个 JS 线程运行 JS 程序。 |
| 事件触发线程 | 归属于渲染进程而不是 JS 引擎,用来控制事件循环。① 当 JS 引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等),会将对应任务添加到事件线程中。② 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。 注意:由于 JS 的单线程关系,所以这些待处理队列中的事件都是排队等待 JS 引擎处理,JS 引擎空闲时才会执行。 |
| 定时触发器线程 | setInterval和setTimeout所在的线程。① 浏览器定时计数器并不是由 JS 引擎计数的。② JS 引擎是单线程的,如果处于阻塞线程状态就会影响计时的准确,因此,通过单独的线程来计时并触发定时。③ 计时完毕后,添加到事件队列中,等待 JS 引擎空闲后执行。注意 :W3C 在 HTML 标准中规定,setTimeout中低于 4ms 的时间间隔算为 4ms。 |
| 异步 HTTP 请求线程 | XMLHttpRequest在连接后通过浏览器新开一个线程请求。当检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调放入事件队列中,再由 JS 引擎执行。 |
| GUI 渲染线程 | 负责渲染浏览器界面,包括:① 解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。②重绘(Repaint)以及回流(Reflow)处理。 |
这里让我们对事件循环先来做个小总结:
- JS线程负责处理JS代码,当遇到一些异步操作 的时候,则将这些异步事件移交给Web APIs 处理,自己则继续往下执行。
- Web APIs线程将接收到的事件按照一定规则按顺序添加到任务队列中(应该是添加到任务集合中的各个事件队列中)。
- JS线程处理完当前的所有任务以后 (执行栈为空),它会去查看任务队列中是否有等待被处理的事件,若有,则取出一个事件回调放入执行栈中执行。
- 然后不断循环第3步。
让我们来看看真正的浏览器中执行是什么个流程吧!
动图展示
第二段代码 
细心的小伙伴可能有发现Web API在计时器时间到达后将匿名回调函数添加到任务队列中了,虽然定时器时间已到,但它目前并不能执行!!!因为JS的执行栈此时并非空,必须要等到当前执行栈为空后才有机会被召回到执行栈执行。由此,我们可以得出一个结论:setTimeout设置的时间其实只是最小延迟时间 ,而并不是确切的等待时间。(当主线程的任务耗时比较长的时候,等待时间将会变得更长)
3. 事件循环(进阶)与异步
3.1 试试 setTimeout(fn, 0)
javascript
function foo() {
console.log('foo')
}
setTimeout(function() {
console.log('setTimeout: 0s')
}, 0);
foo();
运行结果:
arduino
foo
setTimeout: 0s

即使 setTimeout 的延时设置为 0(实际上最小延时 >= 4ms),JS 执行栈也将该延时事件发放给 Web API 处理,Web API 再将事件添加到任务队列中,等 JS 执行栈为空时,该延时事件再压入执行栈中执行。
3.2 事件循环中的 Promise
其实以上的浏览器模型是ES5标准的,ES6+标准中的任务队列在此基础上新增了一种,变成了如下两种:
3.2.1 宏任务 / 微任务
宏任务队列(macrotask queue) :普通优先级的任务,通常包括:
setTimeout/setInterval/setImmediate(Node.js)- I/O 操作(文件读写、Ajax事件 / 网络请求等)
- UI 渲染事件
- 脚本整体代码(第一次执行的同步代码)
微任务队列(microtask queue) :高优先级的任务,通常包括:
Promise.then/Promise.catch/Promise.finallyasync/await中await后面的代码(其实是.then的语法糖)MutationObserver(浏览器)process.nextTick(Node.js,优先级比普通微任务更高)

事件循环的处理流程变成了如下:
- JS 线程负责处理 JS 代码,当遇到一些异步操作的时候,则将这些异步事件移交给 Web APIs 处理,自己则继续往下执行。
- Web APIs 线程将接收到的事件按照一定规则添加到任务队列中,宏事件添加到宏任务队列中,微事件添加到微事件队列中。
- JS 线程处理完当前的所有任务以后(执行栈为空),它会先去微任务队列获取事件,并将微任务队列中的所有事件一件件执行完毕,直到微任务队列为空后再去宏任务队列中取出一个事件执行。
- 然后不断循环第 3 步。

排一下先后顺序: 执行栈 --> 微任务 --> 渲染 --> 下一个宏任务。
3.2.2 单独使用 Promise
javascript
function foo() {
console.log('foo')
}
console.log('global start')
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
控制台输出的结果为:
sql
global start
promise
foo
global end
promise then
动图展示

代码执行过程解析(文字描述)
- 执行同步代码
- 执行
console.log('global start'),控制台输出:global start
- 执行
new Promise(...)
-
注意 :在使用
new关键字创建Promise对象时,传递给Promise的函数称为 executor。- 当
Promise被创建时,executor 函数会自动同步执行。 .then里的回调才是异步执行的部分。
- 当
-
执行
Promise参数中的匿名函数(同步执行):-
执行
console.log('promise'),控制台输出:promise -
执行
resolve(),将Promise状态变为resolved。
-
-
继续执行
.then(...):- 遇到
.then会将回调提交给 Web API 处理。 - Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待执行)。
- 遇到
- 继续执行同步代码
-
执行栈在提交完 Promise 事件后,继续往下执行:
-
执行
foo()函数,控制台输出:foo -
执行
console.log('global end'),控制台输出:global end
-
-
至此,本轮事件循环的同步代码执行完毕,执行栈为空。
- 处理微任务队列
-
事件循环机制首先查看 微任务队列 是否为空:
-
发现有一个 Promise 事件待执行,将其压入执行栈。
-
执行
.then中的回调:- 执行
console.log('promise then'),控制台输出:promise then
- 执行
-
至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。
-
- 检查任务队列
-
执行栈再次为空,事件循环:
- 先查看 微任务队列,发现已空。
- 再查看 宏任务队列,发现也为空。
-
执行栈进入等待事件状态。
3.2.3 Promise 结合 setTimeout
javascript
function foo() {
console.log('foo')
}
console.log('global start')
setTimeout(() => {
console.log('setTimeout: 0s')
}, 0)
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
控制台输出的结果为:
arduino
global start
promise
foo
global end
promise then
setTimeout: 0s
动图展示

- 执行同步代码
- 执行
console.log('global start'),控制台输出: global start
- 处理
setTimeout(改变部分)
-
继续往下执行,遇到
setTimeout:- JS 执行栈将其移交给 Web API 处理。
- 延迟 0 秒后,Web API 将
setTimeout事件添加到 宏任务队列 (此时宏任务队列中有一个setTimeout事件待处理)。
- 继续执行同步代码
-
JS 线程转交
setTimeout事件后,继续往下执行:-
遇到
new Promise(...):-
Promise 的 executor 函数 同步执行:
-
执行
console.log('promise'),控制台输出:promise -
执行
resolve(),将 Promise 状态变为resolved。
-
-
执行
.then(...):- 遇到
.then会将回调提交给 Web API 处理。 - Web API 将该回调添加到 微任务队列(此时微任务队列中有一个 Promise 事件待处理)。
- 遇到
-
-
- 继续执行同步代码
-
执行栈在提交完 Promise 事件后,继续往下执行:
-
执行
foo()函数,控制台输出:foo -
执行
console.log('global end'),控制台输出:global end
-
-
至此,本轮事件循环的同步代码执行完毕,执行栈为空。
- 处理微任务队列
-
事件循环机制首先查看 微任务队列 是否为空:
-
发现有一个 Promise 事件待执行,将其压入执行栈。
-
执行
.then中的回调:- 执行
console.log('promise then'),控制台输出: promise then
- 执行
-
至此,新的一轮事件循环(Promise 事件)执行完毕,执行栈为空。
-
- 处理宏任务队列(改变部分)
-
执行栈再次为空,事件循环:
-
先查看 微任务队列,发现已空。
-
再查看 宏任务队列 ,发现有一个
setTimeout事件待处理:
-
将
setTimeout中的匿名函数压入执行栈执行:- 执行
console.log('setTimeout: 0s'),控制台输出:setTimeout: 0s
- 执行
-
至此,新的一轮事件循环(
setTimeout事件)执行完毕,执行栈为空。
-
7.** 检查任务队列**
-
执行栈再次为空,事件循环:
- 先查看 微任务队列,发现已空。
- 再查看 宏任务队列,发现也为空。
-
执行栈进入等待事件状态。
3.3 事件循环中的 async/await
这里简单介绍下async函数:
-
函数前面 async 关键字的作用就2点:①这个函数总是返回一个promise。②允许函数内使用await关键字。
-
关键字 await 使 async 函数一直等待 (执行栈当然不可能停下来等待的,await将其后面的内容包装成promise交给Web APIs后,执行栈会跳出async函数继续执行),直到promise执行完并返回结果。await只在async函数函数里面奏效。
-
async函数只是一种比promise更优雅得获取promise结果(promise链式调用时)的一种语法而已。
javascript
function foo() {
console.log('foo')
}
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('global start')
async1()
foo()
console.log('global end')
执行的结果如下:
sql
global start
async1 start
async2
foo
global end
async1 end
- 执行同步代码
- 执行
console.log('global start'),控制台输出:global start
- 调用
async1()
-
执行
async1(),进入async1函数体:-
执行
console.log('async1 start'),控制台输出:async1 start -
执行
await async2():await关键字会暂停async1函数的执行,直到await后面的 Promise 返回结果。await async2()会像调用普通函数一样执行async2()。
-
- 执行
async2()
-
进入
async2函数体:-
执行
console.log('async2'),控制台输出:async2 -
async2函数执行结束,弹出执行栈。 -
由于
async2没有显式返回 Promise,它会隐式返回一个已 resolved 的 Promise。
-
4.暂停 async1() 并继续执行同步代码
-
因为
await关键字之后的代码被暂停,async1函数执行结束,弹出执行栈。 -
JS 主线程继续向下执行:
-
执行
foo()函数,控制台输出:foo -
执行
console.log('global end'),控制台输出:global end
-
-
至此,本轮事件循环的同步代码执行完毕,执行栈为空。
- 事件循环处理微任务
-
事件循环机制开始工作:
-
先查看 微任务队列:
-
发现有一个微任务事件,该事件是
async1函数中await async2()之后的代码(可以理解为:用一个匿名函数包裹await之后的代码,作为微任务事件)。 -
执行该微任务:
- 执行
console.log('async1 end'),控制台输出:async1 end
- 执行
-
-
执行栈再次为空,本轮事件执行结束。
-
- 检查任务队列
-
事件循环机制再次查看:
- 微任务队列:已空。
- 宏任务队列:也为空。
-
执行栈进入等待事件状态。
4. 大综合(自测)
4.1 简单融合
javascript
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果:
sql
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
4.2 变形 1
javascript
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise3');
resolve();
}).then(function() {
console.log('promise4');
});
console.log('script end');
输出的结果:
sql
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout
4.3 变形 2
javascript
async function async1() {
console.log('async1 start');
await async2();
setTimeout(function() {
console.log('setTimeout1');
},0)
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout3');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出的结果:
sql
script start
async1 start
async2
promise1
script end
promise2
setTimeout3
setTimeout1
4.4 变形 3
javascript
async function a1 () {
console.log('a1 start')
await a2()
console.log('a1 end')
}
async function a2 () {
console.log('a2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise1')
})
a1()
let promise2 = new Promise((resolve) => {
resolve('promise2.then')
console.log('promise2')
})
promise2.then((res) => {
console.log(res)
Promise.resolve().then(() => {
console.log('promise3')
})
})
console.log('script end')
输出的结果:
sql
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
5. 结语
- JS 是单线程执行的,同一时间只能处理一件事。
- 浏览器是多线程的 ,JS 引擎通过分发这些耗时的异步事件给 Web APIs 线程处理,避免了单线程被阻塞。
- 事件循环机制是为了协调事件、用户交互、JS 脚本、页面渲染、网络请求等事件的有序执行。
- 微任务的优先级高于宏任务。