前端性能优化探讨及浏览器缓存机制

2021-08-04  本文已影响0人  AizawaSayo

【前端工程化解决方案】一文已经深入浅出地为前端优化之路指了方向。

我们再深入展开来探讨一下。

站在用户角度,当使用/浏览一个应用(网站)时,无非是希望访问速度快一些(流畅体验),资源体积(size)小一些(且省流量和内存)。

视频、高帧图片等大资源加载完全较为耗时,毕竟资源过大请求返回的时间更久;而手机端若是网页/App 流量跑太快会饱受诟病,在 256G 仍不够用的今天,用户也不爱安装体积太大的应用。
至于电脑端,Chrome 的多进程机制保证了单个标签页📑的独立和稳定性,但有时打开过多 tab,电脑竟会烫得能煎鸡蛋🍳一般,尤其当运行着其他更耗能的应用(如 VSCode)。这么吃内存倒是浏览器本身的锅了。
针对这个问题推荐一个 Chrome 插件 OneTab,提供临时书签🔖的功能,非常适合像我这样总是不知不觉开一大堆 tab 的人。题外话狂魔😂

CPU、内存、硬盘、虚拟内存

大体上的比喻:“CPU是工厂,硬盘是大仓库,内存是正规中转中心,虚拟内存是临时中转中心”。

通过以上理论我们基本了解为啥运行程序会吃内存,何况谷歌浏览器每个 tab 栏都是一个独立进程。

再看服务端角度:服务器是管理资源和保障数据服务的计算机,负载和运算能力强大。即便如此,它对文件的读写能力也是有上限的,数据库每秒可接受的请求次数同样是有限的。如何在有限范围提供尽可能大的吞吐量?

两者相辅相成,最终网络负荷的数据量少了(减少了不必要的数据传输),网页反应速度快了(网站性能和体验提升),服务器压力小了。这也是我们性能优化的主要目标。

话已及此,来来回回被提到的——缓存就闪亮✨登场了。

缓存类型

宏观上可分为:

微观上可以分为:

一、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 缓存存放位置

按照浏览器查找缓存的顺序/优先级,分别是:

当依次查找以上且都没有命中的话,就会去发起请求获取资源。

(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-TypeCORS等其他参数进行校验。

(3) Disk Cache

硬盘缓存存储容量和时效性优于内存缓存,退出进程时缓存数据不会被清除。但读取速度较慢,不及memory cache,但其覆盖面是最大的。它会根据 HTTP Header 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。绝大部分的缓存都来自 Disk Cache。

大文件大概率会存入硬盘;如当前系统内存使用率高的话,文件也会优先存储到硬盘。毕竟内存需要精打细算地使用。

(4) Push Cache

Push Cache (推送缓存)是 HTTP/2 中的内容,它只在 Session 会话中存在,一旦会话结束就被释放,并且缓存时间也很短暂,同时它也并非严格执行HTTP头中的缓存指令。和 HTTP/2 一样在国内还不够普及。

推荐阅读Jake ArchibaldHTTP/2 push is tougher than I thought 这篇文章,文章中的几个结论:

webkit 资源分成两类,一类是主资源(MainResourceLoader),如 HTML 页面、下载项;一类是派生资源(MainResourceLoader),如 HTML 页面中内嵌的图片和脚本链接。
memory cache只存储派生资源,它对应的类为CachedResource,比如原始数据(JS、字体等),以及解码过的图片数据(base64)。
disk cache同样只储存派生资源(JS、CSS 文件、原始图片等)。

3.2 http 缓存工作流程

出于性能上的考虑,大部分接口都应该合理配置缓存策略,通常将浏览器缓存策略分为两种:强缓存协商缓存

它们先后作用于缓存业务流程,一旦命中强缓存,就不会再去验证协商缓存了。可以根据是否需要向服务器重新发起 http 请求来区分记忆。详见下图:

浏览器缓存机制流程图

当请求某项资源时,如果发现存在本地缓存(包含缓存资源和缓存标识),会先去查看是否命中🎯强缓存,即判断该缓存是否已过期(Cache-ControlExpires),仍在有效期则直接读取缓存,不再发送请求;若已失效,就发请求到服务器检查是否命中协商缓存(Etag/If-None-MatchLast-Modified/If-Modified-Since)。如通过对比发现缓存标识的值与服务端一致,则表示资源未改动,返回304告知浏览器使用本地缓存;不一致则返回200和修改后的资源。然后将请求到的资源和缓存标识都存入浏览器缓存(无缓存时的初次访问也一样存入资源和标识)。

3.3 强缓存

所谓强缓存就是直接使用缓存而不发起请求。

服务端给资源的响应头(Response Headers)做了缓存配置,设置过期时间、缓存类型等。用户再次请求该资源时,浏览器会根据这些信息判断本地缓存是否还在有效期,未过期就直接使用缓存,不再向服务器发送请求且返回状态码200
强缓存可有效控制请求的频率。

chrome 控制台 size 栏可以看到读取缓存的位置

在 chrome 控制台的 Network 可以看到该请求返回200状态码,并且Size会显示from memory cachefrom disk cache
可以看到memory cache请求返回时间都是0ms,读取速度真的是非常快了。

强缓存可以通过设置两种 HTTP Header 实现Cache-ControlExpires,见上图。

(1) Cache-Control

Cache-Control 负责指定请求和响应遵循的缓存机制,可以组合使用多种指令:

(2) Expires

Expires 指定资源缓存过期时间,是服务器端的具体时间点,如Expires: Wed, 21 Oct 2021 08:41:00 GMT。客户端在这个时间前获取此资源都直接读取本地缓存,而不再向服务器请求。
Expires是 http1.0 的产物,缺点在于它是一个绝对时间,而服务端和客户端的时间如果有较大的偏差(本地时间可以自行修改),那么验证结果未必准确。现阶段已经被 http1.1 的 Cache-Control: max-age 替代,仍然存在是一种兼容做法。

(3) 设置强缓存
  1. 后端服务器如 nodeJS:
    res.setHeader('Cache-Control', 'public, max-age=31536000')

  2. 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 实现ETagLast-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 的不足:

  1. 某些服务器不能精确获取文件的最后修改时间。
  2. 只能以秒计时,如果在极短的(不可感知的)时间内改变了资源,Last-Modified 不会变化。那么服务端还是命中了缓存,不会返回正确的资源。
  3. 文件的最后修改时间变了,但其实内容没有变,比如编辑过又改回原来的版本。但 Last-Modified 对比失败导致重新返回了相同的资源。
  4. 如果本地打开了缓存文件,即使没有进行修改,还是会造成 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 对比
(4) 设置协商缓存
  1. 后端服务器如 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 这个包来实现的。

  1. 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 用户行为对于缓存的影响

如果什么缓存策略都没设置,那么浏览器会怎么处理?
对于这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间。

四、缓存策略实际应用

缓存可以说是性能优化中最简单高效的一种优化方式了。一个优秀的缓存策略可以缩短网页请求资源的距离、减少延迟,并且由于避免了相同资源的重复传输,还可以减少带宽、降低网络负荷。

现在比较成熟的【持久化缓存方案】

其他提升性能的手法

在合理配置缓存策略,利用好浏览器缓存之外,如何进一步优化性能呢?

其中代码分割/合并 (code splitting) 要挑不少大梁,webpack SplitChunksPlugin 插件可以很出色地完成这些功能。但是我们追求的文件个数少、单个文件体积小本身是相悖的,这就要靠我们自己结合实际情况达到一个平衡了。

参考文章:
深入理解浏览器的缓存机制
实践这一次,彻底搞懂浏览器缓存机制
前端浏览器缓存知识梳理

上一篇 下一篇

猜你喜欢

热点阅读