即使我完全没有系统学习过JavaScript的事件循环机制,在经过一定时间的经验积累后,也听过一些诸如宏任务和微任务、JavaScript是单线程的、Ajax和Promise是一种异步操作、setTimeout会在最后执行等这类的碎片信息,结合实际的代码也可以保证绝大多数情况下代码是按照我希望的顺序执行,但是当我被实际问到这个问题时,发现自己并不能切实地理解这其中的原理,相关的资料有很多,但还是要用自己的理解来表述一遍。
为什么要有事件循环?首先是个简单的问题,换句话说就是事件循环有什么作用,我为什么要学习这个知识?就像第一段里提到的,众所周知JavaScript是单线程语言,但这并不代表JavaScript不需要异步操作,反向思考一下,如果你所写的所有Ajax操作都是同步的会有什么后果:我们每次向服务端发送请求,整个页面都会因此停滞,直到请求返回,无论响应时间是1毫秒、1秒还是1分钟。对于用户体验来说,这无疑是灾难,所以JavaScript提供了各种异步编程的方式:事件循环、Promise、Generator、Worker等,这里我们还是把目光先聚焦到事件循环上,随着问题的深入,我们会知道事件循环为我们解决了什么问题。
事件循环是怎样运作的?要理解这个问题,推荐先看下这个视频:到底什么是Event Loop呢?,然后是视频中提到的网站:loupe,结合视频我们可以很形象地看到事件是如何在循环中运作的,网站则是根据输入的代码来用动画演示这个过程。
顺着视频的思路我们把JavaScript的执行分成几部分:调用栈(Call stack)、事件循环(Event loop)、回调队列(Callback queue)、其他API(Other apis)。
调用栈因为JavaScript是单线程的,所以只能一句一句地执行我们的代码,编译器每读到一个函数就把它压入栈中,栈顶的函数返回结果时就弹栈,在这个过程中只有同步函数函数会进入调用栈走正常的执行流程,而setTimeout
和Promise
这种异步函数则会进入回调队列,形成事件循环的第一步。
视频中最令我感到意外的是很多我们熟悉的函数并不是JavaScript提供的,而是来自于Web APIs,比如Ajax、DOM、setTimeout等,这些方法的实现并没有出现在V8的源码中,因为它们是由浏览器提供的,更准确地说,应该是运行环境提供的,因为JavaScript的运行环境并不是统一的,不同的浏览器核心就不说了,我们就分成浏览器和Node就可以,看似与我们讨论的事件循环无关,但其中还是存在区别,这个问题我们放在后面说明。
任务队列异步方法经过Web API的处理后会进入任务队列,以setTimeout为例就是浏览器提供了一个定时器,当处理这个方法时就在后台启动定时器,达到设定的时间时就将这个方法添加进任务队列,当这一批的同步任务处理完后,JavaScript就会从队列取出方法放入调用栈执行,所以,实际上我们设定的时间是指这个方法最早什么时候可以执行,而不是延迟多久执行。我们来看一个例子,可以先脑内运行模拟一下结果:
console.log('1')
setTimeout(function setFirstTimeout() {
console.log('2')
new Promise(function (resolve) {
console.log('3')
resolve()
}).then(function () {
console.log('4')
})
},0)
new Promise(function (resolve) {
console.log('5')
resolve()
}).then(function () {
console.log('6')
})
console.log('7')
实际执行一下我们可以得到1、5、7、6、2、3、4这样一个结果,把这段代码放到上文提到的网站里可以很清晰地看到过程,我们定义的setFirstTimeout
这一方法经由Web API的处理后进入了Callback Queue,等待主线程的代码执行完,再通过事件循环这一机制进入调用栈。
这样就都说得通了:setTimeout
为什么总是在最后执行,但事实真是如此吗?我们看下一个问题。
即使没有仔细研究过这个问题,根据经验也知道肯定不是这样,虽然setTimeout会相对延迟执行,但并不总是会在所有代码最后执行,这里就涉及一个更大的问题——宏任务与微任务。我们在上文的代码中添加一个DOM操作。
console.log('1')
$.on('button','click',function onClick(){
console.log('Clicked');
})
setTimeout(function setFirstTimeout() {
console.log('2')
new Promise(function (resolve) {
console.log('3')
resolve()
}).then(function () {
console.log('4')
})
},0)
new Promise(function (resolve) {
console.log('5')
resolve()
}).then(function () {
console.log('6')
})
console.log('7')
直接看结果,当setTimeout的回调方法进入事件队列后,我点击了绑定了事件的按钮,因此点击的回调方法也进入了事件队列,当同步任务处理完之后,根据队列先入先出的之一原则,setTimeout的回调方法就会先被处理,之后才是点击事件的回调方法。
不算巧妙的一个例子,但是DOM操作确实与setTimeout同属宏任务这一类别,相对于宏任务的则是微任务,常见分类如下:
宏任务
- script(整体代码)
- setTimeout
- setInterval
- I/O
- UI交互事件
- postMessage
- MessageChannel
- setImmediate(Node.js 环境)
微任务
- Promise.then
- Object.observe
- MutationObserver
- process.nextTick(Node.js 环境)
其实从上面例子中,应该已经有人发现Promise的执行顺序也不太正常。then
中的回调函数既没有跟着Promise
执行也没有进入回调队列,这里显然不是程序有Bug,正是因为宏任务与微任务有区别。
简单地说,宏任务和微任务各自有着自己的任务队列,执行一个宏任务时,遇到微任务会把它们移到微任务队列中,执行完当前宏任务后再依次执行微任务,让我们把之前的例子再丰富一下:
console.log("1");
setTimeout(function s1() {
console.log("2");
process.nextTick(function p2() {
console.log("3");
});
new Promise(function (resolve) {
console.log("4");
resolve();
}).then(function t2() {
console.log("5");
});
});
process.nextTick(function p1() {
console.log("6");
});
new Promise(function (resolve) {
console.log("7");
resolve();
}).then(function t1() {
console.log("8");
});
console.log("9");
setTimeout(function s2() {
console.log("10");
process.nextTick(function () {
console.log("11");
});
new Promise(function (resolve) {
console.log("12");
resolve();
}).then(function () {
console.log("13");
});
});
以v16版本的node环境执行结果是:1、7、9、6、8、2、4、3、5、10、12、11、13,其他环境会有差异,我们放在后面说,先看眼前的问题,以process.nextTick
是微任务为前提来分析。
- 执行
console.log(1)
- 遇到宏任务
setTimeout
s1,将其添加进Callback Queue - 遇到微任务
process.nextTick
p1,将其添加进Task Queue - 执行
new Promise
中的console.log(7)
- 将微任务
then
t1添加进Task Queue - 执行
console.log(9)
- 遇到宏任务
setTimeout
s2,将其添加进Callback Queue
全局的宏任务执行完我们可以得到这样两个队列,和1、7、9的输出,按规则接下来执行这个宏任务中的微任务p1和t1,得到6和8。
继续下一个宏任务s1:
- 执行
console.log(2)
- 遇到微任务
process.nextTick
p2,将其添加进Task Queue - 执行
new Promise
中的console.log(4)
- 将微任务
then
t2添加进Task Queue
因此,接下来的输出是:2、4、3、5,以此类推,后面的都是差不多的规则,不一一赘述。
Node与浏览器的EventLoop有什么差异?上一个问题应该算是解决了,但也引出了一个新问题,之前我提到是以v16版本的node环境来执行,那么如果不是v16版本的node甚至不用node来运行会有什么结果呢?在这一次,彻底弄懂 JavaScript 执行机制这篇文章的评论区我看到了一些讨论,v10之前的node在事件循环的处理上与浏览器不同,所以得到了另外的结果,我切换到v10的版本后,得到的还是1、7、9、6、8、2、4、3、5、10、12、11、13这样的结果,个人觉得这里以最新版本为准就好了,不打算深究,有兴趣的可以看下那篇文章的评论区。
然后是另一种情况,最开始我是在Vue中验证这段代码的,得到的结果是1、7、9、8、2、4、5、6、10、12、13、3、11,如果是在process.nextTick
是宏任务的前提下,这个结果就是正确的,但是这里我不太清楚为什么。另外我想到了Vue中也有一个nextTick方法,查了一下发现又是一个不同的课题,限于篇幅打算另开一篇来学习,具体的内容也可以看下这篇博客Vue的nextTick具体是微任务还是宏任务?
写这一篇博客本来是想弄懂事件循环这一机制的,没想到里面的内容那么多,在我刚上班的时候,遇到过一个问题JavaScript定时器越走越快的问题,当时我是以为把这个问题搞清楚了,从今天这篇文章的角度回头来看那时候仅仅看到了冰山一角,这篇文章也同样只是写到了事件循环的冰山一角,好在现在我知道这件事了,除了Vue的nextTick这一问题外,还有一个渲染的问题与事件循环相关,之后也会将这部分内容整理成文章,这里先推荐一篇博客和一个视频:
深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示)
深入事件环(In The Loop)