一文搞定浏览器渲染原理

1,629 阅读20分钟

浏览器中的进程介绍

进程和线程的概念

进程:一个进程就是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程。

线程:线程不能单独存在,需要由进程来启动和管理。

进程和线程的特点:

  1. 进程中任一线程执行出错都会导致整个进程崩溃。
  2. 线程之间共享进程中的数据。
  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存,即使其中有线程因执行不当导致内存泄露,进程退出时,这些内存也会被正确回收。
  4. 进程之间的内容相互隔离,使用进程间通信(IPC)机制。

浏览器的发展历程

单进程浏览器时代

浏览器所有功能模块都运行在同一个进程里,这些模块包括网络、插件、js运行环境、渲染引擎和页面。带来的问题:

  • 不稳定:一个插件的意外崩溃会引起整个浏览器的崩溃。
  • 不流畅:所有页面的渲染模块、js执行环境及插件都运行在同一个网页线程中,同一时刻只有一个模块可以执行。
  • 不安全:插件可以使用C/C++等代码编写,通过插件可以获取到操作系统的任意资源,页面运行插件意味着这个插件可以完全操作你的电脑。页面脚本也可以通过浏览器漏洞获取系统权限引发安全问题。

多进程浏览器时代

早期多进程架构中,页面运行在单独的渲染进程中,插件也是单独的插件进程,进程之间通过IPC通信。这就解决了单进程带来的问题:进程间相互隔离,不会互相影响,也不会互相阻塞,而且插件进程和渲染进程使用了安全沙箱,程序在沙箱中运行,不能在硬盘上读写数据。

目前最新的Chrome进程架构图:

包括:1个浏览器(Browser)主进程、1个GPU进程、1个网络(NetWork)进程、多个渲染进程、多个插件进程。由于渲染进程和插件进程需要运行用户代码,因此安全起见,被隔离在沙箱中。各进程功能:

  • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程:核心任务是将HTML、CSS和JavaScript转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个Tab标签页创建一个渲染进程。
  • GPU 进程:最开始为了实现 3D CSS 的效果,随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制。
  • 网络进程:主要负责页面的网络资源加载。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

多进程模型的缺点:

  • 更高的资源占用。因为每个进程都会包含公共基础结构的副本(如 JavaScript 运行环境),这就意味着浏览器会消耗更多的内存资源。
  • 更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了。

新页面的渲染进程策略

确切地说,有连接关系的同一站点(协议相同、根域名相同)会共用一个渲染进程。 以下方法可以使两个标签页进行连接:

  1. 在A标签页中使用a标签如<a href="./b.html" target="_blank"> B </a>打开B标签页,此时B标签页中可以通过window.opener访问A的window。
  2. 通过window.open()方法打开的标签页。

这种有连接关系的标签页叫做浏览上下文组,Chrome浏览器会将浏览上下文组中属于同一站点的标签页分配到同一个渲染进程中。但也有例外的情况:

  1. a标签的ref属性如<a target="_blank" ref="noopener noreferrer">,其中noopener会将新开标签页的opener值设为null,noreferrer表示新开的标签页不要有引用关系。这就使得新开标签页和当前标签页不属于同一个浏览上下文组了。
  2. 如果当前标签页中的iframe和标签页属于不同站点,iframe也会运行在单独的渲染进程中。

导航流程

从用户发出url请求到页面开始解析的过程叫做导航。

从输入url到页面展示的流程:

  1. 用户输入:用户在地址栏输入内容后,地址栏会判断是搜索内容还是请求的url,如果是搜索内容则使用默认搜索引擎来合成带搜索关键字的url;如果是url则会根据规则加上协议合成完整的url。浏览器开始加载,标签页的图标进入加载状态,此时页面还是之前的页面内容。
  2. 请求资源:浏览器进程通过IPC把url请求发送给网络进程,网络进程来发起真正的请求流程。
    • 查找缓存:如有,则直接返回资源给浏览器进程,如无则进入网络请求流程。
    • 建立连接:进行DNS解析获取IP地址,建立TCP连接和TLS连接(如果使用HTTPS的话)。
    • 发起请求:连接建立后,构建请求行、请求头等信息,并将相关Cookie等数据附加到请求头中,然后向服务器发送构建的请求信息。
    • 接收响应:网络进程接收到服务器发来的响应后开始解析响应内容。如果发现状态码是301或302,则从响应头的Location字段读取重定向地址,再发起新的请求。
    • 处理响应数据类型:根据Content-Type判断响应体的数据类型,如果是application/octet-stream则表示数据是字节流类型,会按下载类型处理,该请求会被提交给浏览器的下载管理器,导航流程就此结束。如果是text/html类型,则会开始准备渲染进程。
  3. 准备渲染进程:浏览器进程从网络进程中的得知是html类型后,会为该请求选择或创建一个渲染进程。
  4. 提交文档:浏览器进程向渲染进程发送“提交文档”的消息,渲染进程收到后,和网络进程建立传输数据的“管道”。等响应体数据传输完成后,渲染进程会返回“确认提交”的消息给浏览器进程,浏览器进程更新界面状态:安全状态、地址栏的url、前进后退的历史状态,更新页面。

确认提交后,导航流程结束,开始进入渲染流程。

渲染流程

渲染进程会先创建一个空白页面,叫做解析白屏。之后开始页面解析和子资源加载。

1 构建DOM树(Parse HTML)——产出DOM树

由于浏览器无法理解HTML,因此需要将HTML解析为浏览器能理解的结构——DOM树。DOM是保存在内存中的树结构,DOM是生成页面的基础数据结构,给JavaScript脚本提供一套查询和改变文档结构、样式、内容的接口,也是一道安全防护线,一些不安全的内容在DOM解析阶段就被排除了。

渲染引擎内部有个HTML解析器的模块,负责将HTML字节流转换为DOM结构。解析过程是渐进的,网络进程加载了多少数据,HTML解析器就解析多少数据。渲染进程从之前和网络进程建立的数据管道的一端动态接收字节流,并将其解析为DOM。

解析过程

  1. 通过分词器将字节流转换为Token,分为Tag Token和文本Token。

  2. 将Token解析为DOM节点,然后将节点添加到DOM树中。HTML解析器维护了一个Token栈结构,如果是StartTag Token则入栈,并创建一个DOM节点添加到DOM树中(HTML解析器最初会默认创建一个根为document的空DOM结构);如果是文本Token则生成文本节点添加到DOM树中;如果是EndTag Token则检查栈顶是否是相同标签的StartTag Token,是则出栈。

JavaScript和CSS对DOM构建的影响

解析过程中遇到了script标签时,HTML解析器暂停工作,JavaScript引擎介入,执行脚本,此时只能访问位于script之上已经构建了的DOM。访问后面的元素会返回null,执行操作会报错。脚本执行完,HTML解析器恢复解析。

如果不是内嵌脚本,而是通过src加载的脚本(有src属性的脚本会忽略标签内的代码),会等待下载完成后执行,期间HTML解析器一直是暂停的状态。不过好在Chrome浏览器的预解析操作会在渲染引擎收到字节流之后开启一个预解析线程分析HTML文件中包含的JavaScript、CSS等相关文件然后提前下载这些文件。

在有src属性加载脚本的script标签中添加async属性表明这个脚本是异步的,在后台加载,HTML解析器继续工作,等脚本加载完成后立即打断HTML解析器(如果还没解析完的话)开始执行脚本。
添加defer属性也会异步加载,但是会等到HTML解析完毕,在DOMContentLoaded事件之前执行。这两个属性对无src属性的脚本不会生效。

在这里补充一下DOMContentLoaded和load事件的触发时机。Document的DOMContentLoaded事件在初始的HTML文档加载并解析完毕时触发;并不会等待样式表、图片以及subframes。
Window的load事件在整个页面加载完毕时触发,包括所有的资源如样式表、scripts、iframes、图片等。

对于CSS,由于不直接参与DOM构建,本来是不会阻塞DOM树的构建的。但是如果页面有同步执行的脚本时,因为执行前是不知道脚本有没有操作CSSOM的,因此渲染引擎为了避免脚本执行出错的可能性,直接假定脚本会依赖CSSOM。于是下载CSS文件并解析成CSSOM,再执行脚本。期间HTML解析器一直是暂停的状态,直到脚本执行完毕才继续工作。

页面无脚本时CSS不会阻塞DOM树的构建:

页面有同步脚本时CSS会阻塞DOM树的构建:

页面有异步脚本时,会继续构建DOM树,脚本执行前需要等待CSS。是否继续渲染则需要看CSS是否就绪,如果CSS还在加载中则需要等待。

  • 如果是defer脚本,CSS加载完就开始渲染,只是DOMContentLoaded和load事件会延迟触发;

  • 如果是async脚本,DOMContentLoaded正常触发,CSS加载完就开始渲染,load事件会等待脚本执行完再触发:

总之CSS阻塞DOM树的构建与否取决于脚本执行的时机。

2 样式计算(Recalculate Style)——产出ComputedStyle

样式计算是为了计算出DOM节点中每个元素的具体样式,这个阶段大体可以分为三个步骤:

  1. 构建CSSOM。CSS的来源有三种:通过link标签引用的外部CSS、style标签、元素的style属性。由于浏览器也无法直接理解纯文本的CSS,因此渲染引擎需要先将其转为浏览器可以理解的结构——CSSOM(可以通过document.styleSheets访问)。最终会把所有不同来源的样式都包含进styleSheets中。CSSOM的作用一个是为JavaScript提供操作样式表的接口,一个是为布局树的合成提供基础的样式信息。
  2. 标准化样式属性值:将例如2em、blue、bold之类的数值转换为渲染引擎容易理解的、标准化的计算值。最终解析成类似32px、rgb(255,0,0)、700这样的标准值。
  3. 计算DOM树中每个节点的具体样式:需要使用两个规则:
    • 继承:每个DOM节点都会包含父节点中可以继承的属性,例如body的font-size。
    • 层叠:定义了合并多个来源的属性值的算法,计算结果保存在ComputedStyle的结构内。

3 布局(Layout)——产出布局树

此阶段用来计算DOM树中可见元素的几何位置。有两个步骤:

  1. 创建布局树:遍历DOM树,用所有可见节点构建一颗布局树,不可见的节点如head或display:none的元素都会被过滤掉。这个过程的样式查询会使用到ComputedStyle。

  1. 布局计算:计算布局树中节点的坐标位置,把布局计算的结果重新写回布局树中。因此布局树既是输入内容,也是输出内容,这也是布局阶段一个不合理的地方,Chrome团队正在重构布局代码,下一代布局系统叫LayoutNG,将会尝试分离输入和输出,简化布局算法。

4 分层(Update Layer Tree)——产出图层树

由于页面中可能包含很多复杂的效果如3D变换、页面滚动、z-index的z轴排序等,为了避免每次改动都要引发整个页面重排或重绘,会将页面分成多个图层,并生成一颗对应的图层树(LayerTree),层树中的每个节点都对应一个图层,这些图层按照一定顺序叠加在一起(层合成composite)构成了最终的页面图像。

通常,并非布局树的每个节点都占用一个图层,没有图层的节点将会从属于父节点的图层,最终每个节点都会直接或间接包含在一个图层中。处于相同的z轴坐标空间时,就会形成一个图层(或渲染层RenderLayers)。

渲染引擎会为一些特定的节点创建单独的图层:

  1. 满足以下特殊条件:
  • 3D transforms:translate3d、translateZ 等
  • video、canvas、iframe 等元素
  • 通过 Element.animate() 实现的 opacity 动画转换
  • 通过 СSS 动画实现的 opacity 动画转换
  • position: fixed
  • 具有 will-change 属性
  • 对 opacity、transform、fliter、backdrop-filter 应用了 animation 或者 transition
  1. 可以滚动的overflow元素:元素的内容超出容器时,如果设置了overflow使得内容可以滚动,则分别会为容器、滚动的全部内容、滚动条创建各自的图层。这样滚动时,各部分就不会因为重排/重绘而相互影响。

隐式创建图层(合成层)

如果一个元素覆盖在一个拥有独立图层的元素上,并且这个元素的z-index值更大,那这个元素也会被提升到单独的图层中。

层爆炸和层压缩

像上面那种隐式创建图层的情况,如果不加注意,极端场景下可能会创建大量独立图层,也就是层爆炸,会占用GPU和大量的内存资源,严重损耗页面性能。浏览器也有相应的对策,浏览器的层压缩机制,会将隐式创建的多个图层压缩到一个图层中。

z-index:3这个元素是一个独立的图层,其后z-index值更大的三个元素被压缩进了一个图层中:

但是很多特定情况下,浏览器是无法进行层压缩的。例如如果页面的某一部分使用transform和animation制作动画效果,由于可能产生动态交叠的情况,隐式创建图层在不需要交叠的情况下也能发生,导致页面中所有z-index高于它的节点都被提升到单独的图层中,使得页面产生了大量的独立图层。消除的办法就是增大z-index的值,或者合理优化页面元素的结构。

5 图层绘制(Paint)——产出图层的待绘制列表

得到图层树之后,渲染引擎会对图层树中的每个图层进行绘制,把一个图层的绘制拆分成很多小的绘制指令,再把这些指令按照顺序组成一个待绘制列表:

图层绘制阶段输出的内容就是这些待绘制列表。可以在开发者工具中查看一个图层的绘制列表,在区域2拖动进度条可以重现列表的绘制过程:

6 栅格化(raster)——产出合成后的位图

当图层的绘制列表准备好之后,渲染进程中的主线程会把该绘制列表提交给合成线程。

通常页面内容都比屏幕大得多,如果等待所有图层都绘制完毕再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。合成线程会将图层划分为固定的图块(tile),大小通常是256x256或512x512。如果这个图层非常大,会优先处理视口(屏幕上页面的可见区域)附近的图块。

渲染进程维护了一个栅格化的线程池,所有图块的栅格化都是在这个线程池内执行。 栅格化就是按照绘制列表的指令生成图片,每个图层都对应一张图片,合成线程将这些图片合成为“一张”图片。图块是删格化执行的最小单位。

通常,栅格化过程都会使用GPU来加速生成,这个过程叫做快速栅格化或GPU栅格化,渲染进程把生成位图的指令发送给GPU,生成的位图保存在GPU内存中。

但即使只绘制优先级最高的图块,也要耗费不少时间,因为会涉及到一个很关键的因素——纹理上传(存储在共享内存中的位图将作为纹理上传到GPU,最后由GPU将多个位图进行合成),这是因为从计算机内存上传到GPU内存的操作会比较慢。Chrome采取的策略是,在首次合成图块的时候使用一个低分辨率的图片,分辨率减少一半,纹理就减少了四分之三。在首次显示页面内容的时候,将这个低分辨率的图片显示出来,然后合成器继续绘制正常比例的网页内容,绘制完成后再替换掉当前显示的低分辨率内容。

由于合成操作在合成线程上完成,不会影响主线程,这就是为什么经常主线程卡住了但CSS动画依然能执行的原因。而使用JavaScript实现动画时,会牵涉到整个渲染流水线,绘制效率底下。涉及到一些可以使用合成线程来处理CSS特效或动画的情况,可以使用will-change来提前告诉渲染引擎,让它为该元素准备独立的图层,并且开启GPU加速。

例如will-change:transform,opacity;告诉渲染引擎将会对该元素做一些特效变换,渲染引擎会为该元素单独分配一个图层,当这些变换发生时,渲染引擎会通过合成线程直接去处理变换,由于没有涉及到主线程,大大提升了渲染效率。所以CSS动画比JavaScript动画高效。

但这样它占用的内存也会增加,因为从层树开始,后面每个阶段都会多一个层结构,这些都需要额外的内存。

7 显示

一旦所有图块都被栅格化,合成线程就会生成一个绘制图块的指令——“DrawQuard”,然后将该指令提交给浏览器进程。浏览器进程中的viz组件接收到指令后,将页面内容绘制到内存中,最后再将内存中的页面显示在屏幕上。

每个显示器都有固定的刷新频率,通常是60Hz,即每秒更新60张图片,更新的图片都来自于显卡的前缓冲区。显示器每秒固定读取60次前缓冲区中的图像来显示。显卡中的GPU合成新的图像,保存到后缓冲区。然后系统将显卡的前后缓冲区互换,来让显示器读取最新的图像。通常,显卡的更新频率和显示器的刷新频率是一致的,但有时在一些复杂场景中,显卡处理一张图片的速度变慢,就造成了视觉上的卡顿。

页面上的动画效果例如滚动,渲染引擎通过渲染流水线生成新的图片发送到显卡的后缓冲区,要想实现流畅的动画效果,渲染引擎需要每秒更新60张图片到显卡的后缓冲区。每一副图片称为一帧,每秒更新了多少帧称为帧率,如果滚动过程中1秒更新了60帧,帧率就是60Hz(或60FPS)。如果一次动画中,渲染引擎生成某些帧的时间过久,用户就会感觉到卡顿。

渲染流程结束后,渲染进程会发送一个消息给浏览器进程,浏览器进程停止标签图标上的加载动画。

总结

  1. 渲染进程将HTML解析为DOM树结构。
  2. 渲染引擎将CSS样式表解析为CSSOM,并计算出DOM节点的样式。
  3. 创建布局树,并计算元素的布局信息。
  4. 对布局树进行分层,并生成分层树。
  5. 为每个图层生成绘制列表,并将其提交到合成线程。
  6. 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
  7. 合成线程发送绘制图块命令DrawQuad给浏览器进程。
  8. 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上。

几个和性能优化相关的概念

重排

如果修改了元素的几何位置属性,如改变宽高,会引发重新布局及之后一系列流程,这个过程叫做重排。

调用以下DOM API,为了保证结果的准确性,浏览器会触发重排以获取最新的信息:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight(元素包含边框的宽/高)
  • scrollTop、scrollLeft、scrollWidth、scrollHeight(元素包含内边距的包含不可见的滚动内容的宽/高)
  • clientTop、clientLeft、clientWidth、clientHeight(元素包含内边距的宽/高)
  • window.getComputedStyle(elem)、IE里的currentStyle
  • getBoundingClientRect()

重绘

不改变几何位置信息,只改变元素的如背景颜色等信息,引发重新绘制及之后一系列流程,这个过程叫做重绘。

合成

如果既不要布局也不要绘制的属性,渲染引擎会跳过布局和绘制,只执行后续的合成操作,这个过程叫做合成。例如使用CSS的transform来实现动画效果,可以避开重排和重绘阶段,在非主线程上执行合成动画的操作,无需占用主线程的资源,大大提高了效率。

因此在执行效率方面:合成 > 重绘 > 重排。在性能优化方面可以依据这个原则来调整方案。

参考资料

  1. 极客时间:《浏览器渲染原理与实践》
  2. 浏览器合成与渲染层优化:mp.weixin.qq.com/s/knmQ1XRwt…