【精简版】浏览器渲染机制(完整流程概述)

2,906 阅读17分钟

背景

资料:docs.google.com/presentatio…

我们作为工程师,一名开发者,我认为时刻保持好奇心是一件很重要的事情,前端工程师们,不知你们是否好奇过 我们写出的代码究竟是如何变成屏幕上五彩斑斓的像素点的

本文就是来解决上面的这个问题的。

本文目的

但是,浏览器的渲染机制其实是一个很大的研究课题,过去的一个月我做了很多研究,找了很多资料,废了很多心血,越发觉得其中的很多细节可以消耗我很多很多时间,我也仅仅是了解到皮毛,文章中也会有我个人的理解,也可能存在错误,如果有大佬看到,也希望多多指导和批评,我也会积极采纳。

本文的目的是用通俗的语言,为大家介绍浏览器渲染的完整过程,并不会对每一步进行细致深入的讲解,(即让大家读懂是我最大的目标)。所以,阅读本文你可以得到什么呢:

  • 浏览器渲染的大致流程
  • 得到一个和别人喝咖啡的时候的聊天话题
  • 和我变成朋友(这是最关键的一点是吧,嘿嘿)

勘误

流程图 中的主进程 -> 主线程。(画图的时候没注意,抱歉。。。老朽在此认罪)

goal渲染的目标

首先,本文名称是浏览器渲染机制,那我们第一步就是要了解,浏览器渲染的目标是什么?

看上图,我们首先要了解一个content的概念,content指的是网页中这个区域,上面的标签栏,地址栏都不属于content。

所以,我们知道了整个渲染过程我们的输入是前端的代码,输出是content区域的像素点。好,这样我们渲染的目标就很明确了,即:把前端写出的代码转换成网页content区域的像素点!

下面,我们开始,为中间这个RENDER管道,一点一点的加上内容,让它丰满起来。

Blink(浏览器渲染机制之主线程做了啥)

DOM

往后的阅读,希望大家跟着我的思路,跟着思考,我会解释的很通俗,不要中途离开,拉钩哈。(拉完勾之后,说好的阅读完就要阅读完,一句话,一个字没有读,都不是阅读完~ )

DOM阶段介绍

首先,大家去想,我们的输入是code,但是中间过程的处理是,需要一个数据结构的,你这个输入的code说白了只是冰冷的字符串,我们第一步要做的肯定是把它转换成有温度的数据结构。

<div>
  <p> hello </p>
  <p> world </p>
</div>

所以,这个数据结构应该是什么样的呢?我们知道,其实html标签其实是可以嵌套的层次结构,比如很常见的一个div里丢了两个p标签。所以,用一个树形结构,对其进行表示再合适不过了。

这个树形结构也就如上图所示,他也有一个名字:DOM(Document Object Model)。

那么DOM有什么用呢:

  • DOM是页面的内部表示
  • DOM也为js提供了查询和修改的api接口

DOM阶段总结

这一步,浏览器干了什么事情,解析了html,把html转化成了DOM。

输入:html代码

输出:DOM

目前流程图:

STYLE

STYLE阶段介绍

大家现在想一想,现在我们有了DOM,但是这个DOM只是html转化出来的数据结构,上面是没有样式信息的,而我们要得到的数据结构一定是带有样式信息并且能表示标签嵌套层级的数据结构。所以我们在Style这一步,目的是为了可以得到能与DOM有效结合的css结构!!

这一步和DOM的意思也差不多,我们会把css代码转换成CSSOM。所以我们模仿一下前面DOM的话,那么CSSOM有什么作用呢?

  • 能表示css结构的层级
  • 为js提供可以操作css的API,允许用户动态的读取和修改css样式。

下一步,浏览器会再计算出computedStyle,DOM树中的每一个节点都有对应的computedStyle。

STYLE阶段总结

这一步,浏览器干了什么事情,把css解析,转化成了CSSOM,又转换成了和DOM节点一一对应的computedStyle。

输入:css

输出:computedStyle

目前流程图:

LAYOUT

LAYOUT阶段介绍

首先,我们回顾一下,我们前面的操作得到了啥,我们得到了DOM,它可以表示标签的嵌套结构,又得到了computedStyle,它存储着样式信息并可以和DOM节点一一对应。于是,我们想法就很简单,把DOM和computedStyle结合起来。于是乎,就有了LAYOUT这个阶段。

layout布局算法的输入就是DOM和computedStyle。

这一步主要就是确定元素的几何形状,坐标和尺寸。其中有以下几点要处理:

  • 【块流】最简单的情况下,布局按dom的顺序,垂直的排列一个有一个块,也叫块流(block flow)
  • 【行内流】还有文本和行内元素比如span,会在一行从左到右流动,叫做行内流,inline-flow。
  • 【字体】布局也要计算字体。(使用HarfBuzz的文本形状库)
  • 【包围矩形】布局可以为一个元素计算多种类型的边界矩形。 例如,当存在溢出时,布局将计算边框框的矩形和布局溢出矩形。如果节点的溢出是可滚动的,布局还计算滚动边界并为滚动条保留空间。最常见的可滚动DOM节点是document节点本身(dom树的根)。
  • 【复杂布局】比如表格,或者由内容包围的浮动元素

最后这个阶段会生成Layout Tree,这个Layout Tree与DOM相关联,但是不是一一对应的,比如:

  • 一个容器里面有了 div和span,span是行内元素,div是块级元素,于是,为了保证容器里面只有块级盒子,所以会在span的布局对象外面包裹一层匿名的块级盒子。这就是一个LayoutObject没有对应的DOM节点的情况。(如上图anonymous LayoutBlock)
  • 以及如果一个盒子的计算属性中的display属性是none,那么它也没有对应的LayoutObject。

LAYOUT阶段总结

这一步,我们干了什么事情,我们拿到了上一步的结果(DOM和computedStyle),通过布局算法,将两者进行了结合,我们得到了含有样式信息,布局信息的layout tree。但是注意这个layout tree和DOM不是一一对应的。

输入:DOM,computedStyle

输出:layout tree

目前流程图:

paintlayer tree

前面我们得到了Layout Tree,但是现在有一个问题,现在这个东西只能无脑的让文档流靠后的元素覆盖前面的元素。然而浏览器还有个层叠上下文就是决定元素间相互覆盖关系(比如z-index)的东西。这使得文档流中位置靠前位置的元素有可能覆盖靠后的元素。

于是PaintLayer Tree诞生了!!!PaintLayer 这棵树主要用来实现层叠上下文,以保证dom重叠时也能用正确的顺序合成页面,这样才能正确的展示元素的重叠以及半透明元素等等。

我们在此,只需要,为了处理层叠上下文,于是有了paintlayer tree,就ok了。

于是现在的流程图变成了这样:

comp.assign

comp.assign阶段介绍

好的,经过前面几个步骤,我们拿到了一个具有样式属性又可以表示嵌套结构的的数据结构了。所以,我们现在可以想一些额外的问题了?

我们的页面是静态的么?是不是有可能经常变动?

所以,如果变动会导致整个渲染过程重新运行一遍,是不是开销巨大?

所以,这个问题是不是要解决? 是的,必须要解决!!!(大喊,并有哭腔)

于是我们引入了图层合成加速的概念。什么是图层合成加速?

  • 图层合成加速是把整个页面按照一定规则划分成多个图层,在渲染的时候,只要操作必要的图层,其他的图层只要参与合成就好了,以这种方式提高了渲染的效率

合成层作用:

  • 合成层的绘制,渲染会交给GPU处理,比CPU更快
  • repaint时,只用repaint自身即可

于是为了页面变动时的性能,我们引入了comp.assign这个阶段,他会把整个页面按照一定规律划分成多个图层。

comp.assign阶段总结

这一步,我们干了什么事情,我们为了页面变动时不重新运行整个渲染的管道,把整个页面拆分成了很多图层。

输入:paintlayer tree/layout tree

输出:GraphicsLayer(图层) Tree

目前流程图:(我们的内容也越来越充实了)

Prepaint

Prepaint阶段介绍

在描述属性的层次结构这一块,之前的方式是使用图层树(GraphicsLayer Tree)的方式,如果父图层具有矩阵变换(平移、缩放或者透视)、裁剪或者特效(滤镜等),需要递归的应用到子节点,这在极端情况下会有性能问题。 于是,就有了 属性树这个概念。合成器提供了变换树,裁剪树,特效树等。每个图层都有若干节点id,分别对应不同属性树的矩阵变换节点、裁剪节点和特效节点。 在发生变换的时候,可以直接对这些节点进行操作。

Prepaint阶段总结

目的:对这些变换进行单独处理以提高性能。

做了什么:对这些上述变换做了单独处理,构建了 property trees(属性树,注意是trees)

输入:paintlayer tree/layout tree

输出:Property trees

目前流程图:

Paint

Paint阶段介绍

paint阶段,我就简单的一笔带过,就是我们去遍历上一阶段结果(即:GraphicsLayers)并把绘制的操作放在一个数据结构里面(DisplayItem)

Paint阶段也是 blink和cc(合成器组件)对接的阶段,会对DisplayItem List再处理为 cc:layer list 并和prepaint阶段产生的Property trees提交给 合成器线程。

在此我引用了官方的流程图,大家可以以此回顾,我们之前做了什么。

from layout
  |
  v
+------------------------------+
| LayoutObject/PaintLayer tree |-----------+
+------------------------------+           |
  |                                        |
  | PaintLayerCompositor::UpdateIfNeeded() |
  |   CompositingInputsUpdater::Update()   |
  |   CompositingLayerAssigner::Assign()   |
  |   GraphicsLayerUpdater::Update()       | PrePaintTreeWalk::Walk()
  |   GraphicsLayerTreeBuilder::Rebuild()  |   PaintPropertyTreeBuider::UpdatePropertiesForSelf()
  v                                        |
+--------------------+                   +------------------+
| GraphicsLayer tree |<------------------|  Property trees  |
+--------------------+                   +------------------+
      |                                    |              |
      |<-----------------------------------+              |
      | LocalFrameView::PaintTree()                       |
      |   LocalFrameView::PaintGraphicsLayerRecursively() |
      |     GraphicsLayer::Paint()                        |
      |       CompositedLayerMapping::PaintContents()     |
      |         PaintLayerPainter::PaintLayerContents()   |
      |           ObjectPainter::Paint()                  |
      v                                                   |
    +---------------------------------+                   |
    | DisplayItemList/PaintChunk list |                   |
    +---------------------------------+                   |
      |                                                   |
      |<--------------------------------------------------+
      | PaintChunksToCcLayer::Convert()                   |
      v                                                   |
+--------------------------------------------------+      |
| GraphicsLayerDisplayItem/ForeignLayerDisplayItem |      |
+--------------------------------------------------+      |
  |                                                       |
  |    LocalFrameView::PushPaintArtifactToCompositor()    |
  |         PaintArtifactCompositor::Update()             |
  +--------------------+       +--------------------------+
                       |       |
                       v       v
        +----------------+  +-----------------------+
        | cc::Layer list |  |   cc property trees   |
        +----------------+  +-----------------------+
                |              |
  +-------------+--------------+
  | to compositor
  v

Paint阶段总结

做了什么:处理GraphicsLayers,把绘制操作放在DisplayItem中。因为Paint阶段也是主线程最后一个步骤,也要与合成器线程对接,所以会把数据再处理为 cc:layer list并和prepaint阶段产生的Property trees(也会处理为cc property trees)提交给 合成器线程。

输入:GraphicsLayer Tree,Property trees

中间产物:DisplayItem List

输出: cc:layer list,cc property trees

目前流程图:(一下子就变大了嘿)

CC(浏览器渲染机制之合成器线程做了啥)

其实浏览器绘制的整体流程就是Blink一顿操作,并在paint阶段生成cc的数据源,cc进行一系列操作并最终在draw阶段将结果(CF)提交给viz。也就是说,Blink负责网页内容绘制,cc负责将绘制的结果合成并提交给viz。 (现在说有一点早哈。。。我滴错)

commit

我们在前面的操作得到了什么,得到了Layer list和property tree。

于是,commit阶段的核心作用就是更新Layer list(其实一般都叫它layer tree,但是其实他是list)和property trees的副本到合成器线程(compositor线程,也被称为Impl1线程)。 在commit完成之后会根据需要创建Tiles任务,Tiles任务会被Post到Raster线程。

所以,这个阶段做的事情很好理解,就是把前面主进程的产物拷贝一份到合成器线程。

现在的流程图:

Tiling

Tiling阶段介绍

合成器线程接收到数据之后,不会立即开始合成,而是把图层进行分块。这里涉及到一个”分块渲染“的技术,分块渲染会将网页的缓存分成一块一块的,比如:256*256的块,之后进行分块渲染。

为什么要分块渲染?

  • GPU合成通常是使用OpenGL ES贴图实现的,这时候的缓存实际就是纹理(GL Texture),很多GPU对纹理的大小是有限制的,比如长宽必须是2的幂次方,最大不能超过2048或者4096等。无法支持任意大小的缓存。

  • 分块缓存,方便浏览器使用统一的缓冲池来管理缓存。缓冲池的小块缓存由所有WebView共用,打开网页的时候向缓冲池申请小块缓存,关闭网页是这些缓存被回收。 tiling图块也是栅格化的基本单位,栅格化会根据图块与可见视口的距离安排优先顺序进行栅格化。离得近的会被优先栅格化,离得远的会降级栅格化的优先级。

分块最后的结果是 cc:TileTask

Tiling阶段总结

这一阶段会对图层进行分块,最终产生 cc:TileTask, cc:TileTask也是下一个阶段的输入。

输入:cc:layer list

输出:cc:TileTask

目前的流程图:

Raster(光栅化)

Raster阶段介绍

光栅化会执行每一个TileTask, 将图块转换成颜色值的位图。生成的位图中每个单元都保存着这个位图的颜色值与透明度的编码(比如 FFFFFFF,其实就是RGBA的16进制表示)。

不仅如此,光栅化这个过程还会去解码嵌入在页面中的图像资源,绘制操作会引用压缩的数据(比如JPEG,PNG等等),而光栅化会调用适当的解码器对其进行适当的解压。

所以,简单来说,光栅化,就是把图块转换成带有颜色透明度信息的位图的过程。

Raster阶段总结

在这个阶段,会把图块转换成位图,存储在内存中,当然这个内存地址的引用也会被记录,记录在 cc::PictureLayerImpl中(以后会用!)。

输入:cc:TileTask

输出:位图

目前的流程图:

Activate

在Commit之后,Draw之前有一个Activate操作。Raster和Draw都发生在合成器线程里的Layer List上,但是我们知道Raster操作是异步的,有可能需要执行Draw操作的时候,Raster操作还没完成,这个时候就需要解决这个问题。

于是它将LayerTree分为了:

  • PendingTree:负责接收commit,然后将Layer进行Raster操作

  • ActiveTree:会从这里取出光栅化好的Layer进行draw操作。

如图所示,这样保证了,接下来的操作拿的layer一定是已经光栅化好的。

目的:由于raster是异步的,于是为了使draw拿的数据是raster完成的数据,所以引入了activate这个操作!!!!

目前流程图:

Draw

Draw阶段介绍

一看这个名字,就知道整个阶段已经接近了尾声,没错,Draw也确实是合成器线程执行操作的最后一个阶段。

到了Draw这个步骤,当每个图块都被光栅化之后,合成器线程会为每个图块生成draw quads(在屏幕的指定位置绘制图块的指令,也包含了属性树里面的变换,特效等操作),这些draw quads会被封装在CompositorFrame对象里面,CompositorFrame对象也是Render Process(渲染进程)的产物,它会被提交到Gpu Process中。

Draw指的就是 把光栅化的图块,转换成draw quads的过程。 最后CompositorFrame会发送到viz进程(GPU进程)进行最后的渲染。

前文说到过,”在Raster阶段,会把图块转换成位图,存储在内存中,当然这个内存地址的引用也会被记录,记录在 cc::PictureLayerImpl中(以后会用!)。“,不知道记不记得这句话,现在用到了,我们要知道draw quads里面有什么。其实我们主要关心的就是,里面有 绘制的操作(paintOp)和对应位图地址的引用。draw quads会被放在CompositorFrame(CF)中,提交给GPU进程,去完成最后一个阶段。

到此,合成器线程的主要工作也做完了。撒花,合成器线程,你辛苦了。

Draw阶段总结

draw阶段将图块转换成draw quads,里面包含绘制指令,属性树的变换,和位图的地址。draw quad是会被放在CompositorFrame这个对象里面,提交给GPU进程。

输入:光栅化完成的图块

输出:饱含大家希望的CompositorFrame

目前的流程图:

GPU

VIZ

在 Chromium 中 viz 的核心逻辑运行在 GPU 进程中,负责接收其他进程产生的 viz::CompositorFrame(简称 CF),然后把这些 CF 进行合成,并将合成的结果最终渲染在窗口上。

于是在此,我们的代码就成功的渲染在了网页上了,冰冷的代码变成了温暖的五彩斑斓的页面~~

总结

完整流程图

结束语

就这样,浏览器把我们写出的代码变成了屏幕上的像素。

其实,看浏览器渲染机制已经很久了,几个周末+很多很多天的早上8:00 -10:00。虽然现在我的理解还不够深入,文章也可能存在错误,但是我也是在尽我所能的去整理。

鸡血

2020已经结束了很久了,2021已经快开始一个月了,今年开始,我结束了去年下半年浑浑噩噩的生活,去学习,去健身,珍惜每一点时间,让自己变得更加强大,我心中感觉还像一个小孩,但是已经毫无疑问是一个社会人儿了,也会有更大的责任,也开始了自己的事业,愿初心不改,愿越来越好。(一个刚刚好,工作满六个月的男子汉~~加油)