深入理解JavaScript的事件循环(Event Loop)
一、什么是事件循环
JS的代码执行是基于一种事件循环的机制,之所以称作事件循环,MDN给出的解释为
因为它经常被用于类似如下的方式来实现
while (queue.waitForMessage()) {
queue.processNextMessage();
}如果当前没有任何消息
queue.waitForMessage
会等待同步消息到达
我们可以把它当成一种程序结构的模型,处理的方案。更详细的描述可以查看 这篇文章
而JS的运行环境主要有两个:浏览器、Node。
在两个环境下的Event Loop实现是不一样的,在浏览器中基于 规范 来实现,不同浏览器可能有小小区别。在Node中基于 libuv 这个库来实现
JS是单线程执行的,而基于事件循环模型,形成了基本没有阻塞(除了alert或同步XHR等操作)的状态
二、Macrotask 与 Microtask
根据 规范,每个线程都有一个事件循环(Event Loop),在浏览器中除了主要的页面执行线程 外,Web worker是在一个新的线程中运行的,所以可以将其独立看待。
每个事件循环有至少一个任务队列(Task Queue,也可以称作Macrotask宏任务),各个任务队列中放置着不同来源(或者不同分类)的任务,可以让浏览器根据自己的实现来进行优先级排序
以及一个微任务队列(Microtask Queue),主要用于处理一些状态的改变,UI渲染工作之前的一些必要操作(可以防止多次无意义的UI渲染)
主线程的代码执行时,会将执行程序置入执行栈(Stack)中,执行完毕后出栈,另外有个堆空间(Heap),主要用于存储对象及一些非结构化的数据
一开始
宏任务与微任务队列里的任务随着:任务进栈、出栈、任务出队、进队之间交替着进行
从macrotask队列中取出一个任务处理,处理完成之后(此时执行栈应该是空的),从microtask队列中一个个按顺序取出所有任务进行处理,处理完成之后进入UI渲染后续工作
需要注意的是:microtask并不是在macrotask完成之后才会触发,在回调函数之后,只要执行栈是空的,就会执行microtask。也就是说,macrotask执行期间,执行栈可能是空的(比如在冒泡事件的处理时)
然后循环继续
常见的macrotask有:
run <script>(同步的代码执行)
- setTimeout
setInterval
setImmediate (Node环境中)
requestAnimationFrame
I/O
UI rendering
常见的microtask有:
process.nextTick (Node环境中)
Promise callback
Object.observe (基本上已经废弃)
MutationObserver
macrotask种类很多,还有 dispatch event事件派发等
run <script>这个可能看起来比较奇怪,可以把它看成一段代码(针对单个<script>标签)的同步顺序执行,主要用来描述执行程序的第一步执行
dispatch event主要用来描述事件触发之后的执行任务,比如用户点击一个按钮,触发的onClick回调函数。需要注意的是,事件的触发是同步的,这在下文有例子说明
注:
当然,也可认为 run <script>不属于macrotask,毕竟规范也没有这样的说明,也可以将其视为主线程上的同步任务,不在主线程上的其他部分为异步任务
三、在浏览器中的实现
先来看看这段蛮复杂的代码,思考一下会输出什么
console.log('start'); var intervalA = setInterval(() => {
console.log('intervalA');
}, 0); setTimeout(() => {
console.log('timeout'); clearInterval(intervalA);
}, 0); var intervalB = setInterval(() => {
console.log('intervalB');
}, 0); var intervalC = setInterval(() => {
console.log('intervalC');
}, 0); new Promise((resolve, reject) => {
console.log('promise'); for (var i = 0; i < 10000; ++i) {
i === 9999 && resolve();
} console.log('promise after for-loop');
}).then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2'); clearInterval(intervalB);
}); new Promise((resolve, reject) => {
setTimeout(() => {
console.log('promise in timeout');
resolve();
}); console.log('promise after timeout');
}).then(() => {
console.log('promise4');
}).then(() => {
console.log('promise5'); clearInterval(intervalC);
}); Promise.resolve().then(() => {
console.log('promise3');
}); console.log('end');
上述代码结合了常规执行代码,setTimeout,setInterval,Promise
答案为
在解释为什么之前,先看一个更简单的例子
console.log('start'); setTimeout(() => {
console.log('timeout');
}, 0); Promise.resolve().then(() => {
console.log('promise');
}); console.log('end');
大概的步骤,文字有点多
1. 运行时(runtime)识别到log方法为一般的函数方法,将其入栈,然后执行输出 start 再出栈
2. 识别到setTimeout为特殊的异步方法(macrotask),将其交由其他内核模块处理,setTimeout的匿名回调函数被放入macrotask队列中,并设置了一个 0ms的立即执行标识(提供后续模块的检查)
3. 识别到Promise的resolve方法为一般的方法,将其入栈,然后执行 再出栈
4. 识别到then为Promise的异步方法(microtask),将其交由其他内核模块处理,匿名回调函数被放入microtask队列中
5. 识别到log方法为一般的函数方法,将其入栈,然后执行输出 end 再出栈
6. 主线程执行完毕,栈为空,随即从microtask队列中取出队首的项,
这里队首为匿名函数,匿名函数里面有 console的log方法,也将其入栈(如果执行过程中识别到特殊的方法,就在这时交给其他模块处理到对应队列尾部),
输出 promise后出栈,并将这一项从队列中移除
7. 继续检查microtask队列,当前队列为空,则将当前macrotask出队,进入下一步(如果不为空,就继续取下一个microtask执行)
8.检查是否需要进行UI重新渲染等,进行渲染...
9. 进入下一轮事件循环,检查macrotask队列,取出一项进行处理
所以最终的结果是
再看上面那个例子,对比起来只是代码多了点,混入了setInterval,多个setTimeout与promise的函数部分,按照上面的思路,应该不难理解
需要注意的三点:
1. clearInterval(intervalA); 运行的时候,实际上已经执行了 intervalA 的macrotask了
2. promise函数内部是同步处理的,不会放到队列中,放入队列中的是它的then或catch回调
3. promise的then返回的还是promise,所以在输出promise4后,继续检测到后续的then方法,马上放到microtask队列尾部,再继续取出执行,马上输出promise5;
而输出promise1之后,为什么没有马上输出promise2呢?因为此时promise1所在任务之后是promise3的任务,1和3在promise函数内部返回后就添加至队列中,2在1执行之后才添加
再来看个例子,就有点微妙了
<script>
console.log('start'); setTimeout(() => {
console.log('timeout1');
}, 0); Promise.resolve().then(() => {
console.log('promise1');
});
</script>
<script>
setTimeout(() => {
console.log('timeout2');
}, 0); requestAnimationFrame(() => {
console.log('requestAnimationFrame');
}); Promise.resolve().then(() => {
console.log('promise2');
}); console.log('end');
</script>
输出结果
requestAnimationFrame是在setTimeout之前执行的,start之后并不是直接输出end,也许这两个<script>标签被独立处理了
来看一个关于DOM操作的例子,Tasks, microtasks, queues and schedules
<style type="text/css">
.outer {
width: 100px;
background: #eee;
height: 100px;
margin-left: 300px;
margin-top: 150px;
display: flex;
align-items: center;
justify-content: center;
} .inner {
width: 50px;
height: 50px;
background: #ddd;
}
</style> <script>
var outer = document.querySelector('.outer'),
inner = document.querySelector('.inner'),
clickTimes = 0; new MutationObserver(() => {
console.log('mutate');
}).observe(outer, {
attributes: true
}); function onClick() {
console.log('click'); setTimeout(() => {
console.log('timeout');
}, 0); Promise.resolve().then(() => {
console.log('promise');
}); outer.setAttribute('data-click', clickTimes++);
} inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick); // inner.click(); // console.log('done');
</script>
点击内部的inner块,会输出什么呢?
MutationObserver优先级比promise高,虽然在一开始就被定义,但实际上是触发之后才会被添加到microtask队列中,所以先输出了promise
两个timeout回调都在最后才触发,因为click事件冒泡了,事件派发这个macrotask任务包括了前后两个onClick回调,两个回调函数都执行完之后,才会执行接下来的 setTimeout任务
期间第一个onClick回调完成后执行栈为空,就马上接着执行microtask队列中的任务
如果把代码的注释去掉,使用代码自动 click(),思考一下,会输出什么?
可以看到,事件处理是同步的,done在连续输出两个click之后才输出
而mutate只有一个,是因为当前执行第二个onClick回调的时候,microtask队列中已经有一个MutationObserver,它是第一个回调的,因为事件同步的原因没有被及时执行。浏览器会对MutationObserver进行优化,不会重复添加监听回调
四、在Node中的实现
在Node环境中,macrotask部分主要多了setImmediate,microtask部分主要多了process.nextTick,而这个nextTick是独立出来自成队列的,优先级高于其他microtask
不过事件循环的的实现就不太一样了,可以参考 Node事件文档 libuv事件文档
Node中的事件循环有6个阶段
- timers:执行
setTimeout()
和setInterval()
中到期的callback - I/O callbacks:上一轮循环中有少数的I/Ocallback会被延迟到这一轮的这一阶段执行
- idle, prepare:仅内部使用
- poll:最为重要的阶段,执行I/O callback,在适当的条件下会阻塞在这个阶段
- check:执行setImmediate的callback
- close callbacks:执行close事件的callback,例如
socket.on("close",func)
每一轮事件循环都会经过六个阶段,在每个阶段后,都会执行microtask
比较特殊的是在poll阶段,执行程序同步执行poll队列里的回调,直到队列为空或执行的回调达到系统上限
接下来再检查有无预设的setImmediate,如果有就转入check阶段,没有就先查询最近的timer的距离,以其作为poll阶段的阻塞时间,如果timer队列是空的,它就一直阻塞下去
而nextTick并不在这些阶段中执行,它在每个阶段之后都会执行
看一个例子
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4)); console.log(5);
根据以上知识,应该很快就能知道输出结果是 5 3 4 1 2
修改一下
process.nextTick(() => console.log(1)); Promise.resolve().then(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => {
process.nextTick(() => console.log(0));
console.log(4);
});
输出为 1 3 2 4 0,因为nextTick队列优先级高于同一轮事件循环中其他microtask队列
修改一下
process.nextTick(() => console.log(1)); console.log(0); setTimeout(()=> {
console.log('timer1'); Promise.resolve().then(() => {
console.log('promise1');
});
}, 0); process.nextTick(() => console.log(2)); setTimeout(()=> {
console.log('timer2'); process.nextTick(() => console.log(3)); Promise.resolve().then(() => {
console.log('promise2');
});
}, 0);
输出为
与在浏览器中不同,这里promise1并不是在timer1之后输出,因为在setTimeout执行的时候是出于timer阶段,会先一并处理timer回调
setTimeout是优先于setImmediate的,但接下来这个例子却不一定是先执行setTimeout的回调
setTimeout(() => {
console.log('timeout');
}, 0); setImmediate(() => {
console.log('immediate');
});
因为在Node中识别不了0ms的setTimeout,至少也得1ms.
所以,如果在进入该轮事件循环的时候,耗时不到1ms,则setTimeout会被跳过,进入check阶段执行setImmediate回调,先输出 immediate
如果超过1ms,timer阶段中就可以马上处理这个setTimeout回调,先输出 timeout
修改一下代码,读取一个文件让事件循环进入IO文件读取的poll阶段
let fs = require('fs'); fs.readFile('./event.html', () => {
setTimeout(() => {
console.log('timeout');
}, 0); setImmediate(() => {
console.log('immediate');
});
});
这么一来,输出结果肯定就是 先 immediate 后 timeout
五、用好事件循环
知道JS的事件循环是怎么样的了,就需要知道怎么才能把它用好
1. 在microtask中不要放置复杂的处理程序,防止阻塞UI的渲染
2. 可以使用process.nextTick处理一些比较紧急的事情
3. 可以在setTimeout回调中处理上轮事件循环中UI渲染的结果
4. 注意不要滥用setInterval和setTimeout,它们并不是可以保证能够按时处理的,setInterval甚至还会出现丢帧的情况,可考虑使用 requestAnimationFrame
5. 一些可能会影响到UI的异步操作,可放在promise回调中处理,防止多一轮事件循环导致重复执行UI的渲染
6. 在Node中使用immediate来可能会得到更多的保证
7. 不要纠结
深入理解JavaScript的事件循环(Event Loop)的更多相关文章
- 事件循环Event loop到底是什么
摘要:本文通过结合官方文档MDN和其他博客深入解析浏览器的事件循环机制,而NodeJS有另一套事件循环机制,不在本文讨论范围中.process.nextTick和setImmediate是NodeJS ...
- JS事件循环(Event Loop)机制
前言 众所周知,为了与浏览器进行交互,Javascript是一门非阻塞单线程脚本语言. 为何单线程? 因为如果在DOM操作中,有两个线程一个添加节点,一个删除节点,浏览器并不知道以哪个为准,所以只能选 ...
- 简单了解一下事件循环(Event Loop)
关于我 一个有思想的程序猿,终身学习实践者,目前在一个创业团队任team lead,技术栈涉及Android.Python.Java和Go,这个也是我们团队的主要技术栈. Github:https:/ ...
- 事件循环 event loop 究竟是什么
事件循环 event loop 究竟是什么 一些概念 浏览器运行时是多进程,从任务管理器或者活动监视器上可以验证. 打开新标签页和增加一个插件都会增加一个进程,如下图:  浏览器渲染进程是多线程,包 ...
- 一文梳理JavaScript 事件循环(Event Loop)
事件循环(Event Loop),是每个JS开发者都会接触到的概念,但是刚接触时可能会存在各种疑惑. 众所周知,JS是单线程的,即同一时间只能运行一个任务.一般情况下这不会引发问题,但是如果我们有一个 ...
- JavaScipt 中的事件循环(event loop),以及微任务 和宏任务的概念
说事件循环(event loop)之前先要搞清楚几个问题. 1. js为什么是单线程的? 试想一下,如果js不是单线程的,同时有两个方法作用dom,一个删除,一个修改,那么这时候浏览器该听谁的? ...
- JavaScript:彻底理解同步、异步和事件循环(Event Loop) (转)
原文出处:https://segmentfault.com/a/1190000004322358 一. 单线程 我们常说"JavaScript是单线程的". 所谓单线程,是指在JS ...
- JavaScript:彻底理解同步、异步和事件循环(Event Loop)
一. 单线程 我们常说"JavaScript是单线程的". 所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个.不妨叫它主线程. 但是实际上还存在其他 ...
- [转] JavaScript:彻底理解同步、异步和事件循环(Event Loop)
一. 单线程 我们常说“JavaScript是单线程的”. 所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个.不妨叫它主线程. 但是实际上还存在其他的线程.例如:处理A ...
随机推荐
- 文字创作类App分享-简书
今天我用Mockplus做了一套简书App的原型,这是一款文字创作类的App,用户通过写文.点赞等互动行为,提高自己在社区的影响力,打造个人品牌.我运用了Mockplus基础组件.交互组件.移动组件等 ...
- 重读<<大话设计模式>>读书笔记一
面向对象编程几大原则: 1.简单工厂模式 解读:根据不同条件,动态创建合适的对象. 目的: 解决对象创建问题 举例: 计算器根据不同情况,创建适合的对象来处理数据. 2.策略模式 解读:也是根据不同的 ...
- 深入理解JVM(六)类文件结构
6.1 关于类文件 1.class文件的一次编译,到处运行的跨平台性: 2.JVM不止有跨平台性,还有跨语言性,不管是JRuby还是Groovy写出来的程序,只要编译出符合JVM规范的class文件就 ...
- 一不注意,在Unity3D中DllImport 引起的Bug.
单要说这个Bug是很简单,但是得从头说起. 一些大型的网络游戏,或者加载比较多的一些场景时,如果要等待所有模型,贴图等各种资源文件加载完毕才能执行游戏,对用户将会是一个很头大的事情.所以就需要用到动态 ...
- 深入C#的String类
- liunx Ubuntu 设置IP、网关、DNS
说明:在网上给的教程上面通常会有这样的一个误导思路,按照配置文件设置后会不生效的问题,甚至没有一点效果,经过排查发现Linux下设置IP这个话题的入口线索应该分为两种:1为Server版,2为Desk ...
- 自动化部署shell
yum install curl policycoreutils openssh-server openssh-clients postfix [root@linux-node2 ~]# yum in ...
- Beta冲刺 (4/7)
Part.1 开篇 队名:彳艮彳亍团队 组长博客:戳我进入 作业博客:班级博客本次作业的链接 Part.2 成员汇报 组员1(组长)柯奇豪 过去两天完成了哪些任务 共享编辑文章的后端数据处理 展示Gi ...
- Jmeter安装与实例
安装步骤: 安装环境:Windows7 安装包:JDK安装包:Jmeter安装包: 环境变量配置:变量名JAVA_HOME 值:jdk的安装路径 变量名CLASSPATH值:.;%JAV ...
- day_2安装Python解释器和pycharm
首先我们复习一下昨天的内容 ···重点 1:进制转换:二进制 与 十六进制 1111 0101 1010 转换为 f5a 2:内存分布:堆区 和 栈区 计算机的原理: 控制器 运算器 存储器 inp ...