浏览器
2022-04-24 15:17:06

浏览器的缓存

强缓存

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

HTTP/1.0HTTP/1.1当中,这个字段是不一样的。在早期,也就是HTTP/1.0时期,使用的是Expires,而HTTP/1.1使用的是Cache-Control

Expires

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

缺点:服务器的时间和浏览器的时间可能并不一致,那服务器返回的这个过期时间可能就是不准确的。因此这种方式很快在后来的HTTP1.1版本中被抛弃了。

Cache-Control

在HTTP1.1中,采用了一个非常关键的字段:Cache-Control。采用过期时长来控制缓存,对应的字段是max-age。除了max-age还可以配合一下字段

public: 客户端和代理服务器都可以缓存。因为一个请求可能要经过不同的代理服务器最后才到达目标服务器,那么结果就是不仅仅浏览器可以缓存数据,中间的任何代理节点都可以进行缓存。

private: 这种情况就是只有浏览器能缓存了,中间的代理服务器不能缓存。

no-cache: 跳过当前的强缓存,发送HTTP请求,即直接进入协商缓存阶段

no-store:非常粗暴,不进行任何形式的缓存。

值得注意的是,当ExpiresCache-Control同时存在的时候,Cache-Control会优先考虑。

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

协商缓存

强缓存失效之后,浏览器在请求头中携带相应的缓存tag来向服务器发请求,由服务器根据这个tag,来决定是否使用缓存,这就是协商缓存。这样的缓存tag分为两种: Last-ModifiedETag

Last-Modified和If-Modified-Since

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

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

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

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

ETag和If-None-Match

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

浏览器接收到ETag的值,会在下次请求时,将这个值作为If-None-Match这个字段的内容,并放到请求头中,然后发给服务器。

服务器接收到If-None-Match后,会跟服务器上该资源的ETag进行比对: - 如果两者不一样,说明要更新了。返回新的资源,跟常规的HTTP请求响应的流程一样。 - 否则返回304,告诉浏览器直接用缓存。

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

缓存位置

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

  • Service Worker 主要用在离线缓存
  • Memory Cache 内存缓存
  • Disk Cache 磁盘缓存
  • Push Cache 推送缓存

主要策略如下: 比较大的JS、CSS文件会直接被丢进磁盘,反之丢进内存.内存使用率比较高的时候,文件优先进入磁盘

总结

首先通过 Cache-Control 验证强缓存是否可用 - 如果强缓存可用,直接使用 - 否则进入协商缓存,即发送 HTTP 请求,服务器通过请求头中的If-Modified-Since或者If-None-Match字段检查资源是否更新 - 若资源更新,返回资源和200状态码 - 否则,返回304,告诉浏览器直接从缓存获取资源

浏览器的进程和线程

  • 浏览器进程:主要负责tab页的管理。
  • 浏览器渲染进程:负责页面的渲染,每个tab页都会有浏览器渲染进程
  • GPU进程:主要用于3D绘制,例如使用canvas进行3D绘图
  • 插件进程:插件运行在插件进程,每个不同的插件都会运行在一个新的进程

输入url到页面呈现

网络篇

  1. 构建请求
  2. 查找强缓存 如果命中则直接使用
  3. DNS解析 得到域名对应的ip地址,浏览器提供了DNS数据缓存功能。即如果一个域名已经解析过,那会把解析的结果缓存下来,下次处理直接走缓存,不需要经过 DNS解析
  4. 建立TCP连接
  5. 发送http请求
  6. 响应http 响应完成之后要判断Connection字段, 如果请求头或响应头中包含Connection: Keep-Alive,表示建立了持久连接,这样TCP连接会一直保持,之后请求统一站点的资源会复用这个连接。

解析渲染篇

  1. 解析HTML,生成DOM树
  2. 解析css,生成渲染树(包含width,颜色等) CSSOM tree
  3. 将HTML DOM树与CSS规则树结合,生成生成Render tree
  4. 布局Render树(layout/reflow),负责各元素大小、位置的计算
  5. 绘制Render树(painting),绘制页面像素信息
  6. 浏览器将各层信息发送给GPU,GPU将各层合成,显示在屏幕上

重排和重绘

重排

重排也叫回流

简单来说,就是当我们对 DOM 结构的修改引发 DOM 几何尺寸变化的时候,会发生回流的过程。

有以下的操作会触发回流:

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

过程:如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程(包括主线程之外的任务)全部走一遍。

重绘

当 DOM 的修改导致了样式的变化,并且没有影响几何属性的时候,会导致重绘(repaint)。

由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。流程只会出发计算css样式并绘制

重绘不一定导致回流,但回流一定发生了重绘。

合成

还有一种情况,是直接合成。比如利用 CSS3 的transformopacityfilter这些属性就可以实现合成的效果,也就是大家常说的GPU加速

实践

  1. 避免频繁使用 style,而是采用修改class的方式。
  2. 使用createDocumentFragment进行批量的 DOM 操作。
  3. 对于 resize、scroll 等进行防抖/节流处理。
  4. 添加 will-change: tranform ,让渲染引擎为其单独实现一个图层,当这些变换发生时,仅仅只是利用合成线程去处理这些变换,而不牵扯到主线程,大大提高渲染效率。当然这个变化不限于tranform, 任何可以实现合成效果的 CSS 属性都能用will-change来声明。这行代码能够开启 GPU 加速页面渲染,从而大大降低了 CPU 的负载压力,达到优化页面渲染性能的目的

js和css阻塞

load事件:load 应该仅用于检测一个完全加载的页面 当一个资源及其依赖资源已完成加载时,将触发load事件。也就是说,页面的html、css、js、图片等资源都已经加载完之后才会触发 load 事件。

DOMContentLoaded事件:当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,而无需等待样式表、图像和子框架的完成加载。也就是说,DOM 树已经构建完毕就会触发 DOMContentLoaded 事件。

js阻塞了什么

  • js是否会阻塞dom树构建

因为js在执行的过程中可能会操作DOM,发生回流和重绘,所以GUI渲染线程与JS引擎线程是互斥的。

在解析HTML过程中,如果遇到 script 标签,渲染线程会暂停渲染过程,将控制权交给 JS 引擎。内联的js代码会直接执行,如果是js外部文件,则要下载该js文件,下载完成之后再执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染线程,继续 DOM 的解析。

因此,js会阻塞DOM树的构建

  • 是否会阻塞页面的显示呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>hello world</div>
<script>
debugger
</script>
<div>hello world2</div>
</body>
</html>

上面的代码,页面中会先显示hello world,然后遇到script标签则导致阻塞渲染,运行js代码,js代码完成后会再显示hello world2

js不会阻塞位于它之前的dom元素的渲染。现代浏览器为了更好的用户体验,渲染引擎将尝试尽快在屏幕上显示的内容。它不会等到所有DOM解析完成后才布局渲染树。而是当js阻塞发生时,会将已经构建好的DOM元素渲染到屏幕上,减少白屏的时间。

这也是为什么我们会将script标签放到body标签的底部,因为这样就不会影响前面的页面的渲染。

css阻塞了什么

当我们解析 HTML 时遇到 link 标签或者 style 标签时,就会计算样式,构建CSSOM。

css不会阻塞dom树的构建,但是会阻塞页面的显示

  • 会不会阻塞DOM树的构建

css不会阻塞dom树的构建.但是浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSSOM 不没构建好,页面也不会显示内容。

只有当我们遇到 link 标签或者 style 标签时,才会构建CSSOM,所以如果 link 标签之前有dom元素,会加载css发生阻塞,如下面的代码

1
2
3
4
5
6
<body>
<div class="woo-spinner-filled">hello world</div>
<link rel="stylesheet" type="text/css" href="https://h5.sinaimg.cn/m/weibo-pro/css/chunk-vendors.d6cac585.css">
<div>hello world2</div>
</body>

这样做会导致一个问题,就是页面闪烁,在css被加载之前,浏览器按照默认样式渲染

hello world
,当css加载完成,会为该div计算新的样式,重新渲染,出现闪烁的效果。

为了避免页面闪烁,通常 link 标签都放在head中。

下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" type="text/css" href="https://h5.sinaimg.cn/m/weibo-pro/css/chunk-vendors.d6cac585.css">
</head>
<body>
<div class="woo-spinner-filled">hello world</div>
<div>hello world2</div>
</body>
</html>

上面的代码,DOMContentLoaded事件会在30ms左右就完成,也就是说DOM树会在30ms就构建完成,但是在30ms时页面此时依然是空白;而loaded事件会在2.92s发生,并且页面才出现内容.也就是说这时候资源全部加载完毕.由此可见,在head中的link标签加载css较费时,但是不会阻塞DOM树的构建,会阻塞页面的渲染.

  • css会不会阻塞后面js执行

答案是会.

JS 的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。当在JS中访问了CSSDOM中某个元素的样式,那么这时候就需要等待这个样式被下载完成才能继续往下执行JS脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" type="text/css" href="https://h5.sinaimg.cn/m/weibo-pro/css/chunk-vendors.d6cac585.css">
</head>
<body>
<div class="woo-spinner-filled">hello world</div>
<div>hello world2</div>
<script>
console.log('this is a test')
</script>
</body>
</html>

这个例子,就会发现等css加载完成后,才会在控制台打印“this is a test”。

总结

(1)js

  • js既会阻塞dom树的构建,也会阻塞页面的渲染

(2)css

  • css不会阻塞dom树的构建(DOM解析和CSS解析是两个并行的进程,所以这也解释了为什么CSS加载不会阻塞DOM的解析)

  • style不会阻塞页面的渲染(dom会先展示但是没有任何样式,当加载完css后才显示样式,因此会造成闪屏现象)

  • link会阻塞页面的渲染(dom树解析完也不会进行任何渲染,当加载完link后才显示合并cssom一起显示,因此会造成白屏现象)

  • css会阻塞后面js的执行

(3)link和@import的区别

  • @import是CSS提供的语法规则,只有导入样式表的作用;link是HTML提供的标签,不仅可以加载CSS文件,还可以定义RSS,rel连接属性等;
  • 加载页面时,link引入的CSS被同时加载,@import引入的CSS将在页面加载完毕后加载;因此可能造成闪屏现象
  • link标签作为HTML元素,不存在兼容性问题,而@import是CSS2.1才有的语法,故老版本浏览器(IE5之前)不能识别;
  • 可以通过JS操作DOM,来插入link标签改变样式;由于DOM方法是基于文档的,无法使用@import方式插入样式;

HTTPS

对称和非对称加密结合

演示一下整个流程: 1. 浏览器向服务器发送client_random和加密方法列表。 2. 服务器接收到,返回server_random、加密方法以及公钥。 3. 浏览器接收,接着生成另一个随机数pre_random, 并且用公钥加密,传给服务器。(敲黑板!重点操作!) 4. 服务器用私钥解密这个被加密后的pre_random

现在浏览器和服务器有三样相同的凭证:client_randomserver_randompre_random。然后两者用相同的加密方法混合这三个随机数,生成最终的密钥

然后浏览器和服务器尽管用一样的密钥进行通信,即使用对称加密

缺点:尽管通过两者加密方式的结合,能够很好地实现加密传输,但实际上还是存在一些问题。黑客如果采用 DNS 劫持,将目标地址替换成黑客服务器的地址,然后黑客自己造一份公钥和私钥,照样能进行数据传输。而对于浏览器用户而言,他是不知道自己正在访问一个危险的服务器的。

添加数字证书

添加了数字证书认证的步骤。其目的就是让服务器证明自己的身份。为了获取这个证书,服务器运营者需要向第三方认证机构获取授权,这个第三方机构也叫CA(Certificate Authority), 认证通过后 CA 会给服务器颁发数字证书

这个数字证书有两个作用: 1. 服务器向浏览器证明自己的身份。 2. 把公钥传给浏览器。

这个验证的过程发生在什么时候呢?

当服务器传送server_random、加密方法的时候,顺便会带上数字证书(包含了公钥), 接着浏览器接收之后就会开始验证数字证书。如果验证通过,那么后面的过程照常进行,否则拒绝执行。

HTTPS加解密过程

image-20220525213117645

垃圾回收机制

在JavaScript中,数据类型分为两类,简单类型和引用类型,对于简单类型,内存是保存在栈(stack)空间中,复杂数据类型,内存是保存在堆(heap)空间中。

  • 基本类型:这些类型在内存中分别占有固定大小的空间,他们的值保存在栈空间,我们通过按值来访问的
  • 引用类型:引用类型,值大小不固定,栈内存中存放地址指向堆内存中的对象。是按引用访问的。

由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是操作系统自动分配和释放回收的

由于堆内存所存大小不固定,系统无法自动释放回收,所以需要JS引擎来手动释放这些内存

引用计数

在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要”简化成“对象有没有其他对象引用到它”,如果没有对象引用这个对象,那么这个对象将会被回收。上例子:

1
2
3
4
5
let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1 
let obj2 = obj1; // A 的引用个数变为 2

obj1 = 0; // A 的引用个数变为 1
obj2 = 0; // A 的引用个数变为 0,此时对象 A 就可以被垃圾回收了

但是引用计数有个最大的问题: 循环引用。

1
2
3
4
5
6
7
function func() {
let obj1 = {};
let obj2 = {};

obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。

标记清除

JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。

标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。

V8的垃圾回收算法

那么问题来了,对于存活周期短的,回收掉就算了,但对于存活周期长的,多次回收都回收不掉,明知回收不掉,却还不断地去做回收无用功,那岂不是很消耗性能?

对于这个问题,V8做了分代回收的优化方法,通俗点说就是:V8将堆分为两个空间,一个叫新生代,一个叫老生代,新生代是存放存活周期短对象的地方,老生代是存放存活周期长对象的地方

新生代通常只有1-8M的容量,而老生代的容量就大很多了。对于这两块区域,V8分别使用了不同的垃圾回收器和不同的回收算法,以便更高效地实施垃圾回收

新生代

在JavaScript中,任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。在新生代中,主要使用Scavenge算法进行垃圾回收,Scavenge算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。

Scavange算法将新生代堆分为两部分,分别叫from-spaceto-space,工作方式也很简单,就是将from-space中存活的活动对象复制到to-space中,并将这些对象的内存有序的排列起来,然后将from-space中的非活动对象的内存进行释放,完成之后,将from spaceto space进行互换,这样可以使得新生代中的这两块区域可以重复利用。

image-20220530133234703

新生代中的对象什么时候变成老生代的对象?

在新生代中,还进一步进行了细分。分为nursery子代intermediate子代两个区域,一个对象第一次分配内存时会被分配到新生代中的nursery子代,如果经过下一次垃圾回收这个对象还存在新生代中,这时候我们将此对象移动到intermediate子代,在经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器会将该对象移动到老生代中,这个移动的过程被称为晋升

老生代

老生代里,回收算法不宜使用Scavenge算法,为啥呢,有以下原因:

  • Scavenge算法是复制算法,反复复制这些存活率高的对象,没什么意义,效率极低
  • Scavenge算法是以空间换时间的算法,老生代是内存很大的空间,如果使用Scavenge算法,空间资源非常浪费,得不偿失啊。。

所以老生代里使用了Mark-Sweep算法(标记清理)Mark-Compact算法(标记整理)

Mark-Sweep(标记清理)

Mark-Sweep分为两个阶段,标记和清理阶段,之前的Scavenge算法也有标记和清理,但是Mark-Sweep算法Scavenge算法的区别是,后者需要复制后再清理,前者不需要,Mark-Sweep直接标记活动对象和非活动对象之后,就直接执行清理了。

  • 标记阶段:对老生代对象进行第一次扫描,对活动对象进行标记
  • 清理阶段:对老生代对象进行第二次扫描,清除未标记的对象,即非活动对象
image-20220530133626739

由上图,我想大家也发现了,有一个问题:清除非活动对象之后,留下了很多零零散散的空位

Mark-Compact(标记整理)

这个时候Mark-Compact算法出现了,他是Mark-Sweep算法的加强版,在Mark-Sweep算法的基础上,加上了整理阶段,每次清理完非活动对象,就会把剩下的活动对象,整理到内存的一侧,整理完成后,直接回收掉边界上的内存

image-20220530133811523

全停顿(Stop-The-World)

说完V8的分代回收,咱们来聊聊一个问题。JS代码的运行要用到JS引擎,垃圾回收也要用到JS引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿

由于新生代空间小,并且存活对象少,再配合Scavenge算法,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象

Orinoco优化

orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它提出了增量标记、懒性清理、并发、并行的优化方法。

增量标记(Incremental marking)

咱们前面不断强调了先标记,后清除,而增量标记就是在标记这个阶段进行了优化。当垃圾少量时不会做增量标记优化,但是当垃圾达到一定数量时,增量标记就会开启:标记一点,JS代码运行一段,从而提高效率

惰性清理(Lazy sweeping)

增量标记只是针对标记阶段,而惰性清理就是针对清除阶段了。在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。这个优化就叫做惰性清理

整理标记和惰性清理的出现,大大改善了全停顿现象。但是问题也来了:增量标记是标记一点,JS运行一段,那如果你前脚刚标记一个对象为活动对象,后脚JS代码就把此对象设置为非活动对象,或者反过来,前脚没有标记一个对象为活动对象,后脚JS代码就把此对象设置为活动对象。总结起来就是:标记和代码执行的穿插,有可能造成对象引用改变,标记错误现象。这就需要使用写屏障技术来记录这些引用关系的变化

并发(Concurrent)

并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。

并行

并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

V8当前的垃圾回收机制

2011年,V8应用了增量标记机制。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使得垃圾回收时间大幅度缩短。

Prev
2022-04-24 15:17:06
Next