浏览器原理-深入理解事件循环

       

目录

1. 浏览器进程模型

1.1 进程

1.1 线程

1.3 浏览器中的进程和线程

1.4 渲染主线程与事件循环

2.异步任务简述

2.1 何为异步?

2.2 JS为何会阻碍渲染? 

2.3 任务有优先级吗?

2.4 优先级输出练习


事件循环是运行在浏览器渲染主线程中的一种机制,该主线程是浏览器众多线程中的一个核心组成部分,负责处理JavaScript代码的执行、DOM的更新、样式的计算以及页面的渲染等任务,要深入掌握事件循环,有必要探究浏览器进程的模型,特别是渲染进程内部的结构,包括其主线程如何协同其他线程(如网络线程、解析线程等)共同处理网页的加载、解析、渲染以及响应各种用户事件。

1. 浏览器进程模型

1.1 进程

        程序运行需要有它自己专属的内存空间,可以把这块内存空间简单的理解为进程 每个应用至少有一个进程,进程之间相互独立,即使要通信,也需要双方同意

        想象一下,每个电脑上的应用程序就像是一个独立的小房子(我们可以称之为“进程”)。每个小房子都有它自己的院子(也就是专属的内存空间),这样每个应用程序就可以在自己的小天地里自由地玩耍,不会影响到其他应用程序的小房子。

        当我们打开一个应用,比如浏览器,它就会在自己的小房子里开始运作。浏览器这个应用比较特别,它可能会因为需要打开多个网页而分出更多的小房间(这些就是浏览器内部的多个进程,但每个进程仍然有它自己的内存空间)。

        这些进程之间就像是邻居,虽然它们住在同一个社区(也就是电脑里),但它们是各自为政的。如果它们想要交流(比如传递数据),那就需要双方打开一扇窗(也就是进行某种形式的通信),并且都同意这种交流方式才行。

        所以,简单来说,进程就是应用程序为了运行而分配的一个独立空间,每个进程都是独立的,它们之间的通信需要双方同意才能进行。

1.1 线程

        有了进程后,就可以运行程序的代码了。运行代码的东西称之为「线程」。

        一个进程至少有一个线程,所以在进程开启后会自动创建一个线程来运行代码,该线程称之为主线程。

        如果程序需要同时执行多块代码,主线程就会启动更多的线程来执行代码,所以一个进程中可以包含多个线程。

        想象一下,进程就像是你的厨房(一个工作空间),而线程则是厨房里忙碌的厨师或助手(执行任务的实体)。

        当你开始准备一顿饭(启动一个进程)时,至少会有一个厨师(主线程)在厨房里忙碌,负责按照食谱(程序代码)一步步完成烹饪。这个主厨师(主线程)是厨房里最早开始工作的,也是最重要的一个。

        但是,有时候一顿饭的准备过程很复杂,需要同时处理多个任务,比如切菜、炒菜、煮饭等。这时候,主厨师(主线程)可能会觉得忙不过来,于是他就会请一些帮手(其他线程)来一起帮忙。这些帮手(其他线程)和主厨师一样,都在厨房里(同一个进程内)工作,但各自负责不同的任务。

        所以,线程就是进程中的“工人”,它们负责执行程序代码中的具体任务。一个进程至少有一个线程(主线程),但根据需要,可以创建更多的线程来同时执行多个任务,提高程序的效率。这就像厨房里可以有一个主厨和多个助手一起工作,让整顿饭的准备过程更加快速和有序。

1.3 浏览器中的进程和线程

        浏览器内部工作极其复杂。

        为了避免相互影响,为了减少连环崩溃的几率,当启动浏览器后,它会自动启动多个进程。

 可以在浏览器的任务管理器中查看当前的所有进程。

其中,最主要的进程有:

1. 浏览器进程

   主要负责界面显示、用户交互、子进程管理等。浏览器进程内部会启动多个线程处理不同的任务。

2. 网络进程

 负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。

3. 渲染进程(重点)

渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。 默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签页之间不相互影响。

将来该默认模式可能会有所改变,可参见官方文档

1.4 渲染主线程与事件循环

渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画 60 次
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数
  • ......

那么问题来了,渲染主线程需要处理如此多的任务,为什么不多开线程去分别处理不同的任务?

答案:不是不想,而是不能。主要有以下几种原因:

  1. 线程安全性:DOM(文档对象模型)和CSSOM(CSS对象模型)是浏览器渲染页面的基础,它们的状态需要在整个渲染过程中保持一致。由于DOM和CSSOM的修改可能会引发复杂的重排(reflow)和重绘(repaint)操作,这些操作需要精确控制且对上下文敏感,因此它们必须在一个线程中顺序执行以保证线程安全。如果允许多个线程同时修改DOM或CSSOM,将会导致数据竞争和状态不一致的问题。

  2. 布局和绘制的复杂性:网页的布局和绘制过程依赖于多个因素,包括元素的尺寸、位置、样式以及它们之间的相互关系。这些计算需要在一个有序的、可控的环境中执行,以确保最终页面的布局和渲染效果是正确的。如果允许多个线程同时进行布局和绘制,将会导致渲染结果的不一致性和难以预测的行为。

  3. 全局JavaScript执行的单线程模型:JavaScript是网页开发中最重要的语言之一,它允许开发者直接操作DOM和CSSOM。由于JavaScript代码可能会在任何时候修改DOM或CSSOM,因此JavaScript的执行必须在一个单一的线程中同步进行。这是为了避免JavaScript代码在修改DOM或CSSOM时与其他线程的操作发生冲突。如果允许多个线程同时执行JavaScript代码,将会导致DOM和CSSOM的状态变得不可预测,进而可能引发运行时错误。

  4. 性能优化和简化复杂性:虽然多线程可以提高某些任务的并行处理能力,但在渲染进程中,多线程的引入可能会带来额外的复杂性和性能开销。例如,线程之间的同步和通信需要额外的资源和时间来管理,这可能会降低整体的渲染性能。此外,多线程还可能引入死锁、竞态条件等并发问题,这些问题在单线程模型中则相对容易避免。

渲染主线程需要处理如此多的任务,应该如何调度?

  • ⽐如: 我正在执⾏⼀个 JS 函数,执⾏到⼀半的时候⽤户点击了按钮,我该⽴即 去执⾏点击事件的处理函数吗?
  • 我正在执⾏⼀个 JS 函数,执⾏到⼀半的时候某个计时器到达了时间,我 该⽴即去执⾏它的回调吗?
  • 浏览器进程通知我“⽤户点击了按钮”,与此同时,某个计时器也到达了时 间,我应该处理哪⼀个呢?
  • .....

渲染主线程想出了⼀个绝妙的主意来处理这个问题:排队

​​事件循环运行机制图
简略队列类型图

如图:

1. 在最开始的时候,渲染主线程会进⼊⼀个⽆限循环,消息队列的类型有很多,包括但不限于微队列(Promise任务、MutationObserve)、交互队列(点击事件、鼠标移动事件等等)、延时队列(setTimeout、setInterval),不同的任务可以压入不同的队列,但是相同的任务必须处于同一个队列。

2. 每⼀次循环会检查消息队列中是否有任务存在。如果有,就取出第⼀个任务 执⾏,执⾏完⼀个后进⼊下⼀次循环;如果没有,则进⼊休眠状态,取出任务遵循微队列交互队列延时队列的优先级。

3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务(例如延时线程监听到延时时间到达后,会将setTimeout回调函数包装成任务并压入延时队列中)。新任务会加到消息队列的末尾。在添加新任务时,如果主线程是休眠状态,则会 将其唤醒以继续循环拿取任务。

这样⼀来,就可以让每个任务有条不紊的、持续的进⾏下去了。

整个过程,被称之为事件循环(消息循环)。

2.异步任务简述

2.1 何为异步?

代码在执⾏过程中,会遇到⼀些⽆法⽴即处理的任务。⽐如:

  • 计时完成后需要执⾏的任务 —— setTimeout 、 setInterval
  • ⽹络通信完成后需要执⾏的任务 ——XHR 、 Fetch
  • ⽤户操作后需要执⾏的任务 —— addEventListener

如果让渲染主线程等待这些任务的时机达到,就会导致主线程⻓期处于 「阻塞」的状态,从⽽导致浏览器「卡死」

同步任务运行实例图(假设js没有异步任务)

渲染主线程承担着极其重要的⼯作,⽆论如何都不能阻塞!

任务运行实例图

因此,浏览器选择异步来解决这个问题 使⽤异步的⽅式,渲染主线程永不阻塞

虽然JS是单线程,但是浏览器主线程下具有诸多子线程可以帮忙监听异步或需要时机到达的【任务】是否达到执行任务的条件,这些子线程本身不运行任务,只是监听,一旦条件到达,则压入对应的消息队列中,等待渲染主进程取出任务后执行。

⾯试题:如何理解 JS 的异步?

参考答案: JS是⼀⻔单线程的语⾔,这是因为它运⾏在浏览器的渲染主线程中,⽽渲染 主线程只有⼀个。 ⽽渲染主线程承担着诸多的⼯作,渲染⻚⾯、执⾏ JS 都在其中运⾏。 如果使⽤同步的⽅式,就极有可能导致主线程产⽣阻塞,从⽽导致消息队列 中的很多其他任务⽆法得到执⾏。这样⼀来,⼀⽅⾯会导致繁忙的主线程⽩ ⽩的消耗时间,另⼀⽅⾯导致⻚⾯⽆法及时更新,给⽤户造成卡死现象。 所以浏览器采⽤异步的⽅式来避免。具体做法是当某些任务发⽣时,⽐如计 时器、⽹络、事件监听,主线程将任务交给其他线程去处理,⾃身⽴即结束 任务的执⾏,转⽽执⾏后续代码。当其他线程完成时,将事先传递的回调函 数包装成任务,加⼊到消息队列的末尾排队,等待主线程调度执⾏。 在这种异步模式下,浏览器永不阻塞,从⽽最⼤限度的保证了单线程的流畅 运⾏。

2.2 JS为何会阻碍渲染? 

先看一段代码

<h1> awesome!</h1>
<button>change</button>
<script>
var h1 = document.querySelector('h1');
var btn = document.querySelector('button');
// 死循环指定的时间
function delay(duration) {
var start = Date.now();
while (Date.now() - start < duration) {}
 }
btn.onclick = function () {
h1.textContent = '很帅!';
delay(3000);
 };
</script>

上面代码主要是点击事件触发时,修改h1标签的显示文本,需要考虑的是点击按钮后,修改后的文本是否会马上显示到页面上?

答案是:不会。

原因在于,渲染主进程在执行全局JS时,遇到点击事件,会将点击事件交予交互线程监听,当用户点击按钮后,交互线程会将事件回调函数包装成任务并压入交互队列中等待渲染主线程取出并执行。而执行该点击事件回调时,又遇到了h1.textContent = '很帅!';这条代码修改了h1的显示信息,触发了重绘,会重新生成绘制任务并压入消息队列中等待执行。而delay(3000);会被立即执行,因此,虽然显示信息在死循环延时执行前就已经被修改,但由于页面变化需要重绘才能看到,所以实际上看到的效果是点击后延时3s才改变。

2.3 任务有优先级吗?

任务没有优先级,在消息队列中先进先出。

但消息队列是有优先级的。

根据 W3C 的最新解释:

每个任务都有⼀个任务类型,同⼀个类型的任务必须在⼀个队列,不同类型 的任务可以分属于不同的队列。 在⼀次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执⾏。 浏览器必须准备好⼀个微队列微队列中的任务优先所有其他任务执⾏。W3C官方解释

随着浏览器的复杂度急剧提升,W3C 不再使⽤宏队列的说法。

在⽬前 chrome 的实现中,⾄少包含了下⾯的队列:

  • 延时队列:⽤于存放计时器到达后的回调任务,优先级「中」
  • 交互队列:⽤于存放⽤户操作后产⽣的事件处理任务
  • 优先级「⾼」 微队列:⽤户存放需要最快执⾏的任务,优先级「最⾼」

添加任务到微队列的主要⽅式主要是使⽤ Promise、MutationObserver 例如:

/ ⽴即把⼀个函数添加到微队列
Promise.resolve().then(函数)

⾯试题:阐述⼀下 JS 的事件循环 // ⽴即把⼀个函数添加到微队列   

参考答案: 事件循环⼜叫做消息循环,是浏览器渲染主线程的⼯作⽅式。 在 Chrome 的源码中,它开启⼀个不会结束的 for 循环,每次循环从消息 队列中取出第⼀个任务执⾏,⽽其他线程只需要在合适的时候将任务加⼊到 队列末尾即可。 过去把消息队列简单分为宏队列和微队列,这种说法⽬前已⽆法满⾜复杂的 浏览器环境,取⽽代之的是⼀种更加灵活多变的处理⽅式。 根据 W3C 官⽅的解释,每个任务有不同的类型,同类型的任务必须在同⼀ 个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级, 在⼀次事件循环中,由浏览器⾃⾏决定取哪⼀个队列的任务。但浏览器必须 有⼀个微队列,微队列的任务⼀定具有最⾼的优先级,必须优先调度执⾏。

⾯试题:JS 中的计时器能做到精确计时吗?为什么?

参考答案: 不⾏,因为: 1. 计算机硬件没有原⼦钟,⽆法做到精确计时 2. 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调⽤的 是操作系统的函数,也就携带了这些偏差 3. 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层, 则会带有 4 毫秒的最少时间,这样在计时时间少于 4 毫秒时⼜带来 了偏差 4. 受事件循环的影响,计时器的回调函数只能在主线程空闲时运⾏,因此 ⼜带来了偏差 

2.4 优先级输出练习

setTimeout(function () {
  console.log(1);
}, 0);

function delay(duration) {
  var start = Date.now();
  while (Date.now() - start < duration) {}
}
delay(3000);
console.log(2);

输出结果:2 (延时3s)1

function a() {
  console.log(1);
  Promise.resolve().then(function () {
    console.log(2);
  });
}
setTimeout(function () {
  console.log(3);
}, 0);

Promise.resolve().then(a);

console.log(5);

输出结果:5 1 2 3 

  • 43
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值