3、前端性能优化(二):浏览器缓存策略(强缓存、协商缓存、Service Worker)

聊到前端性能,缓存绝对是绕不开的核心话题。我个人习惯把缓存比作「前端性能的加速器」——用好了,页面秒开;用不好,用户等得心焦。今天咱们就掰开揉碎,把浏览器缓存策略讲透。

一、缓存到底在解决什么问题?

说白了,缓存就是「把已经拿到的资源存起来,下次直接用」。你想想看,用户每次访问页面都要从服务器下载几十个 JS、CSS、图片文件,网络再快也扛不住。缓存的核心目标就两个:减少网络请求降低资源加载延迟

我在项目中遇到过最典型的场景:一个后台管理系统,每次刷新都要加载 3MB 的 vendor.js。用户抱怨「点个菜单要等两秒」。后来加了缓存策略,第二次访问直接本地加载,速度提升 80%。嗯,这就是缓存的威力。

二、强缓存:最暴力的加速方式

强缓存,就是浏览器在本地直接拿资源,连服务器都不问。它靠两个 HTTP 头控制:ExpiresCache-Control

2.1 Expires(HTTP/1.0 时代的老将)

Expires 是一个绝对时间,比如 Expires: Wed, 21 Oct 2025 07:28:00 GMT。浏览器在这个时间之前,直接从本地缓存读取资源。但问题很明显——它依赖客户端时间。用户改了系统时间,缓存就乱套了。我刚开始做前端时就被坑过:用户反馈页面不更新,查了半天发现他电脑时间调慢了一年。

2.2 Cache-Control(HTTP/1.1 的现代方案)

现在主流都用 Cache-Control,它用相对时间,更靠谱。常用指令:

  • max-age=3600:资源在 3600 秒内有效
  • public:允许所有中间缓存(CDN、代理)缓存
  • private:只允许浏览器缓存,不允许中间缓存
  • no-cache:不是不缓存,而是每次都要问服务器「资源变没变」
  • no-store:完全不缓存,敏感数据用这个

核心原则:强缓存命中时,状态码是 200 (from disk cache)200 (from memory cache)。网络面板里看到这个,说明缓存生效了。

举个例子,我们项目里的静态资源(图片、字体、打包后的 JS/CSS)都设置 Cache-Control: public, max-age=31536000,一年有效期。为什么敢设这么长?因为文件名带了 hash,内容变了文件名就变,不存在更新问题。

三、协商缓存:服务器说了算

强缓存过期了怎么办?这时候浏览器会带着「凭证」去问服务器:资源变没变?没变就返回 304,用缓存;变了就返回新资源。这就是协商缓存。

协商缓存有两种实现方式,我分别说说。

3.1 Last-Modified / If-Modified-Since

服务器返回资源时带上 Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT。浏览器下次请求时带上 If-Modified-Since,服务器对比时间。如果没变,返回 304。

但这种方式有坑:文件修改时间变了但内容没变,也会触发重新下载。我曾经遇到过 CI 部署时自动修改了文件时间戳,导致所有用户都重新下载了一遍资源,服务器差点扛不住。

3.2 ETag / If-None-Match(更精准的方案)

ETag 是服务器根据文件内容生成的唯一标识(通常是 hash)。内容变了 ETag 才变。浏览器请求时带上 If-None-Match,服务器对比 ETag。这种方式更精确,我现在的项目都优先用 ETag。

我的建议:强缓存和协商缓存配合使用。静态资源用强缓存(max-age 设大),HTML 页面用协商缓存(no-cache + ETag)。这样既保证性能,又确保页面及时更新。

四、Service Worker:缓存界的「核武器」

Service Worker 是什么?说白了,它是一个运行在浏览器后台的 JavaScript 脚本,可以拦截网络请求、管理缓存。它比强缓存和协商缓存更灵活——你可以完全控制「什么资源缓存、什么时候更新、离线怎么处理」。

我记得第一次在项目中引入 Service Worker,是为了解决一个棘手问题:用户在地铁里信号不好,页面经常加载失败。用了 Service Worker 做离线缓存后,用户至少能看到上次访问的内容,体验提升了一大截。

4.1 Service Worker 的生命周期

  1. 注册:浏览器下载并注册 Service Worker 脚本
  2. 安装:触发 install 事件,通常在这里预缓存关键资源
  3. 激活:触发 activate 事件,清理旧缓存
  4. 运行:拦截 fetch 事件,决定如何处理请求

4.2 一个简单的缓存策略示例

// 注册 Service Worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

// sw.js - 缓存策略
const CACHE_NAME = 'my-cache-v1';
const urlsToCache = ['/', '/styles/main.css', '/scripts/main.js'];

// 安装时预缓存
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

// 拦截请求:优先从缓存取,没有则网络请求
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});

这段代码实现了「缓存优先」策略。用户访问过的页面,下次直接走缓存。如果缓存没有,才去网络请求。嗯,这里要注意:install 事件里如果有一个资源下载失败,整个 Service Worker 安装就会失败。所以预缓存的资源一定要稳定。

4.3 三种常见的缓存策略

策略 适用场景 说明
Cache First(缓存优先) 静态资源、图片 先查缓存,有则返回,无则网络请求并缓存
Network First(网络优先) API 数据、动态内容 先请求网络,失败则用缓存兜底
Stale While Revalidate(缓存更新策略) 新闻列表、文章 立即返回缓存,同时后台发起网络请求更新缓存

避坑指南:我曾经在生产环境遇到过 Service Worker 缓存了旧版本 JS,导致页面功能异常。原因是 Service Worker 更新后,旧版本还在缓存里。解决方案:在 activate 事件中清理旧缓存,并给缓存名称加版本号(如 my-cache-v2)。

五、三种缓存策略的对比与选择

为了让你更直观地理解,我画了一张流程图,展示浏览器缓存的核心决策逻辑:

浏览器缓存决策流程图 发起网络请求 强缓存有效? 从缓存读取(200) 协商缓存? 返回 304 用缓存 从服务器获取 Service Worker 可拦截所有请求 自定义缓存策略 请求发起 判断节点 缓存命中 Service Worker 层

从这张图可以清楚看到:Service Worker 是最高优先级的拦截层,它可以在强缓存和协商缓存之前就决定如何处理请求。而强缓存和协商缓存是浏览器内置的机制,Service Worker 则给了开发者完全的控制权。

六、实际项目中的缓存策略组合

说了这么多理论,咱们看看实际项目怎么配。我一般这样组合:

  • HTML 页面Cache-Control: no-cache + ETag。每次都要问服务器,确保用户看到最新内容。
  • JS/CSS 打包文件Cache-Control: public, max-age=31536000 + 文件名 hash。一年缓存,内容变了文件名就变。
  • 图片/字体Cache-Control: public, max-age=86400(一天)。更新不频繁,但也不像打包文件那样完全不变。
  • API 接口:不用浏览器缓存,用 Service Worker 的 Network First 策略,保证数据实时性。

小技巧:调试缓存时,打开 Chrome DevTools 的 Network 面板,勾选「Disable cache」可以强制不走缓存。但别忘了调试完关掉,否则你测的都是真实网络请求。

最后说一句:缓存策略没有银弹。你需要根据业务场景、资源类型、更新频率来灵活组合。我见过不少团队一上来就设 max-age=31536000,结果上线后用户死活看不到新功能。嗯,缓存是把双刃剑,用好了是神器,用不好是坑。


公众号:蓝海资料掘金营,微信deep3321