深入理解JavaScript的事件循环
最近阅读《高性能JavaScript》时,第六章谈到“通过定时器将JavaScript执行代码的控制权先让给浏览器用于更新UI状态,然后再将控制权交回给JavaScript代码,这样就可以使得页面更为流畅”,就联想到了之前理解的事件循环。
这篇文章就是为了解释为什么这么做可以提升页面的流畅度。
事件循环(Event Loop)
单线程的JavaScript
总所周知,JavaScript语言的一大特点就是单线程,也就是说在一个时间段里,JavaScript只能做一件事情(浏览器是多线程)。 多线程可以实现应用的并行处理,从而以更高的CPU利用率提高整个应用程序的性能和吞吐量。
但是JavaScript却以单线程进行,为什么呢?
JavaScript是浏览器脚本语言,用于与用户交互以及操作DOM。 考虑如下情况,如果有两个并发的操作,对同一个DOM节点分别进行删除和修改样式,此时浏览器就无法决定到底采用哪个线程的操作。类似数据库,我们可以采用“锁”来处理并发,但是这会平添复杂度。所以,JavaScript语言没有支持多线程操作。 那又考虑这种情况,既然JavaScript是单线程,在某一时刻内只能执行特定的一个任务,并且会阻塞其它任务执行。那么如果用户触发了一个非常耗时的I/O操作,那么按道理后续的所有操作都得等到I/O操作完成后方可进行。但是,事实上,后续的任务不必等待这个耗时的I/O操作完成,原因就是JavaScript与生俱来的异步和回调。
而这背后恰好就是本文的主题——————事件循环
定义
事件循环包含了至少两个任务队列,宏任务队列和微任务队列。
宏任务
宏任务包含创建文档对象、解析HTML、执行主线JavaScript代码、更改当前URL以及各种事件,例如页面加载、输入、网络事件和定时器等等。宏任务运行完成后,浏览器继续其他的任务调度,如重新渲染页面或者垃圾回收。
微任务
微任务包括promise、回调函数、DOM发生变化等。微任务更新应用程序的状态,必须在浏览器任务继续执行其他任务(渲染UI视图或者进行下一个宏任务)之前执行。
两个基本原则
- 一次处理一个任务
- 一个任务开始直到运行完成,不会被其他任务中断
在微任务队列清空后,事件循环会检查当前是否需要重新渲染UI,如果需要则渲染UI视图。
补充
- 两个任务队列都是独立于事件循环的,这意味着任务队列的添加发生在事件循环外。
- 所有微任务都会在下一次渲染前完成,目的是在渲染前更新应用程序状态。
- 浏览器会尝试以每秒渲染60次页面,以达到每秒60帧的速度。所以,一次循环最理想的时间应该不超过16ms。
- 浏览器完成页面渲染后,进入下一轮事件循环迭代后,可能出现3种情况
- 如果事件循环执行到“is rendering needed”且浏览器处于另一个16ms结束之前(即浏览器尚未自动触发页面渲染时),浏览器可能不会选择在当前的时间循环中执行更新UI操作,因为更新UI是一个复杂且耗性能的操作。
- 如果事件循环执行到“is rendering needed”且浏览器刚好离上一次渲染16ms左右时(即浏览器即将自动触发页面渲染时),此时浏览器会进行UI更新。
- 执行下一个事件循环耗时超过16ms,浏览器将无法以目标帧率重新渲染页面,且UI无法被更新。如果延迟不大是很难察觉到,但是,如果有非常耗时的操作,这个时候用户会觉得网页十分卡顿,甚至浏览器会提示“无响应脚本”。
前情回顾
现在,用事件循环和简单的例子来分析《高性能的JavaScript》中的那句话。 需求:给包含1000个数字的数组中的每个元素取绝对值(假设对一个数字进行需求操作耗时1ms)。
情况1(不使用定时器): 由于JavaScript主线程代码属于宏任务的一种,所以一次事件循环需要处理1000个数字,所以1s事件循环才进行到UI更新阶段,但是由于耗时过长,UI状态不会被更新,页面出现卡顿甚至堵塞。
情况2(使用定时器): 将一次处理1000个数字的任务分割为20个每次处理50个数字的任务。由于定时器是宏任务的一种,所以一次事件循环只处理50个数字,由于此时微任务队列为空,所以50ms后事件循环进行到UI更新阶段,然后根据情况进行UI渲染,页面未出现卡顿或者堵塞。
当然,如果只是单纯的处理数据,我们可以考虑使用Web Workers。
总结
- JavaScript是单线程的,同一时刻是只能执行一个任务。
- 事件循环包含一个宏任务队列和至少一个微任务队列。事件循环一次迭代,至多执行一个宏任务但是会执行完所有的微任务。
- Web应用越复杂,积极主动管理UI线程就越重要,即使JavaScript代码很重要,也不能影响用户体验