起因

即使我完全没有系统学习过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是单线程的,所以只能一句一句地执行我们的代码,编译器每读到一个函数就把它压入栈中,栈顶的函数返回结果时就弹栈,在这个过程中只有同步函数函数会进入调用栈走正常的执行流程,而setTimeoutPromise这种异步函数则会进入回调队列,形成事件循环的第一步。

Web API

视频中最令我感到意外的是很多我们熟悉的函数并不是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一定是在所有代码最后执行吗——宏任务与微任务

即使没有仔细研究过这个问题,根据经验也知道肯定不是这样,虽然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是微任务为前提来分析。

  1. 执行console.log(1)
  2. 遇到宏任务setTimeouts1,将其添加进Callback Queue
  3. 遇到微任务process.nextTickp1,将其添加进Task Queue
  4. 执行new Promise中的console.log(7)
  5. 将微任务thent1添加进Task Queue
  6. 执行console.log(9)
  7. 遇到宏任务setTimeouts2,将其添加进Callback Queue

全局的宏任务执行完我们可以得到这样两个队列,和1、7、9的输出,按规则接下来执行这个宏任务中的微任务p1和t1,得到6和8。

Callback Queue Task Queue
s1 p1
s2 t1

继续下一个宏任务s1:

  1. 执行console.log(2)
  2. 遇到微任务process.nextTickp2,将其添加进Task Queue
  3. 执行new Promise中的console.log(4)
  4. 将微任务thent2添加进Task Queue
Task Queue
p2
t2

因此,接下来的输出是: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)

有关JavaScript事件循环的若干疑问探究的更多相关文章

  1. c#封装DBHelper类 c# 图片加水印 (摘)C#生成随机数的三种方法 使用LINQ、Lambda 表达式 、委托快速比较两个集合,找出需要新增、修改、删除的对象 c# 制作正方形图片 JavaScript 事件循环及异步原理(完全指北)

    c#封装DBHelper类   public enum EffentNextType { /// <summary> /// 对其他语句无任何影响 /// </summary> ...

  2. 一篇文章图文并茂地带你轻松学完 JavaScript 事件循环机制(event loop)

    JavaScript 事件循环机制 (event loop) 本篇文章已经默认你有了基础的 ES6 和 javascript语法 知识. 本篇文章比较细致,如果已经对同步异步,单线程等概念比较熟悉的读 ...

  3. JavaScript 事件循环

    JavaScript 事件循环 事件循环 任务队列 async/await 又是如何处理的呢 ? 定时器问题 阻塞还是非阻塞 实际应用案例 拆分 CPU 过载任务 进度指示 在事件之后做一些事情 事件 ...

  4. JavaScript 事件循环及异步原理(完全指北)

    引言 最近面试被问到,JS 既然是单线程的,为什么可以执行异步操作? 当时脑子蒙了,思维一直被困在 单线程 这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内 ...

  5. 深入理解 JavaScript 事件循环(一)— event loop

    引言 相信所有学过 JavaScript 都知道它是一门单线程的语言,这也就意味着 JS 无法进行多线程编程,但是 JS 当中却有着无处不在的异步概念 .在初期许多人会把异步理解成类似多线程的编程模式 ...

  6. 深入理解 JavaScript 事件循环(二)— task and microtask

    引言 microtask 这一名词是 JS 中比较新的概念,几乎所有人都是在学习 ES6 的 Promise 时才接触这一新概念,我也不例外.当我刚开始学习 Promise 的时候,对其中回调函数的执 ...

  7. javascript事件循环机制 浅尝手记

    引入 众所周知Javascript是一个单线程的机制,虽然可以依托多线程的浏览器实现页面如何实现页面复杂的渲染.事件响应,但仍不会改变其单线程的本质:所以对于js的事件循环机制的了解是一个前端人员的必 ...

  8. JavaScript事件循环(Event Loop)机制

    JavaScript 是单线程单并发语言 什么是单线程 主程序只有一个线程,即同一时间片断内其只能执行单个任务. 为什么选择单线程? JavaScript的主要用途是与用户互动,以及操作DOM.这决定 ...

  9. JavaScript事件循环机制

    事件循环 事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作.这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务. 单次循环迭代中,最多处理一个宏 ...

随机推荐

  1. 使用SymPy

    最近工作的原因,需要进行一些积分运算,通过一些搜索得知了SymPy,记录一下使用历程. 1. SymPy介绍 SymPy是关于Symbolic Mathematics的Python库,它旨在成为一个功 ...

  2. 4月2日 python学习总结

    昨天内容回顾: 1.迭代器 可迭代对象: 只要内置有__iter__方法的都是可迭代的对象 既有__iter__,又有__next__方法 调用__iter__方法==>得到内置的迭代器对象 调 ...

  3. [SPDK/NVMe存储技术分析]011 - 内核态ib_post_send()源码剖析

    OFA定义了一组标准的Verbs,并在用户态提供了一个标准库libibverbs.例如将一个工作请求(WR)放置到发送队列的Verb API是ibv_post_send(), 但是在Linux内核,对 ...

  4. CF1487G String Counting (容斥计数)

    传送门 考虑$c[i]>n/3$这个关键条件!最多有2个字母数量超过$n/3$! 没有奇数回文?长度大于3的回文串中间一定是长度为3的回文串,所以合法串一定没有长度=3的回文,也就是$a[i]\ ...

  5. 玩转SpringBoot之定时任务@Scheduled线程池配置

    序言 对于定时任务,在SpringBoot中只需要使用@Scheduled 这个注解就能够满足需求,它的出现也给我们带了很大的方便,我们只要加上该注解,并且根据需求设置好就可以使用定时任务了. 但是, ...

  6. XStream类的对象将javaBean转成XML

    [省市联动] servlet端: //返回数据xml(XStream) XStream xStream = new XStream(); //把路径设置别名 xStream.alias("c ...

  7. vue渐进式?

    小到可以只使用核心功能,比如单文件组件作为一部分嵌入:大到使用整个工程,vue init webpack my-project来构建项目:VUE的核心库及其生态系统也可以满足你的各式需求(core+v ...

  8. vue自定义指令?

    除核心指令之外的指令, 使用directive进行注册. 指令自定义钩子函数: bind, inserted, update, componentUpdated, unbind

  9. 学习ELK日志平台(四)

    一:需求及基础: 场景: 1.开发人员不能登录线上服务器查看详细日志 2.各个系统都有日志,日志数据分散难以查找 3.日志数据量大,查询速度慢,或者数据不够实时 4.一个调用会涉及到多个系统,难以在这 ...

  10. 剑指Offer9——使用双栈模拟队列

    剑指Offer9--使用双栈模拟队列 队列Queue是具有FIFO(First in First out)特性的数据结构,栈Stack是具有LIFO(后进先出)特性的数据结构.下面提供一种思路使用双栈 ...