前言
变得浮躁了,很久没有写过一篇完整的博客了,在此写篇博客整理下主流浏览器的渲染流程,虽然这篇文章在很早之前就想写,但那时终究未下笔。浏览器作为 web 应用的主要载体,与前端开发者息息相关,而其中最关键的就是渲染引擎,了解浏览器的渲染原理,有助于我们从更高的维度去审视页面,出现问题时也可以让我们在一定程度上透过事物看本质。
渲染进程
浏览器的渲染进程的核心任务是将 HTML、CSS、JavaScript 等,解析渲染成可以和用户交互的一个网页,浏览器的排版引擎、JavaScript 引擎就是在该进程中运行。一般而言,浏览器会为每一个单独的窗口创建一个单独的渲染进程,并且,出于安全考虑,这些渲染进程都是以沙箱模式运行,互不影响。但也有例外,比如通过 window.open()
打开一个同源页面,那这新开的同源页面会复用上一个页面的渲染进程,该页面渲染进程崩溃,使用同一个渲染进程的页面也会崩溃。这些都与浏览器的安全策略有关,这里不再赘述。
构建 DOM 树
HTML 即「超文本标记语言」,浏览器是无法直接理解的。需要经过编译之后变成一个 DOM 树,方便渲染进程去处理。其过程简单概况就是:字节 → 字符 → 令牌 → 节点 → 文档对象模型(DOM)。
字节 → 字符:当浏览器从磁盘或网络中拿到一个 HTML 文件时,需要对其进行原始字节的读取,并根据文件的编码格式(如 uft8
) 等,将它转换成各个字符,也就是需要将原始字节转换成我们所看的 HTML 字符串。
字符 → 令牌:浏览器将一个将原始字节转换得到 HTML 字符内容后,第一时间需要考虑如何对其进行拆分,需要进行词法分析,经过一个令牌化(tokenizer
)的过程得到 token ,这个过程可以算是编译原理中最重要的环节。词法分析是对字符串进行识别的一个过程,比如一个 HTML 文件里面有大量的标签,需要对它们进行标识,输出一个 token
构成的单词流,想了解这方面的细节,可以去学习编译原理这门课程。
令牌 → 节点:得到 token
后,浏览器会结合 token
解析 HTML,去实例化各个节点(Node),Node 是每一个 Element
的基类。Node
这个类包含有三个指针:前节点、后节点、父节点,每一个继承自 Node 的类,都会包含这三个属性。
节点 → 文档对象模型(DOM):构建完 Node 后,会根据这些 Node 中的包含父子节点关系的指针去构建一颗 DOM 树,这个过程可能需要大量的时间,可以打开浏览器的 devtools 在 performance 界面分析其 parse HTML
的时间。
构建 CSSOM(CSS 对象模型)
和 HTML 文件一样,浏览器也无法直接理解 CSS,需要同处理 HTML 一般:字节 → 字符 → 令牌 → 节点 → CSSOM,将 CSS 构建成「CSS 对象模型(CSSOM)」,CSSOM 是一个以树结构描述的数据,方便查询或修改。由于 CSS 会有「继承」、「相对单位」等概念的存在,所以为了确定某个节点的具体属性,往往需要递归整个节点的父节点,去获取其确定的值。比如:子节点的 width: 50%;
,是个相对值,为了确定其具体的绝对值,就需要去寻找它的第一个非相对宽度的祖先节点,才能确定该子节点的具体宽度。这是一个十分耗费性能的操作,但大大方便了开发者,这是一个取舍过程,比如我司的「海报编辑器」的数据模型,为了更快的渲染而放弃了这一机制,一律使用绝对单位。
构建渲染树
当构建完成「DOM 树」和「CSSOM」后,需要将两者结合,构建一个渲染树,也称为布局树。在 DOM 中包含了很多不可见的标签,比如 head、meta 等标签,还有使用了 display: none;
的标签。构建渲染树时,会把这些不可见的标签统统忽略掉,渲染树只包含渲染网页时所需要的标签(可见的影响布局的标签)。
大体上是以下几个步骤:
- 从 DOM 树的根节点开始遍历每个可见元素,会忽略不可见标签及通过 CSS 隐藏的标签,如
display: none;
,但visibility: hidden;
不会被忽略,因为它仅仅是视觉不可见,但却占着位置,影响布局。 - 对于可见的节点,去 CSSOM 匹配对应的样式并将其应用到布局树上。
布局阶段
在上一阶段得到一个完整的布局树后,浏览器就从布局树的根节点开始遍历,计算每个节点的布局信息、位置、大小等(相对于视口的绝对像素),然后保存在布局树中。
分层
到了这一步,拿到了布局树,及各个元素的位置信息,但并不会直接进行绘制,而是会经过一个名为「分层」的阶段。可以在 chrome 里面打开 developer tools,点击右侧那个三个小点的图标(more),选择 More tools 里面的 Layers 就可以看到当前页面的分层情况了,如下图所示。
分层是什么
可以参考 Photoshop 里面的图层的概念,通常情况下,每个图层都是独立的,不会互相影响的,通过多个图层合成一张完整的图片。HTML 页面也是如此,浏览器将上一阶段得到的布局渲染树进行图层分解操作,得到多个图层,这就是浏览器的分层。
为什么需要进行分层
一个复杂的网页,不仅包含着大量的节点,还有着复杂的动画效果,图片等。试想一下,如果这些「元素」都集中在一个图层中,会是怎样的效果。都集中在一个图层上,倘若其中一个炫酷的动画,作出改变的时候,会牵一发而动全身,整个图层都会重新绘制,单图层的渲染策略是极其低效的,这就是为何需要进行分层的原因。分层后,动画单独一个图层,动画发生时,仅改变动画本身的图层,不影响其他图层,就避免重排和重绘,这也是典型的分而治之的思想。
如何分层
知道为何进行分层后,就需要考虑如何分层。浏览器会遍历布局渲染树将其转换成一个称为「层树」(Layer Tree)的东西,每一个节点都属于有一个图层,如果这个节点没有自己的图层,那么它会属于父节点的图层。
分层的条件:
时代在变化,这些也可能会变化,但大体上是一下几种类型。
- 具有 3D 或透视等 CSS 属性的元素。
- 使用加速视频解码<video> 元素。
- 3D 上下文(webgl)或加速 2D 上下文的 <canvas> 元素。
- 混合的插件,如 FLASH。
- 不透明度(opacity)使用了CSS动画,或者使用了 webkit 的动画转换(transform)的元素。
- 使用了加速的 CSS 过滤器的元素。
- 后代节点包含了复合层的元素(后代节点属于父节点的层)。
- 拥有层叠上下文属性的元素会被提升为一层。如:z-index、position: fixed 等。
可以参考原文:chromium 的 设计文档:GPU Accelerated Compositing in Chrome。
绘制(合成)
得到层树 LayerTree
后,渲染引擎会对它的每个图层进行绘制。类似把大象装进冰箱只需要三个步骤,渲染引擎会把一个图层的绘制拆分成很多个绘制指令,包含有绘制的操作、坐标、样式等,再将这些指令按顺序组成一个绘制指令表,有了绘制指令表后会进行栅格化的操作。
栅格化
栅格化又称光栅化,狭义上可以理解为把对应的图层转化为像素阵列图,执行绘制指令绘制成一张张图片。
另外绘制指令也不在主线程中执行,在得到绘制列表后,主线程会将绘制列表提交给渲染引擎的「合成线程」,由合成线程去执行绘制指令。
由于有些图层很大,全部进行绘制会产生极大的性能开销,所以合成线程会将图层进行分块,然后将位于视口(可见区域)的图块优先生成位图,这种栅格化的操作,称为「异步分块光栅化」。图层分块称为「tile 机制」,很多开放世界游戏都使用这种机制来绘制无缝地图,如 GTA5。
栅格化线程把视口附近的图块栅格化后,会存储到该分块的像素缓冲区(显存)中。
合成
图块全被栅格化后,就会生成一个绘制图块的指令(DrawQuad),将该指令提交给浏览器进程,由 viz(可视化) 模块从显存中读取位图来绘制图块,将内容写到内存中,再内存中的图片显示在显示器上。
整体流程
从上面所分析的内容可以得到,一个浏览器渲染的关键路径,如下图:
整体来说,就是浏览器中的网络线程会将加载进来的数据,如 HTML 之类的通过 IPC(进程间的通讯)传递给渲染进程。渲染进程中的主线程会将 HTML 解析成 DOM;将 CSS 解析成 CSSOM(styleSheets)并计算单位绝对值得到样式表;然后将 DOM 和 CSSOM 结合生成仅含可见包元素的布局树,并应用样式和计算布局;再将布局树进行分层操作,得到层树;层树进行绘制操作(paint),生成绘制指令表;此时,主线程会将布局树和绘制指令表一起传递给合成线程,合成线程在将图层拆分成各个图块(tile)也称为贴片;接着会传递给 GPU 进行栅格化图层,将图块转换成位图,存储在显存中;栅格化线程处理完毕后,合成线程会得到称为 “draw quads” 的图块信息,将这些信息合成一个帧(frame)称为合成器帧,通过 IPC 传递给浏览器进程;最后,浏览器进程得到当前合成帧后,viz(可视化模块)会调用 GPU 进行相应的绘制操作,将当前图片信息绘制到显示器上。
重排(回流)
当你通过 JavaScript 或者 CSS 改变了元素的布局信息,如宽高、位置、可见性等属性时,会触发重新的布局流程,及其后续的一系列操作,这种行为成为重排或者回流。重排需要更新一套完整的渲染流程,对性能的开销无疑是巨大的。
引起回流的操作
能引起元素「几何布局信息发生变化」的操作,都会触发回流。
重绘
当你改变不会影响元素布局的属性的时候,比如元素的颜色、背景颜色、边框颜色、阴影等,会触发重新绘制,重新生成绘制指令表,及后续的一系列流程。因为没有改变布局信息,所以不会重新布局,执行效率会比重排高一些。
直接合成
当然,有些操作不会在主线程中执行,而是在合成线程中执行,如 css
中 transform
属性,就不会经过回流和重绘的阶段。
一些问题的解答
改变一个元素的 innerHTML
后,立即使用 alert
阻断进程 ,视图会被更新吗?
先说不会,虽然改变一个元素的 innerHTML
是一个同步的操作,但是渲染确实另外一回事。从上文对浏览器渲染流程分析可以得知:运行 JavaScript 代码是在主线程中运行的,而一套完整的渲染流程,不仅需要主线程,还需要合成线程等来配合完成,innerHTML
改变之后,渲染呈现则是后续的事情,alert
阻断了整个渲染。
CSS 会不会阻塞渲染?
主流浏览器上 CSS 会阻塞渲染,但是却不会阻塞 Dom
树的构建。渲染引擎在解析 Html
文件的时候,碰到外部 css
资源时,就会调用相应的进程去加载资源,如网络进程去加载 CSS 文件,但却不会影响到原本的 HTML 的解析。至于阻塞页面的渲染,是因为渲染需要构建渲染树 RenderTree
,而渲染树是 Dom
结合 Style
得到的,只有等 CSS 加载并解析完成之后,才能构建渲染树,进行后续的渲染流程。主流的浏览器为了防止页面闪烁,会这样设置,不过也有一些浏览器会先渲染 DOM,等 Style 解析完成后,再以补丁的形式渲染上去,虽然能更早的看到网页内容,但页面在渲染样式的时候回发生闪烁现象,会给用户很不友好的体验,这系列非主流的浏览器不在本文的范畴。
JavaScript 脚本会阻塞渲染吗?
会阻塞渲染,同时也会阻塞 Dom
树的构建。当解析 Html
的时候,碰到 script
标签时,会停止构建 Dom
的构建,去下载 js 脚本并执行里面的代码,等执行完毕后,才会继续构建 Dom
。因为浏览器并不知道 script
里面的代码会不会改变 Dom
的结构,可以理解为执行当前碰到的 js 脚本,也是在构建 Dom 树,所以往往在当前 script 标签里的 js 直接获取该标签之后的 dom 节点,由于 dom 树尚未构建到那个节点,所以会提示找不到需要获取的那个节点。
这样一来,构建完成 DOM 树的时间会大大增加,需要加上网络下载脚本的时间及运行脚本的时间。所以大家都推荐将 script 标签放在 body
标签的底部。看到这里,可能有些朋友会问,为啥不直接放在 html
标签的上面呢?因为从 HTML 2.0 开始,在 body
之后在出现任何元素,都是不符合规范的。
最优解则是将非首次渲染需要的 script 标签标记为 async
,异步的 script 不会阻塞住 Dom 的构建。解析器碰到异步的 script 的时候,会知道这段代码可以延后执行,在下载的同时,不会执行 js,等解析完成 html 后再去执行该代码。
js 造成不断重绘重排的动画,如何避免卡顿
当你使用 JavaScript 写了一个会不断造成重排或重绘的动画时,浏览器在每一帧都会重新计算布局、重新绘制。在当前帧布局和绘制完成后,js 解释器就会拿到主线程的使用权,这时,如果你 js 的同步操作执行时间过长,没有及时归还主线程来进行布局和绘制,就会导致页面动画卡顿的现象。
这种情况可以使用 requestAnimationFrame
这个 api 来解决,这个方法注册的回调会在每一帧之前(有些浏览器是每一帧之后)调用。将 js 任务在这个方法的回调中执行,就相当于将任务切片了,分在每一帧之后调用,并在每一帧结束之前,归还主线程的使用权,从而避免动画的卡顿。这就和「并发」的概念类似,虽然只有一个主线程,但两者交替执行。
总结
- 更改布局信息会触发回流,重新布局;只改样式信息,仅需要重新绘制,不需要重新布局;直接合成阶段,不需要占用主线程,仅在合成线程中执行,如 css 动画,所以应该使用 css 动画相关的属性来做动画。
- CSS 会阻塞渲染树的生成,所以最后今早下载,将 link css 放在 html 文档的开头。
- 不带有异步标识的 script 脚本,会阻塞 Dom 树的构建,所以将 script 放在 body 标签之内的地步,最好使用
async
、defer(推荐)
来标识成异步,并放在顶部,尽早下载。 - 使用
requestAnimationFrame
回调来绘制动画,可以有效的防止动画卡顿。