【前端工程化解决方案】一文已经深入浅出地为前端优化之路指了方向。
我们再深入展开来探讨一下。
站在用户角度,当使用/浏览一个应用(网站)时,无非是希望访问速度快一些(流畅体验),资源体积(size
)小一些(且省流量和内存)。
视频、高帧图片等大资源加载完全较为耗时,毕竟资源过大请求返回的时间更久;而手机端若是网页/App 流量跑太快会饱受诟病,在 256G 仍不够用的今天,用户也不爱安装体积太大的应用。
至于电脑端,Chrome 的多进程机制保证了单个标签页📑的独立和稳定性,但有时打开过多 tab,电脑竟会烫得能煎鸡蛋🍳一般,尤其当运行着其他更耗能的应用(如 VSCode)。这么吃内存倒是浏览器本身的锅了。
针对这个问题推荐一个 Chrome 插件 OneTab,提供临时书签🔖的功能,非常适合像我这样总是不知不觉开一大堆 tab 的人。题外话狂魔😂
CPU、内存、硬盘、虚拟内存
CPU:中央处理单器,Cntral Pocessing Uit 的缩写,是计算机的运算和控制核心。CPU 负责处理程序指令,并执行相应操作。在电脑各部件之间起到协调和控制作用,保证它们的顺利工作。
硬盘:存储资料和软件等数据的硬件设备,容量大、断电数据不丢失。硬盘寻道通常比较耗时间(固态硬盘好很多),还有先将数据传入内存这一中转步骤,因此从硬盘“读写”数据的速度相对于内存慢一些。
内存:即物理内存,是相对于硬盘这个“外部存储”而言的。它读取速度快,但是容量有限,而且是带电存储的(一旦断电数据就会清除)。所以要长时间储存数据需要使用硬盘。
CPU 并不能直接调用存储在硬盘上的系统、程序和数据,必须先将硬盘上的内容传送到内存中,再由CPU去读取运行。因而,作为硬盘和 CPU 的“中转站”,内存对电脑运行速度有较大的影响。虚拟内存:当运行数据超出物理内存容纳限度的时候,部分数据就会自行“溢出”,这时系统就会将硬盘上的部分空间模拟成内存——虚拟内存,并将暂时不运行/使用的程序或数据存放到这部分空间之中,以备需要时及时调用。
大体上的比喻:“CPU是工厂,硬盘是大仓库,内存是正规中转中心,虚拟内存是临时中转中心”。
通过以上理论我们基本了解为啥运行程序会吃内存,何况谷歌浏览器每个 tab 栏都是一个独立进程。
再看服务端角度:服务器是管理资源和保障数据服务的计算机,负载和运算能力强大。即便如此,它对文件的读写能力也是有上限的,数据库每秒可接受的请求次数同样是有限的。如何在有限范围提供尽可能大的吞吐量?
- 一是让用户在服务端资源未更新时直接使用客户端缓存,避免重复获取带来的带宽浪费。
- 二是客户端缓存失效的情况下,发送请求从服务端缓存中直接获取目标数据并返回,从而减少源服务器计算量,有效提升响应速度的同时服务更多的用户。服务端缓存包括反向代理服务器(
Nginx
)、CDN 缓存。源服务器自身还有数据库缓存,只是不在我们讨论的范畴。
两者相辅相成,最终网络负荷的数据量少了(减少了不必要的数据传输),网页反应速度快了(网站性能和体验提升),服务器压力小了。这也是我们性能优化的主要目标。
话已及此,来来回回被提到的——缓存就闪亮✨登场了。
缓存类型
宏观上可分为:
- 共享缓存:能被各级代理缓存的缓存。
- 私有缓存:用户专享,不能被代理缓存的缓存。
微观上可以分为:
- CDN缓存
- 代理服务器缓存
-
客户端缓存,又分:
- http 缓存:强缓存和协商缓存
- 本地存储:localStorage、sessionStorage、cookie、IndexDB 客户端本地数据库缓存、appCache 应用层缓存
一、CDN缓存
CDN 全称 Content Delivery Network,即内容分发网络。也叫 CDN 加速服务器。
通俗地讲,CDN 就是一些缓存服务器的承包商。比如某网站托管的服务器在北京,且采用 CDN 了技术服务。那么 CDN 就会把北京服务器的数据分发到很多其他部署 CDN 技术 的服务器上。这样一来,用户在浏览网站的时候,CDN 会选择一个离用户最近的 CDN 边缘节点来响应用户的请求,海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器上了。
不仅让用户在最短的请求时间拿到资源,降低了访问延时;还分流了来自四面八方的海量请求,大大减轻了源服务器的负载压力。
本地缓存失效后,浏览器会向 CDN 边缘节点(异地节点)发起请求。CDN 缓存策略因服务商不同而不同,但一般都会遵循 HTTP 标准协议,通过 HTTP 响应头中的Cache-control: max-age
字段来设置 CDN 边缘节点数据缓存时间。
如果 CDN 节点的缓存也过期了,节点就会向源服务器发出回源请求,从服务器拉取最新数据来更新节点本地缓存,并将最新数据返回给客户端。
CDN 服务商一般会提供基于文件后缀、目录多个维度来指定 CDN 缓存时间,为用户提供更精细化的缓存管理。
二、代理服务器缓存
代理服务器是浏览器和源服务器之间的中间服务,如Nginx
反向代理服务器。浏览器先向这个中间服务器发起 web 请求,经过权限验证、缓存匹配等处理后,再将请求转发到源服务器。代理服务器缓存的运作原理与浏览器缓存原理类似,只是规模更大、面向群体更广。
它属于共享缓存,很多地方都可以使用其缓存资源,对于节省流量有很大作用。
三、浏览器的 http 缓存(着重讲)
浏览器缓存其实就是浏览器保存通过 http 获取的所有资源,将网络资源存储在本地的一种行为。那么具体存放在哪里呢?
3.1 http 缓存存放位置
按照浏览器查找缓存的顺序/优先级,分别是:
Service Worker
-
Memory Cache
(内存) -
Disk Cache
(硬盘) 知道我们上面👆为什么要讲相关知识了吧。 Push Cache
当依次查找以上且都没有命中的话,就会去发起请求获取资源。
(1) Service Worker
Service Worker 是运行在浏览器背后的独立线程,一般可用来实现缓存功能。因为涉及到请求拦截,必须使用 HTTPS 传输协议来保障安全。它的缓存与浏览器其他内建的缓存机制不同,可以让我们自由控制缓存哪些文件、如何匹配缓存、如何读取缓存,并且缓存是持续性的。
当没有在 Service Worker 命中缓存时,就会调用 fetch 函数来获取数据 (也就是根据缓存查找优先级去查找)。但不管是从 Memory Cache 还是网络请求中拿到的数据,浏览器都会显示我们是从 Service Worker 中获取的内容。
(2) Memory Cache
内存缓存读取高效,但保持时效短,数据在 Tab 页面关闭时即被释放了。
当我们普通刷新访问过的页面,会发现很多资源都来自于memory cache
。
内存缓存中有一块重要的缓存资源是 preloader 相关指令 (如<link rel="preload">
、<link rel="prefetch">
)下载的资源。预加载是页面优化的常见手段之一,浏览器会利用空闲时间,一边解析js/css
文件,一边请求这些有 preloader 标记的资源。
需要注意是,内存缓存在缓存资源时并不关心返回资源的响应头Cache-Control
是什么值,同时资源的匹配也并非仅仅用URL做对比,还可能会对Content-Type
、CORS
等其他参数进行校验。
(3) Disk Cache
硬盘缓存存储容量和时效性优于内存缓存,退出进程时缓存数据不会被清除。但读取速度较慢,不及memory cache
,但其覆盖面是最大的。它会根据 HTTP Header 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。
大文件大概率会存入硬盘;如当前系统内存使用率高的话,文件也会优先存储到硬盘。毕竟内存需要精打细算地使用。
(4) Push Cache
Push Cache (推送缓存)是 HTTP/2 中的内容,它只在 Session 会话中存在,一旦会话结束就被释放,并且缓存时间也很短暂,同时它也并非严格执行HTTP头中的缓存指令。和 HTTP/2 一样在国内还不够普及。
推荐阅读Jake Archibald
的 HTTP/2 push is tougher than I thought 这篇文章,文章中的几个结论:
- 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差
- 可以推送
no-cache
和no-store
的资源 - 一旦连接被关闭,Push Cache 就被释放
- 多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。
- Push Cache 中的缓存只能被使用一次
- 浏览器可以拒绝接受已经存在的资源推送
- 你可以给其他域名推送资源
webkit 资源分成两类,一类是主资源(
MainResourceLoader
),如 HTML 页面、下载项;一类是派生资源(MainResourceLoader
),如 HTML 页面中内嵌的图片和脚本链接。
memory cache
只存储派生资源,它对应的类为CachedResource
,比如原始数据(JS、字体等),以及解码过的图片数据(base64)。
disk cache
同样只储存派生资源(JS、CSS 文件、原始图片等)。
3.2 http 缓存工作流程
出于性能上的考虑,大部分接口都应该合理配置缓存策略,通常将浏览器缓存策略分为两种:强缓存和协商缓存。
它们先后作用于缓存业务流程,一旦命中强缓存,就不会再去验证协商缓存了。可以根据是否需要向服务器重新发起 http 请求来区分记忆。详见下图:
当请求某项资源时,如果发现存在本地缓存(包含缓存资源和缓存标识),会先去查看是否命中🎯强缓存,即判断该缓存是否已过期(Cache-Control
和Expires
),仍在有效期则直接读取缓存,不再发送请求;若已失效,就发请求到服务器检查是否命中协商缓存(Etag/If-None-Match
和Last-Modified/If-Modified-Since
)。如通过对比发现缓存标识的值与服务端一致,则表示资源未改动,返回304
告知浏览器使用本地缓存;不一致则返回200
和修改后的资源。然后将请求到的资源和缓存标识都存入浏览器缓存(无缓存时的初次访问也一样存入资源和标识)。
3.3 强缓存
所谓强缓存就是直接使用缓存而不发起请求。
服务端给资源的响应头(Response Headers)做了缓存配置,设置过期时间、缓存类型等。用户再次请求该资源时,浏览器会根据这些信息判断本地缓存是否还在有效期,未过期就直接使用缓存,不再向服务器发送请求且返回状态码200
。
强缓存可有效控制请求的频率。
在 chrome 控制台的 Network 可以看到该请求返回200
状态码,并且Size
会显示from memory cache
或from disk cache
。
可以看到memory cache
请求返回时间都是0
ms,读取速度真的是非常快了。
强缓存可以通过设置两种 HTTP Header 实现:
Cache-Control
和Expires
,见上图。
(1) Cache-Control
Cache-Control 负责指定请求和响应遵循的缓存机制,可以组合使用多种指令:
-
max-age:表示缓存的时间,单位是秒(s)。它是一个相对时间。
如Cache-Control: max-age=300
代表在这个请求正确返回的5分钟(300秒)内再次请求资源,就会命中强缓存,不需要去进行协商缓存。 - no-cache:每次都进行协商缓存,即发送请求到服务器用缓存标识来验证缓存是否有效(强一致)。
- no-store:禁止使用缓存,每一次都要重新请求数据。也就没有所谓的强缓存、协商缓存了。
- public:表示该资源可以被所有用户缓存,包括客户端和 CDN 等中间代理服务器。
- private:该资源只能被用户的浏览器缓存,不允许 CDN 等代理服务器对其缓存。
-
immutable:和
max-age
的时间配合使用,客户端在缓存有效期内,即使用户显式地刷新页面也不去向服务器请求,而是直接读取本地缓存。
(2) Expires
Expires 指定资源缓存过期时间,是服务器端的具体时间点,如Expires: Wed, 21 Oct 2021 08:41:00 GMT
。客户端在这个时间前获取此资源都直接读取本地缓存,而不再向服务器请求。
Expires是 http1.0 的产物,缺点在于它是一个绝对时间,而服务端和客户端的时间如果有较大的偏差(本地时间可以自行修改),那么验证结果未必准确。现阶段已经被 http1.1 的 Cache-Control: max-age 替代,仍然存在是一种兼容做法。
(3) 设置强缓存
后端服务器如 nodeJS:
res.setHeader('Cache-Control', 'public, max-age=31536000')
Nginx:
location / {
if ($request_filename ~* ^.*?\.(gif|jpg|jpeg|png|bmp|swf)$){
# add_header Cache-Control no-cache;
add_header Cache-Control max-age=60;
# expires 30d;
}
index index.html index.htm
}
Cache-Control 与 Expires 可以在服务端配置同时启用, Cache-Control 优先级高。
3.4 协商缓存
协商缓存是发起请求与服务端数据对比后发现一致,就不再回传重复的资源,继续使用本地缓存。减少了不必要的响应数据。
强缓存失效后,浏览器携带本地缓存标识向服务器发起请求,通过与服务端资源的缓存标识对比,一致则返回304
状态码和Not Modified
,直接使用本地缓存;否则返回200
和请求结果,浏览器会将更新后的资源和缓存标识再存入本地缓存。
而且强缓存判断缓存是否有效的依据是有无超出某个时间,而不关心服务端文件是否已经更新。只要缓存还在这个有效期就不会发出请求,那如果服务端的对应资源更新了浏览器并不能及时知道。这种方式显然不够有保障。这就是为何我们需要协商缓存策略。
协商缓存可以通过设置两种 HTTP Header 实现:
ETag
和Last-Modified
。
(1) Last-Modified 和 If-Modified-Since
Last-Modified 是服务器响应请求时,返回的响应头(Response Headers)中资源在服务端的最后修改时间。
值的格式是一个格林尼治时间:last-modified: Mon, 26 Jul 2021 08:58:00 GMT
浏览器请求一个资源,如果检测到本地缓存有 Last-Modified 这个首部字段,就会在请求头添加 If-Modified-Since,值就是缓存的 Last-Modified;服务器收到这个请求,会用 If-Modified-Since 的值与服务端该资源的最后修改时间对比,如果一致返回304
和空的响应体,表示命中缓存,直接读取本地缓存;如果 If-Modified-Since 的值小于服务器端资源的最后修改时间,代表资源已更新,于是返回新的资源文件和200
。
Last-Modified 的不足:
- 某些服务器不能精确获取文件的最后修改时间。
- 只能以秒计时,如果在极短的(不可感知的)时间内改变了资源,Last-Modified 不会变化。那么服务端还是命中了缓存,不会返回正确的资源。
- 文件的最后修改时间变了,但其实内容没有变,比如编辑过又改回原来的版本。但 Last-Modified 对比失败导致重新返回了相同的资源。
- 如果本地打开了缓存文件,即使没有进行修改,还是会造成 Last-Modified 改变,从而服务端命中缓存失败导致发送相同的资源。
(2) ETag 和 If-None-Match
既然根据文件修改时间来决定缓存重用尚有不足,能否直接通过文件内容是否改变来决定缓存策略?于是 HTTP1.1 的 ETag 应运而生。
ETag 是服务端响应请求时,响应头中根据资源内容生成的一段 hash 字符串,用以标识资源。只要资源内容发生变化,Etag 就会重新生成,可以保证每一个资源的 Etag 都是唯一的。
向服务端请求一个资源时,如果本地缓存有 Etag 值,会将本地缓存的 Etag 值赋给请求头的 If-None-Match,服务端接收到请求,会验证传来的 If-None-Match 和服务器该资源的 ETag 是否一致,从而精确地判断相对于客户端资源是否已经修改了。如果发现一致则返回304
,表示命中缓存,客户端直接读取本地缓存;如果不匹配那么返回200
和新的资源(响应头中包含新的 ETag)。
(3) Etag 和 Last-Modified 对比
- 精度:Etag 优于 Last-Modified。Last-Modified 的时间单位是秒,无法应用于某个文件在1秒内变化多次的情况。如果是负载均衡服务器,各个服务器生成的 Last-Modified 也有可能不一致。
- 性能:Last-Modified 要优于 Etag。Last-Modified只需要记录时间,而 Etag 需要服务器通过算法来计算出一个hash值。
- 优先级:服务器校验优先考虑 Etag,同时存在则只有 Etag / If-None-Match 生效。
(4) 设置协商缓存
- 后端服务器如 nodeJS:
res.setHeader('Last-Modified': Mon, 21 OCt 2021 01:54:36 GMT)
如果是用 Express 框架搭的 Node.js 服务器,可以这样为动态资源设置 Etag:
// 强 Etag
app.set('etag', 'strong') // weak 则是弱 Etag
// 或者自定义函数
app.set('etag', function (body, encoding) {
return generateHash(body, encoding) // consider the function is defined
})
通过express.static(root, [options])
配置的静态资源始终发送弱 ETag。Express 内部是基于 etag 这个包来实现的。
- Nginx:
如果用 Nginx 部署项目,再在响应头加上Cache-Control: no-cache
就可以了。它会自动帮我们在响应头上加上 Etag 和 Last-Modified 两项。
location /static/ {
# 将对静态资源的请求映射到资源的磁盘路径上
alias /root/code/animal-home/dist/static;
# web应用根本不会收到请求,static 的请求都被 Nginx 处理了
autoindex on;
}
通常我们都是将静态资源放到 CDN 上,不会这样处理,这里只是提供协商缓存的实现方式。
3.5 用户行为对于缓存的影响
-
打开网页,地址栏输入地址:查找
disk cache
中是否有匹配的缓存。如果有则按其配置的缓存策略进行校验和读取 (先强缓存后协商缓存)。 -
普通刷新 (F5,MacOS:Command + R):按优先级查找本地缓存(因为 Tab 未关闭,
memory cache
是可用的),跳过强缓存,但会进行协商缓存校验。 -
强制刷新 (Ctrl + F,MacOS:Command + Shift + R):不使用浏览器缓存,因此发送的请求头均带有
cache-control: no-cache
(为了兼容,还有pragma: no-cache
)。直接从服务器返回最新内容,跳过强缓存和协商缓存。(资源的 size 栏自然不会有from disk cache
或from memory cache
)
如果什么缓存策略都没设置,那么浏览器会怎么处理?
对于这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间。
四、缓存策略实际应用
缓存可以说是性能优化中最简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离、减少延迟,并且由于避免了相同资源的重复传输,还可以减少带宽、降低网络负荷。
对与频繁变动的资源:跳过强缓存,采用协商缓存
首先需要对其设置cache-control: no-cache
使浏览器每次都请求服务器,然后配合ETag
/Last-Modified
来验证资源缓存是否最新。这种做法虽然不能节省请求数量,但能显著减少响应数据。
项目的index.html
文件一般都采用这种模式。对于不常变化的资源:采用强缓存,辅以协商缓存
通常给它们的Cache-Control
配置一个很长的有效期,如max-age=31536000
(一年),这样浏览器之后只要请求相同的 URL 便会命中强缓存。至于资源更新,则需要在文件名(或者路径)中添加根据数据摘要算法生成的 hash 值 或 版本号等动态字符,从而达到更改引用 URL 的目的,让之前的强缓存失效 (其实并未立即失效,只是不再使用了而已)。
项目的静态资源就属于这一类,一般我们都放到 CDN 以降低访问延时和减轻源站负载。
CDN节点的缓存策略一般是通过响应头的Cache-control: max-age
来设置节点上数据的缓存时间。当浏览器本地缓存的资源过期之后,不会直接向源站点请求资源,而是向 CDN 边缘节点请求。若 CDN 中的缓存也过期了,那就由它向源站点发出回源请求(协商缓存)来获取最新资源。
在线提供的类库 (如lodash.min.js
) 均采用动态版本号命名。
而项目的 css、js、图片等静态资源则采取在文件名后添加 hash值的方式。只要资源内容变更了,名称中的 hash 值便不一样。
现在比较成熟的【持久化缓存方案】
- html 文件:不开启强缓存,把 html 放到自己的服务器上,采用协商缓存,关闭服务器本身的缓存。
- 静态资源文件 (js、css、img 等):开启长期时效的强缓存,采用内容摘要 hash 命名,并上传至 CDN 服务商。
从而精确控制缓存。因为每次发布的静态资源名都是独一无二的,等于每次都更改了资源路径,即增量式(非覆盖)发布文件,不会丢失之前版本导致线上的用户访问失效。
每次发布更新的时候,先将静态资源部署到 CDN 服务上(优化网络请求),然后再覆盖式上传 html 页面到源服务器,这样既保证了老用户能正常访问,又能让新用户看到最新的页面。
其他提升性能的手法
在合理配置缓存策略,利用好浏览器缓存之外,如何进一步优化性能呢?
- 基于上述持久化缓存方案,缓存策略削减了请求数据和次数、CDN 实现了就近分流请求,但浏览器针对同一域名的并发请求数仍是有限制的(Chorme 是 6 个),js 太多还是会造成阻塞。因此需要合并零散的 js 文件来尽量降低请求数;
- 把更新频率低和频繁修改的资源不要放在一个 js 里,即把稳定代码(第三方插件)单独拆出来,以便于开启长期缓存。有些第三方库也可以直接用线上加速的外链;
- 共用率高的代码(比如公共组件)从不同文件里拎出来,提高复用避免冗余,同时加以适当的缓存时效;
- 太大的代码也单独拆出来,比如 UI组件库,包含在其他 js 里导致单个文件过大请求返回太慢;
- 除了访问速度流畅还希望文件体积尽量小,那么就需要 tree-shaking、代码压缩和图片压缩了(除了用各种插件处理,还可以在服务端开启 gzip 压缩配置);
- 在 index.html 引入的 js、css 的标签加上
rel='preload'
预加载指令; - 使用异步组件,如懒加载路由,减少首屏体积,避免加载延时甚至白屏。
其中代码分割/合并 (code splitting) 要挑不少大梁,webpack SplitChunksPlugin 插件可以很出色地完成这些功能。但是我们追求的文件个数少、单个文件体积小本身是相悖的,这就要靠我们自己结合实际情况达到一个平衡了。