浏览器工作原理及实践

159 阅读54分钟

你好,我是南一。这是我在准备面试八股文的笔记,如果有发现错误或者可完善的地方,还请指正,万分感谢🌹

浏览器工作原理及实践

Chrome架构:Chrome打开一个页面有多少个进程?

在回答这个问题之前先了解一下进程与线程

什么是进程和线程?

我们知道多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。那什么又是进程呢?

进程就是一个程序的运行实例。详细解释就是:启动一个程序,操作系统会为该程序创建一块内存,用来存取代码、运行中的数据和一个执行任务的主线程,我们把这样一个运行环境叫做进程。进程是资源分配的最小单位。

线程是依附于进程,进程中使用多线程并行处理能提高运算效率

进程与线程的关系的特点

  1. 进程中任意一个线程执行出错,都会导致整个进程的崩溃。

    线程出错破坏其他兄弟线程的内存,导致其他线程出现严重错误,操作系统检测到异常,就会kill掉进程,其他线程也就一起被干掉了

  2. 线程之间共享进程中的资源

    一个进程可以拥有多个线程,每个线程使用其所属进程的栈空间。同一进程内的多个线程会共享部分状态,多个线程可以读写同一块内存(一个进程无法直接访问另一进程的内存)。同时,每个线程还拥有自己的寄存器和栈,其他线程可以读写这些栈内存。

  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存

    一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收

  4. 进程之间的内容相互隔离

    进程隔离是为保护操作系统中进程互不干扰而采用的技术。原理是操作系统控制页表 把不同进程的虚拟地址映射到不同的物理地址上,就可以防止进程间相互操作数据。正是因为进程间的数据是严格隔离的所以如果一个进程崩溃或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时就要用到进程间通信(IPC)机制了

  5. 进程执行开销大,线程执行开销小。

单进程浏览器时代

早期的单进程浏览器,所有的功能模块都是放在一个进程里面,这些模块包含网络、插件、Javascript运行环境、渲染引擎和页面等。

单进程浏览器

问题1:不稳定

插件是最容易出问题的模块,一但一个插件崩溃就会导致整个浏览器崩溃

除了插件外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就可能引起渲染引擎模块的崩溃

问题2:不流畅

从上图可以看到,所有的页面渲染模块、JavaScript执行环境以及插件都运行在页面线程中,意味着同一时间只能有一个模块可以执行。

假如有一个 JS 脚本无限循环,就会导致其他模块没有机会执行。就会导致浏览器失去响应,变卡顿

问题3:不安全

这里依然可以从插件和页面脚本两个方面来解释该原因

插件可以使用C/C++等代码编写,通过插件可以获取到操作系统的任意资源,当你在页面运行一个插件时也就意味着这个插件能完全操作你的电脑。如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。

至于页面脚本,它可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题。

多进程浏览器时代

早期多进程架构

从图中可以看出,Chorme的页面是运行在单独的渲染进程中,插件也是运行在单独的插件进程中,进程之间是通过 IPC 机制进行通信。

**我们先看看如何解决不稳定的问题。**由于进程之间是相互隔离的,当一个页面或插件崩溃时,影响到的仅仅是当前的页面进程或插件进程,并不会影响到浏览器和其他页面,这就完美解决页面或插件崩溃导致整个浏览器崩溃的问题。

接下来看看不流畅的问题如何解决。 现在,JavaScript 运行在页面的渲染进程中,所以即使 JavaScript 阻塞了渲染进程,影响到的只是当前的渲染页面,并不会影响浏览器和其他页面,因为其他页面的脚本也是运行在自己的渲染进程中。

对于内存泄漏的解决办法就更简单了,因为当关闭一个页面,整个渲染进程也会被关闭,之后该进程所占用的内存都会被系统回收,这就轻松解决浏览器页面内存泄漏的问题。

最后看看两个安全问题怎么解决。 采用多进程架构的额外好处就是可以使用安全沙箱,可以把沙箱看作是给操作系统的进程上了一把锁,沙箱里面的程序是可以运行的,但是不能在你的硬盘上写入任何数据,也不能在敏感位置读取任何数据,例如文档和桌面。Chrome把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或插件进程里面执行了恶意的程序,恶意程序也无法突破沙箱去获取系统权限。

沙箱:经典的沙箱系统的实现途径一般是通过拦截系统调用,监视程序行为,然后依据用户定义的策略来控制和限制程序对计算机资源的使用,比如改写注册表,读写磁盘等。

目前的多进程架构

最新Chrome浏览器包括:1个浏览器(Browser)主进程、1个GPU进程、1个网络(NetWork)进程、多个渲染进程和多个插件进程

接下来逐个分析这几个进程的功能。

  • 浏览器进程:主要负责界面显示(浏览器除标签页外的界面)、用户交互(地址栏、书签、前进后退按钮等)、子进程管理,同时提供存储等功能。
  • 渲染进程:负责控制显示tab标签页内所有的内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页,排版引擎 Blink 和 JS 引擎 V8 都是运行在该进程中,默认情况下Chrome会为每一个Tab标签页创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU进程:其实,Chrome刚开始发布的时候还没有GPU进程。而GPU进程的初衷是为了实现3D CSS 的效果,只是随后网页、Chrome的UI界面都选择采用CPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引进了CPU进程
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面,后面才独立出来,成为一个单独的进程
  • **插件进程:**主要负责插件的运行,因插件容易崩溃,所以需要插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响

至此,可以回答开头的问题了,Chrome浏览器打开一个页面至少有4个进程,1个浏览器进程,1个网络进程,1个GPU进程、1个渲染进程。如果打开的页面有运行插件的话,就得加一个插件进程。

双刃剑

多进程模型提升浏览器的稳定性,流畅性,安全性但也带来了一些问题

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

未来面向服务架构

一个数据包的“旅程”

TCP 连接的建立

TCP 连接建立的过程叫握手,握手需要在客户端和服务器之间交换三个TCP报文段。

TCP首部部分字段意义

  • 确认号:占4个字节,是期望收到对方下一个报文段的第一个数据字节的序号总之,若确认号 = N,则表明:到序号 N - 1 为止的所有数据都已正确收到
  • 确认ACK:仅当ACK = 1时确认号字段才有效。当ACK = 0时,确认号无效。TCP 规定,在连接建立后所有传送的报文段都必须把ACK置为1
  • 同步 SYN:在建立连接时用来同步序号。当SYN = 1ACK = 0时,表明这是一个连接请求保文段。对方若同意建立连接,则在响应的报文段中使 SYN = 1ACK = 1.因此 SYN1就表示这是一个连接请求或连接接受报文
  • 终止 FIN:用来释放一个连接。当FIN = 1时,表明此报文段的发送方的数据已经发送完毕,并请求释放运输连接

Snipaste_2022-10-30_15-09-53

上图,主机A运行的是TCP客户端程序,主机B运行的是TCP服务器程序。最初两端的TCP进程都处于CLOSED(关闭)状态。图中主机下方框表示TCP进程所处的状态。

​ 一开始,BTCP 服务器进程先创建传输控制块 TCB,准备接受客户进程的连接请求。然后服务器进程就处于LISTEN(收听)状态,等待客户端的连接请求。如有,即做出响应。

ATCP 客户进程也是首先创建传输控制块 TCB。然后,在打算建立 TCP 连接时,向 B 发出连接请求报文段,这时首部中的同步位 SYN = 1,同时选择一个初始序号 seq = xTCP 规定, SYN 报文段(即 SYN = 1 的保文段)不能携带数据,但要消耗掉一个序号。这时,TCP客户进程进入 SYN-SENT(同步已发送)状态。

B 收到连接请求报文段后,如果同意建立连接,则向A发送确认。在确认报文段中应把SYN位和ACK位都置1,确认号是ack = x + 1,同时也为自己选择一个初始序号 seq = y。请注意,这个报文段也不能携带数据,但同样要消耗一个序号。这时TCP服务器进程进入SYN-RCVD(同步收到)状态。

TCP 客户进程收到 B 的确认后,还要向 B 给出确认。确认报文段 ACK1,确认号 ack = y + 1,而自己的序号seq = x + 1TCP 标准规定,ACK报文段可以携带数据,但如果不携带数据则不消耗序号,在这种情况下,下一个报文段的序号仍是seq = x + 1。这时,TCP 连接已经建立,A 进入ESTABLISHED(已建立连接)状态。

​ 当 B 收到 A 的确认后,也进入ESTABLISHED(已建立连接)状态。

为什么需要三报文握手?

最后一次确认是为了防止已失效的连接请求报文段又突然传送到B,造成资源的浪费

TCP 连接的释放

数据传输结束后,通信的双方都可以释放连接

Snipaste_2022-10-30_16-19-02

​ 现在 AB 都处于 ESTAB-LISHED 状态。A 的应用进程先向其TCP发出连接释放报文段,并停止再发送数据,主动关闭TCP连接。A 把连接释放报文段首部的终止控制位FIN1,其序号 seq = u,它等于前面已经传送过的数据的最后一个字节的序号加1。这时 A 进入 FIN-WAIT-1(终止等待1)状态,等待 B 的确认。请注意,TCP规定,FIN 报文段即使不携带数据,它也消耗一个序号。

B 收到连接释放报文段后发出确认,确认号ack = u +1,而这个报文段自己的序号是 v,等于B 前面已经传送过的数据的最后一个字节的序号加1。然后B就进入CLOSE-WAIT(关闭等待)状态。TCP服务器进程这时应通知高层应用程序,因而从 AB 这个方向的连接就释放了,这时的TCP连接处于半关闭(half-close)状态,即A已经没有数据要发送了,但B若发送数据,A任要接收。也就是说,从BA这个方向的连接并未关闭,这个状态可能会持续一段时间。

A 收到 B的确认后,就进入了FIN-WAIT-2(终止等待2)状态,等B发出连接释放的报文段。

​ 若B已经没有要向A发送的数据,其应用进程就通知TCP释放连接。这时B发出的连接释放报文段必须使FIN=1。现假定B的序号是w(在半关闭状态,B可能又发送了一些数据)。B还必须重复上次已发送过的确认号ack=u+1。这时B就进入了LAST-ACK(最后确认)状态,等待A的确认。

A 在收到 B 的连接释放报文段后,必须对此发出确认。在确认报文段中把ACK1,确认号ack=w+1,而自己的序号是seq=u+1(根据TCP标准前面发送过的FIN报文段需要消耗一个序号)。然后进入了TIME-WAIT(时间等待)状态。请注意,现在TCP连接还没有释放掉。必须经过时间等待计时器(TIME-WAIT timer)设置的时间 2MSL 后,A 才进入到 CLOSED 状态。时间 MSL 叫作最长报文段寿命,MSL = 2分钟。因此 A 进入到TIME-WAIT状态后,要经过4分钟才能进入到CLOSED状态,才能开始建立下一个新的连接。当A撤销响应的传输控制块TCB后,就结束了这次的TCP连接。

B收到A发出的确认,就进入CLOSED状态。同样,B撤销响应的传输控制块TCB后,就结束了这次的TCP连接。我们注意到,B结束TCP连接的时间要比A早一些

为什么A要在TIME-WAIT状态必须等待 2MSL 的时间?

  1. 为保证A发送的最后一个ACK报文段能够到达B,如果中途丢失,B没收到确认报文段,就会超时重传FIN+ACK这个报文段,此时A在等待才能接收到超时重传的报文
  2. 防止已失效的连接请求报文段出现在本连接中

保活计时器:服务器每收到一次客户的数据,就重新设置保活计时器,时间的设置通常是两小时。若两小时没有收到客户的数据,服务器就发送一个探测报文段,以后每隔75秒就发送一次。若一连发送10个探测报文段后客户仍没有响应,服务器就认为客户端出了故障,接着就关闭这个连接

HTTP请求流程:为什么很多站点第二次打开很快?

一个TCP连接过程包括:建立连接,传输数据和断开连接三个阶段。

而HTTP协议,正式建立在TCP连接基础之上的。HTTP是一种允许浏览器向服务器获取资源的协议,是Web的基础,通常由浏览器发起请求,用来获取不同类型的文件,例如HTML文件、CSS文件、Javascript文件、图片、视频等。此外,HTTP也是浏览器使用最广的协议,所以要学好浏览器,就要先深入了解HTTP。

浏览器发起HTTP请求流程

1、构建请求

浏览器构建请求行信息,构建好后,浏览器准备发起网络请求

GET /index.html HTTP1.1

2、查找缓存

在真正发起网络请求之前,浏览器会先在浏览器缓存中查询是否有要请求的文件。其中,浏览器缓存是一种在本地保存资源副本,以供下次请求时直接使用的技术。

当浏览器发现请求的资源已经在浏览器缓存中存有副本,它会拦截请求,返回该资源的副本,并直接结束请求,而不会再去源服务器重新下载。这样做的好处有:

  • 缓解服务器端的压力,提升性能(获取资源的耗时更短了)
  • 对于网站来说,缓存是实现快速资源加载的重要组成部分

如果缓存查找失败,就会进入网络请求过程了

3、准备IP地址和端口

浏览器使用 HTTP 协议作为应用层协议,用来封装请求的文本信息。并使用 TPC/IP 作为传输层协议将它发到网络上,所以HTTP在工作开始前,浏览器需要通过TCP与服务器建立连接。也就是说HTTP的内容是通过TCP的传输数据阶段来实现的

  • HTTP 网络请求的第一步是:和服务器建立 TCP 连接
  • 建立 TCP 连接的第一步:准备IP地址和端口
  • 怎么获取IP地址和端口呢?

解析用户在地址栏输入的 URL 拿到域名,然后进行DNS域名解析,就可以得到IP地址了,通常情况下,URL没有特别指明端口号的话,那么HTTP协议默认是80端口

4、等待TCP队列

IP地址和端口都有了,那是不是可以直接建立TCP连接呢?

还不行。Chrome有个机制,同一域名下最多只能建立6个TCP连接,如果同一域名下有10个请求发生,那么其中四个请求就会进入排队等待状态,直至进行中的请求完成。

当然,如果当前请求数量少于6,会直接进入下一步,建立TCP连接

5、建立TCP连接

6、发送HTTP请求

一旦建立了TCP连接,浏览器就可以和服务器通信了。而HTTP中的数据正是在这个通信过程中传输的。

首先浏览器会向服务器发送请求行,他包括了请求方法、请求URL和HTTP版本协议。

发送请求行,就是告诉服务器浏览器需要什么资源,最常用的请求方法是Get。在浏览器地址栏输入网址,浏览器默认就是发Get请求

另外一个常用的请求方法是POST,它用于发送一些数据给服务器,比如登录一个网站,就需要通过POST方法把用户的信息。如果使用POST方法,那么浏览器还要准备数据给服务器,这里准备的数据是通过请求体来发送的。

在浏览器发送请求行命令之后,还要以请求头形式发送一些其他的信息,把浏览器的一些基础信息告诉服务器。比如包含了浏览器所使用的操作系统、浏览器内核等信息,以及当前请求的域名信息,浏览器端的Cookie信息,等等

服务器端处理HTTP请求

1、返回请求

一旦服务器处理结束,便可以返回数据给浏览器了。你可以通过工具软件curl来查看返回请求数据

首先服务器会返回响应行,包括协议版本和状态码。

但并不是所有请求都可以被服务器处理的,那么有一些无法处理或者处理出错的信息怎么办呢?服务器会通过请求行的状态码来告诉浏览器它的处理结果,比如:

  • 最常用的状态码是200,表示处理成功;
  • 如果没有找到页面,则会返回404

随后,正如浏览器会随同请求发送请求头一样,服务器也会随同响应向浏览器发送响应头。响应头包含了服务器自身的一些信息,比如服务器生成返回数据的时间、返回数据的类型(JSON、HTML、流媒体等类型),以及服务器要在客户端保存的Cookie等信息。

发送完响应头后。服务器就可以继续发送响应体的数据,通常,响应体就包含了HTML的实际内容。

以上就是服务器响应浏览器的具体过程

2、断开连接

通常情况下,一旦服务器向客户端返回了请求数据,它就要关闭TCP连接。不过如果浏览器或者服务器在其头信息中加入了:

Connection:Keep-Alive

那么TCP连接在发送后仍然保持打开状态,这样浏览器就可以继续通过同一个TCP连接发送请求。保持TCP连接可以省去下次请求时需要建立连接的时间,提升资源加载速度。

3、重定向

请求流程还有一种情况需要了解一下,比如当你在浏览器中打开geekbang.org后,你会发现最终打开的页面地址是 www.geekbang.org。

从图中可以看到,响应行返回的状态码是301,状态301就是告诉浏览器,我需要重定向到另外一个网址。而需要重定向的网址正是正是包含在响应头的Location字段中,接下来,浏览器获取Location字段中的地址并使用该地址重新导航,这就是一个完整重定向执行流程了,这也就解释了为什么输入是geekbang.org,最终打开的却是 www.geekbang.org 了。

现在我们来分析一下开头的问题:

1、为什么很多站点第二次打开速度会很快?

主要是因为第一次加载页面过程中,缓存了一些耗时的数据。

那么哪些数据会被缓存呢?从上面介绍的核心请求路径可以发现,DNS缓存和页面资源缓存这两块数据是会被浏览器缓存的。其中,DNS缓存比较简单,就是在浏览器本地把对应的 IP 和域名关联起来,这里就不做过多分析。

下面重点分析一下浏览器资源缓存,

首先,我们看一下服务器是通过什么方式让浏览器缓存数据的?

从上图第一次请求可以看到,当服务器返回 HTTP 响应头给浏览器时,浏览器是通过响应头中的Cache-Control字段来设置是否缓存该资源。通常,我们还需要为这个资源设置一个缓存过期时长,而这个时长是通过Cache-Control中的Max-age参数来设置的,比如上图设置的缓存时间就是2000秒。

Cache-Control:Max-age=2000

这也就意味着,在该缓存资源还未过期的情况下,如果再次请求该资源,会直接返回缓存中的资源给浏览器。但如果缓存过期了,浏览器则会继续发起网络请求,并且在HTTP请求头中带上:

If-None-Match:"4f80f-13c-3a1xbq2a"

服务器收到请求头后,会根据If-None-Match的值来判断请求的资源是否有更新

  • 如果没有更新,就返回304状态码,相当于服务器告诉浏览器:"这个缓存可以继续使用,这次就不重复发送数据给你了"
  • 如果资源有更新,服务器就直接返回最新资源给浏览器

简要来说,很多网站第二次访问能够秒开,是因为这些网站把很多资源缓存到本地,浏览器缓存直接使用本地副本来回应请求,而不会产生真实的网络请求,从而节省了时间。同时,DNS数据也被浏览器缓存,这又省去了DNS查询环节。

2、登录状态是如何保持的?

  • 用户打开登录界面,在登录框里面填入用户名密码,点击确定按钮。点击按钮会触发页面脚本生成用户登录信息,然后调用POST方法提交用户登录信息给服务器。
  • 服务器接收到浏览器提交的数据之后,查询后台,验证用户登录信息是否正确,如果正确的话,会生成一段表示用户身份的字符串,并把字符串写到响应头的Set-Cookie字段里面,然后把相应头发送给浏览器
Set-Cookie:UID=3431uad
  • 浏览器接收到服务器的响应头后,开始解析响应头,如果遇到响应头里含有Set-Cookie字段的情况,浏览器就会把这个字段信息保存到本地。比如把UID=3431uad保存到本地。

  • 当用户再次访问时,浏览器会发起HTTP请求,但在发起请求前,浏览器会读取之前保存的Cookie数据,并把数据写进请求头里的Cookie字段里,然后浏览器再把请求头发给服务器。

Cookie:UID=3431uad
  • 服务器在收到HTTP请求头数据之后,就会查找请求头里面Cookie字段信息,当查找到包含有UID=3431uad的信息时,服务器查询后台,并判断该用户是已登陆状态,然后生成含有该用户信息的页面数据,并把生成的数据发送给浏览器。

  • 浏览器接收到该含有当前用户的页面数据后,就可以正确展示用户登录的状态信息了

Cookie流程图

简单说,如果服务器发送的响应头含有Set-Cookie字段,浏览器就会将字段内容保存到本地。下次客户端在向服务器请求的时候,浏览器会自动在请求头加上Cookie值然后发送出去,服务器发现客户端发来的Cookie后,就会去检查究竟是从哪个客户端发来的连接请求,然后对比此服务器上的记录,最后得到该用户的状态信息。

HTTP请求流程

典中典:从输入URL到页面展示这中间发生了什么?

从输入URL到页面展示完整流程图

从图中可以看出,整个过程需要各个进程之间配合,所以在开始正式流程之前,我们还是先来快速回顾一下浏览器进程、渲染进程和网络进程的主要职责。

  • 浏览器进程主要负责用户交互、子进程管理和文件储存等功能
  • 网络进程是面向渲染进程和浏览器进程等提供网络下载功能。
  • 渲染进程的主要职责是把从网络下载的HTML,Javascript、CSS、图片等资源解析为可以显示和交互的页面。因为渲染进程所有的内容都是通过网络获取的,会存在一些恶意代码利用浏览器漏洞对系统进行攻击,所以运行在渲染进程里面的代码是不被信任的。这也是为什么Chrome会让渲染进程运行在安全沙箱里,就是为了保证系统的安全

回顾了浏览器的进程架构后,我们再结合上图来看下整个完整的流程,可以看出,整个流程包含了许多步骤,我把其中几个核心的节点用蓝色背景标记出来了。这个过程可以大致描述为如下:

  • 首先,用户从浏览器进程里输入请求信息
  • 然后,网络进程发起URL请求
  • 服务器响应URL请求之后,浏览器进程就又要开始准备渲染进程了
  • 渲染进程准备好之后,需要先向渲染进程提交页面数据,我们称之为提交文档阶段
  • 渲染进程接收完文档信息之后,便开始解析页面和加载子资源,完成页面渲染

这其中,用户发起URL请求到页面开始解析的这个过程,就叫做导航。下面进行逐步分析:

知道了浏览器的几个主要进程的职责之后,那么接下来,我们就从浏览器的地址栏开始讲起

1、用户输入

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

  • 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的URL。
  • 如果判断输入内容符合URL规则,比如输入的是time.geekbang.org,那么地址栏会根据规则,把这段内容加上协议,合成为完整的URL,如time.geekbang.org

当用户输入关键字并键入回车之后,浏览器便进入下图的状态:

从图中可以看出,当浏览器刚开始加载一个地址之后,标签页上的图标便进入了加载状态。但此时图中页面显示的依然是之前打开的页面内容,并没有立即替换为新页面。因为需要等待提交文档阶段,页面内容才会被替换。

2、URL请求过程

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

首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步是要进行DNS解析,以获取请求域名的服务器IP地址。如果请求协议是HTTPS,那么还需要建立TLS连接。

接下来就是利用IP地址和服务器建立TCP连接。连接建立后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的Cookie等数据附加到请求头中,然后向服务器发送构建的请求信息

服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络接收到响应行和响应头之后,就开始解析响应头的内容了。

(1)重定向

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

比如我们在终端里输入以下命令

curl -I http://time.geekbang.org/

curl -I + URL的命令是接收服务器返回的响应头信息。执行命令后,我们看到服务器返回的响应头信息如下:

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

下面我们再使用HTTPS协议对极客时间发起请求,看看服务器的响应头信息是什么样子的。

curl -I https://time.geekbang.org/

我们看到服务器返回如下信息:

从图中可以看出,服务器返回的响应头的状态码是200,这是告诉浏览器一切正常,可以继续往下处理该请求。

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

(2)响应数据类型处理

在处理了跳转信息之后,我们继续导航流程的分析。URL请求的数据类型,有时候是一个下载类型,有时候是正常的HTML页面,那么浏览器是如何区分他们的呢?

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

这里我们还是以极客时间为例,看看极客时间官网返回的Content-Type的值是什么。在终端输入以下命令:

curl -I https://time.geekbang.org/

返回信息如下图:

从图中可以看到,响应头中的Content-Type字段的值是text/html,这就是告诉浏览器,服务器返回的数据是HTML格式

接下来我们再来利用curl来请求极客时间安装包的地址,如下所示:

curl -I https://res001.geekbang.org/apps/geektime/android/2.3.1/official/geektime_2.3.1_20190527-2136_offical.apk

请求后返回的响应头信息如下:

从图中可以看到,响应头中的Content-Type字段的值是application/octet-stream,显示数据是字节流类型的,通常情况下,浏览器会按照下载类型来处理该请求。

需要注意的是,如果服务器配置Content-Type不正确,比如将text/html类型配置成application/octet-stream类型,那么浏览器可能会曲解文件内容,比如会将一个本来用来展示的页面,变成了一个下载文件。

所以,不同的Content-Type的后续处理流程也截然不同。如果Content-Type的字段值被浏览器判断为下载类型,那么请求会被提交给浏览器的下载管理器,同时该URL请求的导航流程就此结束。但如果是HTML,那么浏览器则会继续进行导航流程。由于Chrome的页面渲染是运行在渲染进程中,所以接下来就需要准备渲染进程了

3、准备渲染进程

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

那么什么情况下多个页面会同时运行在一个进程中呢?

要解决这个问题,我们需要先了解一下什么是同一站点(same-site)。具体讲,我们将“同一站点”定义为根域名(例如,geekbang.org)加上协议(例如https://或者http://),还包含了该根域名下的所有子域名和不同端口,比如下面这三个:

https://time.geekbang.org
https://www.geekbang.org
https://www.geekbang.org:8080

它们都是属于同一站点,因为它们的协议都是HTTPS,而且根域名也都是geekbang.org。

Chorme的默认策略是,每一个标签对应一个渲染进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫做process-per-site-instance

**问:**那如果新页面和当前页面不属于同一站点,情况又会发生什么变化呢?

**答:**那么浏览器就会给新页面分配一个新的渲染进程。

**准备渲染进程小结:**打开一个新页面采用的渲染进程策略就是:

  • 通常情况下,打开新页面都会使用单独的渲染进程;
  • 如果从A页面打开B页面,且A和B都属于同一站点的话,那么B页面复用A页面的渲染进程;如果是其他情况,浏览器进程则会为B创建一个新的渲染进程。

渲染进程准备好了之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,所以下一步就是进入了提交文档阶段

4、提交文档

首先要明确一点,这里的文档是指URL请求的响应体数据

  • “提交文档” 的消息是又浏览器进程发出的,渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”
  • 等文档数据传输完成之后,渲染进程会返回“确认提交”的消息给浏览器进程
  • 浏览器进程在收到“确认提交”的消息后,会更新浏览器界面状态,包括了安全状态、地址栏的URL、前进后退的历史状态,并更新Web页面。

更新内容如下图所示

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

到这里一个完整的导航流程就走完了,之后就要进入渲染阶段了

5、渲染阶段

一旦文档被提交,渲染进程便开始页面解析和子资源加载,关于这个阶段的完整过程请看番外篇:渲染流程:HTML、CSS和JavaScript是如何变成页面的

概要:从输入URL到页面渲染展示,这中间发生了什么

  1. 用户输入url并回车
  2. 浏览器进程检查url,组装协议,构成完整url
  3. 浏览器进程通过进程间通信(IPC)把url请求发送给网络进程
  4. 网络进程接收到url请求后检查本地缓存是否缓存了该请求资源,如果有则将资源返回给浏览器进程
  5. 如果没有,网络进程向web服务器发起http请求(网络请求),请求流程如下
    • 进行DNS解析,获取服务器ip地址,端口
    • 利用ip地址和服务器建立TCP连接
    • 构建请求头信息
    • 发送请求头信息
    • 服务器响应后,网络进程接收响应头和响应信息,并解析响应内容
  6. 网络进程解析响应流程
    • 检查状态码,如果是301/302,则需要重定向,从Location字段中读取出地址,重新进行第4步,如果是200,则继续处理请求
    • 200响应处理:检查响应类型Content-Type,如果是字节流类型,则将该请求提交给下载管理器,该导航结束,不再进行后续的渲染,如果是html则通知浏览器进程准备渲染进程准备进行渲染
  7. 准备渲染进程
    • 浏览器进程检查当前url是否和之前打开的渲染进程根域名是否相同,如果相同则复用原来的进程,如果不同,则开启新的渲染进程
  8. 传输数据、更新状态
    • 渲染进程准备好后,浏览器向渲染进程发起提交文档的消息,渲染进程接收到消息后和网络进程建立传输数据的管道
    • 渲染进程接收完数据后,向浏览器进程发送确认提交
    • 浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏url、前进后退的历史状态、更新web界面

番外篇

彻底搞懂浏览器缓存机制

为什么需要浏览器缓存?

我们知道通过HTTP协议,在客户端与服务端建立连接需要消耗时间,两端间的多次通信与数据传输也要消耗时间,不仅会使客户端响应慢用户、体验差,还会增加服务端的压力。所以就出现了浏览器缓存来缓解这个问题。

缓存的意义就在于减少请求,更多的使用本地资源,给用户更好的体验,减轻服务器压力

浏览器缓存机制也就是我们说的HTTP缓存机制,其机制是根据HTTP报文(请求头,响应头)的缓存标识进行的。

缓存流程

浏览器在第一次向服务器发起请求后拿到请求结果,然后根据响应报文中响应头的缓存标识,决定是否缓存资源。简单过程如图:

缓存的关键

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存中

下面围绕这两点展开分析,根据是否需要向服务器发起请求分为强制缓存和协商缓存

强制缓存

强制缓存就是向浏览器缓存查找该请求结果,并根据该结果的缓存规则来决定是否使用该缓存结果的过程,强制缓存的情况主要有三种(暂不分析协商缓存过程)如下:

  1. 不存在该缓存结果和缓存标识,强制缓存失效,则直接向服务器发起请求(跟第一次发起请求一致)

  2. 存在该缓存结果和缓存标识,但该结果已失效,强制缓存失效,则使用协商缓存

  3. 存在该缓存结果和缓存标识,且该结果尚未失效,强制缓存生效,直接返回该结果

强制缓存的缓存规则

当浏览器向服务器发起请求时,服务器会将缓存规则放入HTTP响应报文头中,和请求结果一起返回给浏览器,控制强制缓存的字段分别是 Expires 和 Cache-Control ,其中Cache-Control 优先级比 Expires 高。

Expires

Expires 是HTTP/1.0控制网页缓存的字段,其值为服务器返回该请求结果缓存的过期时间,即再次发起该请求时,如果客户端的时间超过了Expires的值,证明缓存已经过期了,需要重新发起HTTP请求

到了HTTP/1.1,Expires已经被Cache-Control替代,原因在于Expires控制缓存的原理是使用是使用客户端的时间与服务端返回的时间做对比,如果客户端与服务端的时间由于某些原因(比如时区不同)发生误差,那么强制缓存则会直接失效,这样的话强制缓存的存在毫无意义,那么Cache-Control又是如何控制的呢?

Cache-Control

在HTTP/1.1中,Cache-Control是最重的规则,主要用于控制网页缓存,主要取值为:

  • pubilc:所有内容都将被缓存(客户端和代理服务器都可缓存)
  • private:所有内容只有客户端可以缓存,Cache-Control的默认取值
  • no-cache:客户端缓存内容,但是是否使用缓存需要经过协商缓存
  • no-store:所有的内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
  • max-age=xxx (xxx is numeric):缓存内容将在xxx秒后失效

接下来,直接看一个例子:

由上面的例子我们可以知道:

  • HTTP响应报文中expires的时间值,是一个绝对值
  • HTTP响应报文中Cache-Control为max-age=600,是相对值

由于Cache-Control的优先级比Expires高,那么直接根据Cache-Control的值进行缓存,意思就是在说在600秒内再次发起请求,则会直接使用缓存结果,强制缓存生效。

注:在无法确定客户端的时间是否与服务端的时间同步的情况下,Cache-Control相比于expires是更好的选择,所以同时存在时,只有Cache-Control生效。

了解过强制缓存的过程之后,我们拓展性思考一下:

浏览器缓存放在哪里,如何在浏览器中判断强制缓存是否生效?

这里我们以博客的请求为例,状态码为灰色的请求则代表使用强制缓存,请求对应的Size值则代表该缓存存放的位置,分别为from memory cache 和 from disk cache

那么from memory cache 和 from disk cache又分别代表的是什么呢?什么时候会使用from disk cache,什么时候会使用from memory cache呢?

form memory cache代表使用内存中的缓存,from disk cache 则代表使用的是硬盘中的缓存,浏览器读取缓存的顺序为 memory > disk

访问heyingye.github.io/ –> 200 –> 关闭博客的标签页 –> 重新打开heyingye.github.io/ –> 200(from disk cache) –> 刷新 –> 200(from memory cache)

  • 刷新

看到这里可能有人小伙伴问了,最后一个步骤刷新的时候,不是同时存在着from disk cache和from memory cache吗?

对于这个问题,我们需要了解内存缓存(from memory cache)和硬盘缓存(from disk cache),如下:

  • 内存缓存(from memory cache):内存缓存具有两个特点,分别是快速阅读和时效性
    • 快速读取:内存缓存会将编译好解析后的文件,直接存入该进程的内存中,占据该进程一定的内存资源,以方便下次运行使用时的快速获取
    • 时效性:一旦该进程关闭,则该进程的内存则会清空。
  • 硬盘缓存(from disk cache):硬盘缓存则是直接将缓存写入硬盘文件中,读取缓存需要对该缓存存放的硬盘文件进行I/O操作,然后重新解析该缓存内容,读取复杂,速度比内存缓存慢

在浏览器中,浏览器会在js和图片文件解析执行后直接存入内存缓存中,那么当刷新页面时只需直接从内存缓存中读取(from memory cache);而css文件则会存入硬盘文件中所以每次渲染页面都需要从硬盘读取缓存(from disk cache)。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况:

协商缓存生效,返回304,如下

协商缓存失效,返回200和请求结果,

同样协商缓存的标识也是在响应报文的HTTP头中和请求结果一起返回给浏览器的,控制协商缓存的字段分别有:Last-Modified/if-Modified-Since 和 Etag/if-None-Match,其中Etag/if-None-Match的优先级比Last-Modified/if-Modified-Since高。

Last-Modified/if-Modified-Since

Last-Modified是服务器响应请求时,返回该资源文件在服务器最后被修改的时间

If-Modified-Since则是客户端再次发起请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改的时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后修改时间做对比,若服务器的资源最后被修改的时间大于If-Modified-Since的字段值,则重新返回该资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件,如下。

Etag / If-None-Match

Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),如下:

If-None-Match是客户端再次发起请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值,服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比,一致则返回304,代表资源无更新,继续使用缓存文件;不一致则重新返回资源文件,状态码为200,如下

注:Etag / If-None-Match优先级高于Last-Modified / If-Modified-Since,同时存在则只有Etag / If-None-Match生效。

强制缓存优先于协商缓存进行,若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,重新获取请求结果,再存入浏览器缓存中;生效则返回304,继续使用缓存,主要过程如下:

渲染流程:HTML、CSS和JavaScript是如何变成页面的?

导航阶段结束之后,就进入了渲染阶段。

这个阶段很重要,了解其相关流程能让你“看透”页面是如何工作的,有了这些知识,你可以解决一系列相关的问题,比如能熟练使用开发者工具,因为能够理解开发者工具里面大部分项目的含义,能优化页面卡顿问题,使用JavaScript优化动画流程,通过优化样式表来防止强制同步布局,等等。

由于喧嚷机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的HTML经过这些子阶段,最后输出像素。我们把这样一个处理流程叫做渲染流水线。按渲染的时间顺序,流水线可以分为如下几个子阶段:构建DOM树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成。

接下来,在介绍每个阶段的过程中,你应该重点关注以下三点内容

  • 开始每个子阶段都有其输入的内容;
  • 然后每个子阶段有其处理过程;
  • 最终每个子阶段会生成输出内容。

构建DOM树

:为什么要构建DOM树?

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

从图中可以看出,构建DOM树的输入内容是一个非常简单的HTML文件,然后经由HTML解析器解析,最终输出树状结构的DOM。

为了更加直观地理解DOM树,你可以打开Chrome的“开发者工具”,选择“Console”标签来打开控制台,然后在控制台里面输入“document”后回车,这样你就能看到一个完整的DOM树结构,如下图所示:

图中的document就是DOM结构,你可以看到DOM和HTML内容几乎是一样的,但是和HTML不同的是,DOM是保存在内存中的树状结构,可以通过JavaScript来修改DOM的内容,在控制台中输入:

document.getElementsByTagName("p")[0].innerText = "black"

这行代码的作用是把第一个<p>标签的内容修改为black,具体执行结果你可以参考下图:

img

从图中可以看出,在执行了一段修改第一个<p>标签的JavaScript代码后,DOM的第一个p节点的内容成功被修改,同时页面中的内容也被修改了

好了,现在我们已经生成DOM树了,但是DOM节点的样式我们依然不知道,要让DOM节点拥有正确的样式,这就需要样式计算了

样式计算

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

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

CSS的样式来源有哪些?

img

从图中可以看出,CSS样式来源主要有三种:

  • 通过link引用的外部CSS文件
  • <style>标记内的 CSS
  • 元素的style属性内嵌的CSS

和HTML文件一样,浏览器也是无法直接理解这些纯文本的CSS样式,所以当渲染引擎接收到CSS文本时,会执行一个转换操作,将CSS文本转换为浏览器可以理解的结构——styleSheets。为了加深理解,你可以在Chrome控制台中查看其结构,只需要在控制台中输入document.styleSheets,然后就看到如下图所示的结构

img

从图中可以看出,这个样式表包含了很多种样式,已经把那三种来源的样式都包含进去了。当然样式表的具体结构不是我们今天讨论的重点,你只需要知道渲染引擎会把获取到的CSS文本全部转换为styleSheets结构中的数据,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础

2、转换样式表中的属性值,使其标准化

现在我们已经把现有的CSS文本转化为浏览器可以理解的结构了,那么接下来就要对其进行属性值的标准化操作。

要理解什么是属性值标准化,你可以看下面这样一段CSS文本

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,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。

那标准化后的属性值是什么样子的?

img

从图中可以看到,2em被解析成了32px,red被解析成了rgb(255,0,0),bold被解析成了700……

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

现在样式的属性已被标准化了,接下来就需要计算DOM树中每个节点的样式属性了,如何计算呢?

这就涉及到CSS的继承规则和层叠规则了。

首先是CSS继承。CSS继承就是每个DOM节点都包含有父节点的样式。这么说可能有点抽象,我们可以结合具体例子,看下面这样一张样式表是如何应用到DOM节点上的

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

这张样式表最终应用到DOM节点的效果如下图所示:

img

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

为了加深你对CSS继承的理解,你可以打开Chrome的“开发者工具”,选择第一个“element”标签,再选择“style”子标签,你会看到如下界面

img

这个界面展示的信息很丰富,大致可描述为如下

  • 首先,可以选择要查看的元素的样式(位于图中的区域2中),在图中的第1个区域中点击对应的元素元素,就可以了下面的区域查看该元素的样式了。比如这里我们选择的元素是<p>标签,位于html.body.div.这个路径下面
  • 其次,可以从样式来源(位于图中的区域3中)中查看样式的具体来源信息,看看是来源于样式文件,还是来源于UserAgent样式表。这里需要特别提下UserAgent样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是UserAgent样式。
  • 最后,可以通过区域2和区域3来查看样式继承的具体过程。

以上就是CSS继承的一些特性,样式计算过程中,会根据DOM节点的继承关系来合理计算节点样式。

样式计算过程中的第二个规则是样式层叠。层叠是CSS的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。它在CSS处于核心地位,CSS的全称“层叠样式表”正是强调了这一点。关于层叠的具体规则这里就不做过多介绍了,网上资料也非常多,你可以自行搜索学习

总之,样式计算阶段的目的是为了计算出DOM节点中每个元素的具体样式,在计算过程中需要遵守CSS的继承和层叠两个规则。这个阶段最终输出的内容是每个DOM节点的样式,并被保存在ComputedStyle的结构内。

如果你想了解每个DOM元素最终的计算样式,可以打开Chrome的“开发者工具”,选择第一个“element”标签,然后再选择“Computed”子标签,如下图所示:

img

上图红色方框中显示了html.body.div.p标签的ComputedStyle(已计算样式)的值。你想要查看哪个元素,点击左边对应的标签就可以了

布局阶段

现在我们有DOM树和DOM树中元素的样式,但这不足以显示页面,因为我们还不知道DOM元素的几何位置信息。那么接下来就需要计算出DOM树中可见元素的几何位置,我们把这个计算过程叫做布局

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算

1、创建布局树

你可能注意到了DOM树含有很多不可见的元素,比如head标签,还有使用了display:none 属性的元素。所以在显示之前,我们还要额外的构建一颗只包含可见元素的布局树

我们结合下图来看看布局树的构造过程:

img

从上图可以看出,DOM树中所有不可见的节点都没有包含到布局树中。

为了构建布局树,浏览器大体上完成了下面这些工作

  • 遍历DOM树中的所有可见节点,并把这些节点加到布局中;
  • 而不可见的节点会被布局树忽略掉,如head标签下面的全部内容,再比如body.p.span这个元素,因为它的属性包含 dispaly:none,所以这个元素也没有被包进布局树

2、布局计算

现在我们有了一棵完整的布局树。那么接下来,就要计算布局树节点的坐标位置了。布局的计算过程非常复杂,我们这里先跳过不讲,等到后面章节中我再做详细的介绍。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome团队正在重构布局代码,下一代布局系统叫LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单

小结:本节三个阶段的主要内容:在HTML页面内容被提交给渲染引擎之后,渲染引擎首先将HTML解析为浏览器可以理解的DOM;然后根据CSS样式表,计算出DOM树所有节点的样式;接着又计算每个元素的几何坐标位置,并将这些信息保存在布局树中

分层

现在我们有了布局树,而且每个元素的具体位置信息都计算出来了,那么接下来是不是就要开始着手绘制页面了?

答案依然是否定的。

因为页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便的实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一颗对应的图层树(LayerTree)。如果你熟悉PS,相信你很容易理解图层的概念,正是这些图层叠加在一起构成了最终的页面图像。

要想直观的理解什么是图层,你可以打开Chrome的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况

现在你知道了浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。下面我们再来看看这些图层和布局树节点之间的关系,如文中图所示:

img

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

那么需要满足什么条件,渲染引擎才会为特定的节点创建新的层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。

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

页面是个二维平面,但是层叠上下文能够让HTML元素具有三维的概念,这些HTML元素按照自身属性的优先级分布在垂直于这个二维平面的z轴上。你可以结合下图来直观感受一下: img

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

第二点,需要剪裁(clip)的地方也会被创建位图层。

不过首先你需要了解什么是剪裁,结合下面的HTML代码:

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

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

img

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

img

所以说,元素有了层叠上下文的属性或者需要被剪裁,满足这任意一点,就会被提升成为单独一层

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,那么接下来我们看看渲染引擎是怎么实现图层绘制的?

试想一下,如果给你一张纸,让你先把纸的背景涂成蓝色,然后在中间位置画一个红色的圆,最后再在圆上画个绿色三角形。你会怎么操作呢?

通常把绘制操作分解为三步:

  • 绘制蓝色背景
  • 在中间绘制一个红色的圆
  • 再在圆上绘制绿色三角形

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

img

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。

你也可以打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表,如下图所示:

img

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

栅格化(raster)操作

绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的,你可以结合下图来看下渲染主线程和合成线程之间的关系: img

如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?

那我们得先来看看什么是视口,你可以参看下图:

img

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。

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

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

img

然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的,运行方式如下图所示:

img

通常,栅格化过程都会使用GPU来加速生成,使用GPU生成位图的过程叫快速栅格化,或者GPU栅格化,生成的位图被保存在GPU内存中。

相信你还记得,GPU操作是运行在GPU进程中,如果栅格化操作使用了GPU,那么最终生成位图的操作是在GPU中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:

img

从图中可以看出,渲染进程把生成图块的指令发送给GPU,然后在GPU中执行生成图块的位图,并保存在GPU的内存中

合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫viz的组件,用来接收合成线程发过来的DrawQuad命令,然后根据DrawQuad命令,将其页面内容绘制到内存中,最后再将内存显示到屏幕上。

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

渲染流水线大总结

好了,我们现在已经分析完了整个渲染流程,从HTML到DOM、样式计算、布局、图层、绘制、光栅化、合成和显示。下面我用一张图来总结下这整个渲染流程:

img

结合上图,一个完整的渲染流程大致可总结为如下

  • 渲染进程将HTML转换为浏览器能够读懂的DOM结构
  • 渲染引擎将CSS样式表转换为浏览器可以理解的styleSheets,计算出DOM节点的样式
  • 创建布局树,并计算元素的布局信息
  • 对布局树进行分层,并生成分层树
  • 为每个图层生成绘制列表,并将其提交到合成线程
  • 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图
  • 合成线程发送绘制图块的命令DrawQuad给浏览器进程
  • 浏览器进程根据DrawQuad消息生成页面,并显示到显示器上

相关概念

有了上面介绍渲染流水线的基础,我们再来看看三个和渲染流水线相关的概念——“重排”“重绘”和“合成”。理解了这三个概念对于你后续Web的性能优化会有很大帮助

1、更新了元素的几何属性(重排Reflow,又叫回流)

img

从上图可以看出,如果你通过JavaScript或者CSS修改元素的几何位置属性,例如改变元素的宽度、高度等,那么浏览器会触发重新布局,解析之后的一系列子阶段,这个过程就叫重排。无疑,重排需要更新完整的渲染流水线,所以开销也是最大的

2、更新元素的绘制属性(重绘Repaint)

接下来,我们再来看看重绘,比如通过JavaScript更改某些元素的背景颜色,渲染流水线会怎样调整呢?你可以参考下图:

img

从图中可以看出,如果修改了元素的背景颜色,那么布局阶段将不会被执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段,这个过程就叫重绘。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些

3、直接合成阶段

那如果你更改一个既不要布局也不要绘制的属性,会发生什么变化呢?渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。具体流程参考下图

img

在上图中,我们使用了CSS的transform来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。这样的效率是最高的,因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

至于如何用这些概念去优化页面,我们会在后面相关章节做详细讲解的,这里你只需要先结合“渲染流水线”弄明白这三个概念及原理就行

思考时间

在优化Web性能的方法中,减少重绘、重排是一种很好的优化方式,那么结合文中的分析,你能总结出来为什么减少重绘、重排能优化Web性能吗?那又有那些具体的实践方法能减少重绘、重排呢?

  • 触发repaint reflow的操作尽量放在一起,比如改变dom高度和设置margin分开写,可能会出发两次重排
  • 通过虚拟dom层计算出操作总得差异,一起提交给浏览器。之前还用过createdocumentfragment来汇总append的dom,来减少触发重排重绘次数