在现代前端开发中,性能优化与用户体验的提升已经成为核心目标之一。为了实现更快的加载速度、更流畅的交互体验,甚至支持离线访问,浏览器提供了一系列缓存机制。其中,Service Worker 和 HTTP 缓存(即浏览器默认缓存) 是两个关键的技术手段。它们都涉及“缓存”,但工作原理、控制方式和适用场景却截然不同。
理解这两者的关系与差异,不仅有助于我们合理设计资源加载策略,更能为构建 PWA(渐进式 Web 应用)打下坚实基础。
一、从生活场景说起:缓存的本质是“提前准备”
想象你每天早上都要去面包店买一个面包。如果每次都要现做,效率很低。于是你开始想办法优化这个过程:
-
方案一:看保质期
面包店告诉你:“这个面包保质期1小时,1小时内再来,我就直接给你。”
这就像 HTTP 缓存——服务器通过响应头告诉浏览器:“这个资源有效期内可以直接用。” -
方案二:自己存面包
你雇了一个管家,他提前把面包买回来放在家里。你一说要吃,他就立刻拿出来,根本不用去店里。
这就是 Service Worker——它作为一个后台代理,主动拦截请求,优先返回本地缓存。
这两个方案都能减少跑腿次数,但背后的逻辑完全不同:一个是被动遵循规则,另一个是主动掌控流程。
二、HTTP 缓存:浏览器的自动行为
HTTP 缓存,也常被称为“浏览器默认缓存”,是一种由 HTTP 协议定义的、浏览器自动执行的资源复用机制。它的核心在于服务器通过响应头来指导浏览器如何缓存资源。
最常见的控制方式是 Cache-Control 头:
Cache-Control: max-age=3600
这表示该资源在 3600 秒内无需再次请求服务器,浏览器可以直接使用本地缓存。这种机制被称为强缓存,在此期间,浏览器甚至不会向服务器发送任何请求,开发者也不需要写任何 JavaScript 代码,整个过程完全自动化。
当强缓存过期后,浏览器会进入协商缓存阶段。它会携带 ETag 或 Last-Modified 等标识向服务器发起验证请求。如果资源未更新,服务器返回 304 Not Modified,浏览器继续使用缓存;如果已更新,则返回新的资源。
这一整套机制被称为“浏览器默认缓存”,因为它不需要开发者干预,只要服务器正确设置响应头,浏览器就会自动处理缓存逻辑。它是静态资源加速的基础,广泛应用于 CDN、图片、JS、CSS 文件的分发。
三、Service Worker:可编程的网络代理
Service Worker 的最大特点是完全可编程。你可以用 JavaScript 写出复杂的缓存策略,比如:
- 安装时预缓存关键资源;
- 对特定请求返回伪造的响应;
- 实现“缓存优先”或“网络优先”的策略;
- 支持完全离线访问。
例如,在一个新闻类 PWA 应用中,你可以让 Service Worker 在用户首次访问时就缓存首页 HTML、核心 CSS 和 JS。当下次用户打开应用时,即使没有网络连接,Service Worker 也能立即返回缓存内容,实现秒开体验。
此外,Service Worker 还能处理推送通知、后台同步等高级功能,是构建现代 Web 应用的核心技术之一。
需要注意的是,Service Worker 不直接操作 DOM,也无法访问 localStorage,它专注于网络请求的拦截与响应。出于安全考虑,Service Worker 只能在 HTTPS 环境下运行(开发时 localhost 除外),并且需要通过 navigator.serviceWorker.register() 显式注册才能生效。
四、当请求一张图片时,缓存是如何工作的?
假设页面中有一个 <img src="/images/photo.jpg"> 标签,浏览器会按以下流程处理请求:
-
前端发起请求
浏览器解析 HTML,发现图片标签,准备发起网络请求。 -
Service Worker 拦截请求
如果 Service Worker 已注册并激活,它会首先接收到fetch事件。在这个阶段,你可以决定是否直接返回缓存:self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(cached => cached || fetch(event.request)) ); });如果缓存命中,浏览器将直接返回结果,跳过后续所有网络行为。
-
浏览器检查 HTTP 缓存
如果 Service Worker 选择放行请求(即调用fetch(event.request)),浏览器会接着检查自身的 HTTP 缓存。如果资源仍在max-age有效期内,浏览器会直接使用磁盘或内存中的副本。 -
发送网络请求
只有当 Service Worker 未命中缓存,且浏览器的 HTTP 缓存也失效时,真正的网络请求才会被发送到服务器。 -
服务器返回资源
服务器响应图片数据,并附带缓存控制头,如Cache-Control: max-age=3600。 -
浏览器缓存并渲染
浏览器将图片存入本地缓存,供后续 HTTP 缓存使用,同时将其交给页面进行渲染。
可以看到,Service Worker 是整个请求流程的第一道关卡,它有能力在最前端就终止网络请求,而 HTTP 缓存则是在其后的后备机制。
如何使用 Service Worker 实现缓存与离线访问
1. 创建 Service Worker 文件
文件路径: public/sw.js
❓ 为什么要放在 public 目录?
src目录中的文件会被构建工具(如 Vite、Webpack)打包,文件名可能被哈希化(如main.js → main.abc123.js)。- Service Worker 必须通过固定路径注册(如
/sw.js),不能使用动态路径。 public目录中的文件会被原样复制,不参与打包,确保/sw.js路径不变。
// 缓存版本号,用于更新机制
// 当你修改了缓存内容,必须更新版本号,否则浏览器不会安装新 SW
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `huanc-cache-${CACHE_VERSION}`;
// API 缓存单独管理,避免静态资源更新时清掉 API 数据
const API_CACHE_NAME = 'api-cache-v1';
// 要在安装阶段预缓存的资源
// 包括首页、CSS、JS、图片、离线页
// 这些资源在首次加载时就会被存到本地
const PRECACHE_URLS = [
'/',
'/index.html',
'/style.css',
'/main.js',
'/logo.png',
'/offline.html' // 必须提前缓存,否则离线时无法显示
];
✅ 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
// event.waitUntil 确保安装过程不会提前结束
// 直到缓存操作完成,SW 才会进入“等待”状态
event.waitUntil(
caches.open(CACHE_NAME) // 打开指定名称的缓存
.then(cache => cache.addAll(PRECACHE_URLS)) // 添加所有资源到缓存
.then(() => {
// 跳过等待阶段,立即激活新 SW
// 否则需要手动刷新或关闭所有页面才能激活
self.skipWaiting();
})
);
});
为什么需要
skipWaiting()?
默认情况下,旧的 Service Worker 仍控制页面,新的 SW 处于“waiting”状态。
调用skipWaiting()让新 SW 立即激活,避免用户看不到更新。
✅ 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
// 清理不再使用的缓存
// 只保留当前版本的缓存,删除旧版本
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(name => {
// 如果缓存名不是当前版本,也不是 API 缓存,就删除
if (name !== CACHE_NAME && name !== API_CACHE_NAME) {
return caches.delete(name);
}
})
);
})
);
// 让当前 SW 立即控制所有客户端(页面)
// 否则只有新打开的页面才会被控制
self.clients.claim();
});
为什么需要
clients.claim()?
默认情况下,Service Worker 只控制它安装后打开的页面。
调用clients.claim()后,它能立即控制所有已打开的页面。
✅ 拦截网络请求
self.addEventListener('fetch', (event) => {
const { request } = event;
// 如果是页面跳转请求(如刷新、点击链接)
// 需要特殊处理,因为页面可能已离线
if (request.mode === 'navigate') {
event.respondWith(handleHtmlRequest(request));
return;
}
// 如果是 API 请求(如 /api/users)
// 使用“网络优先”策略
if (request.url.includes('/api/')) {
event.respondWith(handleApiRequest(request));
return;
}
// 其他静态资源(CSS、JS、图片等)
// 使用“缓存优先”策略
event.respondWith(handleStaticRequest(request));
});
为什么要区分请求类型?
- 页面和静态资源变化少,适合缓存优先,提升加载速度。
- API 数据变化频繁,适合网络优先,保证数据最新。
处理 HTML 请求(优先缓存,失败用离线页)
async function handleHtmlRequest(request) {
// 先查缓存
const cached = await caches.match(request);
if (cached) return cached;
// 缓存没有,尝试网络请求
try {
const response = await fetch(request);
return response;
} catch (error) {
// 网络失败,返回离线页面
const offline = await caches.match('/offline.html');
return offline;
}
}
为什么 HTML 要先查缓存?
即使用户在线,从缓存加载页面也比网络快,提升体验。
处理静态资源(缓存优先)
async function handleStaticRequest(request) {
// 先查缓存
const cached = await caches.match(request);
if (cached) return cached;
// 缓存没有,请求网络
const response = await fetch(request);
// 将网络响应存入缓存,供下次使用
// clone() 是因为 Response 只能读一次
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
}
为什么要
clone()?
Response对象是流,只能被读取一次。
一份用于返回给页面,一份用于存入缓存,所以需要克隆。
处理 API 请求(网络优先)
async function handleApiRequest(request) {
try {
// 先尝试网络请求
const response = await fetch(request);
// 成功后存入缓存
const cache = await caches.open(API_CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
// 网络失败,返回缓存数据
const cached = await caches.match(request);
return cached;
}
}
为什么 API 要网络优先?
用户期望看到最新数据,比如订单状态、消息列表。
只有在网络失败时才使用缓存,作为降级方案。
2. 注册 Service Worker
文件路径: src/main.jsx
function registerServiceWorker() {
// 检查浏览器是否支持 Service Worker
if ('serviceWorker' in navigator) {
// 等待页面完全加载后再注册
// 避免影响首屏性能
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW 注册成功', registration.scope);
})
.catch(error => {
console.error('SW 注册失败', error);
});
});
}
}
registerServiceWorker();
为什么要
window.load后注册?
避免注册过程阻塞页面加载,影响用户体验。
3. 离线页面
文件路径: public/offline.html
<!DOCTYPE html>
<html>
<head>
<title>离线</title>
</head>
<body>
<h1>当前离线</h1>
<p>请检查网络连接</p>
<button onclick="location.reload()">重试</button>
<script>
// 当网络恢复时自动刷新页面
window.addEventListener('online', () => {
location.reload();
});
</script>
</body>
</html>
当用户断网且缓存中没有请求的页面时,提供友好提示,而不是白屏或报错。
4. 监听网络状态
文件路径: src/App.jsx
import { useState, useEffect } from 'react';
function App() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const onOnline = () => setIsOnline(true);
const onOffline = () => setIsOnline(false);
// 监听浏览器的 online/offline 事件
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
// 组件卸载时移除监听
return () => {
window.removeEventListener('online', onOnline);
window.removeEventListener('offline', onOffline);
};
}, []);
return (
<div>
网络状态:{isOnline ? '在线' : '离线'}
</div>
);
}
让用户知道当前是否在线,可以在 UI 上做相应提示,比如显示“已恢复连接”。
5. 主线程与 Service Worker 通信
主线程发送消息
const getCacheInfo = () => {
// 创建一个消息通道,用于双向通信
const channel = new MessageChannel();
// 接收来自 SW 的回复
channel.port1.onmessage = (event) => {
if (event.data.type === 'CACHE_INFO') {
console.log('缓存信息:', event.data);
}
};
// 发送消息给 SW,携带 port2
navigator.serviceWorker.controller.postMessage(
{ type: 'GET_CACHE_INFO' },
[channel.port2]
);
};
为什么要用
MessageChannel?
postMessage默认是单向通信。
使用MessageChannel可以让 SW 通过port主动回复,实现双向通信。
Service Worker 接收消息
self.addEventListener('message', (event) => {
if (event.data.type === 'GET_CACHE_INFO') {
// 获取所有缓存名称
caches.keys().then(cacheNames => {
// 通过 port 回复
event.ports[0].postMessage({
type: 'CACHE_INFO',
caches: cacheNames,
version: CACHE_VERSION
});
});
}
});
6. 如何更新缓存?
- 修改
CACHE_VERSION = 'v1.0.1' - 构建并部署新版本
- 用户访问时,浏览器发现新版本 SW
- 新 SW 安装 → 激活时删除旧缓存 → 控制页面
为什么必须改版本号?
浏览器通过 SW 文件内容判断是否更新。
如果不改版本号,即使内容变了,浏览器也可能认为没有更新。