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拦截器+业务场景化预加载",形成了完整的性能优化链路:
核心要点回顾
- 分层缓存策略:根据数据特性选择存储介质(内存缓存高频临时数据,LocalStorage缓存低频持久数据),设置合理过期时间。
- 精准预加载:基于用户行为(滚动、hover)触发预加载,通过防抖避免过度请求,平衡体验与带宽消耗。
- 数据一致性保障:缓存键唯一化、数据变更主动清除缓存、合理设置过期时间,避免缓存数据与后端不一致。
后续可扩展方向包括:结合Service Worker实现离线缓存、基于用户行为分析优化预加载策略、使用IndexedDB存储大量结构化数据,进一步提升弱网环境下的数据加载体验。