在了解
Promise/A+
实现的过程中看到了两个关键词:macrotask
,microtask
.这两个是 js 中事件(EventLoop)循环需要执行的任务队列.
首先需要理解下面这些概念,然后才能开始理解事件循环.而浏览器端跟 NodeJS 中也有稍微的一些区别.
任务队列
JavaScript 是单线程事件驱动的,也就是说同一时间内只能做一个任务,做完当前的任务才能继续做下一个任务,轮流着来.这个我们称之为执行栈.
但是如果当某一个任务耗时很长,需要等待很长时间,比如一个网络请求或者说IO
任务(通常这类我们都统称异步任务
),那后面的任务就必须等着.而我们的 CPU 速度又非常的快,不能让他等着吧,那太浪费资源了,因此我们才有了任务队列(callback queue),把那些耗时长的任务扔到任务队列里面去,当当前的执行栈为空的时候再去检查任务队列,如果任务队列里有已经状态为结束的任务,就把此异步任务的回调函数放到执行栈中继续执行.
JavaScript 主线程就是一直在重复上面的过程,直到没有任务执行.需要注意的是任务队列是一个队列数据结构 LIFO,因此是先放入的先执行.而有些是有定时器,因此主线程还需要检测执行时间,当时间到要求的时候才会执行.
定时器
我们通常会看到如下的代码:
setTimeout(() => {
// 执行栈清空后再执行...
}, 0);
这是因为我们需要确定同步操作完成后, 第一时间去做任务队列里的排第一的任务,这个 0 毫秒后立即执行就是这个意思.
setTimeout(() => {
console.log(1);
}, 0);
console.log(2);
执行结果总是先2
然后才1
.有时候你定义的是 10 毫秒后执行,但是这个系统并不会满足你,它无法保证一定能 10 毫秒后去执行那段逻辑代码,因为执行栈可能有很多比较耗时的操作,此时等到去任务队列的时候可能已经十几毫秒了.
事实上, HTML5
规定了低于4毫秒
的延期时间都自动增加到了4毫秒
,而在浏览器端为了省电和节省内存及 CPU 时间,都将一段时间不显示的 tab 的时延增加到 1000 毫秒,另外对于不在充电的笔记本,4毫秒
时延将会被升至为系统定时器的时延,大概在15.6毫秒
.
setTimeout
这个特性可以让我们把很多会阻塞页面渲染的操作提取给它去做,比如某一个任务非常耗时且不太重要,但是又需要运行很多次,我们可以这样做:
function func() {
setTimeout(func, 0);
// 这里是一些dom操作,同步操作...
// 不要忘记最后加上一些结束判断条件,否则这里会一直运行(除非是你特意需要的)
}
setTimeout(func, 0);
上面的代码只能适用一些同步操作,如果需要使用异步操作,那么需要把setTimeout(func, 0)
放到你异步操作后的回调函数来执行:
function func() {
fetch('url')
.then(res => res.text())
.then(res => {
// 处理返回的值
setTimeout(func, 0);
});
}
setTimeout(func, 0);
轮询也是基于上面这类控制代码来完成的.
NodeJS 的事件循环
首先需要记住它们以下具体区别:
- macrotasks:
setTimeout
setInterval
requestAnimationFrame
setImmediate
I/O
UI渲染
- microtasks:
Promise
process.nextTick
Object.observe
MutationObserver
说道 NodeJS 的事件循环就应该提一下libuv, 这是一个开源的异步 IO 库 ,最初就是为 Node.js 开发的,现在很多项目都在用.
任务队列分的就是上面两种,开始我们介绍的是setTimeout
, 它是属于macrotasks
一类.那么这两个任务队列到底有什么区别了,这就是我想搞懂的问题.

以上图为例我们开始分析一下事件循环流程:
当执行栈为空的时候, 开始去macrotask
队列里面执行最早的一个
任务,当macrotask
为空或者执行完成后开始去microtask
队列里执行所有
任务,当microtask
为空或者执行完成后再回过头来从macrotask
再来一次
以上过程内容挺复杂,我只是简单说明了一下,其中的比如说队列先进先出,执行的是最早的那个进入队列的任务,执行完成后将会移出任务等等过程就不用再细说了.
这是个循环,系统会一直检测这两个队列,直到都为空为止,因为有时候你会在macrotask
中创建microtask
任务,而有时候会在microtask
中创建macrotask
任务,因此这是系统会不断的进行循环的原因.而任务队列当然也有先后之分了,整个程序执行的过程就是: first macrotask
--all microtask
--first macrotask
--all microtask
--first macrotask
--all microtask
.....
再简单点可以总结为:
- 在
macrotask
队列中执行最早的那个 task,然后移出 - 执行
microtask
队列中所有可用的任务,然后移出 - 下一个循环,执行下一个
macrotask
中的任务 (再跳到第 2 步)
有些文章跟我这部分表述并不一样,他们会假定一开始没有第一个macrotask
(或者描述的样子带给了读者一种错觉),这其实是错误的,比如上面的事件循环图,其实macrotask
是第一个 task,然后才是microtask
.
首先要知道整个程序一开始就全被推入到了执行栈,执行的过程中会根据不同的调用再进行分配到不同的 task 中去.既然我们弄懂了事件循环了,那么我们开始看看如下的代码:
console.log('start');
const interval = setInterval(() => {
console.log('setInterval');
}, 0);
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve()
.then(() => {
console.log('promise 3');
})
.then(() => {
console.log('promise 4');
})
.then(() => {
setTimeout(() => {
console.log('setTimeout 2');
Promise.resolve()
.then(() => {
console.log('promise 5');
})
.then(() => {
console.log('promise 6');
})
.then(() => {
clearInterval(interval);
});
}, 0);
});
}, 0);
new Promise((resolve, reject) => {
console.log('promise'); // new Promise是同步代码
resolve('promise next');
}).then(res => {
console.log(res);
});
Promise.resolve()
.then(() => {
console.log('promise 1');
})
.then(() => {
console.log('promise 2');
});
console.log('end');
正确答案:
start promise end promise next promise 1 promise 2 setInterval setTimeout 1
promise 3 promise 4 setInterval setTimeout 2 promise 5 promise 6
- 执行的
setTimeout
,setInterval
其实都是调用的浏览器 API,浏览器根据其中时间进行倒计时,当到达了指定的时间后将 task 推入到macrotask
中,因此上面一开始marcotask
是空的 Promise.then
是异步执行的,而创建 Promise 实例的构造函数是同步执行的,如果你需要使用 Promise 那么你需要知道这个.
Node.js 提供了另外两个与"任务队列"有关的方法: process.nextTick
和setImmediate
process.nextTick 方法可以在当前"执行栈"(也就是 microtask)的尾部----下一次 Event Loop(主线程读取"任务队列",也就是 macrotask)之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate 方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行.
process.nextTick(function A() {
console.log(1);
process.nextTick(function B() {
console.log(2);
});
});
setTimeout(function timeout() {
console.log('TIMEOUT FIRED');
}, 0);
// 1
// 2
// TIMEOUT FIRED
上面代码中,由于 process.nextTick 方法指定的回调函数,总是在当前"执行栈"的尾部触发,所以不仅函数 A 比 setTimeout 指定的回调函数 timeout 先执行,而且函数 B 也比 timeout 先执行。这说明,如果有多个 process.nextTick 语句(不管它们是否嵌套),将全部在当前"执行栈"执行.
参考
- 理解事件循环一(浅析)
- Difference between microtask and macrotask within an event loop context
- JavaScript 运行机制详解:再谈 Event Loop
- 45.理解事件循环二(macrotask 和 microtask)
- WHATVG task-queue
- 从 Promise 来看 JavaScript 中的 Event Loop、Tasks 和 Microtasks
说明
本文章仅供参考与学习,如有问题请联系我GitHub,如需要转载请注明出处,谢谢啦.