Chrome架构3 - 加载渲染

本系列文章从内部剖析了 Chrome 浏览器的进程、线程架构设计,各部原理等基础知识,梳理了浏览器是怎样将你的代码变成网站的,解开某个特定技术为什么可以提高性能的疑问。本文是这个系列的第三篇,讲述了 Chome 浏览器渲染器进程是如何处理页面文档、css、js等资源的。

本系列文章共 4 篇,原文发布在 Google 开发者博客,作者 Mariko Kosaka 。文章中涉及非中文局域网友好链接请读者自行解决。

本文是 Chrome 架构系列的第三篇。前面两篇讲述了 CPU,GPU,内存和多进程架构从在地址栏键入到文档开始加载前的导航过程。在这篇文章中,我们将了解文档的加载解析过程,也就是渲染器进程,内部发生了什么。

渲染器进程(Renderer)

加载、渲染流程涉及 Web 性能的许多方面。渲染器进程中发生了太多运算逻辑,因此本文仅作为一个概述。如果您想深入挖掘, 请参阅 the Performance section of Web Fundamentals

Web文档内容都是由渲染器进程处理的

渲染器进程负责选项卡内发生的所有事情。在渲染器进程中,主线程负责处理您编写的大部分代码。但如果您使用了 web worker 或 service worker,这些 JavaScript 将由 worker 线程处理。合成器线程(compositor)栅格线程(raster) 也运行在渲染器进程内以保证高效,流畅地渲染页面。

渲染器进程的核心工作是将 HTML,CSS 和 JavaScript 转换为可以与用户交互的网页应用。

具有主线程,worker线程,合成器线程和栅格线程的渲染器进程具有主线程,worker线程,合成器线程和栅格线程的渲染器进程

解析

构建DOM

当渲染进程接收到浏览器进程提交导航的消息后,便开始接收 HTML 数据,渲染器进程的主线程开始将 HTML 解析为 DOM(Document Object Model)。

DOM 是页面在浏览器内部的表示方法,暴露 DOM 数据结构和 API 使得 Web 开发人员可以通过 JavaScript 操作页面结构与逻辑。

将 HTML 文档解析为 DOM 遵循 HTML 标准。您可能已经注意到,解析 HTML 从来不会引发错误。例如,缺少 </p> 标签是非法的但仍然正常运行。像 Hi! <b>I'm <i>Chrome</b>!</i>(b标签在i标签之前闭合)这样的错误将被视为 Hi! <b>I'm <i>Chrome</i></b><i>!</i>。这是因为 HTML 规范已经提供了处理这些错误的方案。如果您对如何完成这些工作感到好奇,可以阅读 HTML 规范中的 解析错误处理和异常情况介绍 部分。

子资源加载

网站通常使用图片,CSS 和 JavaScript 等外部资源。这些文件需要从网络或缓存加载。主线程可以在解析构建 DOM 时逐个请求它们,但为了加快速度,“预加载扫描器”同时并行运行。如果一旦发现 HTML 文档中存在诸如 <img><link> 资源,预加载解析器会生成网络请求发送给浏览器进程的网络线程去执行。

主线程解析HTML并构建DOM树主线程解析HTML并构建DOM树

JavaScript 可以阻塞解析

当 HTML 解析器遇到 <script> 标签时,会暂停解析 HTML文档,并且加载,解析和执行 JavaScript 代码。为什么?因为 JavaScript 可以使用 document.write() 操作改变整个 DOM 结构(HTML规范中的解析模型概述 有非常直观的展示)。这就是 HTML 解析器在重新解析 HTML 文档之前必须等待 JavaScript 运行完的原因。如果您对 JavaScript 执行中发生的事情感到好奇,V8 团队就此做了详细介绍

告诉浏览器如何加载资源

Web 开发人员可以通过多种方式告诉浏览器如何加载资源。如果您的 JavaScript 不使用 document.write(),您可以添加 asyncdefer 属性到 <script> 标签。然后,浏览器将异步加载和运行 JavaScript 代码,不阻塞解析。如果合适,您也可以使用 JavaScript 模块。使用 <link rel="preload"> 将通知浏览器当前页面非常需要这个资源,并且希望尽快下载。您可以在 资源优先级 介绍中了解更多信息。

样式

只有 DOM 不足以绘制页面,我们还需要 CSS 设置页面元素的样式,同样,主线程解析了 CSS 并确定每个 DOM 节点的样式。这是基于 CSS 选择器将解析出来的样式应用于对应节点。您可以在 DevTools 中 computed 栏中看到此信息。

主线程解析CSS以添加样式主线程解析CSS以添加样式

即使您不提供任何 CSS,每个 DOM 节点也都具有样式。比如 <h1> 字号显示大于 <h2> ,并且他们都定义了边距,这是因为浏览器具有默认 CSS 样式表。如果您想知道 Chrome 的默认 CSS 是什么样的, 您可以在 此处查看源代码

布局

现在,渲染器进程知道了每个节点的样式和结构,但还不足以绘制页面。想象一下,你正通过电话向朋友描述一幅画 “有一个大的红色圆圈和一个小的蓝色方块”,并不足以让你的朋友了解这幅画的外观。

一个人站在一幅画前通过电话线向另外一个人描述这幅画一个人站在一幅画前通过电话线向另外一个人描述这幅画

布局 是查找元素几何位置的过程。主线程遍历 DOM 并计算样式,创建布局树,其中包含 x、y 坐标和边框大小等信息。布局树可以是与 DOM 树类似的结构,但它仅包含与页面上可见内容相关的信息。如果设置 display: none,则该元素不是布局树的一部分(但是布局树中包含 visibility: hidden 元素)。类似地,如果 p::before{content:"Hi!"} 样式具有类似内容的伪类,则它也包含在布局树中,即使它不在 DOM 中。

主线程计算样式、生成布局树、DOM树主线程计算样式、生成布局树、DOM树

确定页面布局是一项具有挑战性的任务。即使是最简单从上到下块流的页面布局,也必须考虑字体的大小以及在哪里换行、分割,因为它们会影响段落的大小和形状; 然后影响下一段所在的位置。



CSS 可以使元素浮动到一侧,设置内容溢出展示策略,并更改输入方向等。可以想象这个布局阶段是一项艰巨的任务。在 Chrome 中,有整个工程师团队专门负责布局。如果你想看到他们工作的细节, 这里有 少量 BlinkOn 会议记录

绘制

拥有DOM,样式和布局仍然不足以绘制页面。假设您正在尝试绘制一幅画。您知道元素的大小,形状和位置,但您仍需要判断绘制它们的顺序。

一个拿着画笔在画布前的人,想知道他是应该先画圆圈还是先画方块一个拿着画笔在画布前的人,想知道他是应该先画圆圈还是先画方块

例如,某些元素可能设置 z-index ,在这种情况下,按 HTML 中的元素顺序绘制将导致不正确的图层顺序。

页面元素按HTML的顺序绘制致错误,因为没有考虑z-index页面元素按HTML的顺序绘制致错误,因为没有考虑z-index

在此绘制步骤中,主线程遍历布局树以创建绘制记录。绘制记录是一个绘画过程的注释,如“背景优先,然后是文本,然后是矩形”。如果您在 <canvas> 中使用JavaScript 绘制元素,那么您可能对此过程很熟悉。

主线程遍历布局树并生成绘制记录主线程遍历布局树并生成绘制记录

更新修改渲染过程的成本很高



渲染过程中最重要的是在每个步骤中,前一个操作的结果用于创建新数据。例如,如果布局树中的某些内容发生更改,则需要为文档受影响的部分重新生成绘制逻辑。

如果要为元素设置动画,则浏览器必须在每个帧之间运行这些操作。我们的大多数显示器每秒刷新60次(60 fps),对人眼来说会很平滑。但是,如果动画丢失某些中间帧,则页面将显得比较卡顿。

时间轴上的动画帧时间轴上的动画帧

即使渲染速度跟上屏幕刷新频率,但这些计算在主线程上运行,这意味着当您的应用程序运行 JavaScript 时也可能会阻塞动画。

时间轴上的动画帧,但JavaScript发生阻塞时间轴上的动画帧,但JavaScript发生阻塞

您可以将 JavaScript 操作划分为小块,并安排在每帧渲染上运行 requestAnimationFrame()。有关此的更多信息,请参阅 优化JavaScript执行。您也可以 在 Web Workers 中运行JavaScript 以避免阻塞主线程。

在动画帧的时间轴上运行的较小的JavaScript块在动画帧的时间轴上运行的较小的JavaScript块

合成

你会如何绘制一个页面?

现在浏览器知道了文档的结构,每个元素的样式,页面的几何形状位置和绘制顺序,那将如何绘制页面呢?将这些原始信息转化为屏幕上像素的过程称为光栅化(raster)。



也许处理这种情况的一种原始的方法是在视窗内部使用栅格部件。如果用户滚动页面,则移动光栅位置,并通过更多光栅填充缺少的部分。这就是 Chrome 首次发布时处理栅格化的方式。但是,现代浏览器运行了一个称为 合成器(compositing) 的更复杂的计算逻辑。

什么是合成

合成是一种将页面的各个部分分层,分别栅格化,并在一个名为合成器线程的单独线程中合成为页面的技术。如果发生滚动,由于图层已经光栅化,因此它所要做的就是合成一个新帧。通过移动图层和合成新帧可以实现动画。



您可以在 DevTools 中使用“图层”面板查看您的网站是如何 划分为多个图层 的。

图层划分

为了找出哪些元素需要在哪些层中,主线程遍历布局树以创建层树(此部分在DevTools性能面板中称为“更新层树”)。如果页面的某些部分应该是单独的图层(如滑入式侧边菜单)但是没有绘制单独图层,那么您可以使用 CSS will-change 属性提示浏览器。

遍历布局树的主线程生成图层树遍历布局树的主线程生成图层树

您可能想要为每个元素提供图层,但是对于过多的图层进行合成可能会导致比每帧光栅化页面的小部分更慢的操作。有关此主题的更多信息,请参阅 Stick to Compositor-Only Properties and Manage Layer Count

光栅、合成器与主线程

一旦创建了图层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。合成器线程然后栅格化每个图层。一个图层可能像页面的整个长度一样大,因此合成器线程将它们分成图块并将每个图块发送到光栅线程。栅格线程栅格化每个块并将它们发送到GPU响应存储中。

栅格线程创建块位图并发送到GPU栅格线程创建块位图并发送到GPU

合成器线程可以考虑不同的光栅线程,以便当前视窗(或附近)内的事物可以先被光栅化。图层还具有多个不同分辨率的图块,可以处理放大操作等功能。

一旦图块被光栅化,合成器线程会收集图块信息称为 draw quads 并创建合成器帧。

draw quads|包含诸如图块在内存中的位置以及页面合成的情况下绘制图块的页面中的位置等信息。
合成器帧|表示页面一帧的绘制所需四边形的集合

然后通过IPC将合成器帧提交给浏览器进程。此时,可以从 UI 线程添加另一个合成器帧以用于更新 UI,或者从其他渲染器进程添加扩展。这些合成器帧被发送到 GPU 以在屏幕上显示。如果滚动事件触发,合成器线程会创建另一个合成器帧以发送到 GPU。

合成器线程创建合成帧,发送到浏览器进程然后发送到GPU合成器线程创建合成帧,发送到浏览器进程然后发送到GPU

合成的好处是它可以在不干涉主线程的情况下完成。合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么 合成动画 被认为是性能友好的最佳选择,因为它可以完全在合成器线程内完成。如果需要再次计算布局或绘图,则必须涉及主线程。