[浏览器知识点] 从输入URL到页面展示发生了什么

浏览器的多进程架构

浏览器是多进程的,浏览器之所以能够运行,是因为系统给它的进程分配了资源(CPU、内存)。

以Chrome为例,介绍一下现代浏览器的多进程架构(multi-process architecture):

其中主要的部分有:

  1. 浏览器进程(Browser Process),也叫主进程(负责协调、主控),只有一个。
  • UI线程(UI Thread):控制浏览器上的按钮和输入框等UI
  • 网络线程(Network Thread):负责本地资源的下载
  • 存储线程(Storage Thread):负责本地缓存文件的访问
  1. 渲染进程(浏览器内核)(内部是多线程的)
  • JS引擎:负责执行JavaScript。也是JS是单线程的由来
  • GUI渲染线程,负责渲染资源,与JS引擎互斥(一个运行一个挂起)
  • 事件触发线程:管理事件循环,按顺序把事件放到JS执行队列
  • 定时器线程:setTimeout并不是JS的功能,只是浏览器开给JS的一个接口
  • 异步请求线程:处理Ajax请求,通过回调函数通知事件触发进程
  1. GPU进程:负责与GPU通信,最多一个,用于3D绘制等。

  2. 第三方插件进程。比如:安装的浏览器插件

上面提到了进程与线程,先来了解一下进程与线程的关系

先看一个形象的比喻:

- 进程是一个工厂,工厂有它的独立资源

- 工厂之间相互独立

- 线程是工厂中的工人,多个工人协作完成任务

- 工厂内有一个或多个工人

- 工人之间共享空间
复制代码

再完善一下概念:

- 工厂的资源 -> 系统分配的内存(独立的一块内存)

- 工厂之间的相互独立 -> 进程之间相互独立

- 多个工人协作完成任务 -> 多个线程在进程中协作完成任务

- 工厂内有一个或多个工人 -> 一个进程由一个或多个线程组成

- 工人之间共享空间 -> 同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)
复制代码

官方定义:

  • 进程 是CPU资源分配的最小单位(是能拥有资源和独立运行的最小单位)
  • 线程 是CPU调度的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程可以有多个线程)

在浏览器中打开一个网页相当于新起了一个进程(进程内有自己的多线程)

打开Chrome浏览器的任务管理器,可以清晰的看到:
image.png
打开多个网页时,浏览器进程、网络进程、GPU进程(如果有音频、视频等内容,也会提供 Audio Service 进程)是共享的,每一个插件单独启动一个进程,默认情况下,每一个页面单独启用一个渲染进程(如果两个站点根域名和协议(https 或 http)相同,从一个页面打开了另一个页面,会复用渲染进程)

说完浏览器的多进程架构,下面就开始了解浏览器从输入URL到页面展示发生了什么?

浏览器从输入URL到页面展示发生了什么?

1. 构建请求

输入URL后,主进程的UI线程接受到用户的URL,判断用户输入的是query还是URL。

如果是URL,把URL转发给网络线程,网络线程会构建请求行信息,构建好之后,浏览器就准备发起网络请求。

2. 查找强缓存

浏览器在发起真正的网络请求前,会先检查浏览器的强缓存,如果命中,直接返回对应资源文件的副本。否则进入下一步。
image.png

2.1 什么是强缓存

什么是缓存?

可以理解成资源副本。当我们向服务器请求资源后,会根据情况将资源copy一份副本存在本地,以方便下次读取。

它与本地存储localStorage、cookie等不同:

  • 本地存储更多的是数据记录,存储量较小,为了本地操作方便。

  • 缓存更多是为了减少资源请求,多用于存储文件,存储量相对交大。

可以看出,缓存的根本作用就是减少没必要的请求

  • 比如用户头像图片,很久才改变一次,但每次都要去请求这张一样的图片,通信一来一回增加了页面的显示时长,过多不必要请求也增加了服务器的压力。如果把这张图片直接缓存在本地,那每次就可以直接本地读取加载,不再发起请求。

好处显而易见:

  • 减少了时长从而优化用户体验
  • 减少流量消耗
  • 还减轻了服务器压力

就浏览器而言,缓存一般分为四类,按浏览器读取优先级顺序依次为:

  • Memory Cache
  • Service Worker Cache
  • HTTP Cache
  • Push Cache

下面要说的就是HTTP Cache,分为强缓存和协商缓存

二者的根本区别是:是否需要发送请求。

  • 强缓存:从本地副本比对读取(保存在硬盘或内存中),可以立马访问到,不去请求服务器,返回的状态码是 200

  • 协商缓存:需要发送请求给服务器比对,问问资源是否有更新,如果没有更新就访问本地缓存;如果更新,服务器会返回更新后的资源文件,返回的状态码是 304

2.2 强缓存的实现

强缓存主要包括expirescache-control

  1. expires

expires 是 HTTP1.0 中定义的缓存字段。当我们请求一个资源,服务器返回时,可以在 Response Headers 中增加 expires 字段表示资源的过期时间。

expires: Thu, 03 Jan 2019 11:43:04 GMT
复制代码

它是一个时间戳(准确点应该叫格林尼治时间),当客户端再次请求该资源的时候,会把客户端时间与该时间戳进行对比,如果大于该时间戳则已过期,否则直接使用该缓存资源。

但是,有个大问题,发送请求时是使用的客户端时间去对比。一是客户端和服务端时间可能快慢不一致,另一方面是客户端的时间是可以自行修改的(比如浏览器是跟随系统时间的,修改系统时间会影响到),所以不一定满足预期。

也就是说,用户可以通过修改自己本地时间,使缓存失效

  1. cache-control

因为上面存在的问题, 所以 HTTP/1.1 中新加入了Cache-Control字段来解决这个问题,通过设置 cache-control: max-age=XXX ,可以实现缓存在 XXX 秒后过期(相对时间),这样就规避了用户可以自己篡改本地时间使缓存失效的问题。

该字段是一个时间长度,单位秒,表示该资源过了多少秒后失效。

当客户端请求资源的时候,发现该资源还在有效时间内则使用该缓存,它不依赖客户端时间。

  1. 在 cache-control 和 Expires 同时存在时,以 cache-control 优先。

image.png

3. DNS解析

发送真正网络请求首先需要进行DNS解析,目的就是找到URL对应的服务器IP地址。

关于DNS解析的过程简单介绍:
大致是先查找本地 DNS 缓存,找不到就问本地 DNS 服务器,再依次问根域 DNS 服务器,一级域名服务器,二级域名服务器,最后把找到的 IP 地址层层传递回来

这里再放一张图帮助大家更直观地理解。

image.png

4. 建立TCP连接

知道服务器的IP地址后,就可以跟服务器正式建立连接了,连接的方式分两种:可靠的TCP和不可靠的UDP。

HTTP协议是基于TCP的,所以需要跟服务器建立TCP连接。怎么建立呢?通过三次握手
如下图所示:

image.png

三次握手即是要确认客户端服务端双方的发送能力接收的能力

最开始双方都属于CLOSED状态,然后服务器开始监听某个端口,进入LISTEN状态。

  • 客户端注重发起连接,发送SYN,自己变成了SYN-SENT状态;
  • 服务端收到,返回SYN和ACK(对应客户端发来的SYN),自己变成了SYN-RECD;
  • 客户端再发送ACK给服务端,自己变成ESTABLEISHED(established)状态,服务端收到ACK之后,也变成这个状态。

其中SYN需要对端的确认,而ACK并不需要,因此SYN消耗一个序列号而ACK不需要。
(记住一个规则:凡是需要对端确认的,一定消耗TCP报文的序列号)

为什么不是两次握手呢?

是因为浏览器和服务器都需要确认对方有正常收发能力。如果两次握手的话,客户端能知道服务端能手能发,但服务端只能知道客户端能发送数据,并不知道客户端接收数据是没问题的

三次握手过程中可以携带数据嘛?

可以,但是只有第三次。前两次不能带而要第三次带的原因分析如下:

  • 防止黑客。会增大服务器攻击风险,防止黑客在第一次握手中的SYN报文中放大量的数据,造成服务器消耗大量的时间和内存空间。
  • established状态相对安全。第三次这个状态已经能够确认服务器的接收发送能力正常了。

5. 发送请求,收到响应

建立了TCP连接,浏览器就可以和服务器通信了,HTTP中的数据就是在这个通信过程中传输的。下面是一个HTTP请求的完整示例:
image.png
服务器收到 HTTP 请求后,会返回给浏览器 HTTP 响应,下面是一个 HTTP 响应的完整示例。
image.png
服务器会通过响应行中的状态码告诉浏览器它的处理结果,常见的状态码有以下几类:

  • 2XX:成功,最常见的是 200 OK
  • 3XX:需要进一步操作,比如 301 永久重定向,302 临时重定向,304 未修改
  • 4XX:请求出错,比如最常见的 404 未找到资源,还有 403 禁止请求
  • 5XX:服务器出错,比如 500 服务器内部错误,502 网关错误

6. 查找协商缓存

在上一步中如果 HTTP 响应行中的状态码为 304 (Not Modified 未修改),内容为空时,那么就相当于告诉浏览器“服务器上的资源跟你本地缓存的副本一样,从缓存中拿就行啦”。

这就是协商缓存的流程,当强缓存过期,或者 cache-control 设置 no-cache 时,就会进行协商缓存,浏览器会发送请求到服务器,根据响应头中的状态码判断是否要从缓存中读取。

6.1 协商缓存的实现

协商缓存主要通过last-modified 和 e-tag 实现。

  1. last-modified

last-modified记录资源最后的修改时间。启用后,请求资源之后的响应会增加一个last-modified字段,如下:

last-modified: Thu, 20 Dec 2018 11:36:00 GMT
复制代码

当再次请求该资源时,请求头中会带有if-modified-since 字段,值是之前返回的 last-modified 的值,如:if-modified-since:Thu, 20 Dec 2018 11:36:00 GMT。

服务端会对比该字段和资源的最后修改时间,若一致则证明没有被修改,告知浏览器可直接使用缓存并返回 304;若不一致则直接返回修改后的资源,并修改 last-modified 为新的值。

过程可查看下图:

image.png

但last-modified有两个缺点:

  • 只要编辑了,不管内容是否真的有改变,都会以这最后修改的时间作为判断依据,当成新资源返回,从而导致了没必要的请求响应,而这正是缓存本来的作用,即避免没必要的请求。
  • 时间精准度只能到秒,如果在一秒内的修改是检测不到更新的,仍会告知浏览器使用旧的缓存。
  1. etag

为了解决上述问题,有了etag。etag 会基于资源的内容编码生成一串唯一的标识字符串,只要内容不同,就会生成不同的 etag。启用 etag 之后,请求资源后的响应返回会增加一个 etag 字段,如下:

etag: "FllOiaIvA1f-ftHGziLgMIMVkVw_"
复制代码

当再次请求该资源时,请求头会带有 if-none-match 字段,值是之前返回的 etag 值,如:if-none-match:”FllOiaIvA1f-ftHGziLgMIMVkVw_”。

服务端会根据该资源当前的内容生成对应的标识字符串和该字段进行对比,若一致则代表未改变可直接使用本地缓存并返回 304;若不一致则返回新的资源(状态码200)并修改返回的 etag 字段为新的值。

etag 优先级也更高。

为什么 HTTP/1.1 要引入 etag 实现协商缓存呢?

  • 有些资源会被周期性的重写,但内容完全一样
  • 有些资源可能被修改,但修改完全没必要让用户重新下载(修改注释或拼写)
  • 有些资源的变化时间会小于一秒(比如实时监视器),所以 Last-Modified 的时间粒度不够了

7. 断开TCP连接

浏览器接受完服务器返回的资源后,需要断开TCP连接。此时需要经历四次挥手。
如下图所示:
image.png

  • 第一次挥手:客户端发送一个FIN,用来关闭客户端到服务端的数据传送,客户端进入FIN_WAIT_1状态。
  • 第二次挥手:服务端收到FIN后,发送一个ACK给客户端,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号),服务端进入CLOSE_WAIT 状态。
  • 第三次挥手:服务端收到FIN,用来关闭服务端到客户端的数据传送,服务端进入LAST_ACK 状态
  • 第四次挥手:客户端收到FIN后。客户端进入TIME_WAIT状态,接着发送一个ACK给服务端,确认序号为收到序号+1,服务端进入CLOSED状态,完成四次挥手。

通俗的说法

  • Client:我所有东西都说完了

  • Server:我已经全部听到了,但是等等我,我还没说完

  • Server:好了,我已经说完了

  • Client:好的,那我们的通信结束

为什么要四次挥手,不是三次呢?

因为多了服务端通知客户端数据发送完毕的第三次挥手

如果没有第三次挥手,而是客户端直接确认关闭连接的第四次挥手,客户端就无法收到服务器还没发完的数据,导致数据丢失。

传输数据要有始有终。

8. 解析HTML,构建DOM树

完成上面的网络请求后,接下来就是浏览器的渲染进程解析和渲染资源的过程。

首先对于HTML文件,浏览器由它生成DOM树(一种浏览器可以理解的树形结构,Document Object Model)

那么浏览器是如何构建DOM树的呢?通过以下四步。

image.png

  1. 转换(Conversion):浏览器读取原始字节形式的HTML,并按照指定的格式(例如UTF-8)把这些字节翻译成单个字符。
  2. 序列化(Tokenizing):浏览器把第一步得到的字符串转换成不同的标记,例如<html>等,每个标记都有自己的含义和规则。
  3. 词法分析(Lexing):把这些标记转换成“对象”,来定义起属性和规则。
  4. 构建DOM(DOM construction):因为 HTML 标签有特定的包含规则,比如 html 包含 body,body 包含 div,我们又通过上一步生成的对象知道了标签之间的父子关系,所以就可以构建出 DOM 树。

每次浏览器处理HTML文件,都会经历上面的四个过程。在HTML比较复杂时,整个流程可能会比较费时。

image.png

9. 样式计算,构建CSSDOM树

样式计算(Style calculation)的目的是为了计算出上面DOM节点中每个元素的具体样式,这个阶段大体分为三步:

  1. 把CSS转换为浏览器能够理解的styleSheets

跟 HTML 文本一样,浏览器是无法直接理解纯文本的 CSS 样式,所以渲染进程在接受到 CSS 文本时,会先执行一个转化操作,将 CSS 文本转化成浏览器能够理解的结构 styleSheets。

在 Chrome 控制台中输入 document.styleSheets 就可以看到如下结构:

image.png

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

image.png

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

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

这里会涉及到CSS的继承规则层叠规则

  • CSS继承就是每个DOM节点都会继承其父节点的样式

image.png

  • 样式层叠,经过计算后会生成CSSDOM树,大致如下图:

image.png

  1. 总结一下浏览器处理CSS的过程,与HTML类似,从字节开始,翻译成字符、序列化、生成节点,最终生成CSSDOM。

image.png

10. 布局(Layout)

虽然我们有了DOM树和SOM树中每个节点的样式,但还不知道这些DOM元素的几何位置,所以接下来就需要计算出DOM树中可见元素的几何位置。我们把这个计算过程叫做布局。

布局阶段可以分为两个子阶段:创建布局树 和 布局计算

布局树的构造过程大概是这样:

image.png
我们可以观察到DOM树中所有display: none的节点都没有出现在布局树中。所以构建布局树的过程可以简单总结如下:

  • 遍历DON树中的可见节点,并把这些节点加到布局树中
  • 不可见节点会被布局树忽略,如head标签下的全部内容,以及样式为display: none的元素

构建完布局树,接下来就是计算布局树节点的实际坐标。(暂且跳过)

11. 生成图层树 (Dividing into layers)

有了布局树,而且还计算除了每个元素的具体位置信息。在绘制之前,还有一个生成图层树的过程。

为什么需要先生成图层树呢?

因为现代的前端页面有着非常复杂多样的效果,比如页面滚动、z-index 方向上的排序等,为了更加方便地实现这些效果,渲染进程还需要为特定的节点生成专用的图层,并生成一颗对应的图层树 (Layer Tree)。布局树和图层树的对应关系大致如下:
image.png

那么问题来了,需要什么条件,渲染进程才会为特定节点创建新的图层呢?

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

页面是二维平面,但层叠上下文能够让HTML具有三维概念。这些HTML元素会按照他们的优先级分布在垂直二维平面的Z轴上。具体的优先级顺序如下:

正z-index > z-index = 0 > inline > float > block > 负z-index > border > background
image.png

  1. 需要裁减的地方也会被创建为图层

裁剪的意思就是要显示的内容超出它的容器(比如 200 x 200 像素的 div 里面里面放着 1000 个字),另外如果出现滚动条,滚动条也会被提升为单独的层,类似下图这样。

image.png

12. 绘制(Paint)

完成构建图层树之后,接下来就是渲染引擎对图层树中每个图层的绘制

具体的实现是渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制的列表,就像下图这样。

image.png

我们可以打开 Chrome 开发者工具的 Layers 标签,选择 “document” 层,实际体验下绘制列表的过程。给大家一个示意图作参考:
image.png
图中圈出来的就是 document 的绘制列表,拖动右侧的进度条就可以重现列表的绘制过程,是不是非常神奇呢?

13. 栅格化(raster)

生成绘制列表后,会进行栅格化。绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。你可以结合下图来看下渲染主线程和合成线程之间的关系
image.png
如上图所示,当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给合成线程,那么接下来合成线程是怎么工作的呢?这里需要先引入一个做过 H5 手机页面开发的都会比较熟悉的标签:

<meta name="viewport" content="width=device-width, initial-scale=1">
复制代码

这里面的 viewport 就是用户可以实际看到的部分,中文翻译叫做视口。

很多时候,页面的长度都是远大于屏幕高度的,所以图层都会比较大,但是通过视口,用户只能看到其中一部分内容,所以在这种情况下,要一次性绘制出所有图层内容的话,就会产生非常大的开销,而且也没有必要。

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

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

image.png

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

14. 合成与显示(Composite and Display)

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

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

image.png

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

渲染流水线总结

下面用一张图总结一下从收到服务器发来的资源后的整个渲染和显示过程,我们把这个过程称为渲染流水线

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

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

参考链接

写在最后

这篇文章并非原创,90%的内容来自一次搞定前端“核心主线”——从输入URL到页面展示发生了什么,中间遇到不太了解的地方又穿插查阅了一些文章进行整理。但是全文基本全是自己通过两天时间逐字敲打完成的,虽然有点慢,但是敲打键盘的过程也是学习的过程,各人觉得相比浏览文章,印象更深,也会发现很多细节。

写完这篇文章,引起了自己对李兵老师的《浏览器工作原理与实践》强烈兴趣,然后拼了这个课程,计划接下来每天更新学习笔记,记录在 浏览器学习笔记中,后面陆续更新。

喜欢参考链接文章中作者的一句话:

学习知识的本质,而不是知识的表象

与君共勉。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享