事件循环机制详解
单线程的JS
我们都知道,JS是一门单线程语言,为什么呢?
因为 js 只运行在 渲染进程的主线程中。
渲染进程的主线程非常忙碌,它的工作包括但不限于:
- dom解析
- css解析
- js执行
- 样式计算,布局计算
- …
在谷歌浏览器中按下 esc + shift
可以看到,浏览器是多进程的
打开浏览器时,首先是加载浏览器主进程,然后利用该进程将其他进程创建。
其中比较重要的进程(对于开发人员):
-
浏览器进程
主要负责网页中的那些按钮的用户交互(比如搜索框,前进后退,设置等),子进程管理..
-
网络进程
负责加载网络资源。网络进程会启动
多个线程
来处理不同的任务 -
渲染进程
渲染进程启动后,会开启一个渲染主线程,负责对html,css,js的解析
关于渲染进程:
当前这个版本的谷歌浏览器每个页面都会有一个
独立
的渲染进程,以保证页面之间不会相互影响。由于这种开销比较大,在将来的版本可能会发生改变,比如变成一个网站一个渲染进程,而不是一个网页一个渲染进程。
为什么js是单线程的?
如果多线程执行js,效率将会提高,那为何js选择了单线程执行?
这与 js 在浏览器中执行的目的有关。
假如js多线程执行,线程1 要求修改某个dom的属性,而 线程2 也要求修改那个dom的属性,那么最后依据哪个线程的执行去操作dom呢?
这就产生了一系列的矛盾,倘若加入 锁
这个工具,又会导致js的复杂度增加,而js创建初衷是一个轻巧的语言,因此,JS诞生被设计为了一个单线程的语言。
单线程导致的问题
但单线程会有一系列存在的问题:
-
执行一些比较耗时的js代码(比如网络请求),可能会导致页面堵塞
因为js执行和页面渲染都执行在渲染主线程中,因此 js执行 和 页面渲染 是同步的,不能同时执行。如果js长时间占用主线程,导致无法渲染页面,用户将会得到一个卡死的页面。
-
js内部也会出现调用顺序的问题,比如正在执行某段代码,此时忽然用户点击了某个按钮触发了事件,是继续执行当前的代码还是转向处理事件的回调呢?
JS的异步执行,就是解决单线程出现的这些问题。
总结:
- 页面的渲染和js的执行都由 浏览器的渲染进程的主线程负责,js是单线程语言。
- 由于单线程会产生诸多的问题,比如页面可能会堵塞,js函数的调度矛盾,因此,浏览器的渲染主线程通过JS异步执行解决这些问题。
异步
所谓的JS异步执行,指的是,JS执行时,会将一些任务交给浏览器的其他线程去完成。JS只需要传入一个回调函数,作为该任务后续的处理。
当其他线程完成任务后,将JS的回调函数包装成任务对象,放在消息队列 message Queue
中。
JS的哪些任务会被主线程交给其他线程去执行呢?
一般是那些无法立即执行的任务:
- 网络请求 —
XHR
,Fetch
(难道要持续等待吗) - 用户操作事件触发后执行的任务 —
addEventListener
(一直等待用户执行吗) - 延时任务 —
setTimeout
setInterva
(等到延时结束吗)
所以对于主线程而言,无论如何他都不能阻塞,因此他把这些 可能产生堵塞的任务交给其他线程执行,其他线程处理完毕后,将主线程需要后续处理的任务包装成任务对象放入任务队列
对于渲染进程而言,它只需要在任务队列中不断的查找需要执行的任务,并将任务对象放入主线程进行执行,我们把这个不断查找任务的过程,称作渲染进程的 事件循环(event loop)
或者消息循环 message loop
机制
提示:
JS异步和事件循环的关系是什么?
JS异步是指JS将一些可能会堵塞(需要一段时间执行)的代码交给其他线程执行,而JS立刻结束掉该任务转而继续执行下面的代码。
事件循环是渲染主线程的运行模式,是JS异步的实现方式。渲染任务,JS的回调任务,都是通过事件循环执行的。
总结:
如何理解JS异步?
JS是一门单线程的语言,这是因为它只运行在浏览器的渲染主线程中。
渲染主线程承担着诸多工作,解析DOM,CSSOM,页面渲染,执行JS都在其中。
如果JS的所有代码都同步执行,就很有可能导致主线程被js堵塞,从而导致其他任务无法被执行,比如页面渲染任务,这样就会造成页面堵塞,对用户产生卡死的现象。
所以浏览器采用异步执行的方式来避免,具体做法是,当某些任务发生时,比如计时器,网络,事件监听,主线程将这些任务交给其他线程处理,自身立即结束任务的执行,转而执行后续的代码,当委托其他线程的任务执行完毕后,将事先传递的回调函数包装成任务,加入到消息队列的末尾,等待主线程的调度执行。
在这种异步模式下,浏览器最大限度的保证了单线程的流畅运行。
事件循环
查看浏览器 渲染引擎的源码可知,
渲染引擎在最开始的时候启动了一个无限循环
-
在当前的循环中,会查询任务队列是否有新的任务需要执行,有则获取该对象继续执行,没有就进入休眠状态。
-
浏览器的其他线程,可以随时的向消息队列中添加任务,在添加任务时,如果渲染进程是休眠的,则会唤醒主线程以继续执行任务。
既然涉及到了在任务队列中查询任务,自然而然就有一个问题,任务有优先级吗?
答案:
任务没有优先级,但任务队列有优先级。
在早期,任务队列分为宏任务和微任务,其中微任务的优先级大于宏任务,事件循环在任务队列查找任务时,优先在 微任务队列查询,微任务队列为空时,才在宏任务队列查询。
但浏览器日渐复杂,W3C标准已经废弃了宏任务和微任务的说法,划分出了更多的队列。
首先,从谷歌浏览器的源码可知,不同类型的任务被标记分类:
enum class TaskType: unsgined char{
kUserInteraction = 2,
...
}
W3C最新解释:
-
每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以在不同的任务队列。再一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。
-
浏览器必须准备好一个微任务队列,微队列的优先级高于其他任务队列。
目前,在 chrome 的视线中,至少包含了下面的队列:
- 延时队列:用于存放计时器线程包装的回调任务,优先级 中
- 交互队列:用于存放用户操作事件产生后的事件处理任务,优先级 高
- 微任务队列: 优先级最高
添加微任务到微队列的主要方式是使用
Promise
,MutationObserver
例如:
Promise.resolve().then(函数)
面试题:阐述一下JS的事件循环
- 事件循环又叫消息循环,是浏览器渲染主线程的工作方式。
- 在渲染主线程开始时,主线程会执行一个无限的循环,每次循环都会从消息队列中取出第一个任务执行,倘若没有任务,则进入休眠状态。
- 浏览器的其他线程可以随时将回调函数包装成任务添加到任务队列的尾部,添加任务会让主线程从休眠状态恢复到执行状态。
- 过去将消息队列简单分为宏任务和微任务,这种说法已经无法满足复杂的浏览器环境,取而代之的是一种更加多变的处理方式。
根据W3C的官方解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。
- 不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务,但浏览器必须有一个微队列,微队列具有最高的优先级,必须优先执行微队列里的任务。
面试题:JS中的计时器能做到精准计时吗?
不行,
- 从硬件层面,无法支持做到精确的计时
- 操作系统层面,浏览器计时操作借助操作系统的计时,而操作系统的计时函数有少量的偏差
- 从渲染线程的执行层面,受事件循环的影响,计时器的回调函数任务必须被主线程选到才会执行,又带来了偏差
- 按照W3C的规范,浏览器实现计时器时,如果嵌套层级超过5层,则会带有至少4毫秒的初始值,如果计时事件少于4毫秒又带来了偏差。