浏览器详解

563 阅读37分钟

浏览器渲染原理

浏览器中的渲染是指根据html字符串,生成最终呈现在屏幕上的一个个像素信息

渲染时间点

当浏览器的网络线程收到服务器响应的HTML文档后,会产生一个渲染任务,并将其传递到渲染主线程的消息队列中

之后在事件循环机制的作用下,渲染主线程取出消息队列中的渲染任务,便开启了页面的渲染

image.png

渲染流水线

在浏览器的地址栏中输入url后,服务器响应给浏览器的HTML文档,它本质上是一个字符串

浏览器需要对该字符串进行下面这些流程的处理,才能形成最终的页面内容并展示到屏幕上:

  1. 解析HTML
  2. 样式计算
  3. 布局
  4. 分层
  5. 绘制
  6. 分块
  7. 光栅化

在上述流程中,每个阶段都有明确的输入和输出,并且上一个阶段的输出会成为下一个阶段的输入

image.png

解析HTML Parse HTML

解析HTML的目的是将混乱难懂的HTML字符串转换成容易操作的JS对象

具体来讲就是通过对HTML字符串进行解析,进而生成两棵树,分别是DOM树(Document Object Model Tree)和CSSOM树(CSS Object Model Tree)

image.png

image.png

渲染主线程对HTML的解析是从上到下进行的,而DOM树的生成是和HTML的解析一起同时进行的(一边解析HTML一边生成DOM树)

当解析到link元素时,主线程就会停止解析HTML,然后等待网络线程加载外部CSS,当CSS加载完成后主线程就会去解析CSS

当解析到script元素时,主线程也会停止解析HTML,然后等待网络线程加载外部JS文件,当外部JS文件下载完成后主线程就会去执行JS代码

现代浏览器为了提高解析效率,都会在解析HTML的同时开启一个预解析线程,预解析线程会快速扫描HTML文档,找出文档中所包含的引用了外部CSS的link元素和引用了外部JS文件的script元素,并将这些外部文件提前下载下来

如果外部文件是CSS文件,预解析线程还能够对CSS进行解析,这可以使渲染主线程在之后生成CSSOM树时更加快速

若主线程解析到link元素的位置,但预解析线程还没有下载好CSS文件,则主线程不会等待,而是继续向后解析,这是因为预解析线程具有对下载的CSS文件的内容进行解析的能力

image.png

若主线程解析到script元素时,会立即停止解析HTML,(如果是外部js文件的话)转而等待JS文件下载完成并启动JS解释器将js代码执行,执行完后才能继续向后解析HTML

image.png

之所以主线程在遇到外部JS文件时需要停止解析,是因为JS代码中可能包含一些修改DOM的操作,即可能会对当前的DOM树进行更改,因此为了避免出现一些不必要的DOM节点创建、删除行为,浏览器选择在JS代码执行完后再继续生成DOM树

当HTML解析完成后,DOM树和CSSOM树也就生成完成了

image.png

样式计算 Computed Style

上一阶段生成的DOM树和CSSOM树,会在此阶段进行“融合”,形成带有最终样式的DOM树(也称渲染树)

“融合”其实就是根据CSSOM树中的内容,为DOM树中的每个结点(DOM对象)计算出它最终的样式

这个计算的过程就是属性值的计算过程

属性值的计算过程会将元素的所有css样式都计算出来,在计算过程中,还会对css属性值中的预设值和相对单位转换为绝对单位,如:将red转换为rgb(255, 0, 0),将1em转换为12px

在JS中使用getComputedStyle(dom)来获取某个元素最终计算出来的样式

image.png

布局 Layout

上一个阶段将DOM树中的每个结点都附带了其最终的样式信息,因此在此阶段就可以根据结点的样式信息计算出结点的几何信息,如结点的尺寸和位置,最终形成布局树

这里所计算出的位置信息,是元素相对于包含块的位置,而不是相对于屏幕的位置

image.png

DOM树的结点和布局树的结点并非一一对应的

DOM树中存在一些display: none的元素,这些元素没有自己的尺寸和位置信息,因此不会出现在布局树中的

image.png

如果某一个元素设置了伪元素::before或::after,这些伪元素是不会出现在DOM树中的,但可能会出现在布局树中(只要其display不为none)

image.png

HTML中所有的文字,如果其没有直接被一个行盒包裹,则浏览器会生成一个匿名行盒将其进行包裹,这在布局树中就可以很直观地展现出来

image.png

另外,布局树中的结点并非DOM对象,使用JS是获取不到布局树中的结点的

虽然JS获取不到布局树中的结点,但是可以利用dom结点来间接获取到布局树中对应节点所记录的元素的几何信息,如dom.clientWidth、dom.clientHeight等

分层 Layer

虽然布局树在此时已经生成完成,但是考虑到如果将所有元素都放置到同一层,则将来用户对页面进行操作时,就需要对页面中的所有元素都重新布局一遍,这是很浪费效率的

分层的意义在于,将元素分层后,某一层元素的变化只会影响到该层的其他元素,重新布局是也只需要对该层进行处理即可,而无需考虑其它层,从而提高了页面渲染效率

image.png

浏览器在内部会使用一套复杂的策略对整个布局树进行分层,堆叠上下文、transform、opacity等都会或多或少的影响(而非决定)浏览器的分层结果

对于旧版本的浏览器,是不存在分层的,现代浏览器通常都会有分层这一步骤

分层的数量并非越多越好,因为分层是需要消耗资源的

绘制 Paint

这里的“绘制”并非在页面中生成像素点,而是根据分层信息生成绘制指令,在之后的步骤中,需要根据此阶段生成的绘制指令来真正地Draw 页面

浏览器会为每一层都生成一套绘制指令

image.png

该阶段完成之后,渲染主线程的工作就结束了,剩余步骤就由其他线程来完成

image.png

分块 Tiling

生成完绘制指令的后,主线程会将每个图层的绘制信息提交给合成线程,之后主线程就去忙着做其它事情了

合成线程和渲染主线程一样,是渲染进程中的线程

合成线程会将每一层都分为多个小的块,分块的好处在于,在真正绘制页面时,可以优先绘制靠近视口的块,而那些不在视口中的块就可以稍后再绘制

image.png

实际上,合成线程是将分块的工作交给多个分块子线程来完成的

image.png

光栅化 Raster

分块完成后,合成线程会将块信息交给GPU进程,以极快的速度完成光栅化

光栅化就是将每个块转变为位图,位图中就记录着块中的所有像素的信息

image.png

GPU进程会开启多个线程来完成光栅化,并且会优先处理靠近视口区域的块

image.png

画 Draw

合成线程拿到每个层、每个块的位图后,生成一个个指引信息(quad)

指引信息中记录有该位图相对于视口的具体位置,并且还会考虑到旋转、缩放等变形效果,因此变形是在这一阶段发生的

合成线程会把指引信息提交给GPU进程,由GPU进程进行系统调用,将指引信息提交给GPU硬件,完成最终的屏幕成像

image.png

相关面试题

  • 什么是reflow?

    当在页面中添加或删除一个DOM,或者改变了页面中某个DOM的尺寸和位置时,这些操作都会直接影响到其它DOM元素的布局信息,因此需要重新生成布局树

    而reflow的本质就是重新生成布局树,当进行了会影响布局树的操作后,就需要重新生成布局树

    布局树的生成是非常耗时的,浏览器为了避免多次连续的操作导致布局树反复被计算,会合并这些操作,在JS代码全部执行完成后再进行统一计算,所以,reflow通常是异步完成的

    之所以说是通常,是因为浏览器考虑到开发者可能会使用JS获取当前最新的DOM的尺寸和位置信息,在使用JS获取布局属性时,浏览器就会立即重新计算布局树,即立即进行reflow,此时reflow就是同步进行的

    image.png

  • 什么是repaint?

    当改变页面中元素的字体颜色,背景颜色时,这些操作不会对DOM的尺寸和位置造成影响,因此布局树也不需要重新生成

    repaint的本质就是根据分层信息重新计算绘制指令

    当改动了可见样式后,就需要重新计算绘制指令,引发repaint

    由于reflow的过程中也涉及绘制指令的重新计算,因此reflow一定会引发repaint

    image.png

  • 为什么transform的渲染效率高

    变形是在draw阶段发生的,并且也只会影响这一个阶段,它不会对前面已发生的阶段造成影响

    由于draw阶段是合成线程负责进行的,和渲染主线程无关,所以transform的效果变化不会影响渲染主线程

    反之,无论渲染主线程如何忙碌,也不会影响transform的变化,因为变形的效果是由合成线程负责的

    image.png

资源提示关键词

现代浏览器提供了一系列资源提示关键字,它们可以使用某些手段来加快页面加载的速度

defer 和 async

defer和async都是在script元素上使用的布尔属性

给script加上async属性后,该外部JS文件的加载将会和HTML的解析并行进行,即JS的加载不会阻塞HTML的解析,但JS的执行仍然会阻塞HTML的解析,并且JS的执行时间发生在JS文件加载完成之后

给script加上defer属性后,该外部JS文件的加载将会和HTML的解析并行进行,与async的不同之处在于,JS代码的执行时间被延后到了HTML解析完成后,DOMContentLoaded事件触发之前

image.png

preload 和 prefetch

preload和prefetch都使用在link元素上,它们作为link元素的rel属性的属性值存在

<link rel="preload" as="style" href="index.css">
<link rel="preload" as="script" href="index.js">

<link rel="stylesheet" href="index.css">
<script src="index.js"></script>

preload和prefetch都是指“预加载”,目的都是为了将今后可能会用到的外部资源提前下载下来,下载完成后浏览器会将这些资源缓存起来,之后在需要时直接使用之前缓存的结果即可,加快了页面的加载速度

预加载的资源下载完成后并不会被解析或执行,而是会被缓存下来

preload和prefetch的区别在于资源下载的优先级不同:

  • 对于preload,当解析到带有preload关键词的link元素时,浏览器便会立即去加载其所引用的外部资源

  • 对于prefetch,当解析到带有prefetch关键词的link元素时,浏览器不会立即加载所引用的外部资源,而是在浏览器空闲时才会去下载这些资源

    何为“浏览器空闲”由浏览器自行决定

image.png

根据这两个关键词的特性,当需要提前加载本页面中就会使用到的资源时,就会使用preload进行预加载,对于本页面不会使用到,但其他页面会使用到的资源,就会使用prefetch进行预加载

预加载的资源不仅限于外部CSS文件,还可以是JS文件、font字体、image图片、document文档等

可以使用as属性来指明预加载的资源的类型,

<link rel="preload" as="script" href="index.js">
<link rel="preload" as="font" href="xxx.font">
<link rel="prefetch" as="image" href="xxx.jpg">
<link rel="prefetch" as="document" href="index.html">

所有预加载的资源在加载时均不会阻塞浏览器会HTML的解析

dns-prefetch

当浏览器从第三方服务器请求资源时,必须先将该跨源域名解析为 IP 地址,然后浏览器才能发出请求,此过程称为 DNS 解析

虽然DNS缓存可以帮助减少此过程的延迟,但DNS解析仍可能会给请求带来明显的延迟,特别是对于打开了与许多第三方的连接的网站,此延迟可能会大大降低加载性能

使用dns-prefetch关键词就可以提前对某个域名进行dns解析,解析结果会缓存在浏览器中,之后在浏览器请求该域名时,就可以直接从缓存中找到对应的域名解析结果,直接对该结果发送请求,减少了域名解析带来的延迟

通过给link元素设置rel属性值为dns-prefetch提供此功能,然后在href属性中指明要跨源的域名

<link rel="dns-prefetch" href="//www.google.com">

prerender

prerender会将之后可能会使用到的页面提前渲染出来,但此时并不会立即将其显示出来,而是将渲染的结果保存在缓存中,等到真正需要用到时才将页面显示出来

<link rel="prerender" href="https://www.baidu.com">

preconnect

http和https的请求和响应消息的传递是基于已建立好的TCP连接进行的,而建立连接的过程需要消耗一定时间

对于https协议,建立完TCP连接后,还需要进行TLS协商

使用preconnect可以让浏览器提前进行上面的动作,对于跨域请求的资源,preconnect还会先进行DNS域名解析(即也具备了dns-prefetch的功能),之后在进行资源请求时直接使用建立好的连接即可

image.png

<link rel="preconnect" href="https://cdn.domain.com">

如果href中的url和页面的url的源不同,则默认之后的请求不会携带cookie,如果需要设置跨域请求也可以携带cookie,则需要给link元素加入crossorigin布尔属性

<link rel="preconnect" href="https://cdn.domain.com" crossorigin>

浏览器的组成部分

Web 浏览器简称浏览器,其主要功能是从服务器中检索 Web 资源并将其显示在 Web 浏览器窗口中

Web 资源通常是 html 文档,但也可以是 PDF、图像、音频、视频或其他类型的内容,资源的位置使用URL(统一资源定位符)来指定

浏览器主要由以下几个部件组成:

  1. 用户界面(user interface)
  2. 浏览器引擎(browser engine)
  3. 渲染引擎(rendering engine)
  4. 网络(networking)
  5. JS解释器(JavaScript interpreter)
  6. 用户界面后端(UI backed)
  7. 数据存储(data storage)

image.png

用户界面(User Interface)

用户界面组件用于呈现浏览器窗口部件,比如地址栏,前进后退按钮,书签,顶部菜单等

image.png

浏览器引擎(Browser Engine)

它是UI和渲染引擎之间的桥梁,用户通过操作UI界面向浏览器引擎传递信息,然后浏览器引擎通过操作渲染引擎将网页或其他资源显示在浏览器中

渲染引擎(Rendering Engine)

渲染引擎,也称浏览器内核,负责在浏览器窗口上显示请求的内容,例如,用户请求一个 HTML 页面,则它负责解析 HTML 文档和 CSS,并将解析和格式化的内容显示在屏幕上

现代主流浏览器的渲染引擎:

  1. FireFox:Gecko Software
  2. Safari:Webkit
  3. Chrome、Opera:Blink
  4. Internet Explorer:Trident

网络(Networking)

该模块处理浏览器内部的各种网络通信

JS解释器(JavaScript Interpreter)

JS解释器用于解释执行JS代码

DOM和CSSOM给JS提供了一个接口,使得JS可以操作DOM和CSSOM

由于浏览器无法预测JS的行为,因此在解析HTML的过程中只要遇到script元素就会立即停止对HTML的解析

不同的浏览器使用不同的JS解释器(也称JS引擎):

  • Chrome:V8
  • Mozilla:SpiderMonkey
  • Microsoft Edge:Chakra
  • Safari:Nitro Webkit

用户界面后端(UI Backed)

用于绘制基本的窗口小部件,例如:下拉列表、文本框、单选框、按钮等,其向下调用的是操作系统提供的接口,因此导致了在不同操作系统下绘制出的效果有所差异

数据存储(Data Storage)

该部分也称为数据持久层(Data Presistence)

浏览器会将可能长期使用的数据保存至本地磁盘中,常见的存储方式有Cookie、Web Storage、Web SQL、Indexed DB、File System等

IndexedDB

随着浏览器功能的不断增强,越来越多网站考虑将大量数据保存在浏览器端,这样可以减少服务器的压力

而现有的浏览器数据存储方案所能存储的数据都十分有限,如:cookie的容量约为4KB,webstorage的容量在2.5MB ~ 10MB之间

因此,需要一种新的本地数据存储方案,IndexedDB也由此而生

IndexedDB是浏览器提供的本地数据库,它可以被JS脚本所创建和操作,它不仅能存储大量数据,并提供查询接口还支持建立索引

IndexedDB不属于关系型数据库,不支持SQL语句,它是一种类似于NoSQL的数据库

IndexedDB具有以下特点:

  • 键值对存储:数据在数据库以“键值对”的形式存储,每一条记录都有对应的主键,主键是独一无二的,不能重复
  • 异步:异步设计是考虑到网页可能会获取大量的数据,而获取大量数据往往需要一定时间,异步策略可以让网页在获取数据时不会出现卡死
  • 支持事务:在一系列操作中,只要有一个步骤失败,整个事务就失败,就需要回滚到事务发生之前的状态。
  • 同源限制:每一个数据库对应创建它的域(但一个域下可以创建多个数据库),页面只能获取自身域中的数据库,不能跨域访问数据库
  • 存储空间大:IndexedDB的存储容量至少为250MB,甚至在理论上可以没有存储上限
  • 支持存储二进制数据:IndexedDB支持存储大部分类型的JS数据,不包括函数和DOM对象,但包括二进制数据(ArrayBuffer对象和Blob对象)

创建数据库

const request = indexedDB.open(DBName, version);
  • DBName:数据库的名称(字符串)
  • version:数据库的版本(数字)

indexedDB是window对象上的一个属性

如果没有找到对应的数据库,则会新建一个数据库

request是IDBRequest对象,对象身上有以下事件:

let db = null;			// 存储数据库对象(IDBDatabase对象)

// 数据库被打开或创建成功后触发
request.onsuccess = (event)=>{
    db = event.target.result;			// 数据库对象(IDBDatabase对象)
}

// 数据库打开失败后触发
request.onerror = (event)=>{
    console.error(event.target.error);	// 错误信息
}

// 数据库在被创建时,或版本号发生变化,或添加或删除数据库表时触发
request.onupgradeneeded = (event)=>{}		// 同success中的event

创建数据库表

通过数据库对象的createObjectStore方法,可以创建一个数据库表

一个数据库表就是一个IDBObjectStore对象

const objectStore = db.createObjectStore(tableName, options);
  • tableName:表的名称

  • options:表的配置,该参数是一个对象,对象中可以有以下属性

    ① keyPath:主键名称(字符串)

    ② autoIncrement:主键是否自增(布尔值)

创建索引

通过数据库表对象的createIndex方法,可以创建一个索引

一个索引就是一个IDBIndex对象

objectStore.createIndex(indexName, fieldName, options);
  • indexName:索引的名称(字符串)

  • fieldName:关联的字段名称(字符串)

  • options:索引的配置

    ① unique:只是field的值在表中是否是唯一的

可以在一张表中创建多个索引

对于包含大量数据的数据库,有了索引可以大幅度提高数据的查询速度

关闭数据库

数据库使用完后,建议关闭数据库,避免占用资源

db.close();

删除数据库

const deleteRequest = indexedDB.deleteDatabase(dbName);

deleteRequest.onsuccess = ()=>{}			// 监听删除成功的事件

deleteRequest.onerror = ()=>{}				// 监听删除失败的事件

插入数据

const request = db.transaction(tableNameArr, mode).objectStore(tableName).add(data);

request.onsuccess = ()=>{}					// 监听写入成功的事件
request.onerror = ()=>{}					// 监听写入失败的事件
  • tableNameArr:操作的表的集合,元素为表的名称(字符串数组)
  • mode:打开的模式,支持"readonly"、"readwrite",默认为"readonly"(字符串)

transaction()方法用于定义一个事务,它是一个IDBTransaction对象,对象的objectStore方法来具体操作某一张表,并使用链式调用add方法的方式添加数据

添加的数据是一个对象,对象的键就是添加的字段的名称,值就是添加的值

add({
    "stuId": 1,
    "stuName": "张三",
    "stuAge": 20
});

add方法会返回一个IDBRequest对象,该对象可以注册success和error两个事件

获取数据

根据主键获取一条数据:

const request = db.transaction(tableNameArr).objectStore(tableName).get(keyValue);

request.onsuccess = ()=>{				// 监听获取成功的事件
	console.log(request.result);		// 查询到的数据
}

request.onerror = ()=>{}				// 监听获取失败的事件

获取所有数据:

const request = db.transaction(tableNameArr).objectStore(tableName).getAll();

request.onsuccess = ()=>{
	console.log(request.result);
}

request.onerror = ()=>{}

获取满足条件的所有数据:

获取满足条件的所有数据,需要使用到游标

游标是一个IDBCursor对象,游标会从第一条记录开始,一步步向后移动,知道移动到最后一条记录的后面

const request = db.transaction(tableNameArr).objectStore(tableName).openCursor();

const list = [];

// 游标创建成功,或指针向后移动一次时都会触发该事件
request.onsuccess = (event)=>{
    const cursor = event.target.result;		// 指针对象,若指针所在位置无数据,则为null
    if(cursor){
        const data = cursor.value;			// 指针所指位置处的数据
        list.push(data);
        cursor.continue();					// 将指针向后移动一次(移动到下一个数据位置)
    }
}

除了需要使用到游标,还需要使用到索引

使用索引的get方法可以获取到满足条件的第一条记录

const request = db.transaction(tableNameArr).objectStore(tableName).index(indexName).get(indexValue);

request.onsuccess = (event)=>{
    const data = event.target.result;			// 查询到的数据
}
  • indexName:索引的名称(字符串)
  • indexValue:索引的值

要想查询满足条件的所有数据,就需要让索引配合游标使用

const data = [];

const request = db.transaction(tableNameArr).objectStore(tableName).index(indexName).openCursor(IDBKeyRange.only(indexValue));

// 索引配合游标后,游标就只会在满足条件的记录集合中进行移动,而不是在表中的所有记录之间进行移动
request.onsuccess = (event)=>{
    const cursor = event.target.result;
    if(cursor){
        const data = cursor.value;
        list.push(data);
        cursor.continue();
    }
}
  • IDBKeyRange是一个查询规则对象,利用它身上的一些方法可以实现查询满足特定条件的所有记录

    ① only(value):指定具体的值

    ② upperBound(x):指定 ≤ x

    ③ upperBound(x, true):指定 < x

    ④ lowerBound(x):指定 ≥ x

    ⑤ lowerBound(x, true):指定 > x

    ⑥ bound(x, y):指定 > x 且 < y

    ⑦ bound(x, y, false, true):指定 ≥ x 且 < y

    ⑧ bound(x, y, true, false):指定 ≥ x 且 ≤ y

    ⑨ bound(x, y, true, true):指定 > x 且 < y

分页获取数据:

indexedDB原生并没有提供分页的API,因此分页功能需要开发者自己书写

function getLimitData(db, tableName, page, size){
    return new Promise((resolve, reject)=>{
        let count = 0;
        let isPass = false;			// 是否已经跳过数据
        const data = [];
        const request = db.transaction([tableName]).objectStore(tableName).openCursor();

        request.onsuccess = (event)=>{
            const cursor = event.target.result;
            if(page > 1 && !isPass){
                isPass = true;
                cursor.advance((page - 1) * size);	// 让指针指向第page-1页的第一条数据
                return;
            }
            if(cursor){								// 越过前面的数据后就正常获取本页数据
                data.push(cursor.value);
                count++;
                if(count < size){					// 还未获取完本页的所有数据
                    cursor.continue();
                }else{								// 已经获取完了
                    resolve(data);
                }
            }else{									// 已经没有数据了
                resolve(data);
            }
        }
        
        request.onerror = ()=>{
        	reject("error");
    	}
    });
}

更新数据

const request = db.transaction(tableNameArr, "readwrite").objectStore(tableName).put(data);

request.onsuccess = ()=>{}

更新的内容应该包含对应记录的主键,put方法是根据主键的值匹配要更新哪一条记录的

如果不存在被更新的数据,或data中不包含主键属性,则put会插入一条新数据到表之中

删除数据

根据主键删除:

const request = db.transaction(tableNameArr, "readwrite").objectStore(tableName).delete(keyValue);

request.onsuccess = ()=>{}

删除多条数据:

const request = db.transaction(tableNameArr, "readwrite").objectStore(tableName).openCursor();

request.onsuccess = (event)=>{
    const cursor = event.target.result;
    if(cursor){
        const deleteRequest = cursor.delete();			// 删除cursor所指的该条数据
        deleteRequest.onsuccess = ()=>{
			console.log("删除成功");
        }
        cursor.continue();
    }
}

浏览器缓存

浏览器缓存是指浏览器在访问网页时将一些数据(如HTML、CSS、JavaScript文件、图像等)存储在客户端本地的临时存储空间中

这样,当用户再次访问同一个页面时,浏览器可以根据某种策略直接从缓存中加载这些数据,而无需重新从服务器下载

使用浏览器缓存有以下好处:

  1. 提升性能和加快加载速度

    通过使用缓存,浏览器避免了重复下载相同的资源文件,减少了服务器的负担和网络传输时间,从而加快了页面加载速度

  2. 减少网络流量和节省带宽

    如果网页的资源文件已经存在于浏览器缓存中,并且没有过期,那么浏览器可以直接从缓存中获取资源,而无需再次向服务器请求,从而减少了网络流量和带宽的消耗

  3. 离线访问支持

    浏览器缓存还可以使网页在离线状态下继续访问,如果用户已经访问过某个页面并缓存了相应的资源,当用户离线时,浏览器可以直接从缓存中加载并显示页面内容

按照位置分类

按照存储的位置划分,可将浏览器缓存分为四类:

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. Push Cache

上面四种缓存从上到下优先级逐渐降低,浏览器在请求资源时,会依次从上到下查找对应的缓存,若最终都没有找到,则浏览器才会将请求发送到网络中

Service Worker

Service Worker是运行在浏览器中的一个线程,利用该线程可以实现数据缓存的功能

Service Worker可以对浏览器发出的请求进行拦截,当浏览器所请求的资源在Service Worker中有命中时,Service Worker就会直接将命中的数据传递给浏览器

否则,Service Worker会代替浏览器向服务器发送请求,待服务器给Service Worker响应数据时,先将数据保存一份到自己身上,再将传递给浏览器

image.png

由于Service Worker涉及到请求和响应拦截的操作,为了保障安全,就要求传输时使用的协议是安全的HTTPS协议

Memory Cache

Memory Cache将内存作为缓存数据的空间

内存相对于磁盘而言,访问速度更快,但数据的持久性却比较差

当用户关闭tab页面时,页面中使用Memory Cache保存的资源所占空间也会被释放

Memory Cache保证了一个页面中如果有多个相同的资源请求,则浏览器实际上只需要请求一次,加快了页面的加载速度

若页面是在刷新的情况下加载出来的,内存中的缓存数据的作用也十分明显

Disk Cache

Memory Cache将磁盘作为缓存数据的空间

相对于Memory Cache,Disk Cache访问数据的速度会慢一些,但由于磁盘的空间比较大,因此可缓存的数据量也更多,并且数据的持久性也更强

即便Disk Cache的容量很大,但也需要进行缓存的清理工作,当已缓存的数据量很大时,浏览器会使用特殊的算法把最有可能过期的资源或最久没有使用的资源进行清除,来释放一些缓存空间

Push Cache

Push Cache是HTTP2.0新增加的一种缓存机制

目前国内对Push Cache的支持度还不高

按照类型分类

按照缓存的类型进行分类,可以分为强制缓存和协商缓存

无论是强制缓存还是协商缓存,其缓存内容都是保存在磁盘中的,即Disk Cache

强制缓存

当浏览器向服务器请求资源时,如果服务器希望浏览器将该资源缓存一段时间,在这段时间内不要再发送请求给我时,直接使用缓存的结果即可,则服务器就可以在响应消息中设置Expires或Cache-Control响应头

浏览器在收到该响应消息后就会将资源缓存下来,并且在资源过期时间还未到达之前,都可以直接从本地缓存中读取资源,而不需要向服务器发送请求

强制缓存能够有效减少请求的数量,提高资源获取速率,也减少服务器压力

Expires

Expires是HTTP 1.0中定义的字段,表示缓存到期的时间,它记录的是一个绝对时间

Expires: Thu, 10 Nov 2017 08:45:11 GMT

在响应消息中设置该消息头,浏览器收到消息后就会将资源缓存下来,之后当需要再次使用到该资源时,在资源未过期之前就可以不再发送请求,而是直接使用缓存中的内容

Cache-control

Cache-control是HTTP 1.1中定义的一个字段,该字段中允许书写多个取值,其中一个取指max-age用于表示资源缓存在多久之后过期(单位为秒),它是一个相对时间

Cache-control: public, max-age=2592000

在这段时间内,客户端都不需要向服务器发送请求

Cache-control中允许出现的取值有以下几个:

  1. max-age

    最大有效时间,其值以秒为单位

  2. no-cache

    不使用强制缓存,但需要使用协商缓存

  3. no-store

    不使用强制缓存和协商缓存,每次都会向服务器发送真实请求

  4. public

    浏览器和代理服务器都可以对资源进行缓存

  5. private

    仅浏览器可以缓存

自从HTTP 1.1出来以后,Expires就逐渐被Cache-control所取代

但为了兼容HTTP 1.0和HTTP 1.1,服务器一般还是会同时将两个响应头都进行设置

如果浏览器同时支持这两个消息头,则会优先使用Cache-control

Cache-Contorl中设置max-age=0相当于是设置了no-cache

协商缓存

协商缓存本质上就是让服务器来告诉客户端缓存的资源是否还能够使用

每当浏览器需要使用资源时,都会给服务器发送过去一个请求,请求中包含缓存资源的相关信息,服务器根据其中的信息,判断该缓存资源是否还能够使用

若服务器表示可以继续使用,服务器就会给浏览器响应一个状态码为304的响应消息(消息中不包含资源数据),浏览器接收到后,就会直接从缓存中读取资源

若服务器不允许继续使用(如服务器将资源进行了更新),服务器就会给浏览器响应一个状态码为200的响应消息,响应消息中还包括最新的资源数据,浏览器接收后,也会将该数据更新到缓存数据库中

仅使用协商缓存,浏览器向服务器发送请求的数量和不使用协商缓存的数量是一样的,只不过在协商缓存方式下如果服务器响应的是状态码为304的消息,消息中就不会携带资源数据,因此响应消息的体积就可以大幅减小,这是协商缓存存在的意义

协商缓存可以和强制缓存配合使用,先使用强制缓存,在缓存资源过期后再进行协商缓存

协商缓存的实现依托于下面两组消息头字段(只需要其中一组即可):

  1. Last-Modified & If-Modified-Since
  2. Etag & If-None-Match
Last-Modified & If-Modified-Since

当浏览器第一次请求服务器时,服务器如果希望浏览器与自己协商缓存该资源时,就可以在响应消息中加入Last-Modified响应头,Last-Modified中记录了该资源在服务器端最近一次修改的时间,它记录的是一个绝对时间

Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT

浏览器收到响应消息后,会将资源和Last-Modified消息头中的内容都给缓存下来

当浏览器需要再次使用该资源时,就会向服务器发出请求,请求消息中会附带一个If-Modified-Since请求头,该请求头中的内容就是浏览器之前所缓存下来的Last-Modified字段的值

服务器收到该请求消息后,将其中的If-Modified-Since请求头与对应资源的最后一次修改的时间进行对比,若相等则表示未修改,并给浏览器响应一个状态码为304的消息;否则表示资源已修改,给浏览器响应一个状态码为200的消息,并将更改后的资源以及新的Last-Modified附带到响应消息中

由于Last-Modified字段记录的时间是以秒为单位的,对于要求资源准确度更高的系统(例如要求资源的更新速度在秒以内),使用这种方式仍可能会导致系统使用的资源是旧资源

Etag & If-None-Match

由于Last-Modified字段无法精确到秒以内,因此HTTP 1.1提出了Etag和If-None-Match这两个消息头

Etag中记录的是一组Hash值,而不再是资源的修改时间,该Hash值是根据资源的内容生成出来的,一旦内容发生改变,Hash值就会改变

当浏览器第一次请求服务器时,如果希望浏览器与自己协商缓存该资源时,就会向响应消息中增加Etag响应头

浏览器收到响应消息后,会将资源和Etag消息头中的内容都给缓存下来

当浏览器再次请求该资源时,就会在请求消息中附带一个If-None-Match请求头,该请求头中的内容就是浏览器之前所缓存下来的Etag字段的值

服务器收到该请求消息后,将其中的If-None-Match请求头的内容与资源对应的Hash值进行判断,若相等则表示未修改,并给浏览器响应一个状态码为304的消息;否则表示资源已修改,给浏览器响应一个状态码为200的消息,并将更改后的资源以及新的Etag附带到消息中

在实践中,往往都会将Last-Modified&If-Modified-Since与Etag&If-None-Match一起使用,以提高兼容性

若浏览器两者都支持,则默认优先使用Etag&If-None-Match

浏览器行为

用户对浏览器的不同操作,会触发不同的缓存读取策略:

  • 打开网页,地址栏输入地址:查找Disk Cache中是否有匹配,如有则使用,如没有则发送网络请求

  • 普通刷新 (F5):由于tab页面并没有关闭,因此Memory Cache是可用的,如果匹配的话Memory Cache会被优先使用,其次才是Disk Cache

  • 强制刷新 (Ctrl + F5):浏览器不使用任何缓存,因此发送的请求头部均带有Cache-control: no-cache(为了兼容,还带了Pragma: no-cache),服务器直接返回200和最新内容

Web Worker

JS是单线程的语言,在进行大量复杂的运算时容易造成浏览器卡死,Web Worker基于此就诞生了

Web Worker可以让Web应用具备后台处理的能力,它本质上是一个新的线程,因此能够充分利用多核CPU

通过将运行耗时长的任务分配给Web Worker,从而避免页面被卡死的现象

创建Web Worker

Web Worker的创建需要借助HTML5提供web worker API,该API可以创建一个在后台运行的线程(俗成创建一个worker)

使用构造函数Worker来创建一个worker,创建worker时,需要指定worker所要执行的脚本文件的地址

const worker = new Worker("./worker.js");

创建完worker后,worker就能在后台运行指定的脚本文件,而不影响主页面代码的执行

主页面和worker脚本之间,通过worker的postMessage方法和message事件进行线程之间的通信

// index.js
const worker = new Worker("./worker.js");

worker.postMessage("hello, I am main thread");			// 向worker脚本发送数据

worker.onmessage = (event)=>{
    console.log(event.data);							// 接收来自worker脚本发送过来的数据
}

在worker脚本中,通过全局的this或self关键字来获取worker对象

// worker.js
console.log(self === this);						// true

self.postMessage("hello, I am worker");			// 向主脚本发送数据

self.onmessage = (event)=>{
    console.log(event.data);					// 接收来自主脚本发送过来的数据
}

注意:worker脚本中的worker对象与主脚本中的worker对象并非同一对象

在worker运行的脚本文件中,可以通过worker的importScripts(url)方法加载其他脚本文件,需要注意的是,只允许加载同源脚本

// worker.js
importScripts("a.js", "b.js", ...);

在主页面中使用下面的方法来关闭worker:

worker.terminate();

Web Worker有很多局限性:

  1. 不能加载跨域JS脚本
  2. worker中不能访问到window对象(一些必要的window的属性和方法会注入到self中)
  3. worker中不能访问到DOM对象
  4. worker加载数据没有JSONP和Ajax高效

Web Worker的类型

Web Worker分为两种:

  • 专用worker
  • 共享worker

使用Worker构造函数创建的worker就是专用worker,它会随着主页面的关闭而关闭,并且它只能在主页面中使用

共享Worker使用构造函数SharedWorker来创建,它可以同时连接多个页面

const worker = new SharedWorker();

跨标签页通信

跨标签页通信即在一个标签页中向另一个标签页发送信息

常见的跨标签通信的方式有:

  • BroadCast Channel
  • LocalStorage
  • Shared Worker
  • IndexedDB
  • Cookie
  • postMessage
  • WebSocket

Broadcast Channel

利用Broadcast Channel可以创建一个用于广播的通信频道,当所有页面都在监听同一频道的消息时,其中一个页面向频道中发送消息,其它监听该频道的页面就能够收到该消息

Broadcast Channel有一个限制,就是需要在同源的页面中使用

// 创建广播频道,需要传入频道的标识
const bc = new BroadcastChannel("channel-1");

// 向广播频道中发送消息
bc.postMessage("...");

// 监听频道,获取其他标签页发来的消息
bc.onmessage = (e)=>{
    console.log(e.data);
}

storage

通过window.onstorage事件可以监听到同源的其他标签页对localstorage的操作,包括增加、删除、修改

window.onstorage = (e)=>{
    console.log(e.key);							// 更新的键的名称
    console.log(e.newValue);					// 更新后的值
    console.log(e.oldValue);					// 更新前的值
    console.log(e.storageArea);					// 另一个标签页的localstorage对象
    console.log(e.url);							// 更新storage的标签页的url
}

Shared Worker

Shared Worker是Web Worker的一种,它是一种共享型的Web Worker,可以在多个页面中共享着使用一个Worker,但需要这些页面是同源的

// index.js
const worker = new SharedWorker("./worker.js");

// 和专用worker不同,共享worker需要使用worker.port来发送消息和监听事件
worker.port.postMessage("Hello, I am index");

worker.port.onmessage = (event)=>{
    console.log(event.data);
}
// other.js
const worker = new SharedWorker("./worker.js");

worker.port.postMessage("Hello, I am other");

// 如果使用addEventListener来注册message事件,则需要加上这句代码
worker.port.start();

worker.port.addEventListener("message", (event)=>{
    console.log(event.data);
});
// worker.js
const ports = [];

self.onconnect = (event)=>{				// 每当有一个页面使用了该worker,就会触发该事件
    const port = event.port[0];			// 页面的连接引用
    ports.push(port);
    
    port.onmessage = (event)=>{			// 接收页面发来的数据
        for(const p of ports){
            if(port !== p){				// 将数据转发给其他页面
                p.postMessage(event.data);
            }
        }
    }
}

IndexedDB

在当前页面中存储到IndexedDB中的数据,在其它所有同源页面中都可以获取到

不过要想通过IndexedDB进行跨标签页通信,还需要借助定时器,来轮询检查数据库中数据的变化

Cookie

在当前页面中通过document.cookie设置的cookie,在其它所有同源页面下都可以获取到

因此,可以在一个页面中设置cookie,在另一个同源的页面中就可以获取到该cookie

由于浏览器并没有提供监听cookie变化的API,因此若使用cookie实现跨标签页通信,则需要配合定时器轮询检查cookie的变化

postMessage

对于其他方法,两个不同的标签页,只有当它们的源相同时,才允许进行跨标签页通信

而postMessage则实现了跨源标签页之间也能安全地进行通信(只要使用得合理)

postMessage实现跨标签页时并不是本页面的window对象上调用,而是要在想要通信标签页的window对象上调用

postMessage调用时需要传递两个参数,第一个是传递的数据,第二个是源规则字符串,规则可以是具体的源,也可以是"*"

源规则字符串用于确保安全,只有当打开的页面的源与该规则一致(看上去一样)时,消息才会被该页面接收,"*"表示允许任何源

// index.html

// targetWindow是目标页面的window对象
const targetWindow = window.open("./test.html");

targetWindow.postMessage("Hello", "http://127.0.0.1:5500");
// 通过执行index.html的js代码打开的test.html页面

// 父标签页调用本window对象的postMessage时触发
window.onmessage = (event)=>{
    console.log(event.data);				// "hello"
}

Websocket

Websocket服务器接收来自某个客户端(或某个标签页)发送过来的消息,并将该消息发送给其他所有客户端(或其他标签页),完成跨标签页通信

性能指标

FCP

FCP(First Contentful Paint,首次内容渲染),是指首次渲染页面时,从发出页面请求开始,到页面中任何内容(如文本、图片、非空白 Canvas 或 SVG)完成渲染的时间,其代表着用户从空白屏到看到部分内容的转折点

优秀标准:≤ 1.8 秒

优化方向:减少资源大小、使用http2、使用CDN

LCP

LCP(Largest Contentful Paint,最大内容绘制),是指从发出页面请求开始,到页面视口(可见区域)内最大内容元素(如图片、视频、块级文本等)完成渲染的时间

优秀标准:≤ 2.5 秒

优化方向:减少资源大小、预加载资源、减少JS阻塞、使用服务端渲染(SSR)

其它性能指标

  • INP:Interaction to Next Paint,从用户交互到到页面视觉反馈的时间

    优化方向:优化事件处理函数

  • CLS:Cumulative Layout Shift,页面布局意外移动的严重程度

    优化方向:预留图片尺寸、避免动态插入内容

  • TTI:Time to Interactive,从页面开始加载,到页面可交互的时间

    优化方向:代码分割,减少JS阻塞

  • TBT:Total Blocking Time,在页面的加载过程中,主线程被阻塞的时间总和

    优化方向:拆分长任务,使用Web Worker

  • TTFB:Time to First Byte,从开始请求页面到接收到响应消息的第一个字节的时间,反映服务器响应速度

    优化方向:浏览器缓存

测量方式

  • 使用浏览器提供的performanceObserver API

  • 使用谷歌浏览器调试控制台的Lighthouse选项卡、Performance选项卡