前端缓存与技术方案(下)

大约 20 分钟基础缓存

HTTP 缓存方案

前端应用中的 HTTP 缓存方案

当访问单页应用(SPA)的首页时,浏览器率先加载的是 HTML 文件,后续再按需加载其它公共资源。刷新页面,可观察 HTML 资源是走的协商缓存,其它大部分资源都命中了强缓存。

因为像 JS、CSS 等资源经过像 webpack 这样的打包工具打包后可以自动生成 hash 文件名,资源变化会导致 hash 名更新。而 HTML 的文件名不会改变。

但我们期望浏览器每次加载时都应该向服务器询问是否更新。否则会出现新版本发布后,浏览器读取缓存 HTML 文件,会导致页面空白报错(旧资源被删除)或应用没有更新(读取了旧资源)的问题。

根据 HTTP 缓存的规则可使用以下缓存方案:

  • 频繁变动的资源,比如 HTML,采用协商缓存
  • CSS、JS、图片资源等采用强缓存,使用 hash 命名

如何让 HTML 文件走协商缓存?

前提:先让浏览器强缓存失效。可以设置如下服务器响应报头:

Cache-Control: max-age=0
Last-Modified: Sat, 04 Sep 2021 08:59:40 GMT

在资源 0 秒就失效的情况下,存在协商缓存触发条件的 Last-Modified 标识,这样每次访问加载的 HTML 资源就会确保是最新的,解决了 HTML 被浏览器强缓存的问题。

Webpack 中的 Hash 模式

在 Webpack 中,有三种常见的哈希类型,分别是 hashchunkhashcontenthash

hash

默认使用。项目级别的 hash,整个项目生成一个唯一的哈希值(即使只修改了一个文件,也会导致所有的文件名都发生变化导致缓存失效,不建议使用)。

chunkhash

入口文件(entry)级别的 hash,基于每个 chunk 的内容生成一个哈希值。因此,当项目中只有部分文件发生变化时,只有受影响的 chunk 的哈希值会发生变化(可通过 CommonsChunkPlugin 插件进行公共模块的提取,将公共库、插件打包成独立文件)。

contenthash

文件内容级别的 hash,基于文件内容生成的哈希值,在文件内容发生变化时,对应的哈希值才会变化。因此,当项目中只有部分文件发生变化时,只有受影响的文件的哈希值会发生变化。

比如,一个 a.js 文件中引入了 a.css,那么当 js 文件被修改后,就算 css 文件并没有被修改,由于该模块发生了改变,同样会导致 css 文件也被重复构建。 此时,针对 css 使用 contenthash 后,只要其内容不变就不会被重复构建。

为了最大化利用 HTTP 缓存中的强缓存优势,可以使用 Webpack 中的 [contenthash][chunkhash] 两个 hash 值来命名输出的文件,以减少不必要的资源重复请求,提升网页的整体打开速度。

用户操作与 HTTP 缓存

Chrome 的三种加载模式

在开发者工具打开时,Chrome 提供了三种加载模式(浏览器刷新按钮上右键鼠标可显示)。

模式一:正常重新加载

Mac: Command + R 快捷键
Windows: Ctrl + R(等同于直接按 F5)

和直接点击浏览器上的刷新按钮效果一样,触发该模式在控制台可以看到大多数资源会命中强缓存,即会优先读取缓存。

模式二:硬性重新加载

Mac: Command + Shift + R
Windows: Ctrl + Shift + R(等同于直接按 Ctrl + F5)

常说的“强制刷新网页”,比如部署代码后仍然访问的是“旧”页面,使用强制刷新(Ctrl + F5)后,所有资源都会重新向服务器获取。

检查请求报头可以发现,所有资源的请求首部都被加上了 cache-control: no-cachepragma: no-cache,两者的作用都表示告知(代理)服务器不直接使用缓存,要求向源服务器发起请求,而 pragma 则是为了兼容 HTTP/1.0

因此硬性重新加载并没有清空缓存,而是禁用缓存。其效果类似于在开发者工具 Network 面板勾选了 Disable cache 选项。

模式三:清空缓存并硬性重新加载

比硬性重新加载多了清空缓存的操作,会将浏览器存储的本地缓存都清空掉(所有访问过的网站缓存都将被清除),再重新向服务器发送请求。

为什么 Ctrl + F5 还是命中了缓存

资源在硬性重新加载后还是命中缓存,说明请求报头上并没有加上特定的两个首部。命中缓存的资源都是随着页面渲染而加载的,而不走缓存的则是等待页面加载完通过脚本异步插入到 DOM 中去的,即资源异步加载命中缓存不受硬性重新加载控制

Tips

如果采用开发者工具 Network 面板勾选 Disable cache 选项方式,那么异步资源也不会读取缓存,原因是缓存被提前禁用了,这与硬性重新加载不同。

base64 图片缓存

base64 格式的图片几乎永远都是 from memory cache,因为浏览器为了节省渲染开销而将其缓存到内存中。

Nginx 与跨域问题

在前端开发中,Nginx 通常被用来解决跨域问题。跨域问题是由于浏览器的同源策略导致的,为了解决这个问题,可以通过设置响应头中的 Access-Control-Allow-Origin 来指定允许访问的域名。因此,在前端访问后端跨域时,需要检查服务端或者 Nginx 配置的 Access-Control-Allow-Origin 是否包含前端域名。

有些时候 Access-Control-Allow-Origin 被设置成 * 代表允许所有域名访问,但可能还会报跨域,其根源其实在前端。比如前端使用 Axios 请求库时如果开启了以下配置:

axios.defaults.withCredentials = true // 允许携带 cookie

此时服务端配置 Access-Control-Allow-Origin 时就不能为 *,或者针对该类型的接口前端请求关闭该配置即可。

同时当前端配置了 axios.defaults.withCredentials = true 时,服务端需配置 access-control-allow-credentials: true

如果浏览器发起了预检请求,那么可能还需要配置 access-control-allow-methodsaccess-control-allow-headers 报头为允许的值。比如:

access-control-allow-headers: Content-Type,Content-Length,Authorization,Accept,X-Requested-With
access-control-allow-methods: PUT,POST,GET,DELETE,OPTIONS

所谓预检请求,也就是浏览器控制台经常会看到的 OPTIONS 请求。

使用 Nginx 配置响应报头

修改跨域相关配置 如果要修改上述的跨域配置,那么首先找到对应的应用端口,修改 location / 中的参数:

server {
    listen 80;
    location / {
        add_header Access-Control-Allow-Origin *; 
        add_header Access-Control-Allow-Methods 'PUT,POST,GET,DELETE,OPTIONS'; 
        add_header Access-Control-Allow-Headers 'Content-Type,Content-Length, Authorization, Accept,X-Requested-With';
        
        if ($request_method = 'OPTIONS') {
            return 204;
        }
    }
}

修改缓存相关配置 Nginx 主要修改缓存方式和过期时间的配置。比如不想 HTML 文件命中强缓存,希望其走协商缓存,可以添加如下响应报头配置:

server {
    listen 80;
    location / {
        if ($request_filename ~* .*.(html|htm)$) {
            add_header Cache-Control 'no-cache';
        }
    }
}

而像 js、css 和图片这样的静态资源,希望浏览器命中强缓存,nginx 可以设置相应的过期时间:

server {
    listen 80;
    location ~ .*.(gif|jpg|jpeg|png|bmp|swf|js|css)$ {
        expires 1d;
    }
}

上述配置以 1 天为例,最终浏览器将返回响应报头 Cache-Control: max-age=86400

Memory Cache 与 Disk Cache

Memory Cache

一种缓存机制,用于存储最近访问的资源,例如图片、CSS 和 JavaScript 文件等。它存储在内存中,读取速度快,可以减少网络请求,提高网页加载速度。但是它的容量较小,不适合存储大型文件。

Disk Cache

可以将一些已经访问过的网页资源保存在本地硬盘上,以便下次访问同一网页时可以更快地加载资源,提高网页加载速度和用户体验。优点是生命周期长,不触发删除操作则一直存在,而缺点则是获取资源的速度相对内存缓存较慢。

Disk Cache 会根据保存下来的资源的 HTTP 首部字段来判断它们是否需要重新请求,如果重新请求那便是强缓存的失效流程,否则便是生效流程。

浏览器缓存机制

缓存获取顺序

当一个资源准备加载时,浏览器会根据其三级缓存原理进行判断:

  • 浏览器会率先查找内存缓存,如果资源在内存中存在,那么直接从内存中加载
  • 如果内存中没找到,接下去会去磁盘中查找,找到便从磁盘中获取
  • 如果磁盘中也没有找到,那么就进行网络请求,并将请求后符合条件的资源存入内存和磁盘中

缓存存储优先级

除了 base64 的图片永远从内存加载外,其他大部分资源会从磁盘加载。

磁盘缓存会将命中强缓存的 JS、CSS、图片等资源保存下来。而内存缓存仅会保存合适的内容。

浏览器内存缓存生效的前提下,JS 资源的执行加载时间会影响其是否被内存缓存。此外图片资源(非 base64)也有和 JS 资源同样的现象,而 CSS 资源比较与众不同,其被磁盘缓存的概率远大于被内存缓存。

Preload 与 Prefetch

preload 也称为预加载,用于在页面加载时预加载资源,以提高页面的性能和用户体验。通过使用 <link rel="preload">,浏览器可以在页面加载时提前下载一些资源,以便在后续的页面渲染过程中更快地获取这些资源。

注意,预加载资源并不一定会被浏览器缓存,因此在使用时,需要根据具体情况来决定是否需要设置缓存策略。另外,预加载资源也可能会对服务器造成额外的负担,因此需要谨慎使用。

preload 则表示预提取,用于在空闲时间预加载资源,以提高页面的性能和用户体验。<link rel="prefetch"> 不会在页面加载时立即下载资源,而是在浏览器空闲时下载资源,以便在后续的页面渲染过程中更快地获取这些资源。

使用 prefetch 加载的资源,刷新页面时大概率会从磁盘缓存中读取,如果跳转到使用它的页面,则直接会从磁盘中加载该资源。

Service Worker

PWA

PWA 全称为 Progressive Web Apps,是一种可以像本地应用一样提供快速、可靠和具有类似原生应用体验的 Web 应用。

PWA 的实现需要借助 Service Worker 技术和 Web App Manifest 文件。 PWA 技术的出现,使得 Web 应用在移动端能够更好地满足用户对于快速、可靠和具有本地应用体验的需求。

以下是 PWA 的几个主要特性:

  • 可靠性:PWA 可以在离线状态下访问,具有快速的加载速度和可靠的性能。

  • 快速性:PWA 具有快速的加载速度,可以在几秒钟内快速加载应用程序。

  • 独立性:PWA 可以像本地应用一样运行,不需要安装,也不需要从应用商店下载。

  • 用户体验:PWA 具有类似原生应用的 UI 交互体验,可以提供推送通知、添加到主屏幕、后台服务等原生应用所具备的功能。

  • 安全性:PWA 使用 HTTPS 协议进行通信,可以提供更高的安全性和保护用户隐私的能力。

  • 可发现性:PWA 可以被搜索引擎索引,可以通过链接分享和搜索引擎等方式被用户发现和访问。

Service Worker 介绍

Service Worker 的本质是一种 JavaScript 脚本,它运行在浏览器的后台线程中,独立于网页主线程,可以拦截和处理网页发出的网络请求,从而可以实现离线缓存、消息推送等功能。 因为它是运行在后台线程中的,所以即使用户关闭了网页,Service Worker 仍然可以继续运行。这使得 Service Worker 成为一种非常有用的技术,可以用来提高网页的性能和用户体验。

Service Worker 依赖 Cache APIopen in new windowFetch APIopen in new window 来实现离线缓存和网络请求拦截。Service Worker 缓存是持久的,独立于浏览器缓存或网络状态

生命周期与缓存 Service Worker 的生命周期包括以下几个阶段:

  • 注册:Service Worker 脚本被注册到浏览器中,此时脚本还没有被激活。

  • 安装:Service Worker 脚本被下载到浏览器中,并进行安装。在安装阶段,通常会进行一些初始化操作,例如打开缓存等。

  • 激活:Service Worker 脚本被激活,此时可以开始拦截网络请求并进行缓存操作。

  • 运行:Service Worker 脚本开始运行,可以处理网络请求并进行缓存操作。

  • 更新:Service Worker 脚本被更新,此时会重新执行安装和激活阶段,并且可以清除旧的缓存。

在 Service Worker 的生命周期中,缓存是一个非常重要的概念。在安装阶段,通常会打开一个缓存,将需要缓存的资源添加到缓存中。在激活和运行阶段,可以从缓存中读取资源,并且可以将新的资源添加到缓存中。通过缓存,可以实现离线缓存和网络请求拦截等功能,从而提高网页的性能和用户体验。

注意,Service Worker 缓存的资源是有时效性的,因为缓存中的资源可能会过期或者被更新。因此,在更新 Service Worker 脚本时,通常需要清除旧的缓存,以便新的资源可以被缓存。

注册 通常会编写以下脚本进行 Service Worker 的注册:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/service-worker.js').then(function(registration) {
      console.log('Service Worker registered:', registration);
    }, function(error) {
      console.log('Service Worker registration failed:', error);
    });
  });
}

这段代码首先检查浏览器是否支持 Service Worker,如果支持,则在页面加载完成后注册 Service Worker。navigator.serviceWorker.register 方法用于注册 Service Worker,参数是 Service Worker 脚本的 URL。注册成功后,会返回一个 ServiceWorkerRegistration 对象,可以用来管理 Service Worker 的生命周期。

默认情况下,Service Worker 的作用范围不能超出其脚本所在的路径。如果该脚本放在根目录下,则代表项目根目录下的所有请求都可以被代理。当然,也可以在注册时使用 scope 参数指定对应的作用域。

安装 在安装阶段,通常会进行以下几个步骤:

  • 下载脚本:浏览器会下载 Service Worker 脚本,并将其保存到缓存中。

  • 缓存静态资源:在 Service Worker 脚本中,可以通过 CacheStorage API 将一些静态资源添加到缓存中,从而实现离线缓存和快速加载等功能。在安装阶段,通常会将一些静态资源添加到缓存中。

  • 安装完成:当 Service Worker 脚本下载完成并缓存静态资源后,安装阶段就完成了。此时,Service Worker 还没有被激活,因此无法拦截网络请求。

在这个阶段,Service Worker 开始接管网络请求,但并不一定立即生效,因为它需要等待所有已打开的页面关闭后才会生效。

激活 在 Service Worker 激活阶段,可以执行一些操作,例如:

  • 缓存静态资源:可以在激活阶段缓存一些静态资源,这样在后续的请求中就可以直接从缓存中获取,从而提高页面加载速度。

  • 清理旧版本缓存:如果我们更新了 Service Worker,可能会有一些旧版本的缓存仍然存在,这些缓存可能会导致页面出现问题。在激活阶段,可以清理这些旧版本的缓存,保证页面正常运行。

  • 发送通知:可以在激活阶段发送一些通知,告知用户 Service Worker 已经更新,或者一些其他信息。

注意,Service Worker 激活阶段只有在 Service Worker 注册成功后才会触发,如果注册失败,则不会进入激活阶段。另外,如果 Service Worker 更新失败,则不会触发激活阶段,而是继续使用旧版本的 Service Worker。

运行 在 Service Worker 注册成功并且激活后,它开始接管网络请求,处理客户端的请求并返回响应的阶段。

在 Service Worker 运行阶段,我们可以执行一些操作,例如:

  • 缓存网络请求:可以在 Service Worker 运行阶段缓存一些网络请求,这样在后续的请求中就可以直接从缓存中获取,从而提高页面加载速度和离线访问能力。

  • 拦截请求:可以在 Service Worker 运行阶段拦截客户端发送的请求,对请求进行处理,然后返回响应。这样可以实现一些高级的功能,例如离线访问、网络请求代理、请求重定向等。

  • 推送消息:可以在 Service Worker 运行阶段向客户端推送消息,例如通知用户有新消息、提醒用户更新等。

注意,Service Worker 运行阶段是一个长期运行的过程,直到 Service Worker 被注销或者浏览器关闭。需要注意一些问题,例如缓存策略、请求处理、错误处理、性能优化等。

更新 指在已经注册并激活的 Service Worker 更新后,新版本的 Service Worker 开始接管网络请求的过程。

当一个新版本的 Service Worker 被注册后,它会等待所有已打开的页面关闭,然后进入激活阶段。在激活阶段,我们可以执行一些操作,例如清理旧版本缓存、发送通知等。然后新版本的 Service Worker 就会开始接管网络请求,处理客户端的请求并返回响应。

在 Service Worker 更新阶段,需要注意一些问题,例如:

  • 缓存策略:如果我们在新版本的 Service Worker 中修改了缓存策略,可能会导致客户端缓存出现问题。因此在更新 Service Worker 时需要注意缓存策略的兼容性。

  • 请求处理:如果我们在新版本的 Service Worker 中修改了请求处理逻辑,可能会导致客户端请求出现问题。因此在更新 Service Worker 时需要注意请求处理逻辑的兼容性。

  • 错误处理:如果新版本的 Service Worker 出现了错误,可能会导致客户端请求出现问题。因此在更新 Service Worker 时需要注意错误处理的兼容性。

注意,如果新版本的 Service Worker 更新失败,可能会导致客户端出现问题。因此在更新 Service Worker 时需要进行充分的测试和验证,确保更新过程的稳定性和可靠性。

出于安全考虑,Service worker 只能在 https 及 localhost 下被使用。

存储型缓存

网站登录背后的存储逻辑

用户在客户端输入账号密码并点击登录后,前端将数据发送给服务端进行验证。如果校验成功,服务端会返回有效的 token 信息,后续客户端请求需要携带该 token 以供服务端验证用户登录的有效性,因此 token 信息在客户端的存储及传输,是用户不必重复登录的关键

服务端自动植入 服务端登录接口通过设置响应报头中的 set-cookie 首部字段,将 token 信息植入浏览器 cookie 中。 set-cookie 指令值包含了必选项 <cookie-name>=<cookie-value> 值和名的形式,同时还包括了可选项 Path(路径)、Domain(域名)、Max-Age(有效时间)等,以分号分隔。 之后前端调用同域下的接口时,浏览器会自动将网站的 cookie 值附加在请求头中传给后端进行校验,前端则不需要关心 token 的存取问题。

前端手动存储 前端存储的方式不受限于浏览器环境(如像 APP 或小程序没有浏览器 cookie 的环境)。

前端获取到服务端登录接口返回的 token 信息,再通过前端存储方法将数据持久化缓存起来,并在退出后手动清除。后续,在调用接口时需要手动将 token 传递给服务端;

浏览器存储型缓存方案

Cookie 存储方案 Cookie 最初是为了辨别用户身份,实现页面间状态的维持和传递。

其存储空间很小,不能超过 4KB。

当 Cookie 在同域下被设置时,它会随着每一次资源请求的请求报头一起传递到服务端进行验证。如果存在过多的 Cookie,将会导致无效资源的传输和性能浪费。

由于 Cookie 无法跨域传输,因此可以利用这一特点在 CDN 域名上进行优化。如果 CDN 资源和主站采用了相同的域名,那么 Cookie 的传输将会导致巨大的性能浪费。相反,可以将 CDN 的域名与主站区分开来,以避免这个问题。

浏览器提供的原始 Cookie 存储 API 使用起来并不是特别方便。可使用 js-cookieopen in new window 库,它封装了 Cookie 的常用操作,提供了简单易用的 API。

Web Storage 存储方案Web Storage 作为 HTML5 推出的浏览器存储机制,其又可分为 Session StorageLocal Storage,两者相辅相成。

Session Storage 作为临时性的本地存储,其生命周期存在于网页会话期间,即使用 Session Storage 存储的缓存数据在网页关闭后会自动释放,并不是持久性的。而 Local Storage 则存储于浏览器本地,除非手动删除或过期,否则其一直存在,属于持久性缓存。

Web Storage 与 Cookie 相比存储大小得到了明显的提升,一般为 2.5-10M 之间(各家浏览器不同)。

注意:Web Storage 存储的数据最终都会转化成字符串类型

存储对象时如果没有提前采用序列化方法 JSON.stringify 转化为字符串对象,那么最终获取的值会变成 [object Object]

可对 Local Storage 封装方法,赋予其过期时间和自动序列化反序列化的能力,此时便无需再关心存储数据的格式问题。

npm 上有处理此问题的包:web-storage-cacheopen in new window

IndexedDB 存储方案 IndexedDB 是一个大规模的 NoSQL 存储系统,它几乎可以存储浏览器中的任何数据内容,包括二进制数据(ArrayBuffer 对象和 Blob 对象),其可以存储不少于 250M 的数据。

npm 上比较流行的封装 IndexedDB 的包 idbopen in new window 可以简化原始 API 的操作流程。

Chrome 浏览器 Application 面板

Chrome Application 面板集成了对浏览器存储数据的一系列操作功能,比如清空存储数据、操作查看 Cookie / Web Storage、查看删除 IndexedDB、调试 Service Worker 等。

HttpOnly

当 Cookie 数据中对应的 HttpOnly 字段显示被勾选时,表示该 Cookie 不可通过 JS 获取和修改。

参考