JavaScript性能优化系列(八)弱网环境体验优化 - 8.3 数据预加载与缓存:提前缓存关键数据

8.3 数据预加载与缓存:提前缓存关键数据

弱网环境下,"等待加载"是用户体验的最大杀手------即使页面骨架屏再精美,长时间的数据加载也会让用户失去耐心。数据预加载与缓存的核心目标是:在用户需要数据前,提前将关键数据加载并缓存,让用户操作时"零等待"。本节将结合Vue3+TypeScript生态,从"预加载策略设计""缓存机制实现""失效控制"三个维度,完整落地数据预加载与缓存方案,解决弱网下的数据加载痛点。

8.3.1 核心原理:预加载与缓存的本质

1. 预加载:"预判用户行为"的资源调度

预加载是基于用户行为分析的"主动资源请求",本质是将"按需加载"提前到"用户可能需要前"。常见的预加载触发场景包括:

  • 页面进入时:加载当前页面的核心数据(如商品列表页进入时预加载前20条商品)。
  • 用户操作时:用户滑动到列表底部前预加载下一页数据;用户 hover 到商品卡片时预加载商品详情。
  • 依赖关联时:加载订单列表时,预加载关联的用户收货地址数据。

预加载的关键是"精准预判"------过度预加载会浪费带宽(弱网下反而适得其反),预加载不足则无法提升体验。

2. 缓存:"数据复用"的性能优化手段

缓存是将请求到的数据存储在本地(如内存、LocalStorage、IndexedDB),避免重复请求。在弱网环境下,缓存的价值体现在:

  • 减少网络请求:相同数据直接从本地读取,无需等待网络响应。
  • 提升响应速度:本地读取速度远快于网络请求,尤其是大量数据场景。
  • 离线可用:结合8.2节的Service Worker,缓存数据可实现离线访问。

3. 预加载+缓存的协同模型

预加载解决"数据提前获取"问题,缓存解决"数据复用"问题,两者结合形成完整的性能优化链路:
是 否 用户进入页面 预加载核心数据 数据是否缓存? 从缓存读取数据 发起网络请求 缓存数据 渲染页面 用户操作触发预加载 预加载下一批数据

8.3.2 技术选型:Vue3生态下的缓存方案

Vue3项目中,数据缓存需结合"存储介质特性""数据时效性"选择方案:

存储介质 优点 缺点 适用场景
内存缓存 读写速度最快,Vue响应式支持好 页面刷新后失效 单次会话内的高频访问数据
LocalStorage 持久化存储,容量较大(5MB) 仅支持字符串,无过期机制 低频变更的静态数据(如用户信息)
IndexedDB 大容量(无明确限制),支持复杂查询 API复杂,需封装使用 大量结构化数据(如商品列表、订单历史)
Service Worker Cache 离线可用,拦截请求自动缓存 仅缓存请求响应,数据操作复杂 与网络请求强关联的数据

本节将基于内存缓存+LocalStorage+IndexedDB 组合方案,结合Vue3的Pinia(状态管理)和axios(请求拦截)实现完整的预加载与缓存逻辑。

8.3.3 基础封装:通用缓存工具类

首先封装通用缓存工具类,统一管理不同存储介质的操作,简化后续业务逻辑开发。

1. 缓存类型定义(types/cache.ts)

typescript 复制代码
// 缓存类型枚举
export enum CacheType {
  MEMORY = 'memory',
  LOCAL = 'local',
  INDEXEDDB = 'indexedDB'
}

// 缓存项结构
export interface CacheItem<T = any> {
  data: T;         // 缓存数据
  expire: number;  // 过期时间戳(毫秒),0表示永不过期
  createTime: number; // 创建时间
}

// 缓存配置
export interface CacheOptions {
  type: CacheType; // 存储介质
  expire: number;  // 过期时间(秒),默认300秒(5分钟)
}

// 默认缓存配置
export const DEFAULT_CACHE_OPTIONS: CacheOptions = {
  type: CacheType.MEMORY,
  expire: 300
};

2. 内存缓存实现(utils/cache/memoryCache.ts)

typescript 复制代码
import { CacheItem, CacheOptions } from '@/types/cache';

// 内存缓存存储容器
const memoryCache = new Map<string, CacheItem>();

/**
 * 内存缓存工具类
 */
export class MemoryCache {
  /**
   * 设置缓存
   * @param key 缓存键
   * @param data 缓存数据
   * @param options 缓存配置
   */
  static set<T>(key: string, data: T, options: CacheOptions = { type: CacheType.MEMORY, expire: 300 }): void {
    const expire = options.expire === 0 ? 0 : Date.now() + options.expire * 1000;
    memoryCache.set(key, {
      data,
      expire,
      createTime: Date.now()
    });
  }

  /**
   * 获取缓存
   * @param key 缓存键
   * @returns 缓存数据(过期返回null)
   */
  static get<T>(key: string): T | null {
    const item = memoryCache.get(key);
    if (!item) return null;
    
    // 检查是否过期
    if (item.expire !== 0 && Date.now() > item.expire) {
      memoryCache.delete(key);
      return null;
    }
    
    return item.data as T;
  }

  /**
   * 删除缓存
   * @param key 缓存键
   */
  static delete(key: string): void {
    memoryCache.delete(key);
  }

  /**
   * 清空缓存
   */
  static clear(): void {
    memoryCache.clear();
  }

  /**
   * 检查缓存是否存在(未过期)
   * @param key 缓存键
   */
  static has(key: string): boolean {
    return this.get(key) !== null;
  }
}

3. LocalStorage缓存实现(utils/cache/localCache.ts)

typescript 复制代码
import { CacheItem, CacheOptions } from '@/types/cache';

/**
 * LocalStorage缓存工具类(封装JSON序列化/反序列化)
 */
export class LocalCache {
  /**
   * 设置缓存
   * @param key 缓存键
   * @param data 缓存数据
   * @param options 缓存配置
   */
  static set<T>(key: string, data: T, options: CacheOptions = { type: CacheType.LOCAL, expire: 300 }): void {
    try {
      const expire = options.expire === 0 ? 0 : Date.now() + options.expire * 1000;
      const item: CacheItem<T> = {
        data,
        expire,
        createTime: Date.now()
      };
      localStorage.setItem(key, JSON.stringify(item));
    } catch (error) {
      console.error('LocalStorage设置失败:', error);
      // 处理存储满的情况:清空过期缓存后重试
      this.clearExpired();
      try {
        localStorage.setItem(key, JSON.stringify({ data, expire: options.expire * 1000 + Date.now(), createTime: Date.now() }));
      } catch (e) {
        console.error('LocalStorage已满,无法设置缓存:', e);
      }
    }
  }

  /**
   * 获取缓存
   * @param key 缓存键
   * @returns 缓存数据(过期返回null)
   */
  static get<T>(key: string): T | null {
    try {
      const str = localStorage.getItem(key);
      if (!str) return null;
      
      const item = JSON.parse(str) as CacheItem<T>;
      
      // 检查是否过期
      if (item.expire !== 0 && Date.now() > item.expire) {
        localStorage.removeItem(key);
        return null;
      }
      
      return item.data;
    } catch (error) {
      console.error('LocalStorage获取失败:', error);
      localStorage.removeItem(key);
      return null;
    }
  }

  /**
   * 删除缓存
   * @param key 缓存键
   */
  static delete(key: string): void {
    localStorage.removeItem(key);
  }

  /**
   * 清空缓存
   */
  static clear(): void {
    localStorage.clear();
  }

  /**
   * 清空过期缓存
   */
  static clearExpired(): void {
    const keys = Object.keys(localStorage);
    keys.forEach(key => {
      try {
        const item = JSON.parse(localStorage.getItem(key) || '{}') as CacheItem;
        if (item.expire !== 0 && Date.now() > item.expire) {
          localStorage.removeItem(key);
        }
      } catch (error) {
        localStorage.removeItem(key);
      }
    });
  }

  /**
   * 检查缓存是否存在(未过期)
   * @param key 缓存键
   */
  static has(key: string): boolean {
    return this.get(key) !== null;
  }
}

4. 统一缓存管理器(utils/cache/index.ts)

typescript 复制代码
import { CacheType, CacheOptions, DEFAULT_CACHE_OPTIONS } from '@/types/cache';
import { MemoryCache } from './memoryCache';
import { LocalCache } from './localCache';
// IndexedDB缓存类后续实现,此处先声明
import { IndexedDBCache } from './indexedDBCache';

/**
 * 统一缓存管理器:根据配置自动选择存储介质
 */
export class CacheManager {
  /**
   * 设置缓存
   * @param key 缓存键
   * @param data 缓存数据
   * @param options 缓存配置
   */
  static set<T>(key: string, data: T, options: Partial<CacheOptions> = {}): void {
    const finalOptions = { ...DEFAULT_CACHE_OPTIONS, ...options };
    switch (finalOptions.type) {
      case CacheType.MEMORY:
        MemoryCache.set(key, data, finalOptions);
        break;
      case CacheType.LOCAL:
        LocalCache.set(key, data, finalOptions);
        break;
      case CacheType.INDEXEDDB:
        IndexedDBCache.set(key, data, finalOptions);
        break;
    }
  }

  /**
   * 获取缓存
   * @param key 缓存键
   * @param options 缓存配置(主要用于指定存储介质)
   */
  static get<T>(key: string, options: Partial<CacheOptions> = {}): T | null {
    const finalOptions = { ...DEFAULT_CACHE_OPTIONS, ...options };
    switch (finalOptions.type) {
      case CacheType.MEMORY:
        return MemoryCache.get<T>(key);
      case CacheType.LOCAL:
        return LocalCache.get<T>(key);
      case CacheType.INDEXEDDB:
        return IndexedDBCache.get<T>(key);
      default:
        return null;
    }
  }

  /**
   * 删除缓存
   * @param key 缓存键
   * @param options 缓存配置
   */
  static delete(key: string, options: Partial<CacheOptions> = {}): void {
    const finalOptions = { ...DEFAULT_CACHE_OPTIONS, ...options };
    switch (finalOptions.type) {
      case CacheType.MEMORY:
        MemoryCache.delete(key);
        break;
      case CacheType.LOCAL:
        LocalCache.delete(key);
        break;
      case CacheType.INDEXEDDB:
        IndexedDBCache.delete(key);
        break;
    }
  }

  /**
   * 检查缓存是否存在
   * @param key 缓存键
   * @param options 缓存配置
   */
  static has(key: string, options: Partial<CacheOptions> = {}): boolean {
    const finalOptions = { ...DEFAULT_CACHE_OPTIONS, ...options };
    switch (finalOptions.type) {
      case CacheType.MEMORY:
        return MemoryCache.has(key);
      case CacheType.LOCAL:
        return LocalCache.has(key);
      case CacheType.INDEXEDDB:
        return IndexedDBCache.has(key);
      default:
        return false;
    }
  }

  /**
   * 清空指定类型的缓存
   * @param type 缓存类型
   */
  static clear(type: CacheType = CacheType.MEMORY): void {
    switch (type) {
      case CacheType.MEMORY:
        MemoryCache.clear();
        break;
      case CacheType.LOCAL:
        LocalCache.clear();
        break;
      case CacheType.INDEXEDDB:
        IndexedDBCache.clear();
        break;
    }
  }
}

8.3.4 核心实现:基于Axios的请求缓存拦截器

结合axios拦截器,实现"请求前查缓存,响应后存缓存"的自动化缓存逻辑,减少业务代码中的重复缓存操作。

1. 请求缓存配置(types/request.ts)

typescript 复制代码
import { CacheOptions } from './cache';

// 请求配置扩展:添加缓存选项
export interface RequestConfig extends AxiosRequestConfig {
  cache?: boolean | Partial<CacheOptions>; // 是否缓存,true使用默认配置,false不缓存
  preload?: boolean; // 是否为预加载请求
}

// 响应数据结构
export interface ResponseData<T = any> {
  code: number;
  msg: string;
  data: T;
}

2. Axios拦截器封装(utils/request.ts)

typescript 复制代码
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { CacheManager } from './cache';
import { RequestConfig, ResponseData } from '@/types/request';
import { useUserStore } from '@/stores/user';

// 创建Axios实例
const service: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000, // 弱网下超时时间设为10秒
  headers: {
    'Content-Type': 'application/json'
  }
});

// 生成请求缓存键(URL+参数+用户ID)
const generateCacheKey = (config: RequestConfig): string => {
  const userStore = useUserStore();
  const userId = userStore.userInfo?.id || 'guest';
  const params = config.params || {};
  const data = config.data || {};
  // 参数排序,避免因参数顺序不同生成不同key
  const sortedParams = Object.keys(params).sort().reduce((obj, key) => {
    obj[key] = params[key];
    return obj;
  }, {} as Record<string, any>);
  const sortedData = Object.keys(data).sort().reduce((obj, key) => {
    obj[key] = data[key];
    return obj;
  }, {} as Record<string, any>);
  return `${config.method?.toUpperCase() || 'GET'}_${config.url}_${JSON.stringify(sortedParams)}_${JSON.stringify(sortedData)}_${userId}`;
};

// 请求拦截器:先查缓存
service.interceptors.request.use(
  (config: RequestConfig) => {
    // 非GET请求不缓存
    if (config.method?.toUpperCase() !== 'GET' || !config.cache) {
      return config;
    }

    // 获取缓存配置
    const cacheOptions = typeof config.cache === 'boolean' ? {} : config.cache;
    const cacheKey = generateCacheKey(config);
    
    // 从缓存获取数据
    const cachedData = CacheManager.get<ResponseData>(cacheKey, cacheOptions);
    if (cachedData) {
      // 缓存命中:返回缓存数据(通过Promise.resolve模拟响应)
      return Promise.reject({
        __isCache: true,
        data: cachedData
      });
    }

    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

// 响应拦截器:缓存响应数据
service.interceptors.response.use(
  (response: AxiosResponse<ResponseData>) => {
    const config = response.config as RequestConfig;
    
    // 仅缓存GET请求且配置了缓存的响应
    if (config.method?.toUpperCase() === 'GET' && config.cache) {
      const cacheOptions = typeof config.cache === 'boolean' ? {} : config.cache;
      const cacheKey = generateCacheKey(config);
      // 缓存成功响应的数据
      if (response.data.code === 0) {
        CacheManager.set(cacheKey, response.data, cacheOptions);
      }
    }

    return response;
  },
  (error) => {
    // 处理缓存命中的情况
    if (error.__isCache) {
      return Promise.resolve({
        data: error.data,
        status: 200,
        statusText: 'OK',
        headers: {},
        config: error.config
      });
    }

    // 弱网下的错误处理:尝试从缓存获取兜底数据
    const config = error.config as RequestConfig;
    if (config.method?.toUpperCase() === 'GET' && config.cache) {
      const cacheOptions = typeof config.cache === 'boolean' ? {} : config.cache;
      const cacheKey = generateCacheKey(config);
      const cachedData = CacheManager.get<ResponseData>(cacheKey, cacheOptions);
      if (cachedData) {
        return Promise.resolve({
          data: cachedData,
          status: 200,
          statusText: 'From Cache (Network Error)',
          headers: {},
          config
        });
      }
    }

    return Promise.reject(error);
  }
);

// 封装请求方法,处理缓存逻辑
export const request = <T = any>(config: RequestConfig): Promise<ResponseData<T>> => {
  return service(config)
    .then((response) => {
      return response.data as ResponseData<T>;
    })
    .catch((error) => {
      if (error.__isCache) {
        return error.data as ResponseData<T>;
      }
      throw error;
    });
};

// 预加载请求方法(不触发页面loading,不处理错误提示)
export const preloadRequest = <T = any>(config: RequestConfig): Promise<void> => {
  return service({ ...config, preload: true })
    .then(() => {})
    .catch(() => {}); // 预加载失败不处理,不影响用户体验
};

export default service;

8.3.5 业务落地:Vue3组件中的预加载实践

结合具体业务场景(商品列表页、详情页),实现"页面进入预加载""滚动预加载""hover预加载"三种常见预加载策略。

1. 商品列表页:页面进入+滚动预加载(views/ProductList.vue)

vue 复制代码
<template>
  <div class="product-list">
    <div class="search-bar">
      <input v-model="keyword" placeholder="搜索商品" />
      <button @click="fetchProductList">搜索</button>
    </div>

    <div class="product-grid">
      <div 
        v-for="product in productList" 
        :key="product.id" 
        class="product-card"
        @mouseenter="handleProductHover(product.id)"
      >
        <img :src="product.image" :alt="product.name" />
        <h3>{{ product.name }}</h3>
        <p class="price">¥{{ product.price }}</p>
      </div>
    </div>

    <div class="loading" v-if="loading">加载中...</div>
    <div class="no-more" v-if="noMore">没有更多商品了</div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { request, preloadRequest } from '@/utils/request';
import { CacheManager, CacheType } from '@/utils/cache';
import type { ProductItem } from '@/types/product';

// 响应式数据
const keyword = ref('');
const productList = ref<ProductItem[]>([]);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const noMore = ref(false);

// 防抖函数:避免频繁触发预加载
const debounce = (fn: Function, delay: number) => {
  let timer: NodeJS.Timeout;
  return (...args: any[]) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
};

// 获取商品列表(带缓存)
const fetchProductList = async () => {
  if (loading.value) return;
  
  loading.value = true;
  try {
    const res = await request<{ list: ProductItem[], total: number }>({
      url: '/api/product/list',
      method: 'GET',
      params: {
        keyword: keyword.value,
        page: page.value,
        pageSize: pageSize.value
      },
      cache: {
        type: CacheType.LOCAL,
        expire: 600 // 缓存10分钟
      }
    });
    
    if (page.value === 1) {
      productList.value = res.data.list;
    } else {
      productList.value.push(...res.data.list);
    }
    
    noMore.value = productList.value.length >= res.data.total;
  } catch (error) {
    console.error('获取商品列表失败:', error);
  } finally {
    loading.value = false;
  }
};

// 预加载下一页数据
const preloadNextPage = debounce(async () => {
  if (noMore.value || loading.value) return;
  
  const nextPage = page.value + 1;
  // 预加载:不更新页面数据,仅缓存
  await preloadRequest({
    url: '/api/product/list',
    method: 'GET',
    params: {
      keyword: keyword.value,
      page: nextPage,
      pageSize: pageSize.value
    },
    cache: {
      type: CacheType.LOCAL,
      expire: 600
    }
  });
}, 300);

// Hover预加载商品详情
const preloadProductDetail = debounce(async (productId: number) => {
  // 检查是否已缓存
  const cacheKey = `GET_/api/product/detail_${JSON.stringify({ id: productId })}_${JSON.stringify({})}_${useUserStore().userInfo?.id || 'guest'}`;
  if (CacheManager.has(cacheKey, { type: CacheType.LOCAL })) return;
  
  // 预加载详情数据
  await preloadRequest({
    url: '/api/product/detail',
    method: 'GET',
    params: { id: productId },
    cache: {
      type: CacheType.LOCAL,
      expire: 300 // 详情缓存5分钟
    }
  });
}, 200);

// 滚动监听:滚动到底部前200px触发预加载
const handleScroll = () => {
  const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
  const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
  const clientHeight = document.documentElement.clientHeight || window.innerHeight;
  
  if (scrollHeight - scrollTop - clientHeight < 200) {
    preloadNextPage();
  }
};

// 事件处理
const handleProductHover = (productId: number) => {
  preloadProductDetail(productId);
};

// 生命周期
onMounted(() => {
  // 页面进入时加载第一页数据
  fetchProductList();
  // 监听滚动事件
  window.addEventListener('scroll', handleScroll);
});

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll);
});
</script>

<style scoped>
.product-list {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.search-bar {
  margin-bottom: 20px;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

.product-card {
  padding: 10px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  cursor: pointer;
  transition: box-shadow 0.2s;
}

.product-card:hover {
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.product-card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
  border-radius: 4px;
}

.price {
  color: #ef4444;
  font-weight: bold;
}

.loading, .no-more {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

2. 商品详情页:依赖预加载(views/ProductDetail.vue)

vue 复制代码
<template>
  <div class="product-detail" v-if="product">
    <div class="basic-info">
      <img :src="product.image" :alt="product.name" />
      <div class="info">
        <h1>{{ product.name }}</h1>
        <p class="price">¥{{ product.price }}</p>
        <p class="desc">{{ product.description }}</p>
      </div>
    </div>

    <div class="related-section">
      <h2>相关推荐</h2>
      <div class="related-list">
        <div v-for="item in relatedProducts" :key="item.id" class="related-item">
          <img :src="item.image" :alt="item.name" />
          <p>{{ item.name }}</p>
        </div>
      </div>
    </div>

    <div class="comment-section">
      <h2>用户评价</h2>
      <div v-for="comment in comments" :key="comment.id" class="comment-item">
        <p>{{ comment.content }}</p>
        <p class="author">------ {{ comment.userName }}</p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { request, preloadRequest } from '@/utils/request';
import { CacheType } from '@/types/cache';
import type { ProductItem, RelatedProduct, CommentItem } from '@/types/product';

const route = useRoute();
const productId = Number(route.params.id);

// 响应式数据
const product = ref<ProductItem | null>(null);
const relatedProducts = ref<RelatedProduct[]>([]);
const comments = ref<CommentItem[]>([]);

// 获取商品详情
const fetchProductDetail = async () => {
  const res = await request<ProductItem>({
    url: '/api/product/detail',
    method: 'GET',
    params: { id: productId },
    cache: {
      type: CacheType.LOCAL,
      expire: 300
    }
  });
  product.value = res.data;
};

// 获取相关推荐
const fetchRelatedProducts = async () => {
  const res = await request<RelatedProduct[]>({
    url: '/api/product/related',
    method: 'GET',
    params: { id: productId },
    cache: {
      type: CacheType.MEMORY,
      expire: 300
    }
  });
  relatedProducts.value = res.data;
};

// 获取用户评价
const fetchComments = async () => {
  const res = await request<CommentItem[]>({
    url: '/api/product/comments',
    method: 'GET',
    params: { id: productId, page: 1, pageSize: 10 },
    cache: {
      type: CacheType.MEMORY,
      expire: 180
    }
  });
  comments.value = res.data;
  
  // 预加载下一页评价
  preloadRequest({
    url: '/api/product/comments',
    method: 'GET',
    params: { id: productId, page: 2, pageSize: 10 },
    cache: {
      type: CacheType.MEMORY,
      expire: 180
    }
  });
};

onMounted(async () => {
  // 优先加载核心数据(详情)
  await fetchProductDetail();
  
  // 并行预加载依赖数据(推荐+评价)
  Promise.all([
    fetchRelatedProducts(),
    fetchComments()
  ]);
});
</script>

<style scoped>
.product-detail {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.basic-info {
  display: flex;
  gap: 20px;
  margin-bottom: 40px;
}

.basic-info img {
  width: 400px;
  height: 400px;
  object-fit: cover;
  border-radius: 8px;
}

.info h1 {
  font-size: 24px;
  margin-bottom: 10px;
}

.price {
  font-size: 20px;
  color: #ef4444;
  margin-bottom: 20px;
}

.related-list, .comment-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 10px;
}

.related-item img {
  width: 100%;
  height: 100px;
  object-fit: cover;
}

.comment-item {
  padding: 10px;
  border-bottom: 1px solid #e5e7eb;
}

.author {
  color: #666;
  font-size: 12px;
}
</style>

8.3.6 实践反例与正例对比

预加载与缓存的常见错误集中在"策略滥用""缓存失效""数据一致性"三个方面,通过以下对比明确优化方向:

反例1:过度预加载,浪费带宽

typescript 复制代码
// 错误:页面进入时预加载所有商品详情
onMounted(async () => {
  const res = await request<{ list: ProductItem[] }>({
    url: '/api/product/list',
    params: { page: 1, pageSize: 100 } // 一次性加载100条
  });
  productList.value = res.data.list;
  
  // 预加载所有商品详情(100个请求)
  res.data.list.forEach(product => {
    fetchProductDetail(product.id); // 无防抖,瞬间发起100个请求
  });
});

问题分析:弱网环境下,一次性发起大量预加载请求会导致带宽拥堵,核心请求被阻塞,反而降低页面加载速度。

正例1:按需预加载+防抖

typescript 复制代码
// 正确:滚动预加载下一页+hover预加载详情
onMounted(() => {
  fetchProductList(); // 仅加载第一页
  window.addEventListener('scroll', handleScroll); // 滚动到底部前预加载下一页
});

// Hover时预加载详情(带防抖)
const handleProductHover = debounce((productId: number) => {
  preloadProductDetail(productId); // 仅预加载hover的商品
}, 200);

优化点:仅预加载用户"可能需要"的数据,通过防抖避免频繁请求,平衡预加载效果与带宽消耗。

反例2:缓存无过期,数据不一致

typescript 复制代码
// 错误:缓存永不过期
const fetchProductDetail = async () => {
  const res = await request<ProductItem>({
    url: '/api/product/detail',
    params: { id: productId },
    cache: {
      type: CacheType.LOCAL,
      expire: 0 // 永不过期
    }
  });
  product.value = res.data;
};

问题分析:商品价格、库存等数据变更后,用户仍看到旧缓存数据,导致"价格显示错误""库存已空却显示有货"等业务问题。

正例2:合理设置过期时间+主动更新

typescript 复制代码
// 正确:设置合理过期时间,数据变更时主动清除缓存
const fetchProductDetail = async () => {
  const res = await request<ProductItem>({
    url: '/api/product/detail',
    params: { id: productId },
    cache: {
      type: CacheType.LOCAL,
      expire: 300 // 5分钟过期
    }
  });
  product.value = res.data;
};

// 商品数据变更时清除缓存
const updateProduct = async (data: any) => {
  await request({
    url: '/api/product/update',
    method: 'POST',
    data
  });
  
  // 清除该商品的详情缓存
  const cacheKey = `GET_/api/product/detail_${JSON.stringify({ id: productId })}_${JSON.stringify({})}_${userId}`;
  CacheManager.delete(cacheKey, { type: CacheType.LOCAL });
};

优化点:根据数据变更频率设置过期时间,数据变更时主动清除相关缓存,保证数据一致性。

反例3:缓存键不唯一,数据错乱

typescript 复制代码
// 错误:缓存键仅用URL,忽略参数
const generateCacheKey = (config: RequestConfig): string => {
  return `${config.method}_${config.url}`; // 无参数,不同商品共用一个缓存键
};

问题分析 :不同商品详情请求的URL相同(/api/product/detail),仅参数不同,导致缓存键重复,用户A查看商品1后,用户B查看商品2时获取到商品1的缓存数据。

正例3:缓存键包含URL+参数+用户ID

typescript 复制代码
// 正确:缓存键包含URL+参数+用户ID
const generateCacheKey = (config: RequestConfig): string => {
  const userId = useUserStore().userInfo?.id || 'guest';
  const params = config.params || {};
  const sortedParams = Object.keys(params).sort().reduce((obj, key) => {
    obj[key] = params[key];
    return obj;
  }, {});
  return `${config.method}_${config.url}_${JSON.stringify(sortedParams)}_${userId}`;
};

优化点:缓存键包含请求方法、URL、排序后的参数、用户ID,确保不同请求的缓存键唯一,避免数据错乱。

8.3.7 代码评审要点

数据预加载与缓存的评审需聚焦"策略合理性""数据一致性""性能影响"三个核心维度,以下是关键评审要点:

1. 预加载策略检查

  • ✅ 是否仅预加载用户"可能需要"的数据(如滚动预加载、hover预加载),无过度预加载?
  • ✅ 预加载是否带防抖/节流,避免频繁请求?
  • ✅ 预加载失败是否不影响主流程,无错误提示干扰用户?

2. 缓存机制检查

  • ✅ 缓存键是否唯一(包含URL、参数、用户ID),无数据错乱风险?
  • ✅ 是否根据数据特性选择合适的存储介质(高频变更数据用内存缓存,低频变更用LocalStorage)?
  • ✅ 是否设置合理的过期时间,无"永不过期"的缓存?

3. 数据一致性检查

  • ✅ 数据变更时是否主动清除相关缓存(如更新商品后清除详情缓存)?
  • ✅ 用户登录/登出时是否清空用户相关的缓存,避免数据泄露?
  • ✅ 缓存命中时是否标注"来自缓存",便于问题排查?

4. 性能影响检查

  • ✅ 预加载是否在核心数据加载完成后进行,不阻塞首屏渲染?
  • ✅ 大量数据缓存是否使用IndexedDB而非LocalStorage,避免存储容量限制?
  • ✅ 弱网下请求超时后是否尝试从缓存获取兜底数据,提升体验?

8.3.8 对话小剧场:预加载的"坑"与优化

【场景】前端团队小美、小迪、小稳,后端大熊,质量工程师小燕在评审商品列表页的预加载优化方案。

小燕(质量工程师):小美,我测你改的商品列表页,弱网下第一次加载还是慢,但第二次点进去快多了------不过我发现个问题,我没点任何商品,浏览器Network里却有一堆商品详情的请求,这不是浪费流量吗?

小美(前端开发):啊...我是页面进入时就预加载了前20个商品的详情,想着用户点进去能直接看,没想到弱网下反而拖慢了列表加载。

小稳(前端开发,老大哥):这就是典型的"过度预加载",弱网下带宽本来就有限,你一次性发20个详情请求,把列表请求的带宽都占了,用户反而要等更久才能看到列表。得按需预加载,比如用户hover到商品卡片时再预加载详情,这样更精准。

小迪(前端开发):对,我上次做电商详情页时也踩过这坑,后来加了hover防抖,200ms内没移开再预加载,既保证了预加载效果,又不会发太多请求。

小美:那我改一下,把"页面进入预加载所有详情"改成"hover预加载单个详情",再加上防抖。不过还有个问题,我发现商品价格改了之后,用户看到的还是旧缓存价格,咋办?

大熊(后端开发):后端这边可以加个"数据版本号",每次数据变更版本号+1,前端请求时带上版本号,缓存时也存版本号,版本号不一致就不用缓存。

小稳:或者简单点,设置合理的过期时间,比如商品详情缓存5分钟,价格这类高频变更的缓存1分钟,同时在数据变更接口里主动清除相关缓存------比如后台改了商品价格,前端调用更新接口后,直接删了这个商品的详情缓存,下次请求就会拿新数据。

小燕:还有个体验问题,我断网后刷新页面,商品列表直接空白了,能不能从缓存里读点数据兜底?

小美:这个我加一下,在axios响应拦截器里,如果网络错误且有缓存,就返回缓存数据,这样断网也能看到之前的商品列表。

小稳:总结一下,预加载要"精准",缓存要"可控"------预加载只做用户"大概率需要"的操作,缓存要设置过期、支持主动清除,这样才能在弱网下既提升体验,又不搞出数据一致性问题。

小美:明白了!我这就改:hover预加载详情+防抖、缓存加过期时间、数据变更清除缓存、断网用缓存兜底。

8.3.9 总结

数据预加载与缓存是弱网环境下提升用户体验的关键手段,其核心是"精准预判用户需求,合理管理缓存生命周期"。本节实现的方案通过"通用缓存工具类+Axios拦截器+业务场景化预加载",形成了完整的性能优化链路:

核心要点回顾

  1. 分层缓存策略:根据数据特性选择存储介质(内存缓存高频临时数据,LocalStorage缓存低频持久数据),设置合理过期时间。
  2. 精准预加载:基于用户行为(滚动、hover)触发预加载,通过防抖避免过度请求,平衡体验与带宽消耗。
  3. 数据一致性保障:缓存键唯一化、数据变更主动清除缓存、合理设置过期时间,避免缓存数据与后端不一致。

后续可扩展方向包括:结合Service Worker实现离线缓存、基于用户行为分析优化预加载策略、使用IndexedDB存储大量结构化数据,进一步提升弱网环境下的数据加载体验。

相关推荐
1***y17836 分钟前
Vue项目性能优化案例
前端·vue.js·性能优化
Irene199138 分钟前
FileList 对象总结(附:不支持迭代的类数组对象表)
javascript·类数组对象·filelist·不支持迭代
Liu.7742 小时前
vue3 路由缓存导致onMounted无效
前端·javascript·vue.js
1***81532 小时前
React组件
前端·javascript·react.js
CS_浮鱼2 小时前
【Linux进阶】mmap实战:文件映射、进程通信与LRU缓存
linux·运维·c++·缓存
__花花世界3 小时前
前端日常工作开发技巧汇总
前端·javascript·vue.js
www_stdio3 小时前
栈(Stack)详解:从原理到实现,再到括号匹配应用
javascript
爬坑的小白4 小时前
vue 2.0 路由跳转时新开tab
前端·javascript·vue.js
爬坑的小白4 小时前
vue x 状态管理
前端·javascript·vue.js