前言

在开发移动端 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,刷新页面即失效,适合临时数据。
  • 持久化缓存:借助 vueuseuseLocalStorage,适合长期存储(如字典数据、用户信息)。

首先定义类型接口:

// 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 项目中两个核心痛点:

  1. 用户体验:消除了页面切换时的加载闪烁,实现了类似原生应用的流畅感。
  2. 开发效率:将缓存逻辑从业务代码中抽离,只需一行代码即可复用。

当然,这个实现还有优化的空间,例如添加请求去重(防止短时间内重复请求同一接口)、聚焦窗口重新验证(Window Focus Revalidation)等高级特性。但在大多数中小型项目中,目前的实现已经足以带来显著的体验提升。

如果不想封装,也可以尝试使用更成熟的第三方库,比如:VueRequest,这个库支持分页SWR等更多复杂场景。

分类: vue 项目实战 标签: vue3SWRAPI缓存

评论

暂无评论数据

暂无评论数据

目录