有关JavaScript事件循环的若干疑问探究
起因
即使我完全没有系统学习过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这种异步函数则会进入回调队列,形成事件循环的第一步。
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是微任务为前提来分析。
- 执行
console.log(1) - 遇到宏任务
setTimeouts1,将其添加进Callback Queue - 遇到微任务
process.nextTickp1,将其添加进Task Queue - 执行
new Promise中的console.log(7) - 将微任务
thent1添加进Task Queue - 执行
console.log(9) - 遇到宏任务
setTimeouts2,将其添加进Callback Queue
全局的宏任务执行完我们可以得到这样两个队列,和1、7、9的输出,按规则接下来执行这个宏任务中的微任务p1和t1,得到6和8。
| Callback Queue | Task Queue |
|---|---|
| s1 | p1 |
| s2 | t1 |
继续下一个宏任务s1:
- 执行
console.log(2) - 遇到微任务
process.nextTickp2,将其添加进Task Queue - 执行
new Promise中的console.log(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 和浏览器渲染、帧动画、空闲回调(动图演示)
有关JavaScript事件循环的若干疑问探究的更多相关文章
- c#封装DBHelper类 c# 图片加水印 (摘)C#生成随机数的三种方法 使用LINQ、Lambda 表达式 、委托快速比较两个集合,找出需要新增、修改、删除的对象 c# 制作正方形图片 JavaScript 事件循环及异步原理(完全指北)
c#封装DBHelper类 public enum EffentNextType { /// <summary> /// 对其他语句无任何影响 /// </summary> ...
- 一篇文章图文并茂地带你轻松学完 JavaScript 事件循环机制(event loop)
JavaScript 事件循环机制 (event loop) 本篇文章已经默认你有了基础的 ES6 和 javascript语法 知识. 本篇文章比较细致,如果已经对同步异步,单线程等概念比较熟悉的读 ...
- JavaScript 事件循环
JavaScript 事件循环 事件循环 任务队列 async/await 又是如何处理的呢 ? 定时器问题 阻塞还是非阻塞 实际应用案例 拆分 CPU 过载任务 进度指示 在事件之后做一些事情 事件 ...
- JavaScript 事件循环及异步原理(完全指北)
引言 最近面试被问到,JS 既然是单线程的,为什么可以执行异步操作? 当时脑子蒙了,思维一直被困在 单线程 这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内 ...
- 深入理解 JavaScript 事件循环(一)— event loop
引言 相信所有学过 JavaScript 都知道它是一门单线程的语言,这也就意味着 JS 无法进行多线程编程,但是 JS 当中却有着无处不在的异步概念 .在初期许多人会把异步理解成类似多线程的编程模式 ...
- 深入理解 JavaScript 事件循环(二)— task and microtask
引言 microtask 这一名词是 JS 中比较新的概念,几乎所有人都是在学习 ES6 的 Promise 时才接触这一新概念,我也不例外.当我刚开始学习 Promise 的时候,对其中回调函数的执 ...
- javascript事件循环机制 浅尝手记
引入 众所周知Javascript是一个单线程的机制,虽然可以依托多线程的浏览器实现页面如何实现页面复杂的渲染.事件响应,但仍不会改变其单线程的本质:所以对于js的事件循环机制的了解是一个前端人员的必 ...
- JavaScript事件循环(Event Loop)机制
JavaScript 是单线程单并发语言 什么是单线程 主程序只有一个线程,即同一时间片断内其只能执行单个任务. 为什么选择单线程? JavaScript的主要用途是与用户互动,以及操作DOM.这决定 ...
- JavaScript事件循环机制
事件循环 事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作.这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务. 单次循环迭代中,最多处理一个宏 ...
随机推荐
- 类型转换Java day8
类型转换自动类型转换 从同种类型的低字节类型值直接转换到高类型字节值的转换可自动转换 类型自动转换示例 byte a = 20; int b = a;//不报错可正常转换 有些类型它在计算时默认以指定 ...
- 12.9 supper
Super super的注意事项 super可以用来在子类中访问父类的public属性或方法,super只能出现在子类中. super()调用的是父类的默认无参构造,super(参数)可以调用父类的有 ...
- Python中将字典转为成员变量
技术背景 当我们在Python中写一个class时,如果有一部分的成员变量需要用一个字典来命名和赋值,此时应该如何操作呢?这个场景最常见于从一个文件(比如json.npz之类的文件)中读取字典变量到内 ...
- spring-注解驱动模式
spring web装配原理: /** * WebApplicationInitializer Spring MVC 提供接口. * * Spring中的web自动配置,也是可以, */ /** * ...
- js的json序列化和反序列化
(1)序列化 即js中的Object转化为字符串 1.使用toJSONString var last=obj.toJSONString(); //将JSON对象转化为JSON字符 2.使用string ...
- 什么是IOC?
IoC是什么 Ioc-Inversion of Control,即"控制反转",不是什么技术,而是一种设计思想.在Java开发中,Ioc意味着将你设计好的对象交给容器控制,而不是传 ...
- SQLyog创建用户并授权的过程
点击你要授权的数据库然后点击用户管理器 然后输入用户名和密码主机选localhost 然后点击创建,然后选择你创建的数据库全选 最后保存就可以了
- 提高scrapy爬取效率配置
提高scrapy爬取效率配置 #增加并发: 默认scrapy开启的并发线程为32个,可以适当进行增加.在settings配置文件中修改CONCURRENT_REQUESTS = 100值为100,并发 ...
- 【推理引擎】如何在 ONNXRuntime 中添加新的算子
如果模型中有些算子不被ONNX算子库支持,我们就需要利用ONNXRuntime提供的API手动添加新算子.在官方文档中已经对如何添加定制算子进行了介绍(https://onnxruntime.ai/d ...
- 乱序数组中第k大的数(顺序统计量)
该问题是顺序统计量中十分经典的问题. 使用快排中的分区法,将第k大的数排序.若双向扫描分区加上三点中值法或绝对中值法,可以保证在 O(n) 时间里找出第k大的数. 补充:可以直接使用C++STL中的n ...