浏览器导航流程

370 阅读35分钟

从输入URL到页面展示

三种思路:

  • 浏览器角度

    • HTTP 请求:根据http 或者https 协议从url 变成http response

      • 基本要求:讲清楚http 的request 和response 结构,可以展开http 的各种缓存机制、状态码、续传处理等
    • DOM 树的构建:从response body 里面解析出来html 代码,以状态机和栈来做html 语言的语法分析得到DOM 树

      • 此处可以具体展开语法分析的算法、状态机的设计
    • CSS 属性的计算:从DOM 树中取出CSS 代码, 再根据CSS 选择器和优先级,为DOM 树上的节点添加CSS 属性

      • 此处可以展开CSS 语法分析、选择器和优先级等具体内容
    • 排版:针对已经带CSS 的DOM 树,获取其中用于排版的CSS 属性, 根据盒模型和格式化上下文计算元素产生的盒及盒的位置

      • 此处可以展开正常流、table、flex、grid 等具体的排版算法
    • 渲染:针对计算好位置的盒,获取其中用于渲染的CSS 属性渲染元素,并且绘制到显示设备

      • 此处可以展开合成层、图形学、shader 等相关知识
  • 设备角度(客户端、网络、服务器端)

    • 阶段一,构造和发起请求
    • 阶段二,网络传输,根据OSI Model 展开具体的网络请求过程,如物理层通讯协议、调制解调、mac 地址、路由、三次握手、流量窗口控制、dns 等
    • 阶段三,服务端的处理,如负载均衡、连接保持、网关、计算型集群、存储型集群、分库、分表、消息队列
    • 阶段四,大致同阶段二
    • 阶段五,客户端处理数据,完成渲染
  • 性能角度

    • 开始传输HTML之前,主要是dns 请求和传输回应的http 头,这个阶段用户看到的就是白屏。该阶段优化的空间也最大,各种强度的缓存,http 复用、tcp 流量窗口控制、cdn、quick 协议等
    • HTML head 传输,html head 标签中的请求全部完成前,浏览器是不会对body 进行任何的解析和渲染的,这个阶段用户看到的只有html 元素,CSS 加载完成后,用户可以看到html 元素的背景。这个阶段优化手段主要是调度:把一些优先级不高,跟首屏不相干的资源延后下载
    • HTML body 传输,body 解析到渲染是流水线式的,这个body 中除了script 之外的请求,如image、video 一般都不会阻碍渲染,加入了defer或者async 的script 也不会阻碍渲染。如果说采用了服务端渲染,此时用户就可以逐渐看到页面的内容了;如果采用JS 代码来渲染,那这个阶段就非常的短,这个阶段优化手段主要是避免同步执行的JS,使用合适清晰度和大小的图片,给图片确定的尺寸,以避免重排等等技术
    • DOMContentLoaded 之后,这个时候JS 代码的渲染框架通常是会在这个事件触发的时候才开始真正意义上的工作,甚至此时才开始向服务端请求一些关键数据。这个阶段的工作完全由JS 代码控制,优化手段是优化JS 的调度。

image-20220216213906340

  • 首先,浏览器进程接收到用户输入的 URL 请求,浏览器进程便将该 URL 转发给网络进程。
  • 然后,在网络进程中发起真正的 URL 请求。
  • 接着网络进程接收到了响应头数据,便解析响应头数据,并将数据转发给浏览器进程。
  • 浏览器进程接收到网络进程的响应头数据之后,发送“提交导航 (Commit Navigation)”消息到渲染进程;
  • 渲染进程接收到“提交导航”的消息之后,便开始准备接收网络进程给到的 HTML 数据,接收数据的方式是直接和网络进程建立数据管道;
  • 最后渲染进程会向浏览器进程“确认提交”。
  • 浏览器进程确认文档被提交后,便开始移除之前旧的文档,然后更新浏览器进程中的页面状态。

用户发出 URL 请求到页面开始解析的这个过程,就叫做导航。

用户输入

当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的 URL。
  • 如果判断输入内容符合 URL 规则,比如输入的是 time.geekbang.org,那么地址栏会根据规则,把这段内容加上协议,合成为完整的 URL,如 time.geekbang.org
 // 是否是正确的网址
 function validURL(url) {
     const reg = /^(https?|ftp)://([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+.)*[a-zA-Z0-9-]+.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(/($|[a-zA-Z0-9.,?'\+&%$#=~_-]+))*$/
     return reg.test(url)
 }

当用户输入关键字并键入回车之后,这意味着当前页面即将要被替换成新的页面,不过在这个流程继续之前,浏览器还给了当前页面一次执行 beforeunload 事件的机会。

beforeunload 事件允许页面在退出之前执行一些数据清理操作,还可以询问用户是否要离开当前页面,比如当前页面可能有未提交完成的表单等情况,因此用户可以通过 beforeunload 事件来取消导航,让浏览器不再执行任何后续工作。

当前页面没有监听 beforeunload 事件或者同意了继续后续流程时,浏览器开始加载一个地址,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没立即替换为新的页面。因为需要等待提交文档阶段,页面内容才会被替换。

浏览器进程有很多负责不同工作的线程(worker thread),其中包括绘制浏览器顶部按钮和导航栏输入框等组件的UI 线程(UI thread),当在导航栏里面输入一个URL 的时候,其实就是UI 线程在处理输入。

从浏览器的角度来看的话,输入其实代表着来自于用户的任何手势动作(gesture)。所以用户滚动页面触碰屏幕以及移动鼠标等操作都可以看作来自于用户的输入事件。

当用户做了一些诸如触碰屏幕的手势动作时,浏览器进程(browser process)是第一个可以接收到这个事件的地方。可是浏览器进程只能知道用户的手势动作发生在什么地方而不知道如何处理,这是因为标签内(tab)的内容是由页面的渲染进程(render process)负责的。因此浏览器进程会将事件的类型(如touchstart)以及坐标(coordinates)发送给渲染进程。为了可以正确地处理这个事件,渲染进程会找到事件的目标对象(target)然后运行这个事件绑定的监听函数(listener)。

URL 请求过程

接下来,便进入了页面资源请求过程。这时,浏览器进程会通过进程间通信(IPC)把 URL 请求发送至网络进程,网络进程接收到 URL 请求后,会在这里发起真正的 URL 请求流程。

首先,网络进程会查找本地缓存是否缓存了该资源:

1、如果有缓存资源,那么直接返回资源给浏览器进程;

2、如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行 DNS 解析①,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。

【端口并非是通过dns 解析获取的】

DNS 解析配置只是配置了域名和ip 的映射。

在建立 TCP 连接时,端口号由发送方和接收方的应用程序协商确定,而不是由 TCP 协议本身制定。具体来说,发送方的应用程序会绑定到一个本地端口号,并指定远程服务器的 IP 地址和端口号,然后发送 SYN 数据包(包含有源端口和目的端口)。接收方的应用程序在接收到 SYN 数据包时,会回复一个 SYN-ACK 数据包,并指定它要绑定到的本地端口号。最后,发送方的应用程序在接收到 SYN-ACK 数据包时,发送一个 ACK 数据包,确认连接已经建立。

【DNS 缓存】

浏览器、操作系统、本地DNS 服务器,它们都会对DNS 结果做一定程度的缓存。

DNS 查询顺序:浏览器缓存 → 操作系统缓存 → 路由器缓存 → DNS 服务器

接下来就是利用 IP 地址和服务器建立 TCP 连接 - 三次握手②

连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送请求③

服务器接收到请求信息后,会根据请求信息处理请求④,然后生成响应数据(包括响应行、响应头和响应体等信息),返回响应⑤(发给网络进程)。

关闭TCP 连接 - 四次握手⑥

浏览器解析并渲染页面⑦

等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。

重定向

在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301 或者 302,那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location 字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。

image-20220216215940240

极客时间服务器会通过重定向的方式把所有 HTTP 请求转换为 HTTPS 请求。也就是说你使用 HTTP 向极客时间服务器请求时,服务器会返回一个包含有 301 或者 302 状态码响应头,并把响应头的 Location 字段中填上 HTTPS 的地址,这就是告诉了浏览器要重新导航到新的地址上。

301 是永久重定向 第一次跳转后,浏览器会自己缓存 ,下次再访问老的地址就是走的是浏览器的缓存不会发请求 302 是临时重定向 所以浏览器不会自作主张,每次都会向服务端询问

在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。

响应数据类型处理

URL 请求的数据类型,有时候是一个下载类型,有时候是正常的 HTML 页面,那么浏览器是如何区分它们呢?— Content-Type

Content-Type 是 HTTP 头中一个非常重要的字段, 它告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。

Content-type 字段的值是 text/html,这就是告诉浏览器,服务器返回的数据是 HTML 格式。

Content-Type 的值是 application/octet-stream,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。

不同 Content-Type 的后续处理流程也截然不同。

  • 如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。
  • 如果是 HTML,那么浏览器则会继续进行导航流程。由于 Chrome 的页面渲染是运行在渲染进程中的,所以接下来就需要准备渲染进程了。
  • Content-Type 有时候会缺失或者是错误的,这种情况下浏览器就要进行MIME 类型嗅探来确定响应类型。

准备渲染进程

默认情况下,Chrome 会为每个页面分配一个渲染进程,也就是说,每打开一个新页面就会配套创建一个新的渲染进程。但是,也有一些例外,在某些情况下(同一站点),浏览器会让多个页面直接运行在同一个渲染进程中。

渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中(服务端返回的数据会先经过网络传输,到达浏览器的网络进程),并没有提交给渲染进程,所以下一步就进入了提交文档阶段。

提交导航

developers.google.com/web/updates…

当浏览器进程接收到网络进程的响应头数据后,并且渲染进程就绪后,浏览器进程会发送一个 IPC(进程间通信)到渲染进程去提交导航。一旦浏览器进程收到渲染进程已经提交的确认消息,导航完毕并且文档加载解析开始。

当渲染进程确认提交之后,更新内容(导航完成状态):

image-20220216221830313

这也就解释了为什么在浏览器的地址栏里面输入了一个地址后,之前的页面没有立马消失,而是要加载一会儿才会更新页面。

首先应该是触发当前页的卸载事件和收集需要释放的内存,这需要占用一些时间,然后主体部分是请求新的URL 时的整个处理流程所耗费的时间。

之后就要进入渲染阶段了。

渲染阶段

【渲染流水线】

  • 构建 DOM 树①

    • HTML 解析器将网络进程传给渲染进程的 HTML 字节流转换为 DOM 结构
  • 创建CSSOM 树②

    • 解析CSS文件:浏览器会将文档中的所有CSS文件下载下来,并将它们解析为样式表对象。每个样式表对象包含了所有样式规则,以及它们对应的选择器和声明。
    • 构建样式规则树:浏览器会根据样式表对象中的选择器来构建一个样式规则树。这个树的每个节点都代表一条CSS样式规则。
    • 匹配元素和选择器:浏览器会遍历文档中的所有元素,并将它们与样式规则树中的选择器进行匹配。如果一个元素和一条规则的选择器匹配成功,那么这个元素就被认为是这条规则的一个目标元素。
    • 计算样式:对于每个DOM 节点,浏览器会计算出它最终应用的样式值,并保存到一个称为“元素样式”的数据结构中。计算过程中需要考虑CSS 的优先级、继承和层叠等问题。
    • 创建CSSOM树:最后,浏览器会将所有的元素样式合并起来,并构建出一棵称为“CSSOM 树”的数据结构。这个树的每个节点都代表了一个元素及其对应的样式信息。
  • 生成Render 树③

    • 所有需要绘制和渲染的元素及其样式和位置信息的树形数据结构
    • 将DOM 树和CSSOM 树进行合并,生成渲染树。在这个过程中,浏览器会过滤掉不需要显示的节点,例如脚本和隐藏元素(display: none)等。
  • 构建Layout 树④ - 渲染树的辅助结构

    • 需要参与布局计算的元素及其位置和尺寸信息的树形数据结构
    • 浏览器会根据元素的最终样式和布局规则(如position/display/float 等)计算出每个元素的位置和尺寸信息,以便能够准确地在屏幕上绘制。
  • 构建Layer Tree⑤ - 优化渲染性能

    • 需要进行图层合成和硬件加速的元素及其位置和尺寸信息的树形数据结构
    • 图层树是基于布局树生成的。当布局树中的元素具有一些特定的样式属性(如transform、opacity、z-index等)时,浏览器会将这些元素放置在单独的图层中,并将它们的绘制过程与其他元素分开处理。这些元素构成了图层树的节点。
  • 绘制⑥

    • 渲染引擎会根据渲染树、布局树和图层树中的信息生成绘制指令
    • GUI 线程会将计算得到的绘制命令封装成图形渲染命令(Graphics Command),然后将这些渲染命令通过IPC(进程间通信)发送给GPU 进程。
    • GPU 进程收到渲染命令后,会将它们转换成GPU 硬件可以执行的指令,并通过硬件加速来进行绘制。GPU 进程将绘制结果存储在GPU 内存中,然后将该内存中的内容传输回渲染进程中的显存,最终将绘制结果显示在屏幕上。

【元素样式】

DOM节点的style属性是一个对象,它表示HTML元素的内联样式(Inline Style)。

元素样式是一种数据结构,用于表示元素应用的所有CSS样式属性的值。它不仅包括内联样式,还包括从样式表中继承的样式属性的值以及其他样式属性的默认值。元素样式通常在计算样式的过程中生成,它被用于确定元素的最终显示效果。

虽然DOM节点的style属性和元素样式都与HTML元素的样式有关,但它们的作用和用法有所不同。DOM节点的style属性通常用于在JavaScript代码中修改元素的样式,而元素样式则是浏览器在渲染文档时用来确定元素最终显示效果的数据结构。

【不需要进行布局和计算大小的元素】

  • display: none的元素。这些元素虽然存在于DOM树中,但是不会出现在渲染树中。
  • 一些伪元素,例如:before和:after。这些元素通常用于添加一些装饰性内容,它们并不在DOM树中存在,因此也不需要进行布局和计算大小。
  • 一些定位的元素,例如position: fixed 的元素。这些元素不会影响其他元素的位置和大小,因为它们是相对于浏览器窗口的位置固定的,所以不需要布局计算。
  • 一些不可见的元素,例如visibility: hidden和opacity: 0的元素。这些元素虽然出现在渲染树中,但是由于不可见,因此不需要进行布局和计算大小。
构建DOM 树

浏览器无法直接理解和使用 HTML,所以需要将 HTML 转换为浏览器能够理解的结构——DOM 树。

从网络传给渲染引擎的HTML 文件字节流是无法被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构 - DOM。

DOM 提供了对HTML 文档结构化的表述。在渲染引擎中,DOM 有三个层面的作用:

  • 从页面的视角来看,DOM 是生成页面的基础数据结构。
  • 从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对 DOM 结构进行访问,从而改变文档的结构、样式和内容。
  • 从安全视角来看,DOM 是一道安全防护线,一些不安全的内容在 DOM 解析阶段就被拒之门外了。

DOM 树的构建过程:

image-20220217163351868

image-20220217163656454

在渲染引擎内部,有一个叫 HTML 解析器(HTMLParser)的模块,它的职责就是负责将 HTML 字节流转换为 DOM 结构。

问题来了:HTML 解析器是等整个 HTML 文档加载完成之后开始解析的,还是随着 HTML 文档边加载边解析的?

HTML 解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。

详细的流程:

网络进程接收到响应头之后,会根据响应头中的 content-type 字段来判断文件的类型,比如 content-type 的值是“text/html”,那么浏览器就会判断这是一个 HTML 类型的文件,然后为该请求选择或者创建一个渲染进程。

渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据丢给 HTML 解析器。

【DOM 的具体生成流程】

  • 通过分词器将字节流转换成Token

     <html>
         <body>
             <div>1</div>
             <div>test</div>
         </body>
     </html>
    

    上述HTML 代码通过词法分析生成的Token:

image-20220218141506113

  • 将Token 解析为DOM 节点,并将DOM 节点添加到DOM 树中

    HTML 解析器维护了一个 Token 栈结构,该 Token 栈主要用来计算节点之间的父子关系,在第一个阶段中生成的 Token 会被按照顺序压到这个栈中。具体的处理规则如下所示:

    • 如果压入到栈中的是 StartTag Token,HTML 解析器会为该 Token 创建一个 DOM 节点,然后将该节点加入到 DOM 树中,它的父节点就是栈中相邻的那个元素生成的节点。

    • 如果分词器解析出来是文本 Token,那么会生成一个文本节点,然后将该节点加入到 DOM 树中,文本 Token 是不需要压入到栈中,它的父节点就是当前栈顶 Token 所对应的 DOM 节点。

    • 如果分词器解析出来的是 EndTag 标签,比如是 EndTag div,HTML 解析器会查看 Token 栈顶的元素是否是 StarTag div,如果是,就将 StartTag div 从栈中弹出,表示该 div 元素解析完成。

      通过分词器产生的新 Token 就这样不停地压栈和出栈,整个解析过程就这样一直持续下去,直到分词器将所有字节流分词完成。

DOM 是保存在内存中树状结构,可以通过 JavaScript 来查询或修改其内容。

???拓展:Binding 系统、阴影树、FlatTreeTraversal算法

现在已经生成 DOM 树了,但是 DOM 节点的样式还不知道,要让 DOM 节点拥有正确的样式,就需要样式计算了。

样式计算

样式计算的目的是为了计算出 DOM 节点中每个元素的具体样式。

把CSS 转换为浏览器能够理解的结构

CSS 样式来源:

image-20220217174648169

浏览器无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets。

 // document.styleSheets 接口只会返回引入和嵌入文档的样式表,不会返回内联样式
 // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/stylesheets
 document.styleSheets

image-20220217174951698

转换样式表中的属性值 - 标准化

属性值标准化:

 body { font-size: 2em }
 p {color:blue;}
 span  {display: none}
 div {font-weight: bold}
 div  p {color:green;}
 div {color:red; }

上面的 CSS 文本中有很多属性值,如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

标准化后的属性值:

image-20220217175537223

计算DOM 树中每个节点的具体样式

样式的属性被标准化后,就需要计算DOM 树中每个节点的样式属性了。

CSS 的继承规则和层叠规则

  • CSS 继承(不是所有的属性都会继承,如display、position、width、height)

    CSS 继承就是每个 DOM 节点都包含有父节点的样式。

     body { font-size: 20px }
     p {color:blue;}
     span  {display: none}
     div {font-weight: bold;color:red}
     div  p {color:green;}
    

    这张样式表最终应用到 DOM 节点的效果:

    image-20220217175959732

从图中可以看出,所有子节点都继承了父节点样式。比如 body 节点的 font-size 属性是 20,那 body 节点下面的所有节点的 font-size 都等于 20。

打开 Chrome 的“开发者工具”,选择第一个“element”标签,再选择“style”子标签:

image-20220217184100042

  • 样式层叠

    层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法

    它在 CSS 处于核心地位,CSS 的全称“层叠样式表”正是强调了这一点。

样式计算阶段的目的:

计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。

这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。 => 样式属性到值的映射

image-20220217184330238

布局阶段

有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,计算出 DOM 树中可见元素的几何位置的过程叫做布局(Layout)。

创建布局树

DOM 树还含有很多不可见的元素,比如head、script标签,还有使用了 display:none 属性的元素。

所以在显示之前,还要额外地构建一棵只包含可见元素布局树。

image-20220217185203293

为了构建布局树:

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中;
  • 不可见的节点会被布局树忽略掉
布局计算

有了一棵完整的布局树,接着就要计算布局树节点的坐标位置。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。

针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

分层

有了布局树,且每个元素的具体位置信息都计算出来了。接下来绘制页面??

No,因为页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。

渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,就形成了最终的页面。

图层和布局树节点之间的关系:

image-20220217225429294

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。如上图中的 span 标签没有专属图层,那么它们就从属于它们的父节点图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?

通常满足以下两点中任意一点的元素就可以被提升为单独的一个图层。

  • 拥有层叠上下文属性的元素会被提升为单独的一层。

    页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。

    明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等,都拥有层叠上下文属性。

    补充资料:developer.mozilla.org/zh-CN/docs/…

  • 需要剪裁(clip)的地方也会被创建为图层。

     <style>
         div {
             width: 200px;
             height: 200px;
             overflow: auto;
             background: gray;
         } 
     </style>
     <body>
         <div >
             <p>所以元素有了层叠上下文的属性或者需要被剪裁,那么就会被提升成为单独一层,你可以参看下图:</p>
             <p>从上图我们可以看到,document层上有AB层,而B层之上又有两个图层。这些图层组织在一起也是一颗树状结构。</p>
             <p>图层树是基于布局树来创建的,为了找出哪些元素需要在哪些层中,渲染引擎会遍历布局树来创建层树(Update LayerTree)。</p> 
         </div>
     </body>
    

    把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域,下图是运行时的执行结果:

image-20220217230906603

出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

image-20220217231556898

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。

渲染引擎实现图层的绘制会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:

image-20220217232025136

绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。

而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。

所以在图层绘制阶段,其实并不是真正地绘出图片,而是将绘制指令组合成一个列表。

image-20220217233501595

区域 1 就是 document 的绘制列表,拖动区域 2 中的进度条可以重现列表的绘制过程。

栅格化(raster)操作

有了绘制列表后,就需要进入光栅化阶段了,光栅化就是按照绘制列表中的指令生成图片。

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。

渲染主线程和合成线程之间的关系:image-20220217233633247

当图层的绘制列表准备好之后,主线程会把该绘制列表commit 给合成线程。

到目前为止,浏览器已经知道了关于页面以下的信息:

  • 文档结构
  • 元素的样式
  • 元素的几何信息
  • 绘画顺序

将以上这些信息转化为显示器的像素的过程叫做光栅化(rasterizing)。

可能一个最简单的做法就是只光栅化视口内(viewport)的网页内容。如果用户进行了页面滚动,就移动光栅帧(rastered frame)并且光栅化更多的内容以补上页面缺失的部分。Chrome的第一个版本其实就是这样做的。

然而,对于现代的浏览器来说,它们往往采取一种更加复杂的的做法叫做合成(compositing)。

ViewPort(视口)

屏幕上页面的可见区域,即用户可以看到的部分。

在有些情况下,有的图层可以很大,比如有的页面使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

基于这个原因,合成线程会将图层划分为图块(tile) ,这些图块的大小通常是 256x256 pixel或者 512x512 pixel。

这个技术叫“分块渲染”。

然后合成线程会按照视口附近的图块来优先生成位图,这样就可以大大加速页面的显示速度。不过有时候,即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素—纹理上传,这是因为从计算机内存上传到GPU 内存的操作会比较慢。

实际生成位图的操作是由栅格化来执行的。

所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。

渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

image-20220217234644745

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作

合成线程可以给不同的光栅线程赋予不同的优先级(priority),进而使那些在视口中的或者视口附近的页面可以先被光栅化。为了响应用户对页面的放大和缩小操作,页面的图层(layer)会为不同的清晰度配备不同的图块。

当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做draw quads 的信息来构建一个合成帧(compositor frame)。

  • draw quads:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:代表页面一个帧的内容的绘制四边形集合,它会被提交到GPU Process 中,平时提到的60fps 输出帧率中的帧就是Compositor Frame。

GPU 栅格化:

image-20220217235002589

渲染进程的合成线程接收到图层的绘制消息时,会通过光栅化线程池将其提交给GPU 进程,在GPU 进程中执行光栅化操作,执行完成,再将结果返回给渲染进程的合成线程。

GPU 光栅化并不是直接调用GPU,而是通过Skia 图形库(谷歌维护的2D图形库,在Android,Flutter,Chromium都有使用)。

合成和显示(Display Compositor)

上面的步骤完成之后,合成线程就会通过IPC 向浏览器进程提交一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的UI 线程(UI thread)提交以改变浏览器的UI(导航栏、窗口等)。这些合成帧都会被发送给GPU 从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给GPU 来更新页面。

GPU 进程的viz Compositor 线程里跑着一个display 合成器,负责合成从不同进程发过来的compositor frame,viz 调用OpenGL 指令来渲染compositor frame 里面的draw quads,把像素点输出到屏幕上的。

www.youtube.com/watch?v=m-J… developers.google.com/web/updates…

到这里,经过这一系列的阶段,编写好的 HTML、CSS、JavaScript 等文件,经过浏览器就会显示出漂亮的页面了。

A compositor frame consists of metadata (device scale, color space, size) and an ordered set of render passes. A render pass contains an ordered set of quads that have references to resources (e.g. gpu textures) and information about how to draw those resources (sizes, scales, texture coordinates, etc).

chromium.googlesource.com/chromium/sr…

Viz:keyou.github.io/blog/2020/0…

image-20220218105115302

【显示器怎么显示图像】

每个显示器都有固定的刷新频率,通常是60HZ,每秒更新 60 张图片,更新的图片都来自于显卡中一个叫前缓冲区的地方,显示器所做的任务很简单,就是每秒固定读取60 次前缓冲区中的图像,并将读取的图像显示到显示器上。

显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。

为了提升每帧的渲染效率,Chrome 引入了分层和合成的机制。

渲染完成

渲染完毕后会触发load事件

  • 当 DOMContentLoaded 事件触发时,仅当DOM 加载完成,不包括样式表,图片。
  • 当 onload 事件触发时,页面上所有的DOM,样式表,脚本,图片都已经加载完成了。(渲染完毕了)
  • 顺序:DOMContentLoaded --> load

css 加载是否会阻塞dom 树渲染?

前提:头部引入css 的情况

css 是由单独的下载线程异步下载的。

  • css 加载不会阻塞DOM 树解析(异步加载时DOM 照常构建)
  • 但会阻塞render 树渲染(渲染时需等css 加载完毕,因为render 树需要css 信息)

因为加载css 的时候,可能会修改下面DOM 节点的样式,如果css 加载不阻塞render 树渲染的话,那么当css 加载完之后,render 树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。

普通图层和复合图层

渲染步骤中提到composite 的概念:

浏览器渲染的图层一般包含两大类:普通图层以及复合图层

首先,普通文档流内可以理解为一个复合图层(这里称为默认复合层,里面不管添加多少元素,其实都是在同一个复合图层中)

其次,absolute 布局(fixed 也一样),虽然可以脱离普通文档流,但它仍然属于默认复合层

然后,可以通过硬件加速的方式,声明一个新的复合图层,它会单独分配资源 (当然也会脱离普通文档流,这样一来,不管这个复合图层中怎么变化,也不会影响默认复合层里的回流重绘)

GPU中,各个复合图层是单独绘制的,所以互不影响

Chrome源码调试 -> More Tools -> Rendering -> Layer borders中,黄色的就是复合图层信息。

如何变成复合图层(硬件加速)

将该元素变成一个复合图层 => 硬件加速技术:

  • 最常用的方式:translate3dtranslateZ
  • opacity属性/过渡动画(需要动画执行的过程中才会创建合成层,动画没有开始或结束后元素还会回到之前的状态)
  • will-chang属性(这个比较偏僻),一般配合opacity 与translate 使用(而且经测试,除了上述可以引发硬件加速的属性外,其它属性并不会变成复合层),作用是提前告诉浏览器要变化,这样浏览器会开始做一些优化工作(这个最好用完后就释放)
  • <video><iframe><canvas><webgl>等元素
  • flash 插件

absolute 和硬件加速的区别

absolute 虽然可以脱离普通文档流,但是无法脱离默认复合层。所以,就算absolute 中信息改变时不会改变普通文档流中render 树,但是浏览器最终绘制时,是整个复合层绘制的,所以absolute 中信息的改变,仍然会影响整个复合层的绘制。(浏览器会重绘它,如果复合层中内容多,absolute 带来的绘制信息变化过大,资源消耗是非常严重的)

硬件加速直接就是在另一个复合层了(另起炉灶),所以它的信息改变不会影响默认复合层 (当然了,内部肯定会影响属于自己的复合层),仅仅是引发最后的合成(输出视图)。

复合图层的作用

一般一个元素开启硬件加速后会变成复合图层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。但是尽量不要大量使用复合图层,否则由于资源消耗过度,页面反而会变的更卡

硬件加速时请使用index

使用硬件加速时,尽可能的使用index,防止浏览器默认给后续的元素创建复合层渲染。

具体的原理: webkit CSS3 中,如果这个元素添加了硬件加速,并且index 层级比较低,那么在这个元素的后面其它元素(层级比这个元素高的,或者相同的,并且releative 或absolute 属性相同的),会默认变为复合层渲染,如果处理不当会极大地影响性能。

其实可以认为是一个隐式合成的概念:如果a 是一个复合图层,而且b 在a 上面,那么b 也会被隐式转为一个复合图层。web.jobbole.com/83575/

【参考资料】 《极客时间:浏览器原理与实践》