手把手实现 SWR 缓存策略,彻底告别页面闪烁
前言
在开发移动端 H5 项目时,我们经常面临一个棘手的用户体验问题:页面切换时的加载闪烁。
当用户在 Tabbar 之间切换时,页面组件通常会重新渲染并触发 API 请求。即使后端响应迅速,用户依然会看到短暂的“白屏”或“骨架屏”,随后内容突然跳出。这种反复的 Loading 状态打断了用户的操作流,体验并不友好。
为了解决这一痛点,通常的思路是进行接口缓存。即:将数据缓存在本地,再次进入页面时优先读取缓存。但如果完全依赖缓存,又会导致数据更新不及时。
如何在“极速渲染”和“数据新鲜度”之间找到平衡?业界有一个成熟的方案——SWR (Stale-While-Revalidate)。本文将带你在 Vue 3 项目中从零实现一套轻量级的 SWR 缓存机制。
什么是 SWR?
SWR 全称是 Stale-While-Revalidate,其核心理念可以概括为:
“优先返回陈旧数据(Stale),同时后台发起请求(Revalidate),数据返回后更新 UI。”
这种策略将网络请求与 UI 渲染解耦,让应用能够瞬间响应用户操作,而不必等待网络请求完成。
为什么选择 SWR?
相比传统的请求模式,SWR 带来了质的飞跃:
- 极致的性能体验:优先读取缓存,用户切换页面时数据几乎“秒开”,完全感知不到加载过程。
- 消除加载闪烁:UI 始终有内容展示,告别了反复出现的 Loading 动画或骨架屏。
- 减轻服务器压力:配合 TTL(Time To Live,有效期)机制,在有效期内的重复访问可以直接拦截请求,降低后端负载。
- 响应式与自动化:结合 Vue 3 的响应式系统,由于数据是 Ref,一旦后台请求返回新数据,视图会自动更新。
核心实现解析
我们的实现分为两层架构:底层的缓存管理工具和上层的 Vue Composable (Hooks)。
1. 缓存管理工具 (src/utils/cache)
为了满足不同场景的需求,我们设计了一个支持内存缓存和持久化缓存的双层缓存工具。
- 内存缓存:使用
Map,刷新页面即失效,适合临时数据。 - 持久化缓存:借助
vueuse的useLocalStorage,适合长期存储(如字典数据、用户信息)。
首先定义类型接口:
// src/utils/cache/types.ts
/** 缓存项结构 */
export interface CacheItem<T = any> {
data: T;
timestamp: number; // 存入时间,用于计算过期
}
export interface GetCacheOptions {
key: string;
ttl: number; // 有效期 (ms)
persist?: boolean; // 是否持久化
}
export interface SetCacheOptions<T> {
key: string;
data: T;
persist?: boolean;
}接下来是具体的实现逻辑,核心在于 getCache 时的 TTL 过期判定:
// src/utils/cache/index.ts
import { useLocalStorage } from "@vueuse/core";
import type { CacheItem, GetCacheOptions, SetCacheOptions } from "./types";
export type * from "./types";
// 内存缓存堆
const memoryCache = new Map<string, CacheItem>();
// 持久化缓存 (基于 LocalStorage)
const persistentCache = useLocalStorage<Record<string, CacheItem>>("swr-cache-storage", {});
/** 读取缓存 */
export function getCache<T>(options: GetCacheOptions): T | null {
const { key, ttl, persist = false } = options;
if (!key) return null;
let item: CacheItem | undefined;
if (persist) {
item = persistentCache.value[key];
} else {
item = memoryCache.get(key);
}
// 判断ttl
if (item && Date.now() - item.timestamp < ttl) {
return item.data;
}
// 过期清理
removeCache(key, persist);
return null;
}
/** 写入缓存 */
export function setCache<T>(options: SetCacheOptions<T>): void {
const { key, data, persist = false } = options;
if (!key) return;
const item: CacheItem = {
data,
timestamp: Date.now()
};
if (persist) {
persistentCache.value[key] = item;
} else {
memoryCache.set(key, item);
}
}
/** 删除缓存 */
export function removeCache(key: string, persist = false): void {
if (!key) return;
if (persist) {
delete persistentCache.value[key];
} else {
memoryCache.delete(key);
}
}
/**清空所有缓存(调试用)*/
export function clearAllCache(): void {
memoryCache.clear();
persistentCache.value = {};
}2. useSWR 组合式函数 (src/composables/useSWR)
这是业务逻辑的封装层,它负责协调“读取缓存”和“发起请求”的竞态关系,并处理 Vue 的响应式状态。
// src/composables/useSWR/types.ts
export interface SWROptions {
/** 缓存时间,单位毫秒,5分钟 = 5 * 60 * 1000 */
ttl?: number;
/** */
auto?: boolean;
/** 是否持久化(必须配合有效 key) */
persist?: boolean;
/** 默认值 */
defaultData?: any;
}import { getCache, setCache } from "@/utils/cache";
import type { SWROptions } from "./types";
export type * from "./types";
/** 默认缓存5分钟 */
const DEFAULT_TTL = 5 * 60 * 1000;
export function useSWR<T>(
key: string | Ref<string>,
fetcher: (key: string) => Promise<T>,
options: SWROptions = {}
) {
const { ttl = DEFAULT_TTL, auto = true, persist = false, defaultData } = options;
const data = shallowRef<T | null>(defaultData ?? null);
const error = ref<Error | null>(null);
const loading = ref(false);
const currentKey = computed(() => (typeof key === "string" ? key : key.value));
const fetchData = async (isRefresh = false) => {
const k = currentKey.value;
if (!k) return;
// 重置错误
error.value = null;
// 读取缓存
const cached = getCache<T>({ key: k, ttl, persist });
// loading 策略
const shouldSetLoading = isRefresh || !cached;
if (shouldSetLoading) {
loading.value = true;
}
// 命中缓存
if (!isRefresh && cached) {
data.value = cached;
}
try {
// 静默刷新
const result = await fetcher(k);
// 竞态锁:如果在等待 fetcher 结果期间,key 发生了变化,则放弃此次结果
if (currentKey.value !== k) return;
// 更新缓存和数据
setCache({ key: k, data: result, persist });
data.value = result;
return result;
} catch (err) {
error.value = err as Error;
console.error(`[useSWR] Error for key "${k}":`, err);
} finally {
loading.value = false;
}
};
/** 重新获取数据 */
const refresh = () => fetchData(true);
// 是否自动获取数据
if (auto) {
if (typeof key === "string") {
fetchData();
} else {
watch(
currentKey,
(newKey, oldKey) => {
if (newKey && newKey !== oldKey) {
fetchData();
}
},
{ immediate: true }
);
}
}
return {
data,
loading,
error,
refresh
};
}
使用示例
以常见的“首页分类列表”为例,这类数据变动频率低,非常适合 SWR。
<template>
<van-tabs v-model:active="activeTab">
<!--
关键点:
1. 首次进入:显示 defaultData 或 loading
2. 二次进入:直接显示缓存数据 (old data),后台静默更新
-->
<van-tab
v-for="category in categoriesSWR.data.value"
:key="category.id"
:title="category.name"
>
<CategoryAppList :category-id="category.id" />
</van-tab>
</van-tabs>
</template>
<script setup lang="ts">
import { useSWR } from "@/composables/useSWR";
import { appCategories } from "@/api/home";
// 定义唯一的 Cache Key
const CACHE_KEY = "home-app-categories";
const categoriesSWR = useSWR(
CACHE_KEY,
async () => {
const result = await appCategories();
// 可以在这里做数据预处理,存入缓存的就是处理后的数据
return result?.records?.sort((a, b) => a.sort - b.sort) ?? [];
},
{
defaultData: [],
ttl: 10 * 60 * 1000, // 缓存 10 分钟
persist: true // 开启持久化,刷新浏览器数据依然在
}
);
</script>在这种实现下,用户第一次打开 App 可能需要加载;但当用户去其他页面溜达一圈再回来,或者关闭 App 再打开(如果开启了 persist),首页分类是瞬间展示的,体验极其丝滑。
总结
通过手写这个轻量级的 useSWR,我们解决了移动端 H5 项目中两个核心痛点:
- 用户体验:消除了页面切换时的加载闪烁,实现了类似原生应用的流畅感。
- 开发效率:将缓存逻辑从业务代码中抽离,只需一行代码即可复用。
当然,这个实现还有优化的空间,例如添加请求去重(防止短时间内重复请求同一接口)、聚焦窗口重新验证(Window Focus Revalidation)等高级特性。但在大多数中小型项目中,目前的实现已经足以带来显著的体验提升。
如果不想封装,也可以尝试使用更成熟的第三方库,比如:VueRequest,这个库支持分页SWR等更多复杂场景。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据