图解浏览器事件循环

图片皆为原创,仅在掘金发布

事件循环 (Event loop,又叫消息循环 Message loop)是浏览器的核心概念之一,是前端学习和技术提升绕不过去的知识。本文通过图解的方式,讲述一下现代浏览器事件循环的运行原理。

浏览器的进程与线程

现代浏览器,以 chrome 为例,广泛采用的是多进程架构。为了减少连环崩溃的几率,启动浏览器后,会将各个任务拆分为多个进程。如下图所示:

chrome 将浏览器任务拆分为多个进程:

  • 浏览器进程(Browser process):我负责界面显示、用户交互、子进程管理等业务。
  • 网络进程(Network process):我负责加载网络资源业务。
  • 渲染进程(Rendering process):我是渲染的主角,负责执行 HTML、css、js 代码。默认情况,浏览器会为每一个 tab 页签开启一个渲染进程,以确保相互不影响。
  • ...

频繁开启进程会造成内存占用过多的情况,chrome 后续会进行改进,考虑相同站点(相同顶级域名和二级域名)共用一个渲染进程。

渲染进程会开启一个渲染主线程,用于无阻塞渲染任务。其余线程协助主线程完成任务。

渲染主线程(Main thread) 负责执行代码、样式与布局(通过优先级、百分比换算、宽高等几何信息高动态计算、相对位置等计算最终样式表)、处理图层、控制60帧刷新、执行各种回调函数等,是最忙的一个,一刻也没有停歇。

此时就有一个问题,主渲染任务都交给一个线程,效率会高吗?为什么不多线程一起渲染?

这是因为浏览器渲染和 JavaScript 执行共用一个线程,而默认情况下, JavaScript 执行又会影响 CSSOM/DOM 树的渲染和结构,所以必须阻塞渲染,他们也必须是单线程操作。在构建 DOM 时,HTML 解析器若遇到了 JavaScript,它会暂停构建DOM,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复 DOM 构建。如果使用多线程处理可能会导致渲染DOM的冲突,复杂度会大幅度提升:

消息循环

上面使用单线程,使得在编写代码时无需考虑复杂的线程同步和互斥问题,从而降低了开发的复杂性,但是终归效率是上不去的。为了解决这个问题,这里巧妙地引入了消息循环来统一解决这个问题。

核心思想:渲染主线程永久轮询执行任务;单独开辟一个消息队列(Message queue)空间用于存放各个线程加入的任务;渲染主线程只是简单的拿取任务执行,而不用操心这个任务是谁送过来的。在 Chrome 的源码中,使用的是一个死循环来完成的:

cpp 复制代码
for(;;) {}

消息队列的无限轮询就叫消息循环,消息循环就发生在渲染主线程里

  1. 最开始,渲染主线程开始轮询
  2. 每一次呢循环检查消息队列是否有任务,有则取出第一个任务执行。
  3. 其他所有线程都可以追加任务进消息队列的末尾。

异步

在消息循环中,肯定会存在一些需要延时执行的代码,js在执行到他们时不会立即触发回调,而是需要某种计时机制或者触发机制来定时触发,他们一般是通过回调函数来返回执行的结果,大致分类如下:

  1. 前端API计时器:setTimeout、setInterval
  2. 前端异步 API:Promise等
  3. 网络通信:XHR、fetch等
  4. 用户交互回调:addEventListener

异步与同步最大的不同是,异步回调触发前的延时对用户是没有必要的。设计浏览器时,异步触发前的那段延时应该另外开辟线程(比如计时线程)单独计数,而不阻塞渲染主线程

异步的渲染过程如下:

可以看到,异步任务在执行完毕后进入消息队列末尾排队即可!主线程只管拿消息任务,而不用管计时任务是否完成。

计时线程其实使用的是操作系统底层的逻辑,这里不做深究。

异步案例

看下面的代码:

js 复制代码
function delay(duration) { /* 死循环 duration 秒 */ }

const btn = document.querySelector('button');
const h1 = document.querySelector('h1');

btn.onclick = function() {
    h1.textContent = 'hello message queue !'
    delay(3000)
}

他执行后会有什么表现?是页面标题变为 'hello message queue !' 后假死3秒钟吗?我们用图解来分析一下:

我们来看图说话:

  1. 用户点击,点击的回调进入消息队列
  2. 这个回调事件分为两部分,同步代码 h1.textContent = 'hello message queue !' 立即执行,延时代码开始执行进入死循环
  3. 最后入队一个绘制任务,在其他代码执行完毕后开始绘制

是不是看出问题来啦?浏览器实际效果是先假死3秒钟再改变标题文字!

绘制任务侧重于渲染原理,不是本文重点,不做赘述。他出现在循环的每一次迭代之后。在每一个事件循环的迭代中,浏览器会先处理所有的同步任务(例如:解析HTML,处理DOM操作等),然后处理异步任务(例如:网络请求,定时器回调等)。在这个异步任务完成后,浏览器会进入下一个事件循环迭代。

理解异步

关于异步,我们是怎么理解的呢?

一般来说,单线程语言是没有严格意义的异步的,代码是从上至下依次执行的。上面讲的异步,是在浏览器维度来讲的,js没有多线程,但是浏览器可以开辟多个线程来维护异步的任务。

渲染主线程执行 js 任务(js线程阻塞执行),主线程有承担渲染的其他任务,如果都同步执行,主线程就会白白消耗时间,页面经常性假死。

采用消息循环+异步时,在异步任务发生时,主线程将这个任务交给其他线程来执行,跳过这个任务往后执行;其他线程执行完毕后将回调包装成任务放入消息队列末尾,等待主线程调用。

这就保证了单线程渲染时,永不阻塞渲染。最大限度保证主线程流畅。

优先级

任务本身没有优先级,渲染主线程依次从对头拿取任务执行。有优先级的是消息队列。

在最新版的 chrome 中,消息队列其实不是一个队列,而是多个,他们有优先级,这个优先级决定了谁先进入渲染主线程被执行:

  • 延时队列(Delay queue):计时器回调任务队列,优先级【中】
  • 交互队列(Interactive queue):用户交互后回调事件队列,优先级【高】
  • 微任务队列(Micro queue):用户数据最快执行队列,优先级【最高】
  • ...

根据 W3C 最新标准,现代浏览器取消宏任务队列的概念

优先级案例

我们用一个例子来解释:

js 复制代码
setTimeout(function() {
  console.log(1);
}, 0);

console.log(2);

Promise.resolve().then(function() {
  console.log(3);
});

console.log(4);

我们将代码拆分几块来分析:

js 代码是同步执行的,所以这 4 块会被 js 执行引擎快速扫描到并加入响应的队列里:

然后渲染主线程会按照队列的优先级拿取任务,由于 2 和 4 号任务是同步代码,直接入渲染主线程,可直接执行,打印 2 和 4;接下来拿取优先级最高的微对列的任务 3 并执行,输出 3,接下来拿交互队列,没有任务;最后获取延时队列的任务,并依次执行。

注意,这里 1 号延时任务执行时,会开辟计时线程来计数,计数完成后,将里边的同步代码 console.log(1); 加入同步的消息队列 (这里就是直接加入渲染主线程)同步执行,就可以输出 1 了。所以输出结果是:2 4 3 1


总结

概念一:消息循环又叫事件循环,是浏览器处理异步的一种调度策略。

概念二:渲染主线程会反复轮询(for循环),不断拿取消息队列的任务来执行;其他线程获取到任务后,在合适的时机将其追加在消息队列末尾。

概念三:任务队列分为 延时队列、交互队列、微任务队列;不同任务队列有不同的优先级。

Q&A

延时队列是怎么计时的?时间精确吗?

延时队列调用的是操作系统的功能,而计算机硬件并没有类似原子钟的设备(使用的是CPU寄存器计时),没有办法做到百分百精准。

按照 W3C 标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最小间隔时间,这又进一步加大了延时的误差。而且计时器回调也只会在渲染主线程空闲时执行,如果延时任务之前有一个很长的 js 同步阻塞,也会造成定时不准确。

Chrome 关于最小延时的源码:


练一练

使用消息队列的理念,口算一下输出是什么吧!

js 复制代码
function test() {
  console.log(1);
  Promise.resolve().then(function() {
    console.log(2);  
  });
}

setTimeout(function() {
  console.log(3);
  Promise.resolve().then(test);
});

Promise.resolve().then(function() {
  console.log(4);
  setTimeout(() => {
    console.log(5);
  });
});

console.log(6);
相关推荐
10年前端老司机1 小时前
什么!纯前端也能识别图片中的文案、还支持100多个国家的语言
前端·javascript·vue.js
摸鱼仙人~1 小时前
React 性能优化实战指南:从理论到实践的完整攻略
前端·react.js·性能优化
程序员阿超的博客2 小时前
React动态渲染:如何用map循环渲染一个列表(List)
前端·react.js·前端框架
magic 2452 小时前
模拟 AJAX 提交 form 表单及请求头设置详解
前端·javascript·ajax
小小小小宇6 小时前
前端 Service Worker
前端
只喜欢赚钱的棉花没有糖7 小时前
http的缓存问题
前端·javascript·http
小小小小宇7 小时前
请求竞态问题统一封装
前端
loriloy7 小时前
前端资源帖
前端
源码超级联盟7 小时前
display的block和inline-block有什么区别
前端
GISer_Jing7 小时前
前端构建工具(Webpack\Vite\esbuild\Rspack)拆包能力深度解析
前端·webpack·node.js