前端总结之浏览器篇

187 阅读19分钟

写这篇是学习了李兵老师的浏览器工作原理与实践,受益匪浅决定写一篇总结,内容虽浅显,但是是对我自己的所接触到的来总结,以便自己日后来复习。有兴趣的同学可以自己学习这门课程。

浏览器的多进程架构

早期浏览器采用单进程架构,也就是说js运行环境、渲染引擎、页面、网络、插件等模块都运行在同一个进程里。这样会带来一些问题。

  1. 不稳定:插件渲染引擎模块的崩溃会造成整个浏览器的崩溃。
  2. 不流畅:由于是单进程,所有的模块都运行在一个线程中,同一时刻只能运行一个模块。当遇到无线循环的JS脚本,浏览器就会变得卡顿。
  3. 不安全:对内存读取的操作也在同一进程内,恶意插件会窃取用户信息、释放病毒等,引起安全性问题。

现代浏览器采用多进程架构,主要分为这么几个进程。

  1. 浏览器进程:用于界面显示,用户交互。提供存储等
  2. 渲染进程:顾名思义,v8和blink都运行在渲染进程上,且存在沙箱机制
  3. GPU进程:用于界面绘制
  4. 网络进程:负责网络请求
  5. 插件进程:负责插件的运行

多进程的架构也意味着占用的资源更高,而且架构复杂度更深,耦合度更高

ps: 进程与线程的区别?

  1. 进程和线程都是在操作系统层面上的
  2. 进程是资源分配的基本单位,线程是任务调度和执行的基本单位
  3. 线程是运行在进程上的,共享进程数据
  4. 进程之间相互独立,通过IPC来通信
  5. 当进程被关闭后,操作系统回收所有资源
  6. 任意一个线程的崩溃,会导致整个进程崩溃

从输入URL到页面呈现的过程

可以说这是一个经常被问起的问题,同时也可以根据对这个问题的回答看出其对浏览器,网络的掌握情况。草草几行就把这个问题回答完,却不能道出其中的细节,很难让别人认为你彻底明白这其中发生的过程。所以更多细节上的学习对一个技术人来说是很重要的。

言归正传!!!

输入内容

当我们在浏览器的地址栏中输入内容时,首先地址栏会进行判断输入的是搜索内容还是请求的URL。当判断输入的内容符合URL规则,地址栏就很根据规则,把这段内容拼接成一个完整的URL。如输入juejin.com,就会拼接成http://wwww.juejin.com

强缓存

接下来就会进入请求阶段,在发起请求之前,首先浏览器的网络进程会判断本地缓存是否缓存了资源,如果有缓存资源,那么就会将缓存资源返回,否则,进入网络请求流程。

DNS

首先,我们输入的是一个域名,而数据包是通过IP地址传递的,所以,先会根据域名得到IP地址。这个过程需要依赖一个服务系统,这个系统将域名和IP一一映射,这个系统就叫域名系统(DNS)。这样我们就可以拿到域名对应的IP地址了。如果我们曾经用域名系统得到过其IP地址,那么浏览器就会将其IP地址缓存下来,下次我们就不要通过DNS再解析IP地址,直接走缓存。

TCP连接

接下来根据IP地址和服务器建立TCP连接。建立连接后,浏览器端会构建请求行、请求头等信息,向服务器发送构建的请求信息。Chrome在同一个域名下最多只能有6个TCP连接,超过6个就需要排队等待。假设现在不需要等待,进入TCP连接阶段。 建立TCP连接需要经历三个阶段:

  1. 三次握手(即总共发送3个数据包确认建立连接)建立客服端和服务器的连接。
  2. 进行数据传输。TCP的数据传输阶段,接收方接收到数据后必须要向对方发送确认包,如果没有发送确认包,就判定数据包丢失,并重新发送该数据包,保证数据准确到达。
  3. 四次挥手断开连接。

请求-响应

建立TCP连接后,即开始发送HTTP请求。浏览器发送HTTP请求要携带请求行请求头请求体,服务器在在收到请求后会生成响应数据(响应行响应头响应体等)。 如果响应行中返回了301或302那么就需要重定向,也就是服务器需要浏览器重定向到其他URL中,其中响应头的location字段会包含重定向的URL地址,然后再重新发起HTTP请求。 在响应的信息中包含了具体返回的是什么数据类型,通过Content-Type字段判断具体是什么类型。 返回了响应的数据之后也不是立刻就断开TCP连接,而是要根据响应头中的Connection字段,如果其字段是Connection: keep-alive,那么就表示这是个持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。否则,断开TCP连接,请求响应流程结束。

渲染

在渲染进程中,将请求到的HTML,CSS,JavaScript渲染成页面。 默认情况下,Chrome会为每一个页面分配一个渲染进程,但是在同一个站点下的不同页面也会使用同一个渲染进程,也就是在一个页面中打开属于同一个站点的另一个页面。 解释一下什么是站点: 根域名baidu.com)和协议(http/https)相同,还包含了该根域名下的所有子域名和不同的端口

https://baidu.com
https://www.baidu.com
https://www.baidu.com:8080

渲染进程和网络进程之间形成管道,将网络进程中的数据进入到渲染进程中渲染。 首先先进行解析部分:

解析阶段

  • 构建DOM
  • 样式计算
  • 生成布局树(Layout Tree
构建DOM树

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

构建DOM树.png 具体是怎么解析的呢? HTML5规范详细的介绍了解析算法。这个算法分为两个阶段:

  • 标记化
  • 建树

对应的两个过程就是词法分析语法分析

样式计算

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

CSS样式的来源主要有三种:

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

格式化样式表

浏览器也是无法直接理解这些纯文本的CSS样式,所以将它转换为浏览器能够理解的结构——styleSheets

标准化样式属性

有一些css样式属性的数值不容易被渲染引擎理解,所以将其转换为渲染引擎容易理解的、标准化的计算值。 如:

body {
    font-size: 2em;
}
p {
    color: red;
}
div {
    font-weight: bold;
}

上面的2emredblod等不容易被理解的数值转为

body {
    font-size: 32px;
}
p {
    color: rgb(255, 0, 0);
}
div {
    font-weight: 700;
}

可以看到2em被解析成了32pxred被解析成了rgb(255,0,0)bold被解析成了700

计算每个节点具体的样式

现在样式已经被格式化标准化了,接下来就可以计算每个节点的样式了,计算方式有两个规则:继承层叠

每个子节点都会默认继承父节点的样式属性,如果父节点中没有找到,就会采用浏览器默认样式UserAgent

层叠规则,CSS最大的特点在于它的层叠规则,也就是最终的样式取决于各个属性共同作用的效果。

生成布局树

现在已经有了DOM树DOM样式,接下来需要确定元素的几何位置,生成布局树。 布局树生成的大致工作:

  1. 遍历DOM树节点,添加到布局树中。
  2. 计算布局树中节点的坐标位置。

布局树中只包含可见的节点,对于head和设置的display:none的元素,并不会包含其中。

至此,解析部分已经完成。 接下来进入渲染阶段:

渲染阶段

  • 建立图层树Layer Tree
  • 图层绘制
  • 合成和显示
建图层树

渲染引擎给页面分了很多图层,这些图层按照一定顺序叠加在一起,形成了最终的页面。浏览器在构建完布局树之后,还会对特定的节点进行分层,构建一棵图层树(Layer Tree)。

通常情况下,并不是布局树的每个节点都包含⼀个图层,如果⼀个节点没有对应的层,那么这个节点就从属于⽗节点的图层。

那什么时候会提升为一个单独的合成层呢?

一、 拥有层叠上下文的节点。 层叠上下文也基本上是有一些特定的CSS属性创建的,一般有以下情况:

  1. HTML根元素本身就具有层叠上下文。
  2. 普通元素设置position不为static并且设置了z-index属性,会产生层3叠上下文。
  3. 元素的 opacity 值不是 1
  4. 元素的 transform 值不是 none
  5. 元素的 filter 值不是 none
  6. 元素的 isolation 值是isolate
  7. will-change指定的属性值为上面任意一个。

层叠上下文示意图.png 从图中可以看出,明确定位属性的元素、定义透明属性的元素、使⽤CSS滤镜的元素等,都拥有层叠上下⽂属性。 二、需要剪裁的地方。 比如一个div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。

图层绘制

渲染引擎实现图层的绘制会把⼀个图层的绘制拆分成很多⼩的绘制指令,然后再把这些指令按照顺序组成⼀个待绘制列表。

实际上渲染进程中绘制操作是由专门的线程来完成的,叫做合成线程

绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程

首先,视口是有具体大小的,当页面非常大的时候,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事就是分块。这些块通常是256 * 256或者512 * 512。这样可以大大加速页面的首屏展示。

渲染进程中专门维护了一个栅格化线程池,专门负责将图块转换为位图数据

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。

生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程

合成和显示

栅格化操作完成后,合成线程会生成一个绘制命令,即"DrawQuad",并发送给浏览器进程。

浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新。

看到这里你也就是明白,当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

总结

至此,从输入URL到页面呈现的过程以及其中的细节已经全部描述完毕,现在我们来整理一下:

  1. 输入URL,首先查看浏览是否有缓存,如果有的话,直接从缓存读取资源。
  2. 没有缓存,DNS解析IP地址,根据IP地址与服务器建立TCP连接。
  3. 发送HTTTP请求,服务器做出响应,如果返回301或302,那么需要重定向,再次向重定向的地址发起HTTP请求。当响应头的Content-Type: text/html,代表返回的是HTML文件,进入解析和渲染阶段。
  4. 解析阶段,浏览器将HTML文件转换为DOM树,并根据CSS文件转换的styleSheets结构计算出每个节点的样式,之后遍历DOM树,计算每个节点的几何位置,构建布局树,布局树中只有可见的节点。
  5. 接下来进入渲染阶段,首先根据特定的节点进行分层,构建一颗图层树。
  6. 对图层进行渲染,渲染引擎会将图层的绘制拆分为一些绘制指令,将这些绘制指令按照顺序组成一个绘制列表。
  7. 浏览器的渲染进程的主线程会将绘制操作交给合成线程,将图层分为一个一个的图块,渲染进程中有一个专门将图块转为位图的栅格化线程池,合成线程就会调用栅格化线程池拿到位图。
  8. 之后合成线程会发给浏览器进程一个绘制命令,浏览器进程根据这个命令,把页面内面内容绘制到内存中,最后再将内存显示在屏幕上。
  9. 渲染结束后,如果响应头的Connection为:keep-alive,那么表示是一个持久连接,如果不是,则断开连接。请求-响应结束。

回流与重绘

回流

回流就是重排。就是当我们对DOM结构的修改引起DOM几何尺寸变化的时候,会发生回流

以下操作会造成回流:

  • 一个DOM元素的几何属性发生变化,常见的几何属性有widthheightpaddingmargintopleftborder等。
  • 使DOM节点发生增减移动
  • 读写offset族、scroll族和client族属性时,浏览器为了获取这些值,需要进行回流操作。
  • 调用window.getComputedStyle方法。

回流的过程相当于将解析合成阶段重新走一遍,开销是非常巨大的。

重绘

当DOM的修改导致样式的变化,并没有影响到几何属性时,会导致重绘

因为没有导致DOM几何属性发生改变,因此元素的位置信息不需要更新,只需要计算样式,跳过了生成布局树建图层树的过程,直接进行合成等后续操作。

可以看到,重绘不一定导致回流,但是回流一定发生了重绘。

浏览器缓存

强缓存

浏览器中缓存分为两种,一种是需要发送HTTP请求,一种是不需要发送的。

首先检查强缓存,这个阶段不需要发送HTTP请求

如何检查呢?

HTTP/1.0时期,使用的是Expires,而在HTTP/1.1使用的是Cache-Control

Expires

Expires即过期时间,存在于服务端返回的响应头中,告诉浏览器在这个过期时间之前可以直接从缓存里面获取数据,无需再次请求。

Expires: Wed, 22 Nov 2021 08:41:00 GMT

表示资源在2021年11月22号8点41分过期,过期了就得向服务端发请求。

但是这个方式有一个弊端,就是服务器的时间和浏览器的时间可能并不一致,那么服务器返回的这个过期时间可能就是不准确的。因此这种方式在HTTP/1.1中被抛弃了。

Cache-Control

HTTP/1.1中采用了这个字段,它与Expires不同是,它并没有采用具体的过期时间点这个方式,而是采用过期时长来控制缓存,对应的字段是max-age,比如

Cache-Control:max-age=3600

代表这个响应返回后在3600秒,也就是一个小时之内可以直接使用缓存。

Cache-Control有很多属性值。

  • public:客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器最后才能到达目标服务器,不仅仅是浏览器可以缓存数据,中间的任何代理点都可以缓存。
  • private:只有浏览器能够缓存,中间代理服务器不能缓存。
  • no-cache:跳过当前的强缓存,发送HTTP请求,直接进入协商缓存阶段。
  • no-store:不进行任何形式的缓存。
  • s-max-age:它和max-age的区别在于s-max-age是针对代理服务器的缓存时长。
  • must-revalidate:加上这个字段一旦缓存过期,就必须回到源服务器验证。

然,还存在一种情况,当资源缓存时间超时了,也就是强缓存失效了,接下来怎么办?没错,这样就进入到第二级屏障——协商缓存了。

协商缓存

强缓存失效后,浏览器在请求头中携带响应的缓存Tag来向服务器发起请求,由服务器根据这个缓存Tag来决定是否使用缓存,这就是协商缓存

具体来说,这样的缓存Tag分为两种:Last-ModifiedETag

Last-Modified

最后修改时间。在浏览器第一次给服务器发送请求后,服务器会在响应头中加上这个字段。

浏览器接收到后,如果再次请求,会在请求头中携带If-Modified-Since字段,这个字段的值就是服务器传来的的最后修改时间。

服务器拿到请求头中的If-Modified-Since的字段后,会和服务器中资源的最后修改时间对比

  • 如果请求头中的这个值小于最后的修改时间,说明该更新了。返回新的资源,和常规的HTTP请求响应一样。
  • 否则返回304,告诉浏览器直接用缓存。

ETag

ETag是服务器根据当前文件的内容,生成的唯一标识,只要里面的内容有改变,这个值就会改变。服务器通过响应头把这个值给浏览器。

浏览器收到ETag,当再次请求时,会在请求头中携带If-None-Match字段,其值为ETag的值,发送给服务器。

服务器收到If-None-Match后,会和服务器上资源的ETag对比

  • 如果两者不同,说明要更新了。返回新的资源,和常规的HTTP请求响应一样。
  • 否则返回304,告诉浏览器直接使用缓存

如果两种方式都支持的话,服务器会优先考虑ETag

http强缓存与协商缓存.png

缓存位置

前面我们已经提到,当强缓存命中或者协商缓存中服务器返回304的时候,我们直接从缓存中获取资源。那这些资源究竟缓存在什么位置呢?

浏览器中的缓存位置一共有四种,按优先级从高到低排列分别是:

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker

Service Worker 借鉴了 Web Worker的思路,即让 JS 运行在主线程之外,由于它脱离了浏览器的窗体,因此无法直接访问DOM。虽然如此,但它仍然能帮助我们完成很多有用的功能,比如离线缓存、消息推送和网络代理等功能。其中的离线缓存就是 Service Worker Cache

Service Worker 同时也是 PWA 的重要实现机制。

Memory Cache 和 Disk Cache

Memory Cache指的是内存缓存,从效率上讲它是最快的。但是从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache就是存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,但是他的优势在于存储容量和存储时长。

好,现在问题来了,既然两者各有优劣,那浏览器如何决定将资源放进内存还是硬盘呢?主要策略如下:

  • 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存
  • 内存使用率比较高的时候,文件优先进入磁盘

Push Cache

推送缓存,这是浏览器缓存的最后一道防线。它是 HTTP/2 中的内容,虽然现在应用的并不广泛,但随着 HTTP/2 的推广,它的应用越来越广泛。