浏览器是如何渲染页面的
翻译自 google 开发者文档 Inside look at modern web browser (opens new window) 并添加了一些的注解
假设你已经了解了 CPU、GPU、进程和线程相关知识
# 一、chrome 浏览器架构
在 chorme 浏览器中,每个 tab 都是独立的进程,下面将解释为什么选择这样的架构
# Chrome 多进程架构图
渲染器进程下显示了多个图层,以表示 Chrome 为每个选项卡运行多个渲染器进程
下表描述了每个 Chrome 进程及其控制的内容:
进程类别 | 控制内容 |
---|---|
浏览器 | 控制应用程序的“chrome”部分,包括地址栏、书签、前后按钮。还处理 Web 浏览器的不可见的特权部分,如网络请求和文件访问。 |
渲染器 | 控制显示网站的选项卡内的任何内容。 |
插件 | 控制网站使用的任何插件,例如闪存。 |
GPU | 独立于其他进程处理 GPU 任务。它被分离成不同的过程,因为 GPU 处理来自多个应用程序的请求,并将其绘制在同一表面上。 |
# tab 多进程架构特性
优势:
- 在最简单的情况下,你可以想象每个选项卡都有自己的渲染器进程。假设你打开了 3 个选项卡,每个选项卡由独立的渲染器进程运行。如果一个选项卡变得无响应,那么你可以关闭无响应选项卡并继续前进,同时保持其他选项卡的活力。如果所有选项卡都在一个进程上运行,当一个选项卡变得无响应时,所有选项卡都无响应。
- 将浏览器的工作分成多个进程的另一个好处是安全性和沙盒化。由于操作系统提供了一种限制进程特权的方法,浏览器可以从某些功能中沙盒某些进程。例如,Chrome 浏览器限制处理任意用户输入(如渲染器进程)的进程的任意文件访问。
劣势:
由于进程有自己的私有内存空间,它们通常包含公共基础设施的副本(如 Chrome 的 JavaScript 引擎 V8)。这意味着更多的内存使用,因为它们不能像是同一进程中的线程那样共享。为了节省内存,Chrome 限制了它可以旋转的进程数量。限制因设备内存和 CPU 功率而异,但当 Chrome 达到限制时,它开始在一个进程中从同一站点运行多个选项卡。
节省更多内存-服务化 Chrome
浏览器进程也采用了相同的方法。Chrome 正在经历架构更改,以将浏览器程序的每个部分作为一项服务运行,允许轻松拆分为不同的进程或聚合成一个进程。
一般的想法是,当 Chrome 在强大的硬件上运行时,它可能会将每个服务拆分为不同的进程,从而获得更大的稳定性,但如果它是在资源有限的设备上,Chrome 将服务整合到一个进程中,从而节省内存占用空间。在这次更改之前,Android 等平台上也采用了类似的整合流程以减少内存使用的方法。
# 站点隔离 - iframe 独立进程
我们一直在讨论每个选项卡模型的一个渲染器进程,该进程允许跨站点 iframe 在单个渲染器进程中运行,并在不同站点之间共享内存空间。在同一渲染器进程中运行 a.com 和 b.com 可能看起来没问题。Same Origin Policy 是 Web 的核心安全模型;它确保一个网站未经同意不得访问来自其他网站的数据。绕过这项政策是安全攻击的首要目标。流程隔离是分离站点的最有效方式。随着 Meltdown 和 Spectre 的实现,我们越来越明显地需要使用流程分离站点。自Chrome 67以来,默认情况下,桌面上启用了站点隔离,选项卡中的每个跨站点 iframe 都会获得单独的渲染器进程。
启用站点隔离是一项多年的工程工作。网站隔离不像分配不同的渲染器进程那么简单;它从根本上改变了 iframe 之间的交谈方式。在在不同进程上运行 iframe 的页面上打开开发工具意味着开发工具必须实现幕后工作,使其看起来无缝。即使运行一个简单的 Ctrl+F 在页面中找到单词,也意味着在不同渲染器进程中搜索。所以 chrome 浏览器工程师将发布网站隔离作为一个重要里程碑!
# 二、导航
在上一节中,我们研究了不同的进程和线程如何处理浏览器的不同部分。在本节中,我们更深入地研究了每个流程和线程如何通信,以显示网站。
让我们看看一个简单的网页浏览用例:你在浏览器中键入 URL,然后浏览器从互联网获取数据并显示页面。在本节中,我们将重点关注用户请求网站和浏览器准备呈现页面的部分——也称为导航。
# 1.处理输入
当用户开始键入地址栏时,UI 线程首先问的是“这是搜索查询还是 URL?”在 Chrome 中,地址栏也是一个搜索输入字段,因此 UI 线程需要解析并决定是将你发送到搜索引擎还是发送到你请求的网站。
# 2.开始导航
当用户点击回车时,UI 线程会发起网络调用以获取网站内容。加载旋转器显示在选项卡的角落,网络线程会通过适当的协议,如 DNS 查找和为请求建立 TLS 连接。
此时,网络线程可能会收到 HTTP 301 等服务器重定向头。在这种情况下,网络线程与服务器请求重定向的 UI 线程通信。然后,将发起另一个 URL 请求。
# 3.解析 response
一旦响应主体(有效负载)开始进来,如有必要,网络线程将查看流的前几个字节。
响应的内容类型标题应该说明它是什么类型的数据,但由于它可能丢失或错误,MIME 类型 (opens new window)嗅探在这里完成。
你可以阅读源代码 (opens new window)了解不同的浏览器如何处理内容类型/有效负载对。
如果响应是 HTML 文件,那么下一步是将数据传递给渲染器进程,但如果是 zip 文件或其他文件,则这意味着这是一个下载请求,因此他们需要将数据传递给下载管理器。
这也是安全浏览检查发生的地方。如果域和响应数据似乎与已知的恶意网站匹配,则网络线程会发出警报以显示警告页面
# 4.查找渲染器进程
一旦所有检查完成,并且网络线程确信浏览器应该导航到请求的网站,网络线程就会告诉 UI 线程数据已准备就绪。然后,UI 线程找到一个渲染器进程来进行网页的渲染。
由于网络请求可能需要数百毫秒才能恢复响应,因此应用了优化来加快此过程。
当 UI 线程在第 2 步向网络线程发送 URL 请求时,它已经知道他们正在导航到哪个网站。
UI 线程尝试主动查找或启动与网络请求并行的渲染器进程。
这样,如果一切正常,当网络线程接收数据时,渲染器进程已经处于待机位置。
# 5.提交导航
现在数据和渲染器进程已经准备就绪,IPC 将从浏览器进程发送到渲染器进程以提交导航。它还传递数据流,以便渲染器进程可以持续接收 HTML 数据。一旦浏览器进程听到在渲染器进程中发生提交确认,导航就完成,文档加载阶段就开始了。
此时,地址栏将更新,安全指示器和站点设置用户界面将反映新页面的站点信息。该选项卡的会话历史记录将更新,以便后退/前退按钮将跨过刚刚导航到的网站。为了在关闭选项卡或窗口时方便选项卡/会话恢复,会话历史记录存储在磁盘上。
到这里,一次导航就全部结束了
# 导航到其他网站
简单的导航完成了!但是,如果用户再次将不同的 URL 放在地址栏中,会发生什么?好吧,浏览器过程经过相同的步骤来导航到不同的网站。但在这样做之前,它需要与当前呈现的网站核实他们是否关心beforeunload 事件。
beforeunload可以创建“离开这个网站?”当你尝试导航或关闭选项卡时发出警报。标签中的所有内容,包括 JavaScript 代码,都由渲染器进程处理,因此当新的导航请求出现时,浏览器进程必须与当前的渲染器进程进行检查。
注意:不要添加无条件的 beforeunload 处理程序。 它会产生更多的延迟,因为处理程序需要在导航开始之前执行。 仅在需要时才应添加此事件处理程序,例如,如果需要警告用户他们可能会丢失他们在页面上输入的数据。
# 三、渲染过程
渲染器流程涉及 Web 性能的许多方面。由于渲染器进程中发生了很多事情,因此此帖子只是一个概述。如果你想更深入地挖掘,Web 基础知识的性能部分 (opens new window)有更多的资源。
# 1.渲染器进程处理网页内容
渲染器进程负责制表符内发生的一切。在渲染器进程中,主线程处理你发送给用户的大多数代码。
如果你使用 Web 工作者或服务人员,有时 JavaScript 的部分内容将由工作者线程处理。
合成器和光栅线程也在渲染器进程中运行,以高效、流畅地呈现页面。
渲染器进程的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以交互的网页。
# 2.DOM 的构建
当渲染器进程收到用于导航的提交消息并开始接收 HTML 数据时,主线程开始解析文本字符串(HTML)并将其转换为文档对象模型 Document Object Model (DOM)。
DOM 是浏览器对页面的内部表示,以及 Web 开发人员可以通过 JavaScript 交互的数据结构和 API。
# 2.子资源加载
网站通常使用外部资源,如图像、CSS 和 JavaScript。这些文件需要从网络或缓存加载。主线程可以在解析构建 DOM 时找到它们时逐一请求它们,但为了加快速度,“预加载扫描仪”并发运行。如果 HTML 文档中有 img 或 link 之类的东西,预加载扫描仪会窥视 HTML 解析器生成的令牌,并在浏览器进程中向网络线程发送请求。
# 3.JavaScript 可以阻止解析
当 HTML 解析器找到 script 标记时,它会暂停 HTML 文档的解析,并且必须加载、解析和执行 JavaScript 代码。 为什么? 因为 JavaScript 可以使用诸如 document.write() 之类的东西来改变文档的形状,这会改变整个 DOM 结构(HTML 规范中解析模型的概述有一个很好的图表)。 这就是为什么 HTML 解析器必须等待 JavaScript 运行才能恢复对 HTML 文档的解析。 如果你对 JavaScript 执行过程中发生的事情感到好奇,V8 团队有关于此的讨论和博客文章。
# 4.提示浏览器如何加载资源
Web 开发人员可以通过多种方式向浏览器发送提示,以便很好地加载资源。
如果你的 JavaScript 不使用 document.write(),你可以将 async 或 defer 属性添加到 script 标签中。
然后,浏览器异步加载和运行 JavaScript 代码,并且不会阻止解析。如果合适,你也可以使用 JavaScript 模块。
rel="preload"是一种通知浏览器当前导航绝对需要该资源的方法,你希望尽快下载。你可以在 资源优先级 (opens new window) 获取更多信息。
# 5.样式计算
拥有 DOM 不足以知道页面会是什么样子,因为我们可以在 CSS 中为页面元素样式。主线程解析 CSS 并确定每个 DOM 节点的计算样式。这是关于基于 CSS 选择器对每个元素应用哪种样式的信息。你可以在 DevTools 的 computed 部分中看到此信息。
即使你不提供任何 CSS,每个 DOM 节点都有一个计算样式。h1 标签显示大于 h2 标签,并为每个元素定义页边空白。这是因为浏览器有一个默认样式表
# 6.布局
现在渲染器进程知道文档的结构和每个节点的样式,但这不足以渲染页面。想象一下,你正试图通过电话向你的朋友描述一幅画。
“有一个大的红色圆圈和一个小的蓝色方块”不足以让你的朋友知道这幅画到底是什么样子。
布局是查找元素几何形状的过程。
主线程穿过 DOM 和计算样式,并创建布局树,该布局树包含 xy 坐标和边界框大小等信息。
布局树的结构可能与 DOM 树相似,但它只包含与页面上可见内容相关的信息。
如果 display: none 应用,则该元素不是布局树的一部分(然而,布局树中有一个 visibility: hidden 的元素)。
同样,如果一个内容像 p::before{content:"Hi!"}伪类应用,即使布局树不在 DOM 中,它也包含在布局树中。
确定页面布局是一项具有挑战性的任务。即使最简单的页面布局,如从上到下的块流,也必须考虑字体有多大,以及在哪里行打破它们,因为这些会影响段落的大小和形状;这会影响以下段落需要的位置。
# 7.绘制
拥有 DOM、样式和布局仍然不足以渲染页面。假设你试图复制一幅画。你知道元素的大小、形状和位置,但你仍然需要判断你按照什么顺序绘制它们。
例如,可能会为某些元素设置 z-index,在这种情况下,按 HTML 中编写的元素顺序进行绘画将导致渲染不正确。
下图:拿着画笔的画布前的人想知道他们应该先画一个圆圈还是先画一个正方形
下图页面元素按 HTML 标记顺序出现,导致渲染错误的图像,因为没有考虑 z 索引
在这个绘制步骤中,主线程在布局树上行走以创建绘制记录。绘画记录是绘画过程的注释,如“先背景,然后是文本,然后是矩形”。如果你使用 JavaScript 绘制了 canvas 元素,你可能熟悉此过程。
# 8.更新渲染管道成本高昂
在渲染管道时,最重要的是,在每个步骤中,上一个操作的结果都用于创建新数据。例如,如果布局树有变化,则需要为文档中受影响的部分重新生成绘制顺序。
如果你正在动画元素,浏览器必须在每个帧之间运行这些操作。我们的大多数显示器每秒刷新屏幕 60 次(60 fps);
当你在每帧屏幕上移动东西时,动画在人眼中看起来会很流畅。
然而,如果动画错过了介于两者之间的帧,那么页面将显示“卡卡的”。
即使你的渲染操作跟上屏幕刷新,这些计算也在主线程上运行,这意味着当你的应用程序运行 JavaScript 时,它可能会被阻止。
你可以使用 requestAnimationFrame()将 JavaScript 操作划分为小块和计划在每个帧上运行。
requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率,一般来说,这个频率为每秒60帧。
# 9.合成器
如果让你去画一幅画,你会怎么下笔?
现在浏览器知道文档的结构、每个元素的样式、页面的几何形状和绘制顺序,它如何绘制页面?在屏幕上将这些信息转换为像素称为光栅化。
也许处理这个问题的一个天真方法是在视口内光栅部件。如果用户滚动页面,则移动光栅框架,并通过光栅填充缺失的部分。
这就是 Chrome 首次发布时处理光栅的方式。然而,现代浏览器运行了一个更复杂的过程,称为合成。
合成是一种将页面的部分分开分层、单独光栅化并在称为合成器线程的单独线程中复合为页面的技术。
如果发生滚动,由于图层已经光栅化,它所要做的就是复合一个新的框架。通过移动图层和合成新帧,动画也可以以同样的方式实现。
# 分成几层
了找出哪些元素需要位于哪些图层中,主线程穿过布局树以创建图层树(此部分在 DevTools 性能面板中称为“更新图层树”)。
如果页面中应该单独分层的某些部分(如滑入侧菜单)没有得到一个,那么你可以使用 CSS 中的 will-change 属性来提示浏览器。
你可能会想为每个元素提供图层,但与每帧对页面的一小部分进行光栅化相比,跨过多层的层合成可能会导致操作速度更慢,因此衡量应用程序的渲染性能至关重要。
# 主线的光栅和复合材料
创建图层树并确定绘制顺序后,主线程将这些信息提交到合成器线程。
然后,合成器线程对每层进行光栅化。图层可能像页面的整个长度一样大,因此合成器线程将它们划分为瓷砖,并将每个瓷砖发送到光栅线程。
光栅线程对每个瓷砖进行光栅化,并将其存储在 GPU 内存中。
合成器线程可以优先处理不同的光栅线程,以便首先对视口(或附近)内的东西进行光栅化。图层还具有多个瓷砖,用于不同的分辨率,以处理缩放操作等操作。
一旦瓷砖光栅化,合成器线程就会收集称为绘图四轴器的瓷砖信息,以创建一个合成器框架。
然后,通过 IPC 将合成器框架提交到浏览器进程中。此时,可以从 UI 线程添加另一个合成器帧,用于浏览器 UI 更改,也可以从其他渲染器进程中添加扩展。
这些合成器帧被发送到 GPU,以将其显示在屏幕上。如果卷轴事件出现,合成器线程将创建另一个合成器帧,以发送到 GPU。
合成的好处是,它不需要涉及主线程。
Compositor 线程不需要等待样式计算或 JavaScript 执行。
这就是为什么只合成动画被认为是流畅性能的最佳选择。如果布局或绘制需要再次计算,则必须涉及主线程。
# 四、处理事件
前面三节我们查看了渲染过程,并了解了合成器。在本帖中,我们将了解当用户输入进来时,合成器如何实现流畅的交互。
# 从浏览器的角度来看输入事件
当你听到“输入事件”时,你可能只想到在文本框或鼠标单击中键入,但从浏览器的角度来看,输入意味着来自用户的任何手势。
鼠标轮滚动是一个输入事件,触摸或鼠标翻也是一个输入事件。
当用户手势(如触摸屏幕)发生时,浏览器进程是最初收到该手势的过程。
然而,浏览器进程只知道该手势的发生位置,因为选项卡内的内容由渲染器进程处理。
因此,浏览器进程将事件类型(如 touchstart)及其坐标发送到渲染器进程。
Renderer 进程通过查找事件目标和运行附加的事件侦听器来适当处理事件。
# 合成器接收输入事件
在上一节中,我们研究了作曲家如何通过合成光栅层来顺利处理滚动。
如果没有将输入事件侦听器附加到页面,Compositor 线程可以创建一个完全独立于主线程的新复合帧。
但如果页面上附有一些活动听众呢?合成器线程将如何确定是否需要处理事件?
# 了解不快速滚动的区域
由于运行 JavaScript 是主线程的工作,当页面被合成时,合成器线程将页面中附加事件处理程序的片段标记为“非快速可滚动区域”。
通过拥有此信息,合成器线程可以确保如果事件发生在主线程区域,则将输入事件发送到主线程。
如果输入事件来自此区域之外,则合成器线程无需等待主线程即可进行合成新帧。
# 编写事件处理程序时请注意
Web 开发中常见的事件处理模式是事件委托。由于事件冒泡,你可以在最顶部的元素附加一个事件处理程序,并根据事件目标委托任务。你可能看到或编写了如下所示的代码。
document.body.addEventListener('touchstart', (event) => {
if (event.target === area) {
event.preventDefault()
}
})
2
3
4
5
由于你只需要为所有元素编写一个事件处理程序,因此此事件委托模式的人体工程学很有吸引力。
然而,如果你从浏览器的角度查看此代码,现在整个页面将被标记为非快速滚动区域。
这意味着,即使你的应用程序不在乎页面某些部分的输入,合成器线程也必须与主线程通信,并在每次输入事件出现时等待它。因此,合成器的流畅滚动能力被击败了。
如何减轻这种情况的发生?
1.你可以在事件侦听器中传递参数 { passive: true }。 这向浏览器提示你仍想在主线程中收听事件,但合成器也可以继续合成新帧。
document.body.addEventListener(
'touchstart',
(event) => {
if (event.target === area) {
event.preventDefault()
}
},
{ passive: true }
)
2
3
4
5
6
7
8
9
2.我们做事件委托时应尽可能的委托到最近上层元素
在指针事件中使用 passive: true 选项意味着页面滚动可以平滑,但垂直滚动可能在你想要阻止默认值以限制滚动方向时开始。
你可以使用 event.cancelable 方法对此进行检查。
document.body.addEventListener(
'pointermove',
(event) => {
if (event.cancelable) {
event.preventDefault() // block the native scroll
/*
* do what you want the application to do here
*/
}
},
{ passive: true }
)
2
3
4
5
6
7
8
9
10
11
12
或者,你可以使用 touch-action 等 CSS 规则来完全消除事件处理程序。
#area {
touch-action: pan-x;
}
2
3
touch-action 支持 IE10 和大部分现代浏览器,但 safiri 不支持
# 查找事件目标
当合成器线程向主线程发送输入事件时,首先要运行的是命中测试以找到事件目标。命中测试使用渲染过程中生成的绘制记录数据来找出事件发生的点坐标下方的内容。
# 尽量减少事件派单到主线程
在上一节中,我们讨论了我们典型的显示器如何每秒刷新 60 次屏幕,以及我们需要如何跟上节奏,以实现流畅的动画。
对于输入,典型的触摸屏设备每秒提供 60-120 次触摸事件,典型的鼠标每秒提供 100 次事件。输入事件的保真度高于我们的屏幕刷新率。
如果像 touchmove 这样的连续事件每秒发送到主线程 120 次,那么与屏幕刷新的速度相比,它可能会触发过多的命中测试和 JavaScript 执行。
下图:时间线事件导致页面卡顿
为了尽量减少对主线程的过度调用,Chrome 合并了连续事件(如 wheel、mousewheel、mousemove、pointermove、touchmove)并将调度推迟到下一个 requestAnimationFrame 之前
使用 requestAnimationFrame 后,事件被合并和延迟,卡顿情况明显改善
任何离散事件,如 keydown、keyup、mouseup、mousedown、touchstart 和 touchend,都会立即发送。
# 使用 getCoalescedEvents 获取帧事件
对于大多数网络应用程序来说,聚合事件应该足以提供良好的用户体验。
然而,如果你正在构建绘图应用程序和根据 touchmove 坐标放置路径等内容,你可能会失去中间坐标来绘制一条光滑的线条。
下图:左侧平滑的触摸手势路径,右侧聚合的有限路径
在这种情况下,你可以在指针事件中使用getCoalescedEvents 方法来获取有关这些聚合事件的信息。
window.addEventListener('pointermove', (event) => {
const events = event.getCoalescedEvents()
for (let event of events) {
const x = event.pageX
const y = event.pageY
// draw a line using x and y coordinates.
}
})
2
3
4
5
6
7
8
# 使用灯塔
如果你想让你的代码对浏览器友好,但不知道从哪里开始,Lighthouse 是一个工具,可以对任何网站进行审计,并为你提供关于哪些正确和需要改进的报告。通读审计列表还可以让你了解浏览器关心哪些事情。
# 最后
读完这四节的内容让我对览器如何执行代码理解更透彻了,也学习到一些新编码知识, 比如事件代理 passive 参数,以前只是见过,不明白原理、requestAnimationFrame的原理等等。
我觉得学习浏览器知识是必要的, 它可以让我写出更高效对浏览器更友好的代码,进而改善用户体验。