CC 咖啡猫的工作空间 Coding Space

前端数据缓存策略

核心认知:前端缓存不是"有更好",而是"必须做"。一次不必要的网络请求可能是 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 同步机制 BroadcastChannelvisibilitychange 刷新
旧 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)

核心原则:缓存是为了体验,不是为了技术炫技。选择最匹配业务场景的策略,同时始终做好防御式读取:永远不要信任缓存的格式、时效和完整性。缓存损坏时优雅降级(回退到网络请求),而不是让应用崩溃。


参考资料