HTTP 缓存
在任何一个前端项目中,访问服务器获取数据都是很常见的事情,但是如果相同的数据被重复请求了不止一次,那么多余的请求次数必然会浪费网络带宽,以及延迟浏览器渲染所要处理的内容,从而影响用户的使用体验。如果用户使用的是按量计费的方式访问网络,那么多余的请求还会隐性地增加用户的网络流量资费。因此考虑使用缓存技术对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。
缓存的原理是在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类有很多,比如代理缓存、浏览器缓存、网关缓存、负载均衡器及内容分发网络等,它们大致可以分为两类:共享缓存和私有缓存。共享缓存指的是缓存内容可被多个用户使用,如公司内部架设的Web代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。
HTTP 缓存应该算是前端开发中最常接触的缓存机制之一,它又可细分为强制缓存与协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器端进行询问以协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求。下面就来具体看HTTP缓存的具体机制及缓存的决策策略。
强制缓存
对于强制缓存而言,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无须与服务器进行任何通信。
创建一个示例,使用 nodejs 的 http 模块开启一个服务器:
// app.js
const http = require('http')
const fs = require('fs')
const url = require('url')
http.createServer((req, res) => {
console.log(req.method, req.url)
const { pathname } = url.parse(req.url)
if (pathname === '/') {
const data = fs.readFileSync('./index.html')
res.end(data)
} else if (pathname === '/imgs/01.png') {
const data = fs.readFileSync('./imgs/01.png')
res.writeHead(200, {
Expires: new Date(Date.now() + 1000 * 60).toUTCString() // 绝对时间
})
res.end(data)
} else if (pathname === '/imgs/02.png') {
const data = fs.readFileSync('./imgs/02.png')
res.writeHead(200, {
'Cache-Control': 'max-age=5' // 滑动时间,以秒为单位,按客户端当前时间滑动
})
res.end(data)
} else {
res.statusCode = 404
res.end()
}
}).listen(3000, () => {
console.log('http://localhost:3000')
})
<!-- index.html -->
<!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>HTTP Cache</title>
</head>
<body>
<h1>HTTP 缓存</h1>
<h2>强制缓存</h2>
<img src="./imgs/01.png" alt="01.png" />
<img src="./imgs/02.png" alt="02.png" />
</body>
</html>
nodemon app.js
开启服务访问 http://localhost:3000
,查看请求日志:
浏览器将频繁访问的缓存放到内存(memory)中,不太频繁访问的放到磁盘(disk)中。
expires
是在 HTTP 1.0 协议中声明的用来控制缓存失效日期时间戳的字段,它由服务器端指定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。
若之后浏览器再次发起相同的资源请求,便会对比 expires
与本地当前的时间戳,如果当前请求的本地时间戳小于expires
的值,则说明浏览器缓存的响应还未过期,可以直接使用而无须向服务器端再次发起请求。只有当本地时间戳大于expires
值发生缓存过期时,才允许重新向服务器发起请求。
从上述强制缓存是否过期的判断机制中不难看出,这个方式存在一个很大的漏洞,即对本地时间戳过分依赖,如果客户端本地的时间与服务器端的时间不同步,或者对客户端时间进行主动修改,那么对于缓存过期的判断可能就无法和预期相符。
为了解决 expires 判断的局限性,从 HTTP 1.1 协议开始新增了 cache-control
字段来对 expires
的功能进行扩展和完善。
从示例(02.png)中可见 cache-control
设置了 max-age=5
的属性值来控制响应资源的有效期,它是一个以秒为单位的时间长度,表示该资源在被请求到后的 5 秒内有效,如此便可避免服务器端和客户端时间戳不同步而造成的问题。除此之外,cache-control
还可配置一些其他属性值来更准确地控制缓存,下面来具体介绍。
no-cache 和 no-store
设置 no-cache
并非像字面上的意思不使用缓存,其表示为强制进行协商缓存,即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。设置 no-store
则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器端给予全新的响应。no-cache
和 no-store
是两个互斥的属性值,不能同时设置。
发送如下响应头可以关闭缓存。
Cache-Control: no-store
指定 no-cache
或 max-age=0
表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。这意味着每次都会发起 HTTP 请求,但当缓存内容仍有效时可以跳过 HTTP 响应体的下载。
Cache-Control: no-cache
Cache-Control: max-age=0
private 和 public
private
和 public
也是 cache-control
的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。
- 若资源响应头中的
cache-control
字段设置了public
属性值,则表示响应资源既可以被浏览器缓存,又可以被代理服务器缓存。 -
private
则限制了响应资源只能被浏览器缓存,若未显式指定则默认值为private
。
对于应用程序中不会改变的文件,你通常可以在发送响应头前添加积极缓存。这包括例如由应用程序提供的静态文件,例如图像,CSS 文件和 JavaScript 文件。
Cache-Control:public, max-age=31536000
max-age 和 s-maxage
max-age
属性值会比 s-maxage
更常用,它表示服务器端告知客户端浏览器响应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题。这便是 s-maxage
存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了 public
属性值时才有效。
总结
由此可见 cache-control
能作为 expires
的完全替代方案,并且拥有其所不具备的一些缓存控制特性,在项目实践中使用它就足够了,目前 expires
还存在的唯一理由是考虑可用性方面的向下兼容。
协商缓存
顾名思义,协商缓存就是在使用本地缓存之前,需要向服务器端发起一次 GET 请求,与之协商当前浏览器保存的本地缓存是否已经过期。
通常是采用所请求资源最近一次的修改时间戳来判断的,为了便于理解,下面来看一个例子:假设客户端浏览器需要向服务器请求一个 manifest.js
的 JavaScript 文件资源,为了让该资源被再次请求时能通过协商缓存的机制使用本地缓存,那么首次返回该图片资源的响应头中应包含一个名为 last-modified
的字段,该字段的属性值为该 JavaScript 文件最近一次修改的时间戳。
示例:
# 该模块是基于 HTTP 协议 ETag 的具体实现
npm i etag
// app.js
const http = require('http')
const fs = require('fs')
const url = require('url')
const etag = require('etag')
http.createServer((req, res) => {
console.log(req.method, req.url)
const { pathname } = url.parse(req.url)
if (pathname === '/') {
const data = fs.readFileSync('./index.html')
res.end(data)
} else if (pathname === '/imgs/03.png') {
// 获取文件最新修改时间
const { mtime } = fs.statSync('./imgs/03.png')
// 获取请求头发送的最新修改时间
const ifModifiedSince = req.headers['if-modified-since']
if (ifModifiedSince === mtime.toUTCString()) {
// 缓存命中
res.statusCode = 304
res.end()
return
}
const data = fs.readFileSync('./imgs/03.png')
res.setHeader('last-modified', mtime.toUTCString())
res.setHeader('Cache-Control', 'no-cache')
res.end(data)
} else if (pathname === '/imgs/04.png') {
const data = fs.readFileSync('./imgs/04.png')
// 根据文件内容生成 ETag
const etagContent = etag(data)
// 获取请求头发送的 If-None-Match
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch === etagContent) {
// 缓存命中
res.statusCode = 304
res.end()
return
}
res.setHeader('etag', etagContent)
res.setHeader('Cache-Control', 'no-cache')
res.end(data)
} else {
res.statusCode = 404
res.end()
}
}).listen(3000, () => {
console.log('http://localhost:3000')
})
<!-- index.html -->
<!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>HTTP Cache</title>
</head>
<body>
<h1>HTTP 缓存</h1>
<h2>协商缓存</h2>
<img src="./imgs/03.png" alt="03.png" />
<img src="./imgs/04.png" alt="04.png" />
</body>
</html>
当我们刷新网页时,由于该 JavaScript 文件使用的是协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次 GET 请求,进行缓存有效性的协商,此次 GET 请求的请求头中需要包含一个 if-modified-since
字段,其值正是上次响应头中 last-modified
的字段值。
当服务器收到该请求后便会对比请求资源当前的修改时间戳与 if-modified-since
字段的值,如果二者相同则说明缓存未过期,可继续使用本地缓存,否则服务器重新返回全新的文件资源。
这里需要注意的是,协商缓存判断缓存有效的响应状态码是 304
,即缓存有效响应重定向到本地缓存上。这和强制缓存有所不同,强制缓存若有效,则再次请求的响应状态码是 200
。
last-modifed 的不足
通过 last-modified
所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:
- 首先它只是根据资源最后的修改时间戳进行判断的,虽然请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证为失效,需要重新进行完整的资源请求。这无疑会造成网络带宽资源的浪费,以及延长用户获取到目标资源的时间。
- 其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,是无法识别出该次文件资源的更新的。
其实造成上述两种缺陷的原因相同,就是服务器无法仅依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存的 Bug 场景。
基于 ETag 的协商缓存
为了弥补通过时间戳判断的不足,从 HTTP 1.1 规范开始新增了一个 ETag
的头信息,即实体标签(Entity Tag)。
其内容主要是服务器为不同资源进行哈希运算所生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的 ETag 标签值就会不同,因此可以使用 ETag
对文件资源进行更精准的变化感知。
示例(04.png)使用 ETag
进行协商缓存图片资源。
再次对该图片资源发起请求时,会将之前响应头中 ETag
的字段值作为此次请求头中 If-None-Match
字段,提供给服务器进行缓存有效性验证。
如果响应头中同时包含了
last-modified
文件修改时间戳和ETag
实体标签两种协商缓存的有效性校验字段,因为ETag
比last-modified
具有更准确的文件资源变化感知,所以它的优先级也更高,二者同时存在时以ETag
为准。
若验证缓存有效,则返回 304
状态码响应重定向到本地缓存。
ETag 的不足
ETag
不像强制缓存中 cache-control
可以完全替代 expires
的功能,在协商缓存中,ETag
并非 last-modified
的替代方案而是一种补充方案,因为它依旧存在一些弊端:
- 一方面服务器对于生成文件资源的
ETag
需要付出额外的计算开销,如果资源的尺寸较大,数量较多且修改比较频繁,那么生成ETag
的过程就会影响服务器的性能。 - 另一方面
ETag
字段值的生成分为强验证和弱验证,强验证根据资源内容进行生成,能够保证每个字节都相同;弱验证则根据资源的部分属性值来生成,生成速度快但无法确保每个字节都相同,并且在服务器集群场景下,也会因为不够准确而降低协商缓存有效性验证的成功率,所以恰当的方式是根据具体的资源使用场景选择恰当的缓存校验方式。
缓存决策
前面我们较为详细地介绍了浏览器 HTTP 缓存的配置与验证细节,下面思考一下如何应用 HTTP 缓存技术来提升网站的性能。假设在不考虑客户端缓存容量与服务器算力的理想情况下,我们当然希望客户端浏览器上的缓存触发率尽可能高,留存时间尽可能长,同时还要 ETag
实现当资源更新时进行高效的重新验证。
但实际情况往往是容量与算力都有限,因此就需要制定合适的缓存策略,来利用有限的资源达到最优的性能效果。明确能力的边界,力求在边界内做到最好。
缓存决策树
在面对一个具体的缓存需求时,到底该如何制定缓存策略呢?我们可以参照图所示的决策树来逐步确定对一个资源具体的缓存策略。
首先根据资源内容的属性判断是否需要使用缓存,如果不希望对该资源开启缓存(比如涉及用户的一些敏感信息),则可直接设置 cache-control
的属性值为 no-store
来禁止任何缓存策略,这样请求和响应的信息就都不会被存储在对方及中间代理的磁盘系统上。
如果希望使用缓存,那么接下来就需要确定对缓存有效性的判断是否要与服务器进行协商,若需要与服务器协商则可为 cache-control
字段增加 no-cache
属性值,来强制启用协商缓存。
否则接下来考虑是否允许中间代理服务器缓存该资源,参考之前在强制缓存中介绍的内容,可通过为 cache-control
字段添加 private
或 public
来进行控制。如果之前未设置 no-cache
启用协商缓存,那么接下来可设置强制缓存的过期时间,即为 cache-control
字段配置 max-age=…
的属性值,最后如果启用了协商缓存,则可进一步设置请求资源的 last-modified
和 ETag
实体标签等参数。
建议能够根据该决策树的流程去设置缓存策略,这样不但会让指定的策略有很高的可行性,而且对于理解缓存过程中的各个知识点也非常有帮助。
缓存决策示例
在使用缓存技术优化性能体验的过程中,有一个问题是不可逾越的:我们既希望缓存能在客户端尽可能久的保存,又希望它能在资源发生修改时进行及时更新。
这是两个互斥的优化诉求,使用强制缓存并定义足够长的过期时间就能让缓存在客户端长期驻留,但由于强制缓存的优先级高于协商缓存,所以很难进行及时更新;若使用协商缓存,虽然能够保证及时更新,但频繁与服务器进行协商验证的响应速度肯定不及使用强制缓存快。那么如何兼顾二者的优势呢?
我们可以将一个网站所需要的资源按照不同类型去拆解,为不同类型的资源制定相应的缓存策略,以下面的HTML文件资源为例:
<!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>HTTP 缓存策略</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<img src="photo.jpg" alt="poto">
<script src="script.js"></script>
</body>
</html>
该 HTML 文件中包含了一个 JavaScript 文件 script.js
、一个样式表文件 style.css
和一个图片文件 photo.jpg
,若要展示出该 HTML 中的内容就需要加载出其包含的所有外链文件。据此我们可针对它们进行如下设置:
首先 HTML 在这里属于包含其他文件的主文件,为保证当其内容发生修改时能及时更新,应当将其设置为协商缓存,即为 cache-control
字段添加 no-cache
属性值;其次是图片文件,因为网站对图片的修改基本都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可采用强制缓存且过期时间不宜过长,故可设置 cache-control
字段值为 max-age=86400
。
接下来需要考虑的是样式表文件 style.css
,由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存来提高重用效率,故可以考虑在样式表文件的命名中增加文件指纹或版本号(比如添加文件指纹后的样式表文件名变为了 style.51ad84f7.css
),这样当发生文件修改后,不同的文件便会有不同的文件指纹,即需要请求的文件 URL 不同了,因此必然会发生对资源的重新请求。同时考虑到网络中浏览器与 CDN 等中间代理的缓存,其过期时间可适当延长到一年,即 cache-control:max-age=31536000
。
最后是 JavaScript 脚本文件,其可类似于样式表文件的设置,采取文件指纹和较长的过期时间,如果 JavaScript 中包含了用户的私人信息而不想让中间代理缓存,则可为 cache-control
添加 private
属性值。
从这个缓存策略的示例中我们可以看出,对不同资源进行组合使用强制缓存、协商缓存及文件指纹或版本号,可以做到一举多得:及时修改更新、较长缓存过期时间及控制所能进行缓存的位置。
缓存设置注意事项
在前面的内容中虽然给出了一种制定缓存决策的思路与示例,但需要明白的一点是:不存在适用于所有场景下的最佳缓存策略。凡是恰当的缓存策略都需要根据具体场景下的请求资源类型、数据更新要求及网络通信模式等多方面因素考量后制定出来,所以下面列举一些缓存决策时的注意事项,来作为决策思路的补充。
1、拆分源码,分包加载
对大型的前端应用迭代开发来说,其代码量通常很大,如果发生修改的部分集中在几个重要模块中,那么进行全量的代码更新显然会比较冗余,因此我们可以考虑在代码构建过程中,按照模块拆分将其打包成多个单独的文件。这样在每次修改后的更新提取时,仅需拉取发生修改的模块代码包,从而大大降低了需要下载的内容大小。
2、预估资源的缓存时效
根据不同资源的不同需求特点,规划相应的缓存更新时效,为强制缓存指定合适的 max-age
取值,为协商缓存提供验证更新的 ETag
实体标签。
3、控制中间代理的缓存
凡是会涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存。
4、避免网址的冗余
缓存是根据请求资源的 URL 进行的,不同的资源会有不同的 URL,所以尽量不要将相同的资源设置为不同的 URL。
5、规划缓存的层次结构
参考缓存决策中介绍的示例,不仅是请求的资源类型,文件资源的层次结构也会对制定缓存策略有一定影响,我们应当综合考虑。
注意事项
缓存是限定域名的
- 根域下的缓存是共享的。比如 a.com、foo.a.com、bar.a.com 的根域都是 a.com,他们是共享缓存;
- 同理,域名不同的缓存不共享。比如 a.com、b.com、c.com,他们之间即使加载相同资源也仅在该域名下有效,不共享。
参考链接
CND 缓存
CDN 全称 Content Delivery Network,即内容分发网络,它是构建在现有网络基础上的虚拟智能网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、调度及内容分发等功能模块,使用户在请求所需访问的内容时能够就近获取,以此来降低网络拥塞,提高资源对用户的响应速度。本节就来对 CDN 缓存所涉及的性能优化内容进行简要介绍。
CDN 概述
前面章节讲到的浏览器缓存方案,它们带来的性能提升主要针对的是浏览器端已经缓存了所需的资源,当发生二次请求相同资源时便能够进行快速响应,避免重新发起请求或重新下载全部响应资源。
显而易见,这些方法对于首次资源请求的性能提升是无能为力的,若想提升首次请求资源的响应速度,除了对资源进行压缩、图片优化等方式,还可借助本节所要介绍的 CDN 技术。
工作原理
回想在初学计算机网络的时候,常见的 B/S 模型都是浏览器直接向服务器请求所需的资源,但实际组网情况并非如此简单。因为通常对热门站点来说,同时发起资源请求的用户规模量往往非常巨大,而如果这些请求都发往同一服务器则极有可能造成访问拥塞。所以更合理的做法是将部分数据缓存在距离用户较近的边缘服务器上,这样不但可以提升对资源的请求获取速度,而且也能有效减少网站根节点的出口带宽压力,这便是 CDN 技术的基本思路。
如果未使用 CDN 网络进行缓存加速,那么通过浏览器访问网站获取资源的大致过程如图所示。
请求的步骤大致可分为四步:
- 当用户在浏览器中输入所要访问的域名时,若本机无法完成域名解析工作,则会转向 DNS 服务器请求对该域名的解析。
- DNS 服务器解析完成返回给浏览器该域名所对应的 IP 地址。
- 浏览器向该 IP 地址指向的服务器发起资源请求。
- 最后服务器响应用户请求将资源返回给浏览器。
如果使用了 CDN 网络,则资源获取的大致过程是这样的。
- 由于 DNS 服务器将对 CDN 的域名解析权交给了 CNAME 指向的专用 DNS 服务器,所以对用户输入域名的解析最终是在 CDN 专用的 DNS 服务器上完成的。
- 解析出的结果 IP 地址并非确定的 CDN 缓存服务器地址,而是 CDN 的负载均衡器的地址。
- 浏览器会重新向该负载均衡器发起请求,经过对用户 IP 地址的距离、所请求资源内容的位置及各个服务器复杂状况的综合计算,返回给用户确定的缓存服务器 IP 地址。
- 对目标缓存服务器请求所需资源的过程。
当然这个过程也可能会发生所需资源未找到的情况,那么此时便会依次向其上一级缓存服务器继续请求查询,直至追溯到网站的根服务器并将资源拉取到本地。
虽然这个过程看起来稍微复杂了一些,但对用户体验来说是无感知的,并且能带来比较明显的资源加载速度的提升,因此对目前所有一线互联网产品来说,使用 CDN 已经不是一条建议,而是一个规定。
针对静态资源
CDN 网络能够缓存网站资源来提升首次请求的响应速度,但并非能适用于网站所有资源类型,它往往仅被用来存放网站的静态资源文件。所谓静态资源,就是指不需要网站业务服务器参与计算即可得到的资源,包括第三方库的 JavaScript 脚本文件、样式表文件及图片等,这些文件的特点是访问频率高、承载流量大,但更新修改频次低,且不与业务有太多耦合。
如果是动态资源文件,比如依赖服务器端渲染得到的 HTML 页面,它需要借助服务器端的数据进行计算才能得到,所以它就不适合放在 CDN 缓存服务器上。
核心功能
CDN 网络的核心功能包括两点:缓存与回源,缓存指的是将所需的静态资源文件复制一份到 CDN 缓存服务器上;回源指的是如果未在 CDN 缓存服务器上查找到目标资源,或 CDN 缓存服务器上的缓存资源已经过期,则重新追溯到网站根服务器获取相关资源的过程。
由于这两个过程与前端性能优化的关系并非特别紧密,所以此处仅介绍概念,暂不进行深入分析。
优化实践
关于 CDN 的性能优化,如何能将其效果发挥到最大程度?其中包括了许多可实践的方面,比如 CDN 服务器本身的性能优化、动态资源静态边缘化、域名合并优化和多级缓存的架构优化等,这些可能需要前端工程师与后端工程师一起配合,根据具体场景进行思考和解决,这里仅介绍一个与前端关系密切的 CDN 优化点:域名设置。
以淘宝网为例,主站请求的域名为 www.taobao.com
,而静态资源请求 CDN 服务器的域名有 at.alicdn.com
、g.alicdn.com
和 img.alicdn.com
等,它们是有意设计成与主站域名不同的,这样做的原因主要有两点:第一点是避免对静态资源的请求携带不必要的 Cookie 信息,第二点是考虑浏览器对同一域名下并发请求的限制。
(1)首先对第一点来说,Cookie 的访问遵循同源策略,并且同一域名下的所有请求都会携带全部 Cookie 信息。
虽然 Cookie 的存储空间就算存满也并不是很大,但如果将所有资源请求都放在主站域名下,那么所产生的效果对于任何一个图片、JavaScript 脚本及样式表等静态资源文件的请求,都会携带完整的 Cookie 信息,若这些完全没有必要的开销积少成多,那么它们所产生的流量浪费就会很大,所以将 CDN 服务器的域名和主站域名进行区分是非常有价值的实践。
(2)其次是第二点,因为浏览器对于同域名下的并发请求存在限制,通常 Chrome 的并发限制数是 6,其他浏览器可能多少会有所差异。这种限制也同时为我们提供了一种解决方案:通过增加类似域名的方式来提高并发请求数,比如对多个图片文件进行并发请求的场景,可以通过扩展如下类似域名的方式来规避限制:
https://img1.alicdn.com/examp1.jpg
https://img2.alicdn.com/examp1.jpg
虽然这种方式对于多并发限制是有效的,但是缓存命中是要根据整个 URL 进行匹配的,如果并发请求了相同的资源却又使用了不同的域名,那么图片之前的缓存就无法重用,也降低了缓存的命中,对于这种情况我们应该考虑进行恰当的域名合并优化。
Service Worker 缓存
Service Worker 是浏览器后台独立于主线程之外的工作线程,正因如此它的处理能力能够脱离浏览器窗体而不影响页面的渲染性能。同时它还能实现诸如推送通知、后台同步、请求拦截及缓存管理等功能,本节将主要讲解其生命周期和对缓存的管理。
Service Worker 概览
Service Worker 是伴随着 Google 推出的 PWA(即 Progressive Web App 渐进式 Web 应用)一同出现的技术,它能够实现诸如消息推送、后台加载、离线应用及移动端添加到主屏等堪比原生应用的功能,同时还具备小程序“无须安装、用完即走”的体验特点。虽然 Service Worker 已被列入 W3C 标准,但在各端上的兼容性并不理想,目前来讲应用比较多的还是在基于 Chrome 的 PC 端浏览器上。
技术由来
我们都知道 JavaScript 的执行是单线程的,如果一个任务的执行占用并消耗了许多计算资源,则势必会导致阻塞执行其他任务,这正是单线程的弊端。为此浏览器引入了 Web Worker,它是一个独立于浏览器主线程之外的工作线程,可以将较复杂的运算交给它来处理,而无须担心这是否会对页面渲染产生负面影响。Service Worker 正是在此基础上增加了对离线缓存的管理能力,它的表现弥补了之前 HTML 5 上采用 AppCache 实现离线缓存的诸多缺陷。
Service Worker 定义了由事件驱动的生命周期,这使得页面上任何网络请求事件都可以被其拦截并加以处理,同时还能访问缓存和 IndexedDB,这就可以让开发者制定自定义度更高的缓存管理策略,从而提高离线弱网环境下的 Web 运行体验。
基本特征
在介绍的技术由来中,其实已经提到了有关 Service Worker 的一些特点,下面来对其进行简要的归纳。
- 独立于浏览器主线程,无法直接操作 DOM。
- 在开发过程中可以通过 localhost 使用,但要部署到线上环境则需要 HTTPS 的支持。
- 能够监听并拦截全站的网络请求,从而进行自定义请求响应控制。
- 在不使用的时候会被中止,在需要的时候进行重启。所以我们不能依赖在其
onmessage
与onfetch
的事件监听处理程序中的全局状态,如果有此需要可以通过访问 IndexedDB API 将全局状态进行存储。 - 广泛使用 Promise 来处理异步。
- 消息推送。
- 后台同步。
生命周期
若想更好地将 Service Worker 的特性应用到实际项目中,来为用户提供零感知的性能优化体验,那么其生命周期就是必须要熟知的,本节就来详细地探讨有关 Service Worker 生命周期的各个环节,如图所示。
首先来看一下使用 Service Worker 的大致过程:通常每一个 Service Worker 都会依赖于各自独立的执行脚本,在页面需要使用时通过请求相应的执行脚本进行 Service Worker 的注册;当注册流程启动后,便可在执行脚本中监听 install 事件来判断安装是否成功,若安装成功则可进行有关离线缓存的处理操作。
但此时的安装成功并不意味着 Service Worker 已经能够取得页面的控制权,只有进行激活后,Service Worker 才能监听页面发出的 fetch 和 push 等事件;如果 Service Worker 的执行脚本发生修改需要进行更新,则更新的流程也会涉及完整的生命周期。
Service Worker 生命周期所涉及的五个关键状态,下面就来依次讨论每个状态中所包含的关键处理操作。
下图展示了 service worker 所有支持的事件:
注册
目前对于 Service Worker 的兼容性来看,依然存在一些浏览器尚未支持的场景,因此在注册之前,需要判断全局环境中是否存在注册 Service Worker 所需的 API,再进行相应的注册操作,具体代码示例如下:
// 仅当浏览器支持 Service Worker 的场景下进行相应的注册
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-test/sw.js')
}
虽然上述代码能够完成对 Service Worker 的注册,但是该代码段位于 JavaScript 脚本的主流程中,它的执行需要请求加载 /sw-test/sw.js
文件,这就意味着在用户访问网站时的首屏渲染可能会被阻塞,进而降低用户体验。
对此进行优化十分简单的方法便是当页面完成加载后再启动 Service Worker 的注册操作,可以通过监听 load 事件来获取页面完成加载的时间点,优化后的代码如下:
// 仅当浏览器支持 Service Worker 的场景下进行相应的注册
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw-test/sw.js')
})
}
在进行性能优化的过程中,经常会遇到类似的情况:引入一项技术带来了某方面性能提升的同时,也可能造成另一方面性能体验的降低。比如这里 Service Worker 可以丰富离线体验,提高开发者对缓存处理的自定义度,但如果不细致考虑资源加载的最佳时机,就很容易造成上述首屏渲染变慢的糟糕体验。
因此在引入任何优化方案时,都需要对优化前后的性能表现进行充分的测试,以避免出现优化方案使得性能指标 A 提升,但却导致性能指标 B 下降。
安装
在注册步骤调用 navigator.serviceWorker.register
函数的处理过程中,会自动下载参数所指定的 Service Worker 执行脚本,然后进行解析与执行。此时处于 Service Worker 生命周期中的安装状态,如果在这个过程中由于某些原因引发了错误,则异步处理的 Promise 会拒绝继续执行,生命周期走到 redundant 终态,并且可在 Chrome 开发者工具的 Application 选项卡中,查看出相应的报错信息,具体如图所示。
如果一切顺利,则对 Service Worker 所请求的执行脚本下载、解析并执行成功,就会触发 install 事件进行 Service Worker 的后续处理。这个事件对每个 Service Worker 来说只会在安装时触发一次,即便执行脚本仅发生了几字节的修改更新,浏览器也会认为这是一个全新的 Service Worker,并在其重新安装后触发独立的 install
事件。
需要注意的是,在该事件中 Service Worker 其实还并未获得页面的控制权,即还不能监听页面上所发出的请求事件,但此时却可以执行一些与页面没有直接操作关系的其他任务,比如缓存一些页面稍后会用到的资源内容,代码示例如下:
const cacheName = 'v1'
// this === window === self === window.self === true
// 监听 SW 安装的 install 事件并进行缓存操作
self.addEventListener('install', function(event) {
// 设置安装步骤的处理内容
// waitUntil 确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成。
event.waitUntil(
caches.open(cacheName).then(function(cache) {
// 参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',
'/sw-test/star-wars-logo.jpg',
'/sw-test/gallery/',
'/sw-test/gallery/bountyHunters.jpg',
'/sw-test/gallery/myLittleVader.jpg',
'/sw-test/gallery/snowTroopers.jpg'
]);
})
);
});
上述代码中通过 event.waitUntil()
方法设置了 Service Worker 安装步骤的处理内容,即将一系列文件添加缓存,如果指定的文件都下载并添加缓存成功,则表明 Service Worker 安装完成;否则只要有一个文件未完成下载或添加缓存失败,则整个安装步骤失败。因此在指定缓存所依赖的文件列表时,应确保其中所包含的文件都能获取成功,或在获取失败后提供相应的错误处理,来避免因个别文件的缓存失败而导致 Service Worker 的安装失败。
另外,在 Service Worker 的 install
事件中其实并未取得页面的控制权,这个限制的主要原因是为了确保整个过程中页面仅由同一个 Service Worker 控制,且每次仅运行唯一的一个版本。
激活
若想要 Service Worker 获得页面的控制权,跳过安装完成后的等待期,十分简单的方式就是直接刷新浏览器,此时新的 Service Worker 便会被激活。当然也可以通过调用 self.skipWaiting()
方法来逐出当前旧的 Service Worker,并让新的 Service Worker 进入 activated
激活态以获得对页面的控制权。代码示例如下:
self.addEventListener('install', event => {
// 让 SW 进入激活态
self.skipWaiting()
})
响应缓存
当 Service Worker 安装成功并进入激活态后,便可以接收页面所发出的 fetch
事件来进行缓存响应的相关操作。下面以一个代码示例进行说明:
// 每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件
self.addEventListener('fetch', event => {
event.respondWith((async () => {
try {
// 获取缓存内容
let response = await caches.match(event.request)
// 缓存无效,发起网络请求获取资源
if (!response) {
response = await fetch(event.request.clone())
// 将新获取的资源纳入缓存中
const cache = await caches.open(cacheName)
cache.put(event.request, response.clone())
}
return response
} catch (err) {
return caches.match('/sw-test/gallery/myLittleVader.jpg')
}
})())
})
上述代码的基本流程是这样的:
- 当捕获到一个页面请求后,首先使用
caches.match()
方法在本地缓存中进行检索匹配; - 如果检索到则返回缓存中储存的资源内容,否则将调用
fetch()
方法发起新的网络请求; - 当接收到请求的响应后,依次判断响应是否有效、状态码是否为 200 及是否为自身发起的请求,经过判断,如果响应符合预期则将其放入对应请求的缓存中,以方便二次拦截到相同请求时能够快速响应。
更新
当发生 Service Worker 执行代码的修改时,便需要对浏览器当前的 Service Worker 进行更新,更新的步骤与初始一个全新 Service Worker 的生命周期相同。
需要注意的是,应当将缓存管理放在 activate
事件的回调中进行处理,其原因是当新的 Service Worker 完成安装并处于等待状态时,此时页面的控制权依然属于旧的 Service Worker,如果不等到激活完成就对缓存内容进行清除或修改,则可能导致旧的 Service Worker 无法从缓存中提取到资源,这会是一个很不好的使用体验。
本地开发注意事项
考虑到 Service Worker 生命周期的相关内容,它能够有效地拦截页面请求并判断缓存是否命中,这对用户体验来说是非常不错的,但同时也引起了开发过程中的一些不便,因为开发调试的需要,即使 Service Worker 的执行代码在前后两次完全相同,也希望能够进行重新提取,以及手动跳过安装后的等待期。幸运的是,Chrome 开发者工具已为这些开发诉求提供了现成的工具,如图所示。
选中“重新加载时更新”复选框,可使得每次刷新浏览器都进行 Service Worker 的更新,无论现有的 Service Worker 执行代码是否发生更新,单击状态行的“停止”按钮,则可让一个处在等待状态的 Service Worker 立即进入激活状态。
除此之外,Service Worker 的设计开发包含了可扩展网站架构思想,它应当给开发者提供对浏览器核心内容的访问方法,而不仅仅是一些简单的高级 API 调用。以下代码示例及注释说明,开发者可以观察 Service Worker 的整个更新周期:
navigator.serviceWorker.register('/sw-test/sw.js').then(reg => {
// 如果 reg.installing 不为 undefined,则说明当前 SW 处理正在安装的状态
reg.installing
// 如果 reg.wating 不为 undefined,则说明当前 SW 处于安装后的等待状态
reg.waiting
// 如果 reg.active 不为 undefined,则说明当前 SW 处于激活状态
reg.active
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing
// 该值为当前 SW 的状态字符串,可取的值包括:installing、installed、activating、activated、redundant,即 SW 的生命周期
newWorker.state
newWorker.addEventListener('statechange', () => {
// 生命周期状态改变所触发的事件
})
})
})
navigator.serviceWorker.addEventListener('controllerchange', () => {
// SW 对页面的控制权发生变更时触发的事件,比如一个新的 SW 从等待状态进入激活状态,获得了对当前页面的控制权
})
高性能加载
为网站应用添加 Service Worker 的能力,就相当于在浏览器后台为该应用增加了一条对资源的处理线程,它独立于主线程,虽然不能直接操作页面 DOM,但可以进行许多离线计算与缓存管理的工作,这将会带来显著的性能提升。下面介绍一点优化资源加载时间的合理做法及注意事项,为确保 Service Worker 能发挥最佳性能提供参考。
当浏览器发起对一组静态 HTML 文档资源的请求时,高性能的加载做法应当是:通过 Service Worker 拦截对资源的请求,使用缓存中已有的 HTML 资源进行响应,同时保证缓存中资源及时更新。静态资源高效加载策略如图所示。
上图处理策略的对应的代码示例如下:
self.addEventListener('fetch', event => {
if (event.request.mode === 'navigate') {
event.respondWith(async () => {
// 拦截页面请求
const normalizedUrl = new URL(event.request.url)
normalizedUrl.search = ''
// 定义对资源重新请求的方法
const fetchResponse = fetch(normalizedUrl)
const fetchResponseClone = fetchResponse.then(r => r.clone())
// 等到请求的响应到达后,更新缓存中的资源
event.waitUntil((async () => {
const cache = await cached.open(cacheName)
await cache.put(normalizedUrl, await fetchResponseClone)
})())
// 如果请求命中缓存,则使用相应缓存,否则重新发起资源请求
return (await cached.match(normalizedUrl)) || fetchResponse
}())
}
})
这里需要注意的是,尽量避免和降低 Service Worker 对请求的无操作拦截,即 Service Worker 对所拦截的请求不进行任何处理,就直接向网络发起请求,然后在得到响应后再返回给页面。代码示例如下:
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request))
})
这是很糟糕的处理方式,因为在不考虑网络实际延迟的情况下,经过了 Service Worker 的拦截转发,会在请求与响应阶段造成不必要的开销。
参考
- 使用 Service Workers - Web API 接口参考 | MDN (mozilla.org)
- Service Worker API - Web APIs | MDN (mozilla.org)
- mdn/sw-test
Push 缓存
HTTP 2 新增了一个强大的功能:服务器端推送,它的出现打破了传统意义上的请求与响应一对一的模式,服务器可以对客户端浏览器的一个请求发送多个响应。
这样会带来性能优化的一个新思路:在传统的网络应用中,客户端若想将应用中所包含的多种资源渲染展示在浏览器中,就需要逐个资源进行请求,但其实一个 HTML 文件中所包含的 JavaScript、样式表及图片等文件资源,是服务器可以在收到该 HTML 请求后预判出稍后会到来的请求,那么就可以利用服务器端推送节省这些多余的资源请求,来提升页面加载的速度。
显然 Push 缓存能显著提升页面加载速度,但在具体使用过程中依然有许多需要注意的地方,本节就来对其相关内容进行详细讨论。
最后一道缓存
内存中的缓存
内存中的缓存是浏览器中响应速度最快且命中优先级最高的一种缓存,但它的驻留周期非常短,通常依赖于渲染进程,一旦页面页签关闭进程结束,内存中的缓存数据就会被回收。
具体到什么资源会放入内存中的缓存,其实具有一定的随机性,因为内存空间有限,首先需要考虑到当前的内存余量,然后再视具体的情况去分配内存与磁盘空间上的存储占比。通常体积不大的 JavaScript 文件和样式表文件有一定概率会被纳入内存中进行缓存,而对于体积较大的文件或图片则较大概率会被直接放在磁盘上存储。
缓存命中优先级
上述四类浏览器缓存的命中优先级从高到低分别是:内存中的缓存、Service Worker 缓存、HTTP 缓存及 HTTP 2 的 Push 缓存。Push 缓存会作为缓存命中的最后一道防线,只有在前面三种缓存均未命中的情况下才会进行询问。这里需要注意的是,只要有高优先级的缓存命中成功,即便设置了低优先级的缓存,也不会对其进行询问。缓存命中优先级如图所示。
基于连接的缓存
在了解了缓存命中优先级后,我们还需要明白 Push 缓存是依赖于 HTTP 2 连接的,如果连接断开,即便推送的资源具有较高的可缓存性,它们也会丢失,这就意味着需要建立新的连接并重新下载资源。考虑到网络可能存在不稳定性,建议不要长时间依赖 Push 缓存中的资源内容,它更擅长的是资源推送到页面提取间隔时长较短的使用场景。
另外,每个 HTTP 2 连接都有自己独立的 Push 缓存,对使用了同一个连接的多个页面来说,它们可以共享该 Push 缓存。但反过来看也需要明白,在将如 JSON 数据等内容与页面响应信息一同推送给客户端时,这些数据资源并非仅被同一页面提取,它们还可以被一个正在安装的 Service Worker 提取使用,这或许会成为 Push 缓存的一个优势。
Push 缓存与预加载
通过讲述有关 HTTP 2 推送的内容,可以察觉到它与 HTTP 的预加载存在许多相似之处,它们的优化原理都是利用客户端的空闲带宽来进行资源文件获取的,这种方式能够很好地将资源的执行与获取进行分离,当浏览器实际需要某个资源文件时,该资源文件其实已经存在于缓存中了,这样便省去了发起请求后的等待时间。
不同之处
Push 缓存和预加载还存在一些不同之处,其中主要的不同点是,Push 缓存是由服务器端决定何时向客户端预先推送资源的,而预加载则是当客户端浏览器收到 HTML 文件后,经过解析其中带有 preload
的标签,才会开启预加载的。其他一些不同之处还包括以下几个方面。
- Push 缓存只能向同源或具有推送权的源进行资源的推送,而预加载则可以从任何源加载资源。
- 预加载使用的是内存中的缓存,而推送使用的 Push 缓存。
- 预加载的资源仅能被发起请求的页面使用,而服务器端 Push 缓存的资源却能在浏览器的不同标签页面中共用。
- 预加载使用的link标签上可以设置 onload 和 onerror 进行相应事件的监听,而 Push 缓存则在服务器端进行监听相对更加透明。
- 预加载可以根据不同的头信息,使用内容协商来确定发送的资源是否正确,Push 缓存却不可以。
使用场景
在分析了 Push 缓存和预加载的异同点之后,会发现两者有其各自擅长的使用场景,首先来看适合使用 Push 缓存的两个场景。
(1)有效利用服务器的空闲时间进行资源的预先推送。例如对于服务器端渲染 HTML 页面的场景,在服务器端生成 HTML 页面的过程中,网络是出于空闲状态的,并且此时客户端浏览器也不会知道将要展示的页面中会包含哪些资源,那么便可以利用这段时间向浏览器推送相关资源。
(2)推送 HTML 中的内联资源。比如 JavaScript 脚本、样式表文件和一些小图标,将这些资源文件进行单独推送,同时也可以很好地利用浏览器缓存,避免每次将 HTML 文件及所包含的资源一并推送。
在这里介绍两种适合使用预加载的场景:CSS 样式表文件中所引用的字体文件;外部CSS样式表文件中使用 background-url
属性加载的图片文件。
使用决策
为了方便决定使用 Push 缓存还是预加载,下面给出一个决策树以供参考,如图所示。
在上图的决策树中,如果资源并不能够利用服务器端空闲时间进行推送,可能就需要根据具体场景进行选取了,如果是内联的关键样式表或 JavaScript 脚本,仅希望更快进行加载则可以使用预加载;对于某些在服务器端就能预判出稍后便会请求的资源,则可使用 Push 推送进行提前缓存。
转载于 (https://blog.csdn.net/u012961419/article/details/124796686)