有关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)
- 遇到宏任务
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。
Callback Queue | Task Queue |
---|---|
s1 | p1 |
s2 | t1 |
继续下一个宏任务s1:
- 执行
console.log(2)
- 遇到微任务
process.nextTick
p2,将其添加进Task Queue - 执行
new Promise
中的console.log(4)
- 将微任务
then
t2添加进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事件循环机制
事件循环 事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作.这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务. 单次循环迭代中,最多处理一个宏 ...
随机推荐
- SQL注入及防止SQL注入
•SQL注入 SQL注入是通过操作输入来修改事先定义好的SQL语句,对用户输入的字符串进行过滤,转义,限制或处理不严谨,导致用户可以通过输入精心构造的字符串去非法获取到数据库中的数据,以达到执行代码对 ...
- mock axios vue的数据传递关系
最近有一个项目要快速出一个前端demo,选择了vue-element框架(虽然其实很想用react,但是为了求快) 本人的基础只有初级的html/css/js知识,和python后端的一些简单工作,和 ...
- jinja2.exceptions.TemplateNotFound 报错处理
一.检查模板文件夹是否正确. 遇到这个问题,首先需要检查你的模板文件夹命名是否规范,Flask默认会在工程下寻找templates文件夹,这个是默认配置,不能写成template或者其他名字.当然,如 ...
- Prometheusbu部署使用-1
Prometheus+grafana部署使用 主机列表: 192.168.161.130 : Prometheus 192.168.161.128 : node-1 192.168.161.129 : ...
- 【推理引擎】在 VS Code 调试 ONNXRuntime 的测试单元
背景:在学习如何往ONNXRuntime中添加新算子时,参考了官方测试代码:onnxruntime/test/shared_lib/test_inference.cc,代码内部使用GTest作为单元测 ...
- java 学习笔记(一)
书 head first java 1. 2.加强版的FOR循环 3. 通过super引用父类 4.多态 5.接口和抽象类 接口 6.多线程锁 7.JDBC
- IO、NIO和AIO的区别
IO和NIO的区别:其本质就是阻塞和非阻塞的区别. 阻塞概念:应用程序在获取网络数据的时候,如果网络传输数据很慢,那么久一直等着,知道传输完毕为止.非阻塞概念:应用程序直接可以获取已经准备就绪好的 ...
- 99%的人都搞错了的java方法区存储内容,通过可视化工具HSDB和代码示例一次就弄明白了
https://zhuanlan.zhihu.com/p/269134063 番茄番茄我是西瓜 那是我日夜思念深深爱着的人啊~ 已关注 6 人赞同了该文章 前言 本篇是java内存区域管理系列教 ...
- 什么是unzip 命令?
· 解压 *.zip 文件:unzip test.zip . · 查看 *.zip 文件的内容:unzip -l jasper.zip .
- 如何集成 Spring Boot 和 ActiveMQ?
对于集成 Spring Boot 和 ActiveMQ,我们使用依赖关系. 它只需要很少的配置,并且不需要样板代码.