📌 导语 (TL;DR)
在电商、资讯类小程序或 H5 开发中,"多 Tab 分类列表"(比如:最新上架 / 劳力士 / 百达翡丽)是最常见的页面结构。
但 80% 的初中级前端工程师是这样写的: 页面里只维护一个 list 和一个 pageNo。当用户从 Tab 1 切到 Tab 2 时,直接清空 list,重置 pageNo = 1,然后重新发请求转菊花。 致命痛点:
-
体验极差: 每次切 Tab 都要白屏等加载。
-
状态丢失: 用户在 Tab 1 好不容易滑到了第 5 页,切去 Tab 2 看了一眼,再切回 Tab 1,又回到了第 1 页!
-
数据串台(竞态 Bug): 快速疯狂来回切 Tab,由于网络延迟,Tab 1 的数据极有可能覆盖到 Tab 2 的页面上。
今天,我们将抛弃臃肿的全局 Mixin,带你用 二维 Map 状态机 + 闭包拦截 的思想,手写一个国内一线大厂(如淘宝、京东)标配的**"零延迟、独立记忆位置、绝对防串台"**的多 Tab 列表基建!
💡 核心设计思想:二维字典缓存 (2D Map Cache)
我们绝不能用一维数组去承载多 Tab 的数据。正确的做法是,为每一个 Tab 开辟一个独立的"平行宇宙"。
在 data 中,我们只维护两个核心大字典:
javascript
data() {
return {
currentTab: 0, // 当前高亮的 Tab
// 🛒 数据源字典:记录每个 Tab 下的商品列表
productMap: {}, // 结构如: { 0: [商品1,商品2], 1: [商品3] }
// 📖 分页状态字典:记录每个 Tab 独立的分页进度和 Loading 状态
paginationMap: {}, // 结构如: { 0: {page: 2, hasMore: true}, 1: {page: 1} }
}
}
借助 Vue 的按需渲染,视图层永远只展示 productMap[currentTab] 的数据。
💻 核心源码:堪称艺术的 loadProducts 逻辑
这段仅仅几十行的函数,藏着解决竞态条件、无缝缓存、响应式修复的 3 个顶级设计:
javascript
methods: {
// 核心加载函数:支持普通切换、上拉加载、强制刷新
async loadProducts(tabIndex, loadMore = false, isRefresh = false) {
// === 🌟 防御与初始化阶段 ===
// 1. 初始化当前 Tab 的专属分页状态(如果是强制下拉刷新,则重置)
if (!this.paginationMap[tabIndex] || isRefresh) {
// ⚠️ 避坑指南:Vue2 必须用 $set,否则深层嵌套失去响应式
this.$set(this.paginationMap, tabIndex, {
pageNo: 1, pageSize: 10, hasMore: true, isLoading: false
});
}
const page = this.paginationMap[tabIndex];
// 2. 三重极限拦截,减少无效请求
if (page.isLoading) return; // 拦截1:正在加载中,防连点狂刷
if (loadMore && !page.hasMore) return; // 拦截2:已经触底,拒绝压榨服务器
// ✨ 神级优化:零延迟缓存命中!
// 如果不是上拉加载,且该 Tab 已经有数据,直接 return!
// 页面会瞬间用 productMap 里的老数据渲染,连网络请求都不发!
if (!loadMore && !isRefresh && this.productMap[tabIndex]?.length > 0) return;
// 状态上锁
page.isLoading = true;
// === 🚀 异步请求与竞态拦截阶段 ===
try {
const res = await api.getWatchList({
categoryId: this.categoryList[tabIndex].id,
pageNo: page.pageNo,
pageSize: page.pageSize
});
// 🛡️ 终极防御:闭包快照防串台(Race Condition)!
// 如果请求花了 3 秒才回来,但用户早就切到别的 Tab 去了
// 直接把这份迟到的脏数据扔进垃圾桶,绝不渲染到屏幕上!
if (tabIndex !== this.currentTab) return;
const products = res.data?.records || [];
// === 📦 响应式数据合并阶段 ===
// 合并商品数据
const newList = loadMore ? [...(this.productMap[tabIndex] || []), ...products] : products;
this.$set(this.productMap, tabIndex, newList);
// ⚠️ 致命细节:分页状态更新必须用全量对象覆盖,否则底部的 loadMoreText 不会触发更新!
const isHasMore = products.length >= page.pageSize;
this.$set(this.paginationMap, tabIndex, {
...page,
pageNo: isHasMore ? page.pageNo + 1 : page.pageNo,
hasMore: isHasMore,
isLoading: false // 解锁
});
} catch (e) {
// 同样的防串台拦截
if (tabIndex !== this.currentTab) return;
console.error('加载商品失败', e);
if (!loadMore) this.$set(this.productMap, tabIndex, []);
this.$set(this.paginationMap, tabIndex, { ...page, isLoading: false }); // 失败也要解锁
}
}
}
🛠️ 极其优雅的视图层配合
底层的 Map 搭好了,.vue 的 <template> 里调用起来就像呼吸一样自然:
javascript
<view class="list">
<view class="item" v-for="item in (productMap[currentTab] || [])" :key="item.id">
{{ item.name }}
</view>
</view>
<view class="load-more">
{{ loadMoreText }}
</view>
配合极致简化的 computed:
javascript
computed: {
loadMoreText() {
const page = this.paginationMap[this.currentTab];
const list = this.productMap[this.currentTab] || [];
if (!page || list.length === 0) return '';
if (page.isLoading) return '加载中...';
return page.hasMore ? '上拉加载更多' : '没有更多了';
}
}
🎯 架构总结 (Takeaways)
这套架构解决了什么?
-
零延迟的极致丝滑: 利用
!loadMore && cache拦截,用户在已加载过的 Tab 间来回切换时,不发请求,瞬间显示。 -
彻底消灭数据串台 Bug: 利用
tabIndex !== this.currentTab闭包比对,在复杂弱网下保护 UI 渲染的绝对纯洁。 -
跨过了 Vue2 最大的坑: 熟练运用
this.$set配合展开运算符...实现嵌套对象的响应式覆盖。
好的前端架构,不仅是给机器跑的,更是对真实用户体验的深度共情。抛弃简陋的一维数组,拥抱二维状态机,你的代码质量将会有质的飞跃!
(完)