前情提要
对于前端开发者,如果你不了解「事件循环」,那么你就不懂前端。
「事件循环」是浏览器的核心,而我们前端又是和浏览器打交道,所以深入了解「事件循环」,非常必要。
是什么
事件循环又叫消息循环,是浏览器渲染主线程的工作方式,也是浏览器处理事件的一种机制。
在 Chrome 源码中,它会开启一个不会结束的 for 循环,每次循环会从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去,我们简单地把消息队列分为 宏队列 和 微队列,这种说法目前已经没法满足复杂的浏览器环境,在W3C 的官方解释中也没有 宏队列 的说法了,现在的消息队列是一种灵活多变的处理方式。
消息队列有多种,比如:
- 延时队列:用于存放计时器到后的回调任务(setTimeout、setInterval)
- 交互队列:用于存放用户操作后产生的事件处理任务(addEventListener)
- 微队列:用于存放需要最快执行的任务(Promise的then方法、MutationObserver)
任务虽然没有优先级,在任务队列中先进先出
但是 消息队列是有优先级的
它们的优先级:微队列 > 交互队列 > 延时队列
在一次事件循环中,浏览器主线程会根据消息队列的优先级,取优先级最高的队列的第一个任务执行。微队列相当于 VIP,优先级最高,必须优先调度执行。html.spec.whatwg.org/multipage/w…
为什么会有事件循环
单线程是异步产生的原因,事件循环是异步的实现方式
由于 JavaScript 是一门单线程语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个,它除了执行 JavaScript 代码,还承担着许多其他的任务(解析、渲染页面、如何分配线程、调度任务等等)。
如果使用同步的方式工作,当遇到了不会马上执行的任务,比如:计时任务、用户操作后才会产生的事件处理函数、网络请求完后需要执行的任务时,就会导致主线程产生阻塞,从而导致消息队列中排着队的其他任务无法得到执行。这让主线程白白浪费了宝贵的时间,在用户视角,由于无法及时更新,会造成页面卡死的现象,极其影响用户体验。
所以,浏览器采用异步的方式来避免。
单线程的运行方式产生的以上问题就是异步产生的原因,为了实现异步,事件循环应运而生。
具体做法是,当主线程执行 JavaScript 代码过程中:
- 遇到了计时任务(setTimeout、setInterval),主线程会交给计时线程,转而继续执行同步代码。延时任务会记着时间,等时间到了,就会将回调函数包装成任务,再将任务添加到延时队列中;
- 遇到了用户操作会才会产生的事件处理函数(addEventListener),主线程会交给事件监听线程,转而继续执行同步代码。事件监听线程会监听页面的用户操作,当监听到了 click 等事件发生,会将回调函数包装成任务,再将任务添加到交互队列中;
- 遇到了发起网络请求的函数(Ajax、axios、fetch等),主线程会交给网络线程去处理,转而继续执行同步代码。网络线程会将网络任务完成后的任务,放到微队列中,让主线程及时处理。
在这种异步模式下,浏览器最大限度地保证了单线程的流畅运行。
Q&A
JavaScript 为何是单线程?
JavaScript 主要作用之一是操作DOM,如果 JavaScript 是多线程语言,那么当多个线程同时操作一个 DOM 元素时,浏览器应该听哪个线程的?
所以,为了避免这个问题,JavaScript 必须是一门单线程语言。
JavaScript 为何会阻塞页面渲染?
承接上一个问题的答案,由于 JavaScript 会操作 DOM,在执行 JavaScript 代码的时候,页面的 DOM 结构很可能会改变,如果当渲染主线程遇到 JavaScript 代码不停止渲染,那么会导致最后渲染出来的结果不可控。
所以,当渲染主线程遇到 JavaScript 时,会停止渲染,当JavaScript 代码执行完后,得到最后确定的 DOM,才会继续向下渲染页面。
添加微任务有哪些方式?
- Promise 的回调函数(.then() 和 .catch())
- 监听 DOM 变化的 MutationObserver
- process.nextTick(Node.js 环境)
JavaScript 中的计时器能做到精确计时吗?为什么?
不能,因为:
- 计算机硬件不支持:硬件没有原子钟,无法做到精确计时;
- 操作系统提供的计时函数有偏差:JS 的计时器最终调用的是操作系统的函数,就携带了偏差;
- 浏览器源码实现计时器规则:按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过了 5 层,少于 4ms 的计时函数会带有 4ms的偏差;
- 受事件循环的影响:计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差。