数据缓存策略:让我们的应用“快如闪电”

前言

现在的开发应用,都在强调用户体验至上,页面加载速度直接影响着用户留存和转化率。据统计,页面加载时间从1秒增加到3秒,跳出率提升32%;从1秒增加到5秒,跳出率提升90%。而缓存,正是提升应用性能最有效的手段之一。

然而,缓存也是一把双刃剑:用得好,应用会快如闪电;用得不好,数据不一致、内存泄漏等问题接踵而至。本文将深入探讨前端缓存的多种形式、实现策略和最佳实践,帮你构建高性能、高可靠性的应用。

为什么需要前端缓存?

减少网络请求,提升加载速度

网络请求是前端性能的最大瓶颈。每次请求都要经过 DNS 解析、TCP 连接、SSL 握手、请求响应等环节,耗时动辄几百毫秒甚至几秒。

typescript 复制代码
// ❌ 没有缓存:每次都要请求
async function getUserInfo(userId: number) {
  return await api.get(`/users/${userId}`)
}

// ✅ 有缓存:重复请求直接返回
const userCache = new Map<number, User>()

async function getUserInfoWithCache(userId: number) {
  if (userCache.has(userId)) {
    return userCache.get(userId)
  }
  
  const user = await api.get(`/users/${userId}`)
  userCache.set(userId, user)
  return user
}

离线访问能力

通过 Service WorkerCache API,我们可以让应用在无网络环境下也能正常运行:

javascript 复制代码
// service-worker.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // 有缓存返回缓存
      if (response) {
        return response
      }
      
      // 无缓存请求网络,并存入缓存
      return fetch(event.request).then((response) => {
        const responseClone = response.clone()
        caches.open('v1').then((cache) => {
          cache.put(event.request, responseClone)
        })
        return response
      })
    })
  )
})

减轻服务器压力

高并发场景下,缓存能有效降低服务器负载。假设一个接口每秒有1000次请求,如果缓存命中率90%,服务器实际只需要处理100次请求。

text 复制代码
服务器压力对比
无缓存: 1000 QPS -> 需要10台服务器
有缓存: 100 QPS -> 只需要1台服务器

前端缓存的多种形式

HTTP 缓存:强缓存、协商缓存

强缓存:浏览器直接读取本地缓存,不发请求

bash 复制代码
# 响应头示例
Cache-Control: max-age=3600  # 缓存1小时
Expires: Wed, 21 Oct 2025 07:28:00 GMT

协商缓存:发送请求,由服务器决定是否使用缓存

bash 复制代码
# 请求头
If-Modified-Since: Wed, 21 Oct 2023 07:28:00 GMT
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

# 响应头
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

Vite 构建产物配置

javascript 复制代码
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        // 给静态资源添加 hash,实现永久缓存
        entryFileNames: 'assets/[name]-[hash].js',
        chunkFileNames: 'assets/[name]-[hash].js',
        assetFileNames: 'assets/[name]-[hash].[ext]'
      }
    }
  }
}

Service Worker 缓存:PWA、离线可用

Service Worker 作为浏览器和网络之间的代理,可以实现精细化的缓存控制:

javascript 复制代码
// service-worker.js
const CACHE_NAME = 'my-app-v1'
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png'
]

// 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(urlsToCache)
    })
  )
})

// 拦截请求:实现不同缓存策略
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 策略1:Cache First(静态资源)
  if (url.pathname.match(/\.(css|js|png|jpg)$/)) {
    event.respondWith(
      caches.match(event.request).then((response) => {
        return response || fetch(event.request)
      })
    )
  }
  
  // 策略2:Network First(API 请求)
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          const responseClone = response.clone()
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
  
  // 策略3:Stale While Revalidate(数据更新不频繁)
  // 先返回缓存,同时发起请求更新缓存
})

内存缓存:Map/WeakMap、ref 存储

内存缓存最快,但数据会随页面刷新而消失:

typescript 复制代码
// 简单的 Map 缓存
const tokenCache = new Map<string, string>()

export function getToken(userId: string) {
  return tokenCache.get(userId)
}

export function setToken(userId: string, token: string) {
  tokenCache.set(userId, token)
  
  // 设置过期时间
  setTimeout(() => {
    tokenCache.delete(userId)
  }, 3600000) // 1小时后过期
}

WeakMap 的优势:不会阻止垃圾回收,适合缓存 DOM 相关数据

typescript 复制代码
// WeakMap 缓存 DOM 数据
const domDataCache = new WeakMap<HTMLElement, any>()

export function getDomData(el: HTMLElement) {
  return domDataCache.get(el)
}

export function setDomData(el: HTMLElement, data: any) {
  domDataCache.set(el, data)
}

持久化缓存:localStorage、sessionStorage、IndexedDB

存储方式 容量 同步/异步 数据类型 生命周期
localStorage 5-10MB 同步 字符串 永久
sessionStorage 5-10MB 同步 字符串 标签页关闭
IndexedDB 数百MB 异步 结构化数据 永久

内存缓存的实现

简单场景:字典数据缓存

后台管理系统中,字典数据(性别、状态、类型等)通常不会频繁变化,非常适合缓存:

typescript 复制代码
// useDictionary.ts
import { ref } from 'vue'
import type { Ref } from 'vue'

interface DictionaryItem {
  value: string | number
  label: string
  [key: string]: any
}

interface CacheItem {
  data: DictionaryItem[]
  expire: number
}

class DictionaryCache {
  private cache = new Map<string, CacheItem>()
  private pendingRequests = new Map<string, Promise<DictionaryItem[]>>()
  
  // 获取字典
  async get(type: string, forceRefresh = false): Promise<DictionaryItem[]> {
    // 强制刷新:忽略缓存
    if (forceRefresh) {
      return this.fetchAndCache(type)
    }
    
    // 检查缓存
    const cached = this.cache.get(type)
    if (cached && cached.expire > Date.now()) {
      return cached.data
    }
    
    // 避免并发重复请求
    if (this.pendingRequests.has(type)) {
      return this.pendingRequests.get(type)!
    }
    
    // 发起请求
    const promise = this.fetchAndCache(type)
    this.pendingRequests.set(type, promise)
    
    try {
      return await promise
    } finally {
      this.pendingRequests.delete(type)
    }
  }
  
  // 预加载字典
  preload(types: string[]) {
    types.forEach(type => {
      this.get(type).catch(() => {
        console.warn(`预加载字典失败: ${type}`)
      })
    })
  }
  
  // 清空缓存
  clear() {
    this.cache.clear()
  }
  
  private async fetchAndCache(type: string): Promise<DictionaryItem[]> {
    try {
      const data = await api.getDictionary(type)
      
      this.cache.set(type, {
        data,
        expire: Date.now() + 3600000 // 1小时过期
      })
      
      return data
    } catch (error) {
      // 如果有过期数据,降级使用
      const expired = this.cache.get(type)
      if (expired) {
        console.warn(`使用过期字典数据: ${type}`)
        return expired.data
      }
      throw error
    }
  }
}

export const dictCache = new DictionaryCache()

// 在组件中使用
import { dictCache } from './cache/dictionary'

export function useDictionary(type: string) {
  const data = ref<DictionaryItem[]>([])
  const loading = ref(true)
  const error = ref<Error | null>(null)
  
  dictCache.get(type)
    .then(items => {
      data.value = items
    })
    .catch(e => {
      error.value = e
    })
    .finally(() => {
      loading.value = false
    })
  
  return { data, loading, error }
}

带过期时间的缓存

typescript 复制代码
// utils/timed-cache.ts
interface TimedCacheItem<T> {
  data: T
  expire: number
  createdAt: number
  hitCount: number
}

class TimedCache<K, V> {
  private cache = new Map<K, TimedCacheItem<V>>()
  private defaultTTL: number // 毫秒
  
  constructor(defaultTTL = 60000) {
    this.defaultTTL = defaultTTL
  }
  
  set(key: K, value: V, ttl?: number) {
    this.cache.set(key, {
      data: value,
      expire: Date.now() + (ttl || this.defaultTTL),
      createdAt: Date.now(),
      hitCount: 0
    })
  }
  
  get(key: K): V | null {
    const item = this.cache.get(key)
    if (!item) return null
    
    // 检查是否过期
    if (item.expire < Date.now()) {
      this.cache.delete(key)
      return null
    }
    
    // 更新命中次数
    item.hitCount++
    return item.data
  }
  
  has(key: K): boolean {
    const item = this.cache.get(key)
    if (!item) return false
    if (item.expire < Date.now()) {
      this.cache.delete(key)
      return false
    }
    return true
  }
  
  delete(key: K) {
    this.cache.delete(key)
  }
  
  clear() {
    this.cache.clear()
  }
  
  // 清理过期缓存
  cleanup() {
    const now = Date.now()
    this.cache.forEach((item, key) => {
      if (item.expire < now) {
        this.cache.delete(key)
      }
    })
  }
  
  // 获取缓存统计
  getStats() {
    const stats = {
      size: this.cache.size,
      totalHits: 0,
      oldest: 0,
      newest: 0
    }
    
    let now = Date.now()
    this.cache.forEach(item => {
      stats.totalHits += item.hitCount
      stats.oldest = Math.min(stats.oldest || item.createdAt, item.createdAt)
      stats.newest = Math.max(stats.newest, item.createdAt)
    })
    
    return stats
  }
}

export const timedCache = new TimedCache<string, any>()

LRU 淘汰策略的实现

当缓存数量超过限制时,淘汰最久未使用的数据:

typescript 复制代码
// utils/lru-cache.ts
interface ListNode<K, V> {
  key: K
  value: V
  prev: ListNode<K, V> | null
  next: ListNode<K, V> | null
}

class LRUCache<K, V> {
  private capacity: number
  private cache = new Map<K, ListNode<K, V>>()
  private head: ListNode<K, V> | null = null
  private tail: ListNode<K, V> | null = null
  
  constructor(capacity: number) {
    this.capacity = capacity
  }
  
  get(key: K): V | null {
    const node = this.cache.get(key)
    if (!node) return null
    
    // 移动到链表头部(表示最近使用)
    this.moveToHead(node)
    return node.value
  }
  
  put(key: K, value: V) {
    if (this.cache.has(key)) {
      // 更新现有节点
      const node = this.cache.get(key)!
      node.value = value
      this.moveToHead(node)
    } else {
      // 创建新节点
      const node: ListNode<K, V> = {
        key,
        value,
        prev: null,
        next: null
      }
      
      // 如果缓存已满,淘汰尾部节点
      if (this.cache.size >= this.capacity) {
        this.removeTail()
      }
      
      // 添加到头部
      this.addToHead(node)
      this.cache.set(key, node)
    }
  }
  
  private moveToHead(node: ListNode<K, V>) {
    if (node === this.head) return
    
    // 从当前位置移除
    if (node.prev) {
      node.prev.next = node.next
    }
    if (node.next) {
      node.next.prev = node.prev
    }
    if (node === this.tail) {
      this.tail = node.prev
    }
    
    // 添加到头部
    node.prev = null
    node.next = this.head
    if (this.head) {
      this.head.prev = node
    }
    this.head = node
    if (!this.tail) {
      this.tail = node
    }
  }
  
  private addToHead(node: ListNode<K, V>) {
    node.prev = null
    node.next = this.head
    if (this.head) {
      this.head.prev = node
    }
    this.head = node
    if (!this.tail) {
      this.tail = node
    }
  }
  
  private removeTail() {
    if (!this.tail) return
    
    this.cache.delete(this.tail.key)
    
    if (this.tail === this.head) {
      this.head = null
      this.tail = null
    } else {
      this.tail = this.tail.prev
      if (this.tail) {
        this.tail.next = null
      }
    }
  }
  
  clear() {
    this.cache.clear()
    this.head = null
    this.tail = null
  }
  
  get size() {
    return this.cache.size
  }
}

export const lruCache = new LRUCache<string, any>(100)

IndexedDB 大规模数据缓存

何时需要 IndexedDB?

IndexedDB 适合存储大量结构化数据,常见场景:

  1. 离线应用数据:PWA 应用的核心数据
  2. 用户操作日志:批量上报前暂存
  3. 大屏历史数据:历史趋势数据
  4. 富文本编辑器内容:自动保存草稿
  5. 音视频文件:分段缓存

使用 idb-keyval 简化操作

原生 IndexedDB API 较为复杂,推荐使用 idb-keyval 简化操作:

bash 复制代码
npm install idb-keyval
typescript 复制代码
// utils/idb-cache.ts
import { get, set, del, clear, keys, createStore } from 'idb-keyval'

// 创建不同的存储空间
const userStore = createStore('user-db', 'user-store')
const logStore = createStore('log-db', 'log-store')
const fileStore = createStore('file-db', 'file-store')

export class IDBCache {
  private store: IDBObjectStore
  
  constructor(dbName: string, storeName: string) {
    this.store = createStore(dbName, storeName)
  }
  
  // 存储数据
  async set<T>(key: string, value: T): Promise<void> {
    await set(key, value, this.store)
  }
  
  // 获取数据
  async get<T>(key: string): Promise<T | undefined> {
    return await get<T>(key, this.store)
  }
  
  // 删除数据
  async delete(key: string): Promise<void> {
    await del(key, this.store)
  }
  
  // 清空存储
  async clear(): Promise<void> {
    await clear(this.store)
  }
  
  // 获取所有键
  async keys(): Promise<string[]> {
    return await keys<string>(this.store)
  }
  
  // 批量存储
  async setMany(entries: [string, any][]): Promise<void> {
    const promises = entries.map(([key, value]) => 
      set(key, value, this.store)
    )
    await Promise.all(promises)
  }
  
  // 批量获取
  async getMany<T>(keys: string[]): Promise<(T | undefined)[]> {
    const promises = keys.map(key => get<T>(key, this.store))
    return await Promise.all(promises)
  }
}

// 创建缓存实例
export const userCache = new IDBCache('my-app', 'users')
export const logCache = new IDBCache('my-app', 'logs')
export const fileCache = new IDBCache('my-app', 'files')

实战:缓存用户操作日志

typescript 复制代码
// services/log.service.ts
import { logCache } from './idb-cache'

interface LogEntry {
  id: string
  type: 'click' | 'view' | 'error' | 'api'
  timestamp: number
  data: any
  userId?: string
}

class LogService {
  private batchSize = 20
  private uploadInterval = 30000 // 30秒
  private timer: ReturnType<typeof setTimeout> | null = null
  
  constructor() {
    // 定时上传日志
    this.startAutoUpload()
    
    // 页面关闭前尝试上传
    window.addEventListener('beforeunload', () => {
      this.uploadNow()
    })
  }
  
  // 记录日志
  async log(log: Omit<LogEntry, 'id' | 'timestamp'>) {
    const entry: LogEntry = {
      id: this.generateId(),
      timestamp: Date.now(),
      ...log
    }
    
    // 先存到 IndexedDB
    await this.saveToIndexedDB(entry)
    
    // 检查是否需要立即上传
    await this.checkBatchUpload()
  }
  
  private async saveToIndexedDB(entry: LogEntry) {
    const key = `log_${entry.timestamp}_${entry.id}`
    await logCache.set(key, entry)
  }
  
  private async checkBatchUpload() {
    const keys = await logCache.keys()
    if (keys.length >= this.batchSize) {
      await this.uploadNow()
    }
  }
  
  private startAutoUpload() {
    this.timer = setInterval(() => {
      this.uploadNow()
    }, this.uploadInterval)
  }
  
  // 立即上传所有日志
  async uploadNow() {
    if (this.timer) {
      clearTimeout(this.timer)
    }
    
    const keys = await logCache.keys()
    if (keys.length === 0) return
    
    const logs = await logCache.getMany<LogEntry>(keys)
    const validLogs = logs.filter(log => log !== undefined) as LogEntry[]
    
    try {
      // 上传到服务器
      await api.uploadLogs(validLogs)
      
      // 上传成功,删除本地日志
      await Promise.all(keys.map(key => logCache.delete(key)))
      
      console.log(`上传 ${validLogs.length} 条日志成功`)
    } catch (error) {
      console.error('上传日志失败', error)
      
      // 重新启动定时器
      this.startAutoUpload()
    }
  }
  
  private generateId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
  }
}

export const logService = new LogService()

// 在组件中使用
import { logService } from './services/log.service'

// 记录用户操作
function handleButtonClick() {
  logService.log({
    type: 'click',
    data: { buttonId: 'submit', page: '/checkout' },
    userId: userStore.user?.id
  })
}

缓存更新策略

写穿策略:同时更新缓存和后端

typescript 复制代码
// services/user.service.ts
class UserService {
  private cache = new Map<number, User>()
  
  // 获取用户(先读缓存)
  async getUser(id: number): Promise<User> {
    if (this.cache.has(id)) {
      return this.cache.get(id)!
    }
    
    const user = await api.getUser(id)
    this.cache.set(id, user)
    return user
  }
  
  // 更新用户(写穿策略)
  async updateUser(id: number, data: Partial<User>): Promise<User> {
    // 1. 先更新后端
    const updated = await api.updateUser(id, data)
    
    // 2. 更新缓存
    this.cache.set(id, updated)
    
    return updated
  }
}

失效策略:更新后端后清除缓存

typescript 复制代码
// services/product.service.ts
class ProductService {
  private cache = new Map<number, Product>()
  
  // 获取产品
  async getProduct(id: number): Promise<Product> {
    if (this.cache.has(id)) {
      return this.cache.get(id)!
    }
    
    const product = await api.getProduct(id)
    this.cache.set(id, product)
    return product
  }
  
  // 更新产品(失效策略)
  async updateProduct(id: number, data: Partial<Product>): Promise<Product> {
    // 1. 更新后端
    const updated = await api.updateProduct(id, data)
    
    // 2. 删除缓存(下次读取时重新获取)
    this.cache.delete(id)
    
    return updated
  }
  
  // 批量失效
  invalidateProduct(id: number) {
    this.cache.delete(id)
  }
  
  invalidateAll() {
    this.cache.clear()
  }
}

定时刷新:轮询更新缓存数据

typescript 复制代码
// composables/usePollingCache.ts
import { ref, onMounted, onUnmounted } from 'vue'

export function usePollingCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    interval?: number      // 轮询间隔
    retryOnError?: boolean // 失败是否重试
    maxRetries?: number    // 最大重试次数
  } = {}
) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)
  
  const {
    interval = 30000,
    retryOnError = true,
    maxRetries = 3
  } = options
  
  let timer: ReturnType<typeof setTimeout> | null = null
  let retryCount = 0
  
  // 获取数据
  const fetchData = async () => {
    loading.value = true
    error.value = null
    
    try {
      const result = await fetcher()
      data.value = result
      retryCount = 0 // 成功重置重试计数
    } catch (err) {
      error.value = err as Error
      
      // 失败重试逻辑
      if (retryOnError && retryCount < maxRetries) {
        retryCount++
        console.log(`第${retryCount}次重试...`)
        setTimeout(fetchData, interval / 2)
      }
    } finally {
      loading.value = false
    }
  }
  
  // 开始轮询
  const startPolling = () => {
    if (timer) return
    
    fetchData()
    timer = setInterval(fetchData, interval)
  }
  
  // 停止轮询
  const stopPolling = () => {
    if (timer) {
      clearInterval(timer)
      timer = null
    }
  }
  
  // 手动刷新
  const refresh = () => {
    stopPolling()
    startPolling()
  }
  
  onMounted(() => {
    startPolling()
  })
  
  onUnmounted(() => {
    stopPolling()
  })
  
  return {
    data,
    loading,
    error,
    refresh,
    stopPolling,
    startPolling
  }
}

// 使用
const { data, loading } = usePollingCache(
  'stock-data',
  () => api.getStockPrices(),
  { interval: 10000 }
)

WebSocket 推送:服务端通知缓存失效

typescript 复制代码
// services/realtime-cache.ts
import { ref } from 'vue'

class RealtimeCache {
  private cache = new Map<string, any>()
  private ws: WebSocket | null = null
  
  constructor() {
    this.connectWebSocket()
  }
  
  private connectWebSocket() {
    this.ws = new WebSocket('wss://api.example.com/realtime')
    
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data)
      
      switch (message.type) {
        case 'invalidate':
          // 失效特定缓存
          this.invalidate(message.key)
          break
        case 'update':
          // 直接更新缓存
          this.set(message.key, message.data)
          break
        case 'invalidate-all':
          // 失效所有缓存
          this.cache.clear()
          break
      }
    }
    
    this.ws.onclose = () => {
      // 断线重连
      setTimeout(() => this.connectWebSocket(), 5000)
    }
  }
  
  get(key: string) {
    return this.cache.get(key)
  }
  
  set(key: string, value: any) {
    this.cache.set(key, value)
  }
  
  invalidate(key: string) {
    this.cache.delete(key)
  }
  
  // 获取数据,优先从缓存读,同时监听失效
  async getData<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
    const cached = this.get(key)
    if (cached) {
      return cached
    }
    
    const data = await fetcher()
    this.set(key, data)
    return data
  }
}

export const realtimeCache = new RealtimeCache()

// 在组件中使用
const user = await realtimeCache.getData(
  `user_${userId}`,
  () => api.getUser(userId)
)

结合 Vue 的响应式缓存

useSWR 风格的缓存

**SWR (Stale-While-Revalidate) ** 策略:先返回缓存数据,同时发起请求更新缓存:

typescript 复制代码
// composables/useSWR.ts
import { ref, watch, onUnmounted, type Ref } from 'vue'

interface CacheEntry<T> {
  data: T
  timestamp: number
  promise?: Promise<T>
}

class SWRCache {
  private cache = new Map<string, CacheEntry<any>>()
  private subscribers = new Map<string, Set<(data: any) => void>>()
  
  get<T>(key: string): T | null {
    const entry = this.cache.get(key)
    if (entry && Date.now() - entry.timestamp < 60000) { // 1分钟内有效
      return entry.data
    }
    return null
  }
  
  set<T>(key: string, data: T) {
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    })
    
    // 通知所有订阅者
    const subscribers = this.subscribers.get(key)
    if (subscribers) {
      subscribers.forEach(cb => cb(data))
    }
  }
  
  subscribe(key: string, callback: (data: any) => void) {
    if (!this.subscribers.has(key)) {
      this.subscribers.set(key, new Set())
    }
    this.subscribers.get(key)!.add(callback)
    
    return () => {
      this.subscribers.get(key)?.delete(callback)
    }
  }
  
  clear() {
    this.cache.clear()
    this.subscribers.clear()
  }
}

const globalCache = new SWRCache()

export function useSWR<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    revalidateOnMount?: boolean
    revalidateOnFocus?: boolean
    refreshInterval?: number
    dedupingInterval?: number // 去重间隔
  } = {}
) {
  const data = ref<T | null>(globalCache.get(key)) as Ref<T | null>
  const isLoading = ref(!data.value)
  const error = ref<Error | null>(null)
  
  let timer: ReturnType<typeof setTimeout> | null = null
  let revalidateTimer: ReturnType<typeof setTimeout> | null = null
  
  const {
    revalidateOnMount = true,
    revalidateOnFocus = true,
    refreshInterval = 0,
    dedupingInterval = 2000
  } = options
  
  // 重新验证数据
  const revalidate = async () => {
    const entry = globalCache['cache'].get(key)
    
    // 去重:短时间内不重复请求
    if (entry?.promise) {
      return entry.promise
    }
    
    isLoading.value = true
    error.value = null
    
    const promise = fetcher()
      .then((newData) => {
        globalCache.set(key, newData)
        data.value = newData
        return newData
      })
      .catch((err) => {
        error.value = err
        throw err
      })
      .finally(() => {
        isLoading.value = false
        // 清除 promise 引用
        setTimeout(() => {
          const entry = globalCache['cache'].get(key)
          if (entry?.promise === promise) {
            entry.promise = undefined
          }
        }, dedupingInterval)
      })
    
    const entry = globalCache['cache'].get(key)
    if (entry) {
      entry.promise = promise
    }
    
    return promise
  }
  
  // 订阅缓存更新
  const unsubscribe = globalCache.subscribe(key, (newData) => {
    data.value = newData
  })
  
  // 初始化加载
  if (revalidateOnMount) {
    revalidate()
  }
  
  // 定时刷新
  if (refreshInterval > 0) {
    timer = setInterval(revalidate, refreshInterval)
  }
  
  // 窗口聚焦时重新验证
  if (revalidateOnFocus) {
    const onFocus = () => {
      if (revalidateTimer) {
        clearTimeout(revalidateTimer)
      }
      revalidateTimer = setTimeout(revalidate, 1000)
    }
    window.addEventListener('focus', onFocus)
    
    onUnmounted(() => {
      window.removeEventListener('focus', onFocus)
    })
  }
  
  onUnmounted(() => {
    unsubscribe()
    if (timer) clearInterval(timer)
    if (revalidateTimer) clearTimeout(revalidateTimer)
  })
  
  return {
    data,
    isLoading,
    error,
    revalidate,
    mutate: (newData: T) => {
      globalCache.set(key, newData)
      data.value = newData
    }
  }
}

在组件中使用

html 复制代码
<template>
  <div>
    <div v-if="isLoading">加载中...</div>
    <div v-else-if="error">出错了: {{ error.message }}</div>
    <div v-else>
      <h2>{{ data.name }}</h2>
      <p>{{ data.description }}</p>
      <button @click="refresh">刷新</button>
    </div>
  </div>
</template>

<script setup>
import { useSWR } from './composables/useSWR'

const { data, isLoading, error, revalidate } = useSWR(
  'product-detail',
  () => api.getProduct(123),
  {
    revalidateOnFocus: true,
    refreshInterval: 30000, // 30秒刷新一次
    dedupingInterval: 2000   // 2秒内重复请求去重
  }
)

function refresh() {
  revalidate()
}
</script>

缓存策略的选择矩阵

缓存策略决策树

graph TD A[需要缓存数据] --> B{数据特点?} B -->|静态数据| C[HTTP 强缓存] B -->|用户数据| D{数据规模?} B -->|配置数据| E[内存缓存] B -->|实时数据| F[SWR 策略] D -->|小| G[localStorage] D -->|大| H[IndexedDB] C --> I[Service Worker] G --> J[内存+持久化] H --> K[内存+持久化] F --> L[内存缓存+WebSocket]

各种缓存的适用场景

缓存类型 适用场景 容量 持久化 速度 复杂度
HTTP 缓存 静态资源、API 响应 不限
Service Worker 离线应用、PWA 不限
内存缓存 频繁访问的小数据 几十MB 极快
localStorage 用户设置、小配置 5-10MB
IndexedDB 大量结构化数据 数百MB
SWR API 数据 可控 可选

缓存设计的黄金法则

  1. 分层缓存:结合多种缓存,发挥各自优势

    复制代码
    内存缓存 → 持久化缓存 → 网络请求
  2. 设置过期时间:避免数据永久陈旧

  3. 实现失效策略:数据更新时及时清除缓存

  4. 控制缓存大小:避免内存溢出

  5. 监控缓存命中率:优化缓存策略

  6. 提供降级方案:缓存失效时能优雅降级

最终建议

缓存不是越多越好,而是要在数据新鲜度访问速度之间找到平衡:

  • 几乎不变的数据(国家列表、静态配置):永久缓存
  • 偶尔变化的数据(用户信息、商品详情):缓存几分钟到几小时
  • 实时数据(股票价格、在线状态):不缓存或短时间缓存

结语

没有最好的缓存策略,只有最适合业务场景的缓存策略,根据数据的特性选择合适的策略,才能让我们的应用真正"快如闪电"。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
wuhen_n2 小时前
自定义指令:为 DOM 操作提供高效的抽象入口
前端·javascript·vue.js
C_心欲无痕2 小时前
前端 PDF 渲染与下载实现
前端·pdf
jiayong232 小时前
可视化流程设计器技术对比:钉钉风格 vs BPMN
java·前端·钉钉
前端不太难2 小时前
Flutter Web / Desktop 为什么“能跑但不好用”?
前端·flutter·状态模式
甘露s2 小时前
新手入门:传统 Web 开发与前后端分离开发的区别
开发语言·前端·后端·web
双河子思2 小时前
自动化控制逻辑建模方法
前端·数据库·自动化
wsad05322 小时前
Vue.js 整合传统 HTML 项目:注册页面实战教程
前端·vue.js·html
XXYBMOOO2 小时前
Flarum 主题定制:从零打造你的赛博朋克/JOJO 风格社区(含全套 CSS 源码)
前端·css
升鲜宝供应链及收银系统源代码服务2 小时前
升鲜宝生鲜配送供应链管理系统生产加工子模块的详细表设计说明
java·大数据·前端·数据库·bootstrap·供应链系统·生鲜配送