现代浏览器内部机制 Part 3 | 渲染进程的一生
共 6473字,需浏览 13分钟
·
2021-03-17 16:53
原文: Inside Look at Modern Web Browser (part 2)[1]
作者: Mariko Kosaka[2]
译者: kyrieliu
这是本系列的第三篇文章(3/4),将会讲述浏览器到底是怎样工作的。在之前的文章中,我们介绍了现代浏览器的多进程架构和导航工作流,在这篇文章中,我们会对渲染进程内部一探究竟。
渲染进程在很多层面上都和页面性能息息相关。考虑到渲染进程内部体系的有很多可以聊的东西,篇幅原因,这篇文章只会对其进行一个总体上的概览。如果好奇的你想去了解更多,不妨去这里[3]看看。
渲染进程处理 Web 页面的所有内容
一个浏览器窗口之内发生的所有事情,都是被渲染进程所掌握着的。前端工程师们的代码由渲染进程中的主线程处理。如果使用了 web worker 或者 service worker,那其中的代码将会由 worker 线程处理。Compositor 线程和 Raster 线程也运行在渲染进程中,它们的作用是高效平滑地渲染出一个页面。
渲染进程最核心的工作是:将 HTML、CSS 和 JavaScript 代码变成一个可与用户交互的 Web 页面。
解析文档
构建 DOM 树
当渲染进程接收到一条即将去导航的信号并开始接收 HTML 数据时,主线程就开始了自己的工作:解析 HTML 文本并将其转换为文档对象模型(Document Object Model,aka DOM)。
DOM 是浏览器内部对一个页面的抽象(代表),也是开发者可以利用 JavaScript 与之相交互的数据结构和 API。
浏览器按照HTML 标准[4] 去解析 HTML 文档。你或许发现了一件事情,就是即使你随便丢给浏览器一个 HTML 文档都不会报错。比如,没有结束标签的 </p>
仍然是一段有效的 HTML。一段错误的标签比如:Hi! <b>我是<i>Chrome</b>!</i>
(b 标签在 i 标签之前提前关闭了)会被当作 Hi! <b>I'm <i>Chrome</i></b><i>!</i>
去对待。这都是因为 HTML 本身才设计之初就希望优雅的解决这些错误。如果你是个好奇的小朋友,可以去看看 An Introduction to Error Handling and Strange Cases in the Parser[5] 这篇文章的相关章节。
子资源加载
一个网站通常会用到很多外部资源比如图片、CSS 和 JavaScript。这些文件都需要从网络或是缓存中加载。当主线程在解析 HTML 文档的时候发现了这些需要额外加载的资源,主线程会一个一个地去请求,但这样会降低解析 HTML 的效率。因此为了提速,“预加载扫描器”闪亮登场了。如果在文档中存在 img 标签或是 link 标签,预加载扫描器会“窥探”到 HTML 解析器生成的 token,并向浏览器进程中的网络线程发起请求。
JavaScript 阻塞解析
当 HTML 解析器遇到了 script 标签时,它会暂停对 HTML 的解析工作,转而去加载、解析并执行 JavaScript 代码。为什么呢?因为 JavaScript 可能改变文档的结构,比如用了 document.write()
之类的函数。这就是为什么 HTML 解析器必须在 JavaScript 执行过后才恢复对 HTML 文档的解析工作。如果你的 JavaScript 代码在执行时的事情有兴趣的话,可以看看 V8 团队的这篇文章[6]。
告诉浏览器你想怎样加载资源
浏览器提供了很多不错的方法给开发者,以帮助他们用不同的姿势在页面上加载资源。如果你的 JavaScript 代码中没有用到诸如 document.write()
之类的代码,你可以在自己的 script 标签上加上 async / defer
属性,浏览器就会异步地加载并执行 JavaScript 代码并且不会阻塞对于文档的解析。需要的话,也可以用到 JavaScript 模块[7]。<link rel="preload">
用于加载当前页面一定会用到的资源,并且开发者希望浏览器能在第一时间加载这些资源。开发者可以针对不同的场景, 赋予资源不同的加载优先级[8],浏览器会按照既定的规则依次加载这些资源,从而起到加载优化的效果。
样式的计算
只有 DOM 是无法完全知道一个页面最终会是什么样子的,因此我们还需要 CSS。主线程在完成对 CSS 的解析和计算后,才会为每个 DOM 节点赋予最终的样式。你可以在 DevTools 的 Computed 中查看到页面的相关信息。
即使开发者不提供任何的 CSS,每个 DOM 节点还是会有各自的样式:<h1>
显示的字体比 <h2>
大、每个元素都有自己的 margin 值。这是因为浏览器本身有一个默认的样式表。如果你想知道 Chrome 的默认 CSS 是啥,去看看 Chrome 的源代码[9]吧。
布局
现在渲染进程知道了文档的结构和每个节点的样式,但这还不足以去渲染一个页面。想想一个场景,你试图通过电话沟通告诉你朋友一幅画长什么样子。“有一个红色的大圆和一个小蓝方块”,你朋友听到以后肯定还是一脸懵逼:“你说了个寂寞?”
布局是查找元素几何形状的过程。主线程遍历 DOM 并计算样式,创建一个具体横纵坐标以及盒子边界大小数据的布局树(layout tree)。布局树可能与 DOM 树相似,但它只包含和页面即将呈现的节点相关的信息。如果某个元素设置了 display: none
,虽然它会呈现在 DOM 树中但并不会包含于布局树当中;如果有一个伪类元素 p::before{ content: 'Hi!' }
, 那么它虽然不在 DOM 树中,但仍然会出现在布局树当中。
页面布局的决策是一项很有挑战的工作。即使是最简单的页面布局,比如一个从上至下的块状元素集合,也需要去决定字体显示多大、在哪里换行...因为这些都会影响到段落的大小和形状,这些也都会影响到相邻的元素最终会显示在哪里。
打个比方,布局就像是你在尽力还原一幅画:你知道大小、形状以及元素的坐标,但你还是需要决定先画哪一个。实际情况是,某个元素可能被设置了 z-index
,如果按照 HTML 的元素编写顺序去“画”,就会导致错误的渲染效果。
在这一绘制的过程中,主线程遍历布局树从而去创建绘制记录。绘制记录是绘制进程的“笔记”,记下了诸如“先是背景,然后文字,接下来是矩形”这样的记录。如果你曾经在 canvas 上用 JavaScript 画过画,那么这个过程对你来说就很熟悉了。
更新渲染的代价是很大的
在渲染中最需要关注到的事情是,在每一步中,先前的操作都会产生新的数据。举个例子,如果布局树的某些东西改变了,文档中相关部分的绘制顺序也需要重新安排一遍。
即使你的渲染操作跟上了屏幕的刷新频率,但这些计算始终是运行在主线程上的,这就意味着当你的应用在运行 JavaScript 时,这些都会被阻塞掉。
你可以将 JavaScript 的操作分割为许多小的块并放在 requestAnimationFrame()
里执行,或者干脆把这些 JavaScript 丢在 Web Worker 里去执行,这样就能避免阻塞主线程。
合成
你会怎样画一个页面呢?
现在浏览器知道了文档的结构、每个元素的样式、页面的几何构成以及绘制的顺序,它会怎样去绘制一个页面呢?将这些信息转化为屏幕上的像素,这个过程叫做光栅化。
什么是合成?
合成是一种将页面的各个部分分为多层,分别对其进行光栅化并在成为合成器线程的单独线程中作为页面进行合成的技术。如果发生了滚动,因为每一层都已经完成了光栅化,剩下需要做的就只是合成出一个窗口。可以通过移动图层并合并新帧来以相同的方式实现动画。
你可以在 DevTools 的 Layers 面板[11]中查看你的页面是如何被切分成层的。
分层
为了确定每个元素各自应该在哪一层,主线程在遍历了布局树后生成了一个叫做 Layer Tree 的东西(不知道怎么翻译就还是保留 Layer Tree 这个名字吧)。如果页面的某一部分需要单独的层级(比如滑入的侧边栏目录)但却没有得到对应的层级,开发者可以用 CSS 的 will-change
属性来告知浏览器。
你可能想给每一个元素一个单独的渲染层,但是与每帧光栅化页面的一小部分相比,在过多数量的图层上进行合成可能会导致操作的速度变慢,因此衡量应用程序的渲染性能也是至关重要的一环。更多内容,可以阅读这篇文章:Stick to Compositor-Only Properties and Manage Layer Count[12]
主线程的光栅与合成
一旦 layer tree 形成并且绘制的顺序定下来后,主线程会将这个消息提交给合成线程。然后,合成器线程将每个图层光栅化。一个图层有可能和整个页面一样大,所以合成器线程将它们切割为很多小块,并将这些块发送给光栅线程。光栅线程将一小块完成光栅化后将其保存在 GPU 的内存当中。
合成线程可以优先处理不同的光栅线程,以便可以首先对可视区域(或者可视区域附近)中的事物进行光栅化。图层还具有用于不同分辨率的多个拼贴,以处理诸如放大动作之类的事情。
光栅化后,合成器线程将收集成为“Draw Quads(绘制四边形?)”的图块信息以创建 合成帧(Compositor frame)。
Draw quads: Contains information such as the tile's location in memory and where in the page to draw the tile taking in consideration of the page compositing.
Compositor frame: A collection of draw quads that represents a frame of a page.
合成帧会通过 IPC 被传递给浏览器进程。这时,来自其他 UI 线程的合成帧可能已经被用于浏览器的 UI 变化了。合成帧被运送至 GPU,目的就是为了显示在屏幕上。如果这时滚动页面,合成器线程又会创建新的合成帧并将之发送到 GPU。
合成的优势是所有的合成操作都是独立于主线程进行的。合成器线程不需要等待样式的计算或是 JavaScript 的执行。这就是 COA(Compositing Only Animations[13])能加速页面性能的原因。如果页面的布局或者绘制需要被重新计算,这种情况下主线程就会被牵扯进来。
总结
在这篇文章中,我们了解到了关于渲染的一系列操作:从解析到合成。如果你能认真阅读到这里,那么恭喜你,目前市面上绝大多数关于页面性能优化的文章你都可以看懂了。
在下一篇(也是这个系列的最后一篇)文章中,我们会了解到合成器线程的更多细节,比如当有用户交互(mousemove、click)时会发生什么。
参考资料
Inside Look at Modern Web Browser (part 2): https://developers.google.com/web/updates/2018/09/inside-browser-part2
[2]Mariko Kosaka: https://developers.google.com/web/resources/contributors/kosamari
[3]这里: https://developers.google.cn/web/fundamentals/performance/why-performance-matters
[4]HTML 标准: https://html.spec.whatwg.org/
[5]An Introduction to Error Handling and Strange Cases in the Parser: https://html.spec.whatwg.org/multipage/parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser
[6]V8 团队的这篇文章: https://mathiasbynens.be/notes/shapes-ics
[7]JavaScript 模块: https://v8.dev/features/modules
[8]赋予资源不同的加载优先级: https://developers.google.cn/web/fundamentals/performance/resource-prioritization
[9]Chrome 的源代码: https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/html/resources/html.css
[10]BlinkOn: https://www.youtube.com/watch?v=Y5Xa4H2wtVA
[11]Layers 面板: https://blog.logrocket.com/eliminate-content-repaints-with-the-new-layers-panel-in-chrome-e2c306d4d752/?gi=cd6271834cea
[12]Stick to Compositor-Only Properties and Manage Layer Count: https://developers.google.cn/web/fundamentals/performance/rendering/stick-to-compositor-only-properties-and-manage-layer-count
[13]Compositing Only Animations: https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
本文来自刘凯里,专注分享有趣的前端知识。刘哥还为大家准备了前端面试官手记,在他的公众号回复【前端面试】即可获取,欢迎大家的关注~