Files
knowledge-kit/Chapter2 - Web FrontEnd/2.31.md
2024-02-23 15:58:55 +08:00

237 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 事件循环
## 浏览器多进程架构
从安全性、高性能等原因出发,目前浏览器已经是多进程架构模式,至于演进历史,本文不再展开,感兴趣的可以查看这篇[“Electron” 一个可圈可点的 PC 多端融合方案]()文章。
现在的架构设计如下:
![最新 Chrome 进程架构图](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/2020-05-07-ChromeMordernArch.png)
一个页面最少包括1个网络进程、1个浏览器进程、1个 GPU 进程、多个渲染进程、多个插件进程
- 浏览器进程:负责界面展示、用户交互(比如滚动条)、子进程管理协作、同时提供存储功能。同时内部会启动多个线程处理不同的任务
- 渲染进程:核心任务是将 HTML、CSS、Javascript 转换为用户可以与之的网页,排版引擎 Blink 和 Javascript 引擎 V8 都是运行在该进程中。默认情况下Chrome 为每个 tab 标签页创建一个新的渲染进程。出于安全考虑,渲染进程都是运行在沙箱机制下的。渲染进程启动后,会开启一个渲染主线程,主线程负责执行 HTML、CSS、JS 代码。
- GPU 进程:最早 Chrome 刚发布的时候是没有 GPU 进程的,而 GPU 的使用初衷是实现 css 3D 效果。随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍需求。最后 Chrome 多进程架构中也引入了 GPU 进程。
- 网络进程:主要负责页面的网络资源请求加载。早期是作为一个模块运行在浏览器进程里面的,最近才独立出来作为一个单独的进程。网络进程内部会启动多个线程来处理不同的网路任务
- 插件进程:主要负责插件的运行。因插件代码由普通开发者书写,所以在 QA 方面可能不是那么完善,代码质量参差不齐,插件容易奔溃,所以需要通过插件进程来隔离,以保证插件进程的奔溃不会对浏览器和页面造成影响。
所以你会发现打开一个页面查看进程发现有4个进程。凡事具有两面性上面说了多进程架构带来浏览器稳定性、安全性、流畅性但是也带来一些问题
- 更高资源占用:每个进程都会包含公共基础结构的副本(如 Javascript 运行环境),这意味着浏览器将会消耗更多的资源
- 更复杂的体系结构:浏览器各模块之间耦合度高、拓展性差,会导致现在的架构很难适应新需求。
## 渲染主线程是如何工作的
渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:
- HTML 解析和构建 DOM 树:渲染主线程会解析 HTML 代码,并构建 DOM文档对象模型树。DOM 树是网页的结构化表示,它描述了网页中的元素、标签和它们之间的关系。
- CSS 解析和构建样式树:渲染主线程会解析 CSS 样式表,并构建样式树。样式树表示了网页中的元素和它们的样式信息,包括颜色、字体、布局等属性。
- 布局Layout渲染主线程会根据 DOM 树和样式树计算元素在页面中的位置和大小确定它们的布局。布局过程也被称为回流reflow或重排relayout
- 绘制Painting渲染主线程会将布局后的元素绘制到屏幕上形成可见的网页内容。这个过程包括生成绘制命令、将命令发送给图形系统并最终在屏幕上绘制出来。
- JavaScript 执行:渲染主线程会执行网页中的 JavaScript 代码处理交互、动画和事件等。JavaScript 的执行可能会引起 DOM 树和样式树的变化,进而触发布局和绘制。
- 处理用户输入:渲染主线程会监听用户的输入事件,如鼠标点击、键盘输入等,并根据用户的操作进行相应的处理。这包括响应用户的点击、滚动、拖拽等操作,以及处理表单提交和页面跳转等事件。
渲染主线程是渲染进程中的核心部分,它负责将网页的结构、样式和交互转化为可视化的网页内容。通过优化渲染主线程的性能和效率,可以提高网页的渲染速度和用户体验。同时,渲染主线程也需要与其他线程(如网络线程、合成线程等)进行协作,以实现流畅的页面渲染和交互效果。
## 主线程如何有条不紊工作
浏览器主线程很繁忙,遇到以下情况该怎么处理:
- 正在执行某个方法,此时用户点击了按钮触发了事件,该立即去执行点击事件的处理函数吗?
- 正在执行某个方法,此时某个计时器到达了时间,该立即去执行它的回调吗?
- Input 输入框内容改变了,触发了 `oninput` 事件 ,此时刚好某个计时器也到达了时间,回调函数和 oninput 事件如何调度?
熟悉其他系统设计的同学可能会立马想到用**队列**来解决问题。浏览器对这个 case 也采用队列,叫做事件队列。
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/JSMainThreadEventLoop.png" style="zoom:30%;">
解释下这个事件队列:
1. 一开始,渲染主线程开启一个事件循环(也可以认为是死循环一直在跑)
2. 每一次循环会检查消息队列中是否有任务存在。如果有,就取出第一个任务执行,执行完一个后进入下一次循环;如果没有,则进入休眠状态
3. 其他所有线程(包括浏览器其他进程中的线程)都可以向这个事件队列中添加任务,新任务会被添加到队列尾部。添加新任务时,如果主线程是休眠的,则会唤醒主线程,继续从事件队列头部读取任务并不断执行
4. 持续这个流程
当然,这个解释是相对宏观宽泛的,具包括哪些队列、不同任务里的任务优先级是怎么样的下面会逐步展开。
1. 执行当前的同步任务JS 引擎每次执行当前的同步任务,将其推入调用栈中执行。同步任务是按照代码顺序执行的,知道遇到异步任务
2. 执行微任务队列在执行同步任务的过程中如果遇到微任务Microtask如 Promise 的回调函数、MutationObserver 的回调函数则被添加到微任务队列Microtask Queue中。当前的同步任务执行完毕后在进入下一轮事件循环之前JS 引擎会按照顺序执行微任务队列中的事件。
3.
1. 每次循环都会检查消息队列中是否存在任务,如果存在,就取出第一个任务执行,执行完一个后进入另一个
## 异步任务
### 何为异步
代码在执行的过程中会遇到一些无法立即处理的逻辑,比如:
- 计时器完成之后需要执行的任务setTimeout、setInterval
- 网络通信完成后需要执行的任务XHR、Fetch
- 用户交互后需要执行的任务addEventListener
如果渲染主线程遇到这些任务时等待的话,就会让主线程处于等待的状态,对于用户而言,就是浏览器的「卡死」。
应对这种情况,浏览器采用异步的设计来解决,如下图
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/JSEventloop.png" style="zoom:40%;">
使用异步的方案,可以使得主线程不等待、不阻塞,高效有序的执行逻辑。
何为异步异步Asynchronous是指一种非阻塞的执行方式允许同时执行多个任务而不会阻塞主线程。异步任务是指那些不会立即执行或不会阻塞代码执行的任务。
以下是常见的异步操作和写法:
1. 回调函数:使用回调函数是一种常见的处理异步操作的方式。在执行异步操作时,可以提供一个回调函数,在操作完成后调用回调函数来处理结果。
2. PromiseES6 引入的一种处理异步操作的机制。它可以用于处理异步操作的成功和失败,并支持链式调用。
3. async/awaitES8 引入的一种更直观处理异步操作的语法。通过在函数前面加上 `async` 关键字,可以在函数内部使用 `await` 关键字来等待异步操作的结果。
4. 定时器函数JavaScript 提供了一些定时器函数,如 `setTimeout``setInterval`,它们可以用来延迟执行代码或定期执行代码
5. XHR 和 Fetch通过使用 AJAX 或 Fetch API可以发送异步请求并获取服务器返回的数据。
6. 交互事件:当事件发生时,注册好的回调函数通过异步来处理事件,而不会阻塞主线程的执行
### 异步的意义
浏览器是一个多进程架构包含诸多进程不同进程核心处理的问题不一样。浏览器进程负责界面展示、用户交互比如滚动条等。JS 是一门单线程语言,因为运行在浏览器渲染主线程中。然而这个浏览器主线程工作特别繁忙,渲染页面、执行 JS、负责用户交互等如果采用同步的方式很有可能会导致主线程阻塞从而导致消息队列 中的很多其他任务无法得到执行。这样一来,一方面会导致繁忙的主线程白 白的消耗时间,另一方面导致⻚面无法及时更新,给用户造成卡死现象。
为了解决同步带来的问题,浏览器采用异步的设计。做法是渲染主线程存在一个事件队列,当某些任务(上述描述的异步任务)发生时,主线程会将该任务派发给其他线程处理(比如定时任务交给计时器线程处理),自身立即结束该任务的执行,转而执行后续逻辑。当派发给其他线程的异步任务执行完毕后,该异步事件的回调函数将被包装成任务,添加到消息队列的尾部,待主线程调度执行。
这种设计使得即使是单线程的 JS运行也不会卡顿渲染主线程有条不紊、高效的执行保证了浏览器的流畅性。
**通过使用异步,可以更好地处理耗时的操作、网络请求、文件读写等任务,提高程序的性能和用户体验。**
## JS 为何会阻塞渲染?
由于 JS 可以操作 DOM如果在修改 DOM 元素属性的同时渲染界面JS 线程与 UI 线程同时运行),那么可能会发生一些不符合预期的结果了,渲染线程前后获得的元素数据不一致。
因此为了防止产生渲染产生不符合预期的结果,**浏览器设置 GUI 渲染线程和 JS 引擎为互斥关系**。当 JS 引擎执行时 GUI 线程会被挂起。GUI 的更新会被保存在一个队列中,等到 JS 引擎线程空闲时立即被执行。
因为互斥所以可以推导出JS 如果执行时间过长就会阻塞页面。
举个例子:
```
<h1>你好</h1>
<button>问好</button>
<script>
let h1 = document.querySelector('h1');
let btn = document.querySelector('button');
let delay = function(duration) {
let startTime = Date.now();
while (Date.now() - startTime < duration) {}
}
btn.onclick = function () {
h1.textContent = '早上好,小刘';
delay(3000);
};
</script>
```
效果就是:点击按钮修改 h1 标签内的文本。但是在点击后停了3秒后才开始执行。那这个现象如何解释呢
T0 时刻:最开始的时候,主线程没有任务任务需要执行。但是主线程告诉交互线程,你需要监听用户的点击事件,点击后需要执行 callback
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/JS-UIClickLag1.png" style="zoom:60%;">
T1时刻当用户点击后交互线程会把 callback 封装成一个任务添加到事件循环队列的尾部。此时主线程依旧没有任务所以主线程事件循环将被唤醒从事件队列的头部取出一个任务去执行。大的一个任务里包含2个子事件修改 DOM 和 延迟3秒。修改 DOM 这句指令执行后浏览器要想看的见需要内部会产生一个绘制任务硬件设备显示图形的画家算法。绘制任务被添加到事件队列尾部后立马执行延迟3秒的事件。
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/JS-UIClickLag2.png" style="zoom:60%;">
T2时刻到达T2时刻后主线程又空了此时从事件队列中读取绘制任务。进而去显示出 DOM 文本修改后的结果但此时前面已经等待了3秒钟所以体感上会有一种卡顿的现象
<img src="https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/JS-UIClickLag3.png" style="zoom:60%;">
## 任务有优先级吗
任务没有优先级,因为在消息队列中,无差别从对头选出任务,给队尾添加任务。
**但消息队列是有优先级的。**怎么理解?因为存在多个队列
根据 W3C 定义:
- 每个任务都有一个任务类型,同一种任务类型的任务归属于同一个队列。不同任务类型的任务被派发到不同的队列中(在一次事件循环中,浏览器可以根据实际情况从不同的任务队列中取出任务并执行)
- 浏览器必须事先准备好1个微任务队列。微队列中的任务优先所有其他任务执行优先级最高
浏览器的复杂度急剧提升W3C 不再使用宏队列的说法。
目前 chromium 中,至少包含以下队列:
- 延时队列:用于存放计时器到达后的回调任务,优先级「中」
- 交互队列:用于存放用户操作后产生的事件处理任务,优先级「高」
- 微队列:用户存放需要最快执行的任务,优先级「最高」
添加任务到微队列的主要方式主要是使用 Promise、MutationObserver。此外新加入的 [`queueMicrotask()`](https://developer.mozilla.org/zh-CN/docs/Web/API/queueMicrotask) 方法增加了一种标准的方式,可以安全的引入微任务。
## JS 事件循环
事件循环又叫消息循环,是指在单线程中处理任务队列的机制。
1. JS 引擎会创建一个全局作用域,并将其添加到调用栈中
2. 如果有同步任务,则直接进入主线程执行该任务
3. 若存在微任务或者用户交互事件、定时器事件等异步任务,则分门别类,添加到对应的任务队列中
4. 当所有的同步任务执行完毕后JS 引擎开始按照队列优先级检查任务队列是否存在未执行的任务
- 如果微任务队列中存在任务则优先从微任务队列对头取出1个任务执行然后再次返回到主线程检查是否有新的同步任务。重复此过程直到微任务队列为空
- 如果微任务队列已经没有任务了,则从交互队列队列中判断,如果用户触发了某个事件,则把交互队列中对应的回调取出来放到主线程执行
- 如果交互队列为空了,而定时器队列不为空,则判断定时器任务有没有到时见,如果已经达到可执行的条件,则把对应的回调事件放到主线程进行执行
- 如果微任务队列和用户交互队列、定时器队列都为空,那么 JS 引擎会抛出一个“There are no more tasks to run.“ 的错误信息,表示事件循环结束
## JS 计时器准吗?
不准。存在以下几个原因:
- 计算机硬件没有原子钟,无法做到精确计时。依靠网络来和拥有原子钟的服务器进行同步
- 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,也就携带了这些偏差
- 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最少时间
- 受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差
## 计算机的时间
没有时间,我们的生活将无序,无法度量。计算机更是如此,比如纳秒级的运算,那么计算机到底是如何感知时间的呢?
计算机依靠**晶振**(全称是石英晶体振荡器),是一种高精度、高稳定度的振荡器。位置一般在主板上,晶振为系统提供很稳定的脉冲信号,一秒内产生的脉冲次数也被称为系统时钟频率。一个标准的脉冲信号如下:
![](https://github.com/FantasticLBP/knowledge-kit/raw/master/assets/time-pulse.png)
几个概念:
- 时钟周期:这里的时钟就是晶振,也就是晶振周期/振荡周期。是计算机的最小时间单元,其他周期只能是它的倍数
- 机器周期性:既生瑜何生亮?时钟周期太小了,小到表述很多东西不够方便。到了 CPU 操作层面来说时钟周期不好用类似人民币虽然早期有1分钱但某个商品价格比较大需要1238902分咋感觉这个单位不那么好用了所以设计了100百元这个度量单位。一个机器周期等于多个时钟周期它是 CPU 的一个基本操作时间单元,比如:取指、译码、存储器读/写、运算等等操作
- 指令周期:是 CPU 完成一条指令的时间,又称为提取-执行周期fetch-and-execute cycle是指 CPU 要执行一条指令经过的步骤,由若干机器周期组成。不同的机器分解指令周期的方式不同。
- 总线周期:它是 CPU 操作指令设备的时间,由于存储器和 I/O 端口是挂接在总线上的对它们的访问是通过总线实现的。通常将进行一次访问所需时间称为一个总线周期。这种访问速度较慢因硬件设备发展的原因各个设备的工作频率无法同步周期不一致甚至相差好几个数量级CPU 很快,硬盘很慢,大家又需要协同工作,所以出现了**分频**的概念。将高频信号变成低频信号。