浏览器技术架构的演进过程
单进程浏览器:
单进程浏览器是一种浏览器设计模型,其中所有的标签页、插件和浏览器本身运行在同一个进程中。
这种浏览器架构很轻,内存和CPU占用相对来说都小,也十分简单,不需要处理多个进程之间的通信和管理,因为只有一个进程,启动也十分快速。
但是随着Web技术的不断发展,现代浏览器需要更强大的架构来支持复杂的Web应用程序和多媒体内容。多进程浏览器更适合应对这些挑战。
缺陷:
1. 不稳定
单进程浏览器的稳定性较差,因为一个标签页或插件的崩溃可能导致整个浏览器崩溃。这会给用户带来不便,尤其是当用户有多个标签页打开时,一个问题可能导致丢失所有的工作。
2. 不流畅
单进程浏览器可能会在处理多个资源密集型标签页时表现出性能问题,因为它们在同一个进程中共享资源。
3. 不安全
安全性方面也存在问题,因为恶意代码可以更容易地访问浏览器的内部数据和进程。当你在页面运行一个页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,从而增加了潜在的安全风险。
多进程浏览器(主流)
2008 年 Chrome 发布时的进程架构
Chrome 的页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,而进程之间是通过 IPC 机制进行通信(如图中虚线部分)。
关于缺陷优化
1. 不稳定问题
进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面。这使得用户可以继续在其他标签页中工作,而不会丢失所有的会话数据。
2. 不流畅问题
多进程浏览器可以更好地利用多核处理器。每个进程都可以独立运行,这意味着在多核系统上,浏览器可以并行处理多个标签页和任务,提供更好的性能和响应性。一个页面对应一个渲染进程,关闭一个页面时,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这样就轻松解决了浏览器页面的内存泄漏问题。
3. 安全性问题
多进程浏览器提高了安全性。每个标签页或插件运行在独立的进程中,把插件进程和渲染进程锁在沙箱里面,这种隔离防止恶意代码跨越进程边界访问浏览器的核心数据和功能。这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。这有助于防止恶意软件的传播和攻击。
浏览器多进程架构
主进程(Browser ):
-
浏览器的多进程架构的核心是主要进程,它是浏览器的控制中心,负责管理其他所有进程。这个进程通常被称为浏览器引擎或浏览器核心。
-
主要进程负责处理用户界面,例如地址栏、书签、前进/后退按钮等。它还管理浏览器窗口和标签页的创建、关闭和切换。
-
主要进程还与浏览器的其他关键组件进行通信,例如网络请求、插件管理、文件下载和安全性检查。
渲染进程:
-
每个标签页通常都在独立的渲染进程中运行。这些渲染进程负责加载和渲染网页内容。
-
渲染进程的分离使得网页在一个标签页崩溃时不会影响其他标签页,从而提高了浏览器的稳定性。
-
为了提高安全性,渲染进程被限制在一个称为沙盒(sandbox)的环境中,阻止恶意网页对用户系统的访问。
插件进程:
-
浏览器通常使用单独的插件进程来管理插件,如Adobe Flash、广告拦截器和PDF阅读器。这使得插件的故障或崩溃不会导致整个浏览器崩溃。
-
插件进程与渲染进程和主要进程分离,以提高浏览器的安全性和稳定性。
GPU进程:
-
现代浏览器还通常包括一个GPU(图形处理单元)进程,负责处理与图形相关的任务,如渲染3D效果、加速视频播放等。
-
GPU进程的独立性可以防止图形任务对浏览器的主要进程和渲染进程造成干扰。
备注:Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了GPU 进程。
NetWork进程:
-
为了提高性能,浏览器通常还有一个或多个网络进程,负责处理网络请求和数据下载。
-
这些网络进程与渲染进程和主要进程分离,以确保网络操作不会阻塞用户界面的响应。
多进程架构的工作原理如下:
-
当用户打开一个新的标签页或窗口时,浏览器会为该标签页创建一个新的渲染进程。
-
渲染进程独立运行,加载和渲染网页内容,与其他进程隔离。
-
主进程负责协调不同进程之间的通信,包括用户输入、导航命令等。
-
插件进程、GPU进程等也运行独立,与渲染进程和主进程隔离。
多进程浏览器架构的优势:
-
隔离性:提高了浏览器的稳定性,因为一个标签页或插件的崩溃不会影响其他部分。
-
安全性:提高了安全性,因为各个进程相互隔离,减少了潜在的恶意代码攻击的机会。
-
性能:提高了性能,因为多个进程可以并行处理多个任务,加快了网页加载速度和响应时间。支持多核CPU,充分利用现代计算机的硬件资源。
-
插件兼容性: 插件运行在独立进程中,不容易引发冲突。
其它:
-
不同浏览器可能采用不同的多进程架构,但通常都包括上述核心组件,以实现更好的性能、安全性和用户体验。
-
浏览器的渲染进程划分,并不是简单的一个页面一个渲染进程,Chromium的处理模型(process-models)决定,而Chrome的默认处理模式就是Process-per-site-instance。
Process-per-site-instance : 默认模式,当你打开一个 tab 访问news.baidu.com/,然后再打开一个 tab 访问 tieba.baidu.com/index.html,... tab 会使用两个进程。如果 tieba.baidu.com/index.html 是通过news.baidu.com/ 页面的 JavaScript 代码打开的,这两个 tab 会使用同一个进程。大家可以思考一下为什么?
渲染进程
对于我们前端仔来说,只需要关注渲染进程 浏览器的渲染进程通常包括以下几个重要线程:
主线程(Main Thread)
主线程是渲染进程的核心线程,负责处理用户输入、布局计算、DOM(文档对象模型)操作、CSS计算、JavaScript解释执行等任务。它还与浏览器的用户界面交互,以响应用户的操作。
渲染线程(Renderer Thread)
渲染线程主要负责将网页的 HTML、CSS 和 JavaScript 转化为可视化的页面。将页面的可视部分呈现到屏幕上。它执行页面的绘制操作,包括处理HTML元素的渲染、绘制图像和文本等。此线程的主要目标是生成页面的位图,以供显示。
合成线程(Compositor Thread):
合成线程负责处理多个图层之间的合成和复合。现代Web页面通常由多个图层组成,包括页面内容、浮动元素、定位元素等。合成线程将这些图层合成为一个最终的页面图像,以提高性能和效果
事件触发线程(Event Thread)
事件线程用于处理用户输入事件,如鼠标点击、键盘输入等。它将用户输入转发给主线程或 JavaScript 执行线程以进行处理。
JS引擎线程(Event Thread):
负责处理解析和执行javascript脚本程序、只有一个JS引擎线程(单线程)、与GUI渲染线程互斥,不能同时执行,只能一个一个来,如果JS执行过长就会导致阻塞掉帧。防止渲染结果不可预期。
定时器线程(Timer Thread)
定时器线程负责处理 JavaScript 中设置的定时器和延时操作,以便触发相应的回调函数。
异步http请求线程(Network Thread):
浏览器有一个单独的线程用于处理AJAX请求、当请求完成时,若有回调函数,将回调事件放入到事件队列中。
垃圾回收线程(Garbage Collection Thread):
这个线程负责检测和回收不再使用的内存,以确保内存资源被有效管理。
主要是为了引出下一个问题,关于浏览器原理想了解更详细,帮大家找到一篇不错的文章
来到了正题
对于前端需要关注的问题:
因为JS引擎线程(单线程)、与GUI渲染线程互斥。要是有一个傻叉任务长期霸占CPU,后面什么事情都干不了,浏览器会呈现卡死的状态,这样的用户体验就会非常差。
三大方向
其实对于"现代前端框架"来说,解决这种问题目前主要有三个大方向:
1. 任务执行再快(高效)一点
2. 协调任务优先级,高优先级优先使用资源
3. 申请新的资源(线程)
两大主流框架的策略
Vue
vue选择的是1,优化每个任务,让它有多快就多快。挤压CPU运算量。
因为对于Vue来说,使用模板让它有了很多优化的空间,配合响应式机制可以让Vue可以精确地进行节点更新,强烈推荐看看这个Vue.js 作者在 VueConf 2019 上海演讲视频,vue自始至终也在往更快的方向走。
-
虚拟dom的高效diff算法,vue2的双端队列对比;vue3基于vue2的最长递增子序列算法以及diff运算量优化(静态节点标记)。
-
响应式数据的绑定,会监听这些数据的变化。一旦数据发生变化,Vue 会自动更新与这些数据相关联的视图。
-
异步更新、合并批量更新,收集需要更新的 DOM 操作,然后在下一个事件循环周期中进行批量更新。这有助于提高性能,避免频繁的 DOM 操作,以及确保多个数据变化只引发一次 DOM 更新。
-
组件级别颗粒度,当组件的状态变化时,只有与该组件相关的部分会被更新,而不是整个页面
React
React选择的是2,快速响应用户,让用户觉得够快,不能阻塞用户的交互。
在React16之前,React的节点更新机制会递归比对VirtualDOM树,找出需要变动的节点,然后同步更新它们, 一气呵成。这个过程 React 称为 Reconcilation,在 Reconcilation 期间,React 会霸占着浏览器资源,一则会导致用户触发的事件得不到响应, 二则会导致掉帧,用户可以感知到这些卡顿。那如何优化呢,方向就是让高优先级的进程或者短进程优先运行,不能让长进程长期霸占资源,对于前端来说,用户的交互能快速得到响应是最重要的。所以在React16的时候,推出了Fiber架构,自此有了质的飞跃。
React Fiber 架构是 React 的一种重要内部架构改进,旨在提高 React 的性能和交互性。它是 React 16 版本中引入的一项重大变化。React Fiber 架构的目标是允许 React 在渲染和更新过程中更好地处理优先级和中断,以确保更流畅的用户体验。
ES6之前普通函数执行的过程中是无法被中断和恢复,ES6推出的 Generator 可以在函数执行的过程中暂停和恢复执行。
javascript
const tasks = []
function * run() {
let task
while (task = tasks.shift()) {
// 🔴 判断是否有高优先级事件需要处理, 有的话让出控制权
if (hasHighPriorityEvent()) {
yield
}
// 处理完高优先级事件后,恢复函数调用栈,继续执行...
execute(task)
}
}
那么React的Fiber 架构是否也是基于Generator呢?
-
虽然 Generator 函数是一种强大的工具,适用于某些异步编程和迭代任务的场景,但它并不适合 React Fiber 这样的复杂、高性能的任务调度和渲染引擎。
-
Generator 函数不能提供对任务调度的直接控制,而 React Fiber 使用自定义的调度器来实现任务的优先级控制。
-
React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。Generator 函数不提供足够的控制来实现这种中断和恢复机制。
看完以上,大家应该有几个疑问?
浏览器没有抢占的条件, 所以React只能用让出机制?
是的,因为浏览器中任务之间的界限很模糊,没有上下文,所以不具备中断/恢复的条件。二是没有抢占的机制,我们无法中断一个正在执行的程序。所以就只能由浏览器给我们分配执行时间切片,我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。
怎么确定有高优先任务要处理,即什么时候让出?
其实浏览器是没有api可以判断判断当前是否有更高优先级的任务等待被执行,但是呢,浏览器提供了requestIdleCallback 接口。
java
interface IdleDealine {
didTimeout: boolean // 表示任务执行是否超过约定时间
timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}
浏览器在空闲的时候就执行我们的回调,这个回调会传入一个期限,表示浏览器有多少时间供我们执行, 为了不耽误事,我们最好在这个时间范围内执行完毕。
浏览器在一帧内可能会做执行下列任务,而且它们的执行顺序基本是固定的:
-
处理用户输入事件
-
Javascript执行
-
requestAnimation 调用
-
布局 Layout
-
绘制 Paint
理想的一帧时间是 16ms (1s / 60),如果浏览器处理完上述的任务(布局和绘制之后),还有盈余时间,浏览器就会调用 requestIdleCallback 的回调。
但在浏览器繁忙的时候,可能不会有盈余时间,这时候requestIdleCallback回调可能就不会被执行。 为了避免饿死,可以通过requestIdleCallback的第二个参数指定一个超时时间。 目前 requestIdleCallback 在safari是不支持的。所以目前 React 自己实现了一个。利用MessageChannel 模拟将回调延迟到'绘制操作'之后执行。
javascript
const el = document.getElementById('root')
const btn = document.getElementById('btn')
const ch = new MessageChannel()
let pendingCallback
let startTime
let timeout
ch.port2.onmessage = function work() {
// 在绘制之后被执行
if (pendingCallback) {
const now = performance.now()
// 通过now - startTime可以计算出requestAnimationFrame到绘制结束的执行时间
// 通过这些数据来计算剩余时间
// 另外还要处理超时(timeout),避免任务被饿死
// ...
if (hasRemain && noTimeout) {
pendingCallback(deadline)
}
}
}
// ...
function simpleRequestIdleCallback(callback, timeout) {
requestAnimationFrame(function animation() {
// 在绘制之前被执行
// 记录开始时间
startTime = performance.now()
timeout = timeout
dosomething()
// 调度回调到绘制结束后执行
pendingCallback = callback
ch.port1.postMessage('hello')
})
}
为了避免任务被饿死,可以设置一个超时时间.。这个超时时间不是死的,低优先级的可以慢慢等待, 高优先级的任务应该率先被执行。目前 React 预定义了 5 个优先级,
-
Immediate(-1) - 这个优先级的任务会同步执行, 或者说要马上执行且不能中断
-
UserBlocking(250ms) 这些任务一般是用户交互的结果, 需要即时得到反馈
-
Normal (5s) 应对哪些不需要立即感受到的任务,例如网络请求
-
Low (10s) 这些任务可以放后,但是最终应该得到执行. 例如分析通知
-
Idle (没有超时时间) 一些没有必要做的任务 (e.g. 比如隐藏的内容), 可能会被饿死
React基于Fiber,也使用了链表结构 ,并且保存了节点处理的上下文信息 ,即使处理流程被中断了,我们随时可以从上次未处理完的Fiber继续遍历下去。再推荐一篇好文!
Vue****作者尤大对React filber的看法:
如果我们可以把更新做得足够快的话,理论上就不需要时间分片了。
时间分片并没有降低整体的工作量,该做的还是要做 , 因此React 也在考虑利用CPU空闲或者I/O空闲期间做一些预渲染。所以跟尤雨溪说的一样:React Fiber 本质上是为了解决 React 更新低效率的问题,不要期望 Fiber 能给你现有应用带来质的提升, 如果性能问题是自己造成的,自己的锅还是得自己背。主要也是因为filber当初设计成单向链表,没有返回指针。
第三种是一种比较激进的方案
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
那可能有人会说了,那js可以利用Web Worker 创造多线程环境,那开多线程不就行了。Web Worker 由于与主线程分离,有一些限制,例如不能直接访问 DOM,所以要保证状态和视图的一致性相当麻烦
当然也有框架去尝试Worker 多线程渲染方案,但是不了了之。
Echarts有做一些比较激进的带交互的尝试的(worker 里负责生成渲染指令,然后渲染指令传回主线程绘制,因为当时 OffscreenCanvas 还不是很能用),但是后来觉得引出的问题要大于带来的收益,当时主要的考虑是就算在 worker 线程里,渲染时间大于 16ms 所带来的交互延迟的感觉还是一样的,最大的优势是不会阻塞 UI 线程,但是这一块我们主要方向是通过渐进渲染来改善 UI 线程的阻塞问题,所以 worker 的渲染就先搁置了。
那么echarts最终是如何去优化的呢!答案就是:
从这三种方向,我们在日常业务开发中可以考虑怎么去优化,其实就是
a. 算法优化,降低时间复杂度(避免不必要运算、空间换时间)。
例如:百度地图渲染热力图,热力图需要计算相邻的点进行聚合,暴力解法的话可以双for遍历,时间复杂度为O(n2),使用kd-tree算法之后,通过空间分割减少了搜索的空间,时间复杂度直接下降到O(log(n)) - O(n)。KD-Tree的优势在于大数据集上的效率更高,因为它可以以较小的时间复杂度来查找最近邻点,而不会随着数据点数量的增加而线性增加查询时间。
b. 再快也要在合适的时候去做合适的事情,例如使用时间切片做分批渲染以及虚拟列表之类。
时间切片伪代码,在下面的示例中,executeTask 函数将长时间运行的任务拆分成多个小块,每块运行chunkSize 次迭代。然后,使用requestIdleCallback在浏览器的空闲时间段内执行这些小块任务。这样,长时间运行的任务不会阻塞主线程,同时保持了应用的响应性。
ini
// 模拟一个需要长时间运行的计算任务
function longRunningTask(iterations) {
let result = 0;
for (let i = 0; i < iterations; i++) {
result += Math.random();
}
return result;
}
// 使用时间切片执行长时间运行任务
function executeTask(iterations, chunkSize) {
let totalIterations = iterations;
let result = 0;
function performChunk() {
const iterationsToRun = Math.min(totalIterations, chunkSize);
result += longRunningTask(iterationsToRun);
totalIterations -= iterationsToRun;
if (totalIterations > 0) {
// 如果仍有任务需要执行,将任务推迟到下一个空闲时间片段
requestIdleCallback(performChunk);
} else {
// 任务完成
console.log("任务完成,结果为:", result);
}
}
// 开始第一个时间切片
requestIdleCallback(performChunk);
}
// 执行长时间运行任务,分为多个时间切片
executeTask(1000000, 10000);
c. 将一些比较耗时的计算放到worker执行。
在这个示例中,我们创建了一个 Web Worker,其中包含一个用于计算π近似值的函数。主线程与 Web Worker 通信,通过 postMessage 向 Web Worker 发送消息以触发计算,然后在 Web Worker 中进行计算,最后将结果发送回主线程,主线程更新页面上的结果。
通过将计算任务放在 Web Worker 中,可以避免长时间运行的任务阻塞主线程,提高了应用的响应性。这对于执行复杂的计算、数据处理或渲染等任务非常有用。
创建一个名为 worker.js 的 Web Worker 脚本文件,该文件将包含需要在后台线程中执行的代码:
ini
// worker.js
// 定义一个计算函数,这个函数将在后台线程中执行
function calculatePI(iterations) {
let pi = 0;
let denominator = 1;
let isPositive = true;
for (let i = 0; i < iterations; i++) {
if (isPositive) {
pi += 4 / denominator;
} else {
pi -= 4 / denominator;
}
denominator += 2;
isPositive = !isPositive;
}
return pi;
}
// 监听主线程发送的消息
self.addEventListener('message', (event) => {
const { iterations } = event.data;
// 执行计算任务
const result = calculatePI(iterations);
// 将计算结果发送回主线程
self.postMessage(result);
});
创建一个 HTML 文件,其中包含主线程代码,用于与 Web Worker 通信并执行计算任务:
xml
<!DOCTYPE html>
<html>
<head>
<title>Web Worker Demo</title>
</head>
<body>
<h1>Web Worker Demo</h1>
<p>计算π的近似值(使用 Web Worker):</p>
<button id="startButton">开始计算</button>
<p id="result"></p>
<script>
// 创建 Web Worker
const worker = new Worker('worker.js');
// 获取页面元素
const startButton = document.getElementById('startButton');
const resultElement = document.getElementById('result');
// 当 Web Worker 发送消息时,更新结果
worker.addEventListener('message', (event) => {
resultElement.textContent = π 的近似值为: ${event.data};
});
// 当按钮被点击时,向 Web Worker 发送消息以触发计算
startButton.addEventListener('click', () => {
const iterations = 1000000; // 计算的迭代次数
worker.postMessage({ iterations });
});
</script>
</body>
</html>