前端数据缓存策略
核心认知:前端缓存不是"有更好",而是"必须做"。一次不必要的网络请求可能是 100ms 的延迟,但用户感知到的等待时间累积起来就是体验崩塌。与后端缓存追求"高并发下的数据库保护"不同,前端缓存的核心目标是 减少网络请求、提升响应速度、支持离线可用。
一、前端缓存层次(多级缓存架构)
前端缓存是一个分层体系,从最快到最慢、从最短暂到最持久:
用户操作
↓
┌─────────────────────────────────────────────┐
│ L1 内存缓存(变量 / Store / Query 缓存) │ 响应: μs 级 容量: MB 级
├─────────────────────────────────────────────┤
│ L2 HTTP 缓存(强缓存 / 协商缓存) │ 响应: ms 级 容量: 磁盘配额
├─────────────────────────────────────────────┤
│ L3 Storage 缓存(localStorage / Session) │ 响应: ms 级 容量: 5~10MB
├─────────────────────────────────────────────┤
│ L4 IndexedDB / 文件系统 │ 响应: 10ms 级 容量: GB 级
├─────────────────────────────────────────────┤
│ L5 Service Worker / Cache API │ 响应: 可离线 容量: 磁盘配额
└─────────────────────────────────────────────┘
↓
API / CDN / 后端
各层级对比
| 层级 | 存储位置 | 访问速度 | 持久性 | 容量 | 共享范围 | 适合场景 |
|---|---|---|---|---|---|---|
| L1 内存缓存 | 进程内存 | μs 级 | 无(页面刷新即失) | MB 级 | 当前 Tab | 全局状态、API 响应缓存、计算结果 |
| L2 HTTP 缓存 | 磁盘 | ms 级 | 强(按 HTTP 头控制) | 磁盘配额 | 跨 Tab | 静态资源(JS/CSS/图片)、API 响应 |
| L3 Storage | 磁盘 (Key-Value) | ms 级 | 强 | 5~10MB | 同源跨 Tab | 用户偏好、Token、小体积业务数据 |
| L4 IndexedDB | 磁盘 (NoSQL) | 10ms 级 | 强 | GB 级 | 同源跨 Tab | 大体积数据(离线文档、媒体缓存) |
| L5 Service Worker | 磁盘 | 离线可用 | 持久 | 磁盘配额 | 同源跨 Tab | 离线缓存、PWA、静态资源代理 |
多级缓存协作流程
请求数据
↓
L1 内存缓存 → 命中 → 直接返回 ✅
↓ 未命中
L2 HTTP 缓存 → 命中(强缓存/协商缓存)→ 返回,回填 L1 ✅
↓ 未命中(或已过期)
L3/L4 Storage/IndexedDB → 命中 → 返回,回填 L1
↓ 未命中
发起 API 请求 → 后端返回数据
↓
逐级回填:L1(内存)→ L2(HTTP Cache 交由浏览器)→ L3/L4(持久化)
二、HTTP 缓存策略
HTTP 缓存是浏览器自带的缓存机制,不需要任何 JS 代码介入,只需后端配合设置响应头。
2.1 强缓存
浏览器直接从本地缓存读取,不发起网络请求(Network 面板显示 (disk cache) 或 (memory cache))。
| 响应头 | 值示例 | 说明 | 优先级 |
|---|---|---|---|
Cache-Control |
max-age=31536000 |
HTTP/1.1 标准,相对时间(秒) | 高(覆盖 Expires) |
Expires |
Thu, 31 Dec 2026 23:59:59 GMT |
HTTP/1.0 标准,绝对时间戳 | 低 |
Cache-Control 常用指令:
| 指令 | 含义 | 适用场景 |
|---|---|---|
max-age=3600 |
资源可缓存 3600 秒 | 所有可缓存资源 |
s-maxage=3600 |
覆盖 max-age,仅对 CDN/代理生效 | CDN 缓存控制 |
public |
允许任何中间节点缓存(CDN、代理) | 静态资源 |
private |
仅允许浏览器缓存,禁止中间节点缓存 | 用户个性化数据 |
no-cache |
可以缓存,但每次使用前必须向服务器验证(走协商缓存) | HTML 页面 |
no-store |
禁止任何缓存(完全不存) | 敏感数据、支付信息 |
must-revalidate |
缓存过期后必须重新验证 | 强一致性场景 |
2.2 协商缓存
缓存有记录,但不确定是否过期,向服务器问一下还能不能用。如果资源未修改,服务端返回 304 Not Modified,不传 body,节约带宽。
# 请求流程
第一次请求:
Response: ETag: "abc123" Last-Modified: Mon, 01 Jan 2026 00:00:00 GMT
第二次请求:
Request: If-None-Match: "abc123" If-Modified-Since: Mon, 01 Jan 2026 00:00:00 GMT
Response: 304 Not Modified (空 body,浏览器用本地缓存)
| 方案 | 响应头 | 请求头 | 精确度 | 实现难度 |
|---|---|---|---|---|
| ETag | ETag: "hash" |
If-None-Match: "hash" |
高(内容 hash) | 高(需要计算 hash) |
| Last-Modified | Last-Modified: GMT |
If-Modified-Since: GMT |
低(秒级粒度) | 低(取文件 mtime) |
ETag 优先于 Last-Modified。浏览器会同时发两个头,服务端以 ETag 为准。如果 ETag 匹配,直接返回 304,即使 Last-Modified 不一致。
2.3 静态资源缓存策略(hash 文件名 + 长缓存)
前端工程化的核心缓存策略:
构建产物:
app.a3b2c1.js ← hash 由文件内容计算
chunk-vendors.d4e5f6.js
main.7g8h9i.css
logo.1a2b3c.png
响应头设置(最长缓存):
Cache-Control: public, max-age=31536000, immutable
HTML 文件(入口文件):
响应头设置(不缓存或短期缓存):
Cache-Control: no-cache
或
Cache-Control: public, max-age=0, must-revalidate
原理:文件名 hash 随内容变化。内容变了 → hash 变了 → URL 变了 → 浏览器自动请求新文件;内容没变 → hash 不变 → 走强缓存。不需要手动清缓存。
immutable指令(Chrome 96+ 支持):告诉浏览器"这个资源永不变,连协商缓存都不用发",配合 hash 文件名使用效果最佳。
2.4 HTML 文件缓存策略
| 策略 | 响应头 | 效果 | 适用场景 |
|---|---|---|---|
| 不缓存 | Cache-Control: no-store |
每次都请求 | 高频动态内容 |
| 短期缓存 | Cache-Control: public, max-age=300 |
5 分钟内不请求 | 中低频更新 |
| 协商缓存 | Cache-Control: no-cache |
每次验证,304 则不传 body | SPA 入口 HTML (推荐) |
推荐 SPA HTML 策略:
Cache-Control: no-cache
ETag: "build-20261201-abc123"
每次请求都向服务器验证,若 HTML 没变则返回 304,成本极低,但能保证用户每次刷新都能拿到最新的 HTML(其中引用的 JS/CSS 都是 hash 版本)。
三、API 数据缓存(TanStack Query)
最成熟的前端数据缓存方案是 TanStack Query(原 React Query),同时支持 React 和 Vue。它本质是一个 智能缓存管理器,不是简单的请求封装。
3.1 核心概念
// ─── React 示例 ───
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchUserList, createUser } from '@/api/user';
export function UserListPage() {
// useQuery 自动管理缓存、加载态、错误态
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['users', { page: 1 }],
queryFn: () => fetchUserList({ page: 1 }),
staleTime: 30 * 1000, // 30 秒内认为数据"新鲜",不重新请求
gcTime: 5 * 60 * 1000, // 5 分钟后未使用的缓存才从内存清除(v5 前叫 cacheTime)
});
return (
<div>
{isLoading && <Spin />}
{error && <Alert message={error.message} type="error" />}
{data?.map(user => <UserCard key={user.id} user={user} />)}
</div>
);
}
<!-- ─── Vue 示例 ─── -->
<script setup lang="ts">
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
import { fetchUserList, createUser } from '@/api/user';
import { ElLoading, ElAlert } from 'element-plus';
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['users', { page: 1 }],
queryFn: () => fetchUserList({ page: 1 }),
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
});
</script>
<template>
<div>
<div v-if="isLoading"><ElLoading /></div>
<ElAlert v-else-if="error" type="error">{{ error.message }}</ElAlert>
<div v-else>
<UserCard v-for="user in data" :key="user.id" :user="user" />
</div>
</div>
</template>
3.2 staleTime vs gcTime(核心理解)
这是 TanStack Query 最容易被混淆的两个概念:
useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 30 * 1000, // ⭐ 数据"新鲜期":30 秒内不重新请求
gcTime: 5 * 60 * 1000, // ⭐ 缓存"存活期":5 分钟后才从内存清除
});
| 参数 | 旧名称 (v4) | 含义 | 过期间隔内会怎样 |
|---|---|---|---|
staleTime |
staleTime |
数据被认为"新鲜"的时长 | 直接返回缓存,不发起请求 |
gcTime |
cacheTime |
数据不新鲜后,在内存中保留的时长 | 组件卸载后,缓存仍保留 gcTime 时长,再次挂载可直接使用旧数据 |
时间线图解:
时间线:
0s useQuery 首次请求 → 数据到达
0~30s 数据新鲜(staleTime 内)→ 任何重新请求都返回缓存,不发网络
30s 数据变为"stale"(过期)
30s~5min 数据 stale 但仍在内存中(gcTime 内)
- 组件可见:自动触发 refetch(后台更新)
- 组件不可见:缓存保留,等下次挂载
5min 缓存从内存移除(gcTime 到期)
- 下次需要时重新请求
常见配置策略:
| 业务场景 | staleTime | gcTime | 说明 |
|---|---|---|---|
| 配置字典(几乎不变) | 30 min | 1 hour | 长时间信任缓存 |
| 用户列表(偶有变化) | 30 s | 5 min | 30s 内不请求,之后后台更新 |
| 实时数据(价格/库存) | 0 | 2 min | 每次进入页面都请求 |
| 表单枚举值 | Infinity | 1 hour | 永不刷新,除非手动失效 |
3.3 缓存失效与更新
// ─── React 示例 ───
function UserCreatePage() {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: UserCreateDTO) => createUser(data),
onSuccess: () => {
// ✅ 1. 失效指定查询,下次访问自动拉取最新数据
queryClient.invalidateQueries({ queryKey: ['users'] });
// ✅ 2. 或者直接更新缓存(避免多余请求)
queryClient.setQueryData(['users', { page: 1 }], (old) => ({
...old,
items: [newUser, ...old.items],
}));
message.success('创建成功');
},
});
return <Form onFinish={(values) => createMutation.mutate(values)} />;
}
<script setup lang="ts">
// ─── Vue 示例 ───
import { useQueryClient } from '@tanstack/vue-query';
import { ElMessage } from 'element-plus';
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: UserCreateDTO) => createUser(data),
onSuccess: () => {
// 失效用户列表缓存
queryClient.invalidateQueries({ queryKey: ['users'] });
ElMessage.success('创建成功');
},
});
</script>
常用失效策略:
| 方式 | 代码 | 效果 |
|---|---|---|
| 全部失效 | invalidateQueries() |
所有缓存重新请求 |
| 精确失效 | invalidateQueries({ queryKey: ['users'] }) |
只失效 users 相关 |
| 模糊失效 | invalidateQueries({ queryKey: ['users', { page: 1 }] }) |
匹配指定参数 |
| 直接设值 | setQueryData(key, updater) |
直接更新缓存,不请求 |
| 立即更新 | refetchQueries({ queryKey: [...] }) |
强制立即重新请求 |
3.4 refetchOnWindowFocus
// 全局配置(推荐)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
refetchOnWindowFocus: true, // 用户切换 Tab 回来后自动刷新
refetchOnReconnect: true, // 网络恢复后自动重试
retry: 2, // 失败重试 2 次
},
},
});
| 配置 | 默认值 | 含义 | 建议 |
|---|---|---|---|
refetchOnWindowFocus |
true |
窗口重新获得焦点时 refetch stale 数据 | 保持默认 |
refetchOnReconnect |
true |
网络重新连接时 refetch | 保持默认 |
refetchInterval |
false |
轮询间隔(ms),设为 3000 即每 3 秒请求一次 | 仅实时场景开启 |
retry |
3 |
失败自动重试次数 | 建议 1~2 |
3.5 乐观更新(Optimistic Updates)
先更新 UI,请求失败再回滚。适用于"用户大概率成功"的操作(点赞、收藏、编辑)。
// ─── React 示例 ───
function useToggleLike() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ postId, liked }: { postId: string; liked: boolean }) =>
toggleLike(postId, liked),
// 1. 触发 mutation 时立即执行
onMutate: async ({ postId, liked }) => {
// 取消正在进行的查询(防止覆盖乐观更新)
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
// 保存之前的快照,用于回滚
const previous = queryClient.getQueryData(['posts', postId]);
// 直接更新缓存
queryClient.setQueryData(['posts', postId], (old: Post) => ({
...old,
liked,
likeCount: old.likeCount + (liked ? 1 : -1),
}));
return { previous }; // 传给 onError
},
// 2. 请求失败,回滚到之前的快照
onError: (err, { postId, liked }, context) => {
if (context?.previous) {
queryClient.setQueryData(['posts', postId], context.previous);
}
message.error('操作失败,已回滚');
},
// 3. 无论成功失败,最终重新拉取确保一致性
onSettled: (data, error, { postId }) => {
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
}
<script setup lang="ts">
// ─── Vue 示例 ───
import { useQueryClient } from '@tanstack/vue-query';
import { ElMessage } from 'element-plus';
const queryClient = useQueryClient();
const toggleLikeMutation = useMutation({
mutationFn: ({ postId, liked }: { postId: string; liked: boolean }) =>
toggleLike(postId, liked),
onMutate: async ({ postId, liked }) => {
await queryClient.cancelQueries({ queryKey: ['posts', postId] });
const previous = queryClient.getQueryData(['posts', postId]);
queryClient.setQueryData(['posts', postId], (old: Post) => ({
...old,
liked,
likeCount: old.likeCount + (liked ? 1 : -1),
}));
return { previous };
},
onError: (err, { postId }, context) => {
if (context?.previous) {
queryClient.setQueryData(['posts', postId], context.previous);
}
ElMessage.error('操作失败,已回滚');
},
onSettled: (data, error, { postId }) => {
queryClient.invalidateQueries({ queryKey: ['posts', postId] });
},
});
</script>
3.6 防御式编程:永远不要信任后端返回的数据
// ❌ 反例:直接信任后端数据
useQuery({
queryKey: ['user', id],
queryFn: async () => {
const res = await fetch(`/api/user/${id}`);
return res.json(); // 万一后端返回 null、字段缺失、类型不对?
},
});
// ✅ 正例:运行时校验 + 默认值兜底
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string().default(''),
email: z.string().email().optional(),
avatar: z.string().url().default('https://default-avatar.com'),
status: z.enum(['active', 'disabled']).default('active'),
});
useQuery({
queryKey: ['user', id],
queryFn: async () => {
const raw = await fetch(`/api/user/${id}`).then(r => r.json());
// 运行时校验确保数据符合预期
return UserSchema.parse(raw);
},
staleTime: 30 * 1000,
});
四、页面级缓存
4.1 Vue:KeepAlive 组件缓存页面实例
<!-- ─── Vue Router + KeepAlive ─── -->
<script setup lang="ts">
import { ref } from 'vue';
import { RouterView, useRouter } from 'vue-router';
// 需要缓存的页面名称列表
const cachedViews = ref<string[]>(['UserList', 'ProductList']);
// 动态管理缓存列表
function addCache(name: string) {
if (!cachedViews.value.includes(name)) {
cachedViews.value.push(name);
}
}
function removeCache(name: string) {
cachedViews.value = cachedViews.value.filter(v => v !== name);
}
</script>
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews" :max="10">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</template>
KeepAlive 参数说明:
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
include |
string / RegExp / string[] |
— | 只有名称匹配的组件会被缓存 |
exclude |
string / RegExp / string[] |
— | 名称匹配的组件不会被缓存 |
max |
number |
— | 最多缓存多少个组件实例,超过时 LRU 淘汰 |
页面生命周期变化:
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue';
// 首次进入执行 setup
const data = ref(null);
// 从缓存激活时触发(替代 mounted 做数据刷新)
onActivated(() => {
// 页面回到前台时检查数据是否需要刷新
if (isStale(data.value)) {
fetchData();
}
});
// 离开但被缓存时触发
onDeactivated(() => {
// 保存滚动位置等
saveScrollPosition();
});
</script>
4.2 React:手动实现 KeepAlike(社区方案)
React 官方没有 KeepAlive 等价物,常见方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| react-activation | 第三方库,用 KeepAlive 组件包裹 |
功能全,但有破坏性更新风险 |
| display: none 隐藏 | 用 CSS 隐藏而非卸载 | 简单但不释放 DOM 内存 |
| 状态持久化 + 条件渲染 | 卸载前保存状态到 Store/Zustand,重新挂载时恢复 | 控制力强,代码量多 |
| portal 到隐藏容器 | 将内容渲染到不可见的 DOM 节点 | 复杂,不推荐 |
// ─── React + react-activation 方案 ───
import { KeepAlive, AliveScope } from 'react-activation';
import { useLocation, useOutlet } from 'react-router-dom';
function AppLayout() {
const location = useLocation();
const outlet = useOutlet();
const cachedPaths = ['/users', '/products', '/dashboard'];
return (
<AliveScope>
<div className="app-content">
{cachedPaths.includes(location.pathname) ? (
<KeepAlive name={location.pathname} when max={10}>
{outlet}
</KeepAlive>
) : (
outlet // 不缓存的路由直接渲染
)}
</div>
</AliveScope>
);
}
// 在缓存页面中
function UserListPage() {
useActivate(() => {
// 页面被激活时触发
console.log('UserList 被激活');
});
useUnactivate(() => {
// 页面被缓存时触发
saveScrollPosition();
});
return <UserTable />;
}
// ─── React + Zustand 手动实现缓存(更可控) ───
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface PageCache {
scrollPositions: Record<string, number>;
formData: Record<string, any>;
setScrollPosition: (path: string, pos: number) => void;
setFormData: (path: string, data: any) => void;
clearCache: (path: string) => void;
}
const usePageCache = create<PageCache>()(
persist(
(set) => ({
scrollPositions: {},
formData: {},
setScrollPosition: (path, pos) =>
set((state) => ({
scrollPositions: { ...state.scrollPositions, [path]: pos },
})),
setFormData: (path, data) =>
set((state) => ({
formData: { ...state.formData, [path]: data },
})),
clearCache: (path) =>
set((state) => {
const { [path]: _, ...restPos } = state.scrollPositions;
const { [path]: __, ...restData } = state.formData;
return { scrollPositions: restPos, formData: restData };
}),
}),
{ name: 'page-cache-storage' }
)
);
// 在页面卸载前保存状态
function UserListPage() {
const setScrollPosition = usePageCache((s) => s.setScrollPosition);
const scrollPosition = usePageCache(
(s) => s.scrollPositions[location.pathname] ?? 0
);
const tableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 恢复滚动位置
if (tableRef.current) {
tableRef.current.scrollTop = scrollPosition;
}
return () => {
// 卸载前保存滚动位置
if (tableRef.current) {
setScrollPosition(location.pathname, tableRef.current.scrollTop);
}
};
}, []);
return <div ref={tableRef}>{/* 表格内容 */}</div>;
}
4.3 滚动位置恢复
// ─── 通用滚动位置恢复工具 ───
const SCROLL_KEY = 'scroll_position';
export function useScrollRestore(pageKey: string) {
// 保存
function saveScroll() {
const pos = {
x: window.scrollX,
y: window.scrollY,
};
sessionStorage.setItem(`${SCROLL_KEY}:${pageKey}`, JSON.stringify(pos));
}
// 恢复
function restoreScroll() {
const saved = sessionStorage.getItem(`${SCROLL_KEY}:${pageKey}`);
if (saved) {
try {
const { x, y } = JSON.parse(saved);
requestAnimationFrame(() => window.scrollTo(x, y));
} catch {
// 解析失败忽略
}
}
}
return { saveScroll, restoreScroll };
}
| 存储方式 | 持久性 | 适用场景 |
|---|---|---|
sessionStorage |
同 Tab 刷新有效 | 浏览器返回/前进恢复 |
localStorage |
持久保存 | 跨 Tab、跨会话 |
| Zustand + persist | 可按需配置 | 统一的缓存状态管理 |
五、Storage 缓存实践
5.1 localStorage 封装(异步 + 防御式)
// ─── 安全封装的 localStorage 工具 ───
const PREFIX = 'app_';
const VERSION_KEY = `${PREFIX}cache_version`;
interface CacheItem<T> {
data: T;
expireAt: number | null; // null 表示永不过期
version: number;
}
class StorageCache {
private currentVersion: number;
constructor(version: number) {
this.currentVersion = version;
this.checkVersion();
}
// 版本变更时自动清空旧缓存
private checkVersion() {
const storedVersion = localStorage.getItem(VERSION_KEY);
if (storedVersion && Number(storedVersion) !== this.currentVersion) {
this.clearAll();
}
localStorage.setItem(VERSION_KEY, String(this.currentVersion));
}
// 防御式读取:任何异常都返回 null
get<T>(key: string): T | null {
try {
const raw = localStorage.getItem(`${PREFIX}${key}`);
if (!raw) return null;
const item: CacheItem<T> = JSON.parse(raw);
// 检查过期
if (item.expireAt && Date.now() > item.expireAt) {
localStorage.removeItem(`${PREFIX}${key}`);
return null;
}
// 检查版本
if (item.version !== this.currentVersion) {
localStorage.removeItem(`${PREFIX}${key}`);
return null;
}
return item.data;
} catch {
// ⚠️ 防御:localStorage 可能被用户禁用、配额满、数据损坏
return null;
}
}
set<T>(key: string, data: T, ttlMs?: number) {
try {
const item: CacheItem<T> = {
data,
expireAt: ttlMs ? Date.now() + ttlMs : null,
version: this.currentVersion,
};
localStorage.setItem(`${PREFIX}${key}`, JSON.stringify(item));
} catch (e) {
// ⚠️ 配额满:清理过期或低频数据后重试,或降级
console.warn('StorageCache set failed, quota may be full', e);
this.evictExpired();
}
}
remove(key: string) {
localStorage.removeItem(`${PREFIX}${key}`);
}
// 清理过期数据
evictExpired() {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key || !key.startsWith(PREFIX)) continue;
try {
const item: CacheItem<unknown> = JSON.parse(
localStorage.getItem(key)!
);
if (item.expireAt && Date.now() > item.expireAt) {
localStorage.removeItem(key);
}
} catch {
localStorage.removeItem(key!);
}
}
}
clearAll() {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((k) => localStorage.removeItem(k));
}
}
// 实例化,版本号随业务迭代
export const storageCache = new StorageCache(1);
5.2 IndexedDB 存大体积数据
IndexedDB 原生 API 繁琐,推荐使用封装库:
| 库 | 大小 | API 风格 | 特点 |
|---|---|---|---|
| localforage | ~11KB | localStorage 式回调/Promise |
兼容性好,自动降级到 localStorage |
| idb | ~6KB | Promise + TypeScript | 轻量,保留原 IndexedDB 灵活性 |
| Dexie.js | ~30KB | SQL-like 链式 API | 功能最强,支持索引、查询、事务 |
// ─── localforage 示例 ───
import localforage from 'localforage';
// 配置
localforage.config({
name: 'myapp',
storeName: 'api_cache', // 表名
driver: [
localforage.INDEXEDDB, // 优先 IndexedDB
localforage.WEBSQL, // 降级 WebSQL
localforage.LOCALSTORAGE, // 最后降级 localStorage
],
version: 1.0,
});
// 使用(与 localStorage 完全相同)
await localforage.setItem('user:1001', userData);
const cached = await localforage.getItem('user:1001');
await localforage.removeItem('user:1001');
await localforage.clear();
// ─── idb 示例(更灵活) ───
import { openDB } from 'idb';
const db = await openDB('myapp', 1, {
upgrade(db) {
// 创建对象存储
const store = db.createObjectStore('api_cache', {
keyPath: 'key',
});
store.createIndex('by_expire', 'expireAt');
},
});
// 写入
await db.put('api_cache', {
key: 'user:1001',
data: userData,
expireAt: Date.now() + 3600_000,
createdAt: Date.now(),
});
// 读取
const cached = await db.get('api_cache', 'user:1001');
if (cached && cached.expireAt > Date.now()) {
return cached.data;
}
// 清理过期
const tx = db.transaction('api_cache', 'readwrite');
const index = tx.store.index('by_expire');
let cursor = await index.openCursor();
while (cursor) {
if (cursor.value.expireAt < Date.now()) {
cursor.delete();
}
cursor = await cursor.continue();
}
5.3 缓存版本管理与清理策略
// ─── 统一的缓存管理模块 ───
const CACHE_VERSION_KEY = 'app_cache_version';
const CURRENT_VERSION = 2; // 每次业务变更时递增
export function initCacheSystem() {
const lastVersion = localStorage.getItem(CACHE_VERSION_KEY);
if (lastVersion !== String(CURRENT_VERSION)) {
// 版本变更,全量清理
clearAllCaches();
localStorage.setItem(CACHE_VERSION_KEY, String(CURRENT_VERSION));
console.log(`[Cache] 版本 ${lastVersion ?? 'none'} → ${CURRENT_VERSION},已清空所有缓存`);
}
// 启动定时清理(避免堆积过期数据)
setInterval(
() => {
storageCache.evictExpired();
},
10 * 60 * 1000
); // 每 10 分钟清理一次
}
| 清理策略 | 触发时机 | 清理范围 |
|---|---|---|
| 版本号变更 | 应用启动时检测 | 全量清除 |
| TTL 过期 | 读取时懒清理 + 定时批量清理 | 过期单条 |
| LRU 淘汰 | 存储空间满时 | 最久未访问 |
| 空值覆盖 | 后端返回空/错误数据 | 对应 key |
六、离线缓存(PWA + Service Worker)
6.1 Service Worker 生命周期
Service Worker 是浏览器与网络之间的代理脚本,核心生命周期:
注册 → 下载 → 安装 → 等待 → 激活 → 运行
↓
(拦截 fetch 请求)
| 阶段 | 事件 | 说明 |
|---|---|---|
| 注册 | navigator.serviceWorker.register('/sw.js') |
页面 JS 触发注册 |
| 安装 | install |
预缓存静态资源 |
| 等待 | — | 等待旧 SW 控制的页面关闭 |
| 激活 | activate |
清理旧缓存 |
| 运行 | fetch |
拦截网络请求,决定缓存策略 |
// sw.js - Service Worker 脚本
const CACHE_NAME = 'myapp-v2';
// 安装:预缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/assets/app.a3b2c1.js',
'/assets/main.7g8h9i.css',
'/assets/logo.1a2b3c.png',
]);
})
);
self.skipWaiting(); // 立即激活
});
// 激活:清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim(); // 接管所有同源页面
});
// 请求拦截:网络优先,缓存兜底
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// 网络成功:更新缓存
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => {
// 网络失败(离线):从缓存读取
return caches.match(event.request).then((cached) => {
if (cached) return cached;
// 如果连缓存都没有,返回离线兜底页面
return caches.match('/offline.html');
});
})
);
});
6.2 Workbox 简化方案
手写 Service Worker 容易出错,推荐使用 Workbox(Google 维护的 SW 工具集):
// workbox.config.js
// 构建时由 workbox-webpack-plugin 或 vite-plugin-pwa 生成
import { registerRoute } from 'workbox-routing';
import {
StaleWhileRevalidate,
NetworkFirst,
CacheFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// 策略 1:静态资源(JS/CSS/图片)→ 缓存优先
registerRoute(
/\.(?:js|css|png|jpg|svg|woff2)$/,
new CacheFirst({
cacheName: 'static-assets',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天
}),
],
})
);
// 策略 2:API 请求 → 网络优先,缓存兜底
registerRoute(
/\/api\//,
new NetworkFirst({
cacheName: 'api-responses',
plugins: [
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60, // 1 天
}),
],
})
);
// 策略 3:字体/图标 → 缓存优先
registerRoute(
/\/fonts\//,
new CacheFirst({
cacheName: 'fonts',
plugins: [
new ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 年
}),
],
})
);
// 策略 4:HTML 页面 → 网络优先(确保用户看到最新版本)
registerRoute(
/\/$|\.html$/,
new NetworkFirst({
cacheName: 'pages',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 0, // 不过期,但每次都先试网络
}),
],
})
);
Workbox 策略选择:
| 策略 | 行为 | 适用场景 |
|---|---|---|
CacheFirst |
有缓存用缓存,无缓存请求网络 | 不变的静态资源(hash 文件名) |
NetworkFirst |
先请求网络,失败时用缓存兜底 | HTML 页面、API 请求 |
StaleWhileRevalidate |
先返回缓存,后台更新缓存 | 非关键数据、用户头像 |
NetworkOnly |
始终走网络,不走缓存 | 支付、登录等敏感请求 |
CacheOnly |
始终走缓存,不走网络 | 离线模式下的固定资源 |
6.3 离线兜底页面
<!-- public/offline.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>离线 - MyApp</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
color: #333;
}
.offline-card {
text-align: center;
padding: 48px;
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}
.offline-icon { font-size: 64px; }
h1 { margin: 16px 0 8px; }
p { color: #666; }
</style>
</head>
<body>
<div class="offline-card">
<div class="offline-icon">📡</div>
<h1>网络连接已断开</h1>
<p>请检查网络后重试,已缓存的内容仍可正常访问</p>
</div>
</body>
</html>
// sw.js 中处理离线兜底
self.addEventListener('fetch', (event) => {
// 只拦截导航请求(页面跳转)
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match('/offline.html');
})
);
}
});
七、缓存一致性问题
前端缓存一致性的挑战与后端类似,但多了一层"用户感知":
7.1 一致性问题场景
| 场景 | 问题 | 示例 |
|---|---|---|
| 多 Tab 编辑 | Tab A 改了数据,Tab B 显示旧数据 | 用户管理:修改用户名两个 Tab 不同 |
| 接口缓存过时 | TanStack Query staleTime 内数据已变化 | 列表页看到的不是最新数据 |
| 乐观更新失败 | UI 已更新但请求失败,需回滚 | 点赞数已 +1,但网络错误 |
| 离线恢复后同步 | 离线期间的数据变更如何同步到服务端 | 离线编辑文档,上线后合并 |
7.2 前端如何应对(对标 Java 的"延迟双删")
前端没有"删缓存"一说,但有更优雅的方案:
Java 后端:更新 DB → 删 Redis → sleep(100ms) → 再删 Redis
前端: 发起请求 → 乐观更新 UI → 请求失败 → 回滚 UI → 重新拉取
// ─── 缓存一致性策略:乐观更新 + 失败回滚 + 重新拉取 ───
// React + TanStack Query + Zustand
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { create } from 'zustand';
// 1. 乐观更新 + 失败回滚(见 3.5 节)
// 2. 多 Tab 数据同步:监听 visibilitychange
function useTabSync(queryKey: QueryKey) {
const queryClient = useQueryClient();
useEffect(() => {
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 回到当前 Tab 时,使数据过期(stale),下一次用到时会自动刷新
queryClient.invalidateQueries({ queryKey });
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [queryKey]);
}
// 3. 全局数据一致性锚点:用版本号判断是否需要刷新
interface ConsistencyAnchor {
version: number;
updatedAt: number;
}
const useConsistencyStore = create<{
anchors: Record<string, ConsistencyAnchor>;
notifyChange: (key: string) => void;
checkStale: (key: string, localVersion: number) => boolean;
}>((set, get) => ({
anchors: {},
notifyChange: (key) => {
// 当某个实体被修改时,更新版本号(通过 BroadcastChannel 跨 Tab 同步)
set((state) => ({
anchors: {
...state.anchors,
[key]: {
version: (state.anchors[key]?.version ?? 0) + 1,
updatedAt: Date.now(),
},
},
}));
},
checkStale: (key, localVersion) => {
const anchor = get().anchors[key];
return anchor ? anchor.version > localVersion : false;
},
}));
// 跨 Tab 同步
const channel = new BroadcastChannel('app-cache-sync');
channel.onmessage = (event) => {
if (event.data.type === 'INVALIDATE') {
queryClient.invalidateQueries({ queryKey: [event.data.entity] });
}
};
// 修改数据后广播到其他 Tab
function notifyOtherTabs(entity: string) {
channel.postMessage({ type: 'INVALIDATE', entity });
}
7.3 三种一致性策略对比
| 策略 | 复杂度 | 数据一致性 | 用户体验 | 适用场景 |
|---|---|---|---|---|
| 直接请求(staleTime=0) | 低 | 强 | 差(每次 loading) | 支付、交易状态 |
| 内存缓存 + 后台刷新(staleTime=30s + refetchOnFocus) | 低 | 最终一致 | 好(无缝更新) | 大多数列表、详情页 |
| 乐观更新 + 失败回滚 | 中 | 最终一致 | 极好(无感) | 点赞、收藏、评论、编辑 |
| 乐观更新 + WebSocket 推送 | 高 | 实时一致 | 极好 | 协作编辑、聊天、消息通知 |
7.4 防御式缓存设计原则
// ❌ 反例:只存不验证
function loadUser(id: string) {
const cached = localStorage.getItem(`user:${id}`);
return cached ? JSON.parse(cached) : null;
}
// ✅ 正例:多层验证
function loadUserSafe(id: string): UserDTO | null {
try {
const raw = localStorage.getItem(`user:${id}`);
if (!raw) return null;
const parsed = JSON.parse(raw);
// 1. 结构验证 - 关键字段必须存在
if (!parsed?.id || !parsed?.name) {
localStorage.removeItem(`user:${id}`);
return null;
}
// 2. 时效验证
if (parsed._expireAt && Date.now() > parsed._expireAt) {
localStorage.removeItem(`user:${id}`);
return null;
}
// 3. 数据合理性验证
if (typeof parsed.name !== 'string' || parsed.name.length > 100) {
localStorage.removeItem(`user:${id}`);
return null;
}
return parsed as UserDTO;
} catch {
// 4. 解析异常(JSON 损坏)
localStorage.removeItem(`user:${id}`);
return null;
}
}
八、各方案对比总结表
8.1 缓存方案选型总表
| 方案 | 持久性 | 容量 | 速度 | 离线支持 | 跨 Tab | 实现成本 | 适用于 |
|---|---|---|---|---|---|---|---|
| 内存变量 | ❌ 刷新即失 | MB | ⭐⭐⭐ | ❌ | ❌ | 极低 | 全局状态、当前表单 |
| Pinia/Zustand | ❌ 刷新即失 | MB | ⭐⭐⭐ | ❌ | ❌ | 低 | 组件共享状态 |
| TanStack Query | ❌ 内存+gcTime | MB | ⭐⭐⭐ | ❌ | ❌ | 低 | API 数据缓存(推荐) |
| HTTP 强缓存 | ✅ 磁盘 | 磁盘配额 | ⭐⭐⭐ | 是 | ✅ | 无(后端设头) | 静态资源、图片 |
| HTTP 协商缓存 | ✅ 磁盘 | 磁盘配额 | ⭐⭐ | 是 | ✅ | 无(后端设头) | HTML、API 响应 |
| localStorage | ✅ 磁盘 | 5~10MB | ⭐⭐ | 是 | ✅ | 低 | Token、偏好设置 |
| sessionStorage | ❌ Tab 关闭即失 | 5MB | ⭐⭐ | ❌ | ❌ | 低 | 会话临时数据 |
| IndexedDB | ✅ 磁盘 | GB | ⭐⭐ | 是 | ✅ | 中 | 大体积 JSON、媒体文件 |
| Service Worker | ✅ 磁盘 | 磁盘配额 | ⭐⭐ | ✅ PWA | ✅ | 高 | 离线应用、PWA |
8.2 各缓存层最佳搭配推荐
┌─────────────────────────────────────────────────────┐
│ SPA 标准配置 │
├─────────────────────────────────────────────────────┤
│ 静态资源 (JS/CSS/图片) │
│ → HTTP 强缓存: Cache-Control: public, max-age=31536000 │
│ → hash 文件名(内容变了 URL 就变) │
├─────────────────────────────────────────────────────┤
│ API 数据 │
│ → TanStack Query: staleTime=30s, gcTime=5min │
│ → 需要离线的 API 用 NetworkFirst SW 策略 │
├─────────────────────────────────────────────────────┤
│ 配置字典 / 字典数据 │
│ → TanStack Query: staleTime=Infinity │
│ → 首次加载后 localStorage 持久化 │
├─────────────────────────────────────────────────────┤
│ 用户 Token / 认证信息 │
│ → localStorage 或 httpOnly Cookie │
├─────────────────────────────────────────────────────┤
│ 表单草稿 / 编辑器内容 │
│ → IndexedDB(localforage) │
│ → 定时自动保存 + 退出确认 │
├─────────────────────────────────────────────────────┤
│ 离线 PWA 缓存 │
│ → Workbox: CacheFirst 静态资源 + NetworkFirst API │
│ → 离线兜底页面 │
└─────────────────────────────────────────────────────┘
8.3 常见踩坑点
| 问题 | 表现 | 原因 | 解决方案 |
|---|---|---|---|
| 缓存强数据 | 用户更新了信息,页面还是旧的 | staleTime 过长 | 合理设置 staleTime;写操作后 invalidateQueries |
| localStorage 满了 | setItem 静默失败 |
单域名配额 5MB | 使用 IndexedDB;及时清理过期数据;try/catch 防御 |
| CSS 更新不生效 | 浏览器用了旧 CSS | 文件名没加 hash | Webpack/Vite 统一配置 hash 文件名 |
| 302 被缓存 | 用户登录跳转异常 | Service Worker 缓存了重定向响应 | 敏感请求用 NetworkOnly |
| JS 报错"缓存损坏" | JSON.parse 报错 |
存储被其他脚本污染或截断 | 读取时 try/catch + 兜底 |
| 多 Tab 数据不一致 | Tab A 改了,Tab B 看不到 | 缺少跨 Tab 同步机制 | BroadcastChannel 或 visibilitychange 刷新 |
| 旧 Service Worker 不更新 | 用户一直用旧版本 | SW 等待旧页面关闭 | 安装时 self.skipWaiting();激活时 self.clients.claim() |
九、总结
前端缓存选择的决策逻辑:
是否涉及网络请求?
├── 是
│ ├── 静态资源(JS/CSS/图片)→ 强缓存 + hash 文件名
│ ├── API 数据 → TanStack Query (staleTime + gcTime)
│ ├── HTML 页面 → 协商缓存(no-cache + ETag)
│ └── 需要离线可用 → 追加 Service Worker
└── 否(纯前端数据)
├── 临时共享到组件 → 内存(Pinia/Zustand)
├── 小体积持久数据 → localStorage(封装防御)
└── 大体积 / 结构化数据 → IndexedDB(localforage)
核心原则:缓存是为了体验,不是为了技术炫技。选择最匹配业务场景的策略,同时始终做好防御式读取:永远不要信任缓存的格式、时效和完整性。缓存损坏时优雅降级(回退到网络请求),而不是让应用崩溃。
参考资料: