Showing
2 changed files
with
323 additions
and
7 deletions
docs/improvements/带Tab的加载列表-状态保持改进方案.md
0 → 100644
| 1 | +# 带 Tab 的加载列表 - 状态保持改进方案 | ||
| 2 | + | ||
| 3 | +## 改进背景 | ||
| 4 | + | ||
| 5 | +当前实现在带 Tab 的加载列表(如搜索页面)中存在以下问题: | ||
| 6 | + | ||
| 7 | +1. **分页状态无法分别跟踪**:使用单一的 `currentPage` ref,切换 tab 时会重置页码 | ||
| 8 | +2. **滚动位置无法保持**:使用页面级滚动,切换 tab 后滚动位置会丢失 | ||
| 9 | + | ||
| 10 | +用户期望的行为: | ||
| 11 | +- 在一个列表中滚动到某个位置(比如第 3 页) | ||
| 12 | +- 点击另一个 tab | ||
| 13 | +- 再切换回原来的 tab | ||
| 14 | +- 期望列表保持原来的滚动位置和分页状态,继续往下滚动时能从原来的位置加载 | ||
| 15 | + | ||
| 16 | +## 当前实现的问题 | ||
| 17 | + | ||
| 18 | +### ❌ 问题 1:分页状态无法分别跟踪 | ||
| 19 | + | ||
| 20 | +```javascript | ||
| 21 | +// src/pages/search/index.vue:168 | ||
| 22 | +const currentPage = ref(0) // 单一页码,无法分别跟踪两个tab | ||
| 23 | + | ||
| 24 | +// 切换 tab 时重置 | ||
| 25 | +const onTabClick = async (tabId) => { | ||
| 26 | + // ... | ||
| 27 | + currentPage.value = 0 // ❌ 重置为0,丢失了原来的页码 | ||
| 28 | +} | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +**影响**: | ||
| 32 | +- 产品列表滚动到第 3 页 | ||
| 33 | +- 切换到资料列表 | ||
| 34 | +- 再切回产品列表 | ||
| 35 | +- 页码变成 0,需要重新从第 1 页开始 | ||
| 36 | + | ||
| 37 | +### ❌ 问题 2:滚动位置无法保持 | ||
| 38 | + | ||
| 39 | +LoadMoreList 组件使用的是**页面级滚动**,而非 `scroll-view` 组件: | ||
| 40 | + | ||
| 41 | +```vue | ||
| 42 | +<!-- src/components/LoadMoreList/index.vue:39 --> | ||
| 43 | +<view class="load-more-content"> | ||
| 44 | + <!-- 列表内容 --> | ||
| 45 | +</view> | ||
| 46 | +``` | ||
| 47 | + | ||
| 48 | +**影响**: | ||
| 49 | +- 列表滚动到某个位置 | ||
| 50 | +- 切换 tab(列表重新渲染) | ||
| 51 | +- 页面滚动位置会重置到顶部 | ||
| 52 | + | ||
| 53 | +### ✅ 问题 3:数据可以保持 | ||
| 54 | + | ||
| 55 | +列表数据本身会被保留: | ||
| 56 | + | ||
| 57 | +```javascript | ||
| 58 | +// src/pages/search/index.vue:159-162 | ||
| 59 | +const products = ref([]) // 产品列表数据 | ||
| 60 | +const files = ref([]) // 资料列表数据 | ||
| 61 | +``` | ||
| 62 | + | ||
| 63 | +但这还不够,因为分页状态和滚动位置都丢失了。 | ||
| 64 | + | ||
| 65 | +## 改进方案 | ||
| 66 | + | ||
| 67 | +### 核心思路 | ||
| 68 | + | ||
| 69 | +1. **为每个 tab 维护独立的分页状态**(page、hasMore) | ||
| 70 | +2. **使用 scroll-view 替代页面级滚动**,并保存每个 tab 的滚动位置 | ||
| 71 | +3. **切换 tab 时保存和恢复滚动位置** | ||
| 72 | +4. **切换 tab 时不重新请求已有数据** | ||
| 73 | + | ||
| 74 | +### 实现代码 | ||
| 75 | + | ||
| 76 | +#### 1. 状态管理改进 | ||
| 77 | + | ||
| 78 | +```javascript | ||
| 79 | +// ❌ 原来的实现(单一状态) | ||
| 80 | +// const currentPage = ref(0) | ||
| 81 | +// const hasMore = ref(true) | ||
| 82 | + | ||
| 83 | +// ✅ 改进方案:为每个 tab 维护独立的状态 | ||
| 84 | +const tabState = ref({ | ||
| 85 | + product: { | ||
| 86 | + page: 0, | ||
| 87 | + hasMore: true, | ||
| 88 | + scrollTop: 0 // 保存滚动位置 | ||
| 89 | + }, | ||
| 90 | + file: { | ||
| 91 | + page: 0, | ||
| 92 | + hasMore: true, | ||
| 93 | + scrollTop: 0 | ||
| 94 | + } | ||
| 95 | +}) | ||
| 96 | + | ||
| 97 | +// 当前的 page 和 hasMore 从 tabState 中获取 | ||
| 98 | +const currentPage = computed(() => { | ||
| 99 | + return activeTab.value ? tabState.value[activeTab.value].page : 0 | ||
| 100 | +}) | ||
| 101 | + | ||
| 102 | +const hasMore = computed(() => { | ||
| 103 | + return activeTab.value ? tabState.value[activeTab.value].hasMore : true | ||
| 104 | +}) | ||
| 105 | +``` | ||
| 106 | + | ||
| 107 | +#### 2. LoadMoreList 组件改进 | ||
| 108 | + | ||
| 109 | +```vue | ||
| 110 | +<template> | ||
| 111 | + <view class="load-more-list" :class="{ 'has-header': showHeader }"> | ||
| 112 | + <!-- 可选固定头部 --> | ||
| 113 | + <view v-if="showHeader" class="load-more-header sticky top-0 z-10"> | ||
| 114 | + <slot name="header"></slot> | ||
| 115 | + </view> | ||
| 116 | + | ||
| 117 | + <!-- 使用 scroll-view 替代普通 view --> | ||
| 118 | + <scroll-view | ||
| 119 | + class="load-more-content" | ||
| 120 | + :class="{ 'no-padding': noPadding }" | ||
| 121 | + :scroll-top="scrollTop" | ||
| 122 | + scroll-y | ||
| 123 | + @scroll="handleScroll" | ||
| 124 | + @scrolltolower="handleScrollToLower" | ||
| 125 | + > | ||
| 126 | + <!-- 列表内容(保持不变) --> | ||
| 127 | + <view v-if="loading && list.length === 0"> | ||
| 128 | + <!-- loading --> | ||
| 129 | + </view> | ||
| 130 | + | ||
| 131 | + <view v-else class="list-container"> | ||
| 132 | + <view | ||
| 133 | + v-for="(item, index) in displayList" | ||
| 134 | + :key="item[keyField] || index" | ||
| 135 | + class="list-item" | ||
| 136 | + > | ||
| 137 | + <slot name="item" :item="item" :index="index"></slot> | ||
| 138 | + </view> | ||
| 139 | + | ||
| 140 | + <!-- 空状态和加载更多提示 --> | ||
| 141 | + </view> | ||
| 142 | + </scroll-view> | ||
| 143 | + </view> | ||
| 144 | +</template> | ||
| 145 | + | ||
| 146 | +<script setup> | ||
| 147 | +import { computed } from 'vue' | ||
| 148 | +import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro' | ||
| 149 | + | ||
| 150 | +const props = defineProps({ | ||
| 151 | + // ... 原有 props | ||
| 152 | + scrollTop: { | ||
| 153 | + type: Number, | ||
| 154 | + default: 0 | ||
| 155 | + } | ||
| 156 | +}) | ||
| 157 | + | ||
| 158 | +const emit = defineEmits(['load-more', 'refresh', 'scroll']) | ||
| 159 | + | ||
| 160 | +/** | ||
| 161 | + * scroll-view 滚动事件 | ||
| 162 | + * @param {Object} e - 滚动事件对象 | ||
| 163 | + */ | ||
| 164 | +const handleScroll = (e) => { | ||
| 165 | + emit('scroll', e) | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +/** | ||
| 169 | + * scroll-view 触底事件 | ||
| 170 | + */ | ||
| 171 | +const handleScrollToLower = () => { | ||
| 172 | + // 如果正在加载或没有更多数据,不执行 | ||
| 173 | + if (props.loadingMore || props.loading || !props.hasMore) { | ||
| 174 | + return | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1) | ||
| 178 | + const nextPage = props.page + 1 | ||
| 179 | + emit('load-more', nextPage) | ||
| 180 | +} | ||
| 181 | + | ||
| 182 | +// ... 其他代码保持不变 | ||
| 183 | +</script> | ||
| 184 | + | ||
| 185 | +<style lang="less"> | ||
| 186 | +.load-more-content { | ||
| 187 | + // 设置固定高度,使 scroll-view 正常工作 | ||
| 188 | + height: calc(100vh - 200rpx); | ||
| 189 | + padding: 32rpx; | ||
| 190 | + | ||
| 191 | + &.no-padding { | ||
| 192 | + padding: 0; | ||
| 193 | + } | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +// ... 其他样式保持不变 | ||
| 197 | +</style> | ||
| 198 | +``` | ||
| 199 | + | ||
| 200 | +#### 3. 搜索页面改进 | ||
| 201 | + | ||
| 202 | +```javascript | ||
| 203 | +// scroll-view 的滚动位置 | ||
| 204 | +const scrollViewScrollTop = ref(0) | ||
| 205 | + | ||
| 206 | +/** | ||
| 207 | + * scroll-view 滚动事件 | ||
| 208 | + * @param {Object} e - 滚动事件对象 | ||
| 209 | + */ | ||
| 210 | +const handleScroll = (e) => { | ||
| 211 | + if (!activeTab.value) return | ||
| 212 | + | ||
| 213 | + // 保存当前 tab 的滚动位置 | ||
| 214 | + tabState.value[activeTab.value].scrollTop = e.detail.scrollTop | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +/** | ||
| 218 | + * Tab 点击处理(改进版) | ||
| 219 | + * @param {string} tabId - Tab ID | ||
| 220 | + */ | ||
| 221 | +const onTabClick = async (tabId) => { | ||
| 222 | + if (activeTab.value === tabId) return | ||
| 223 | + | ||
| 224 | + // 保存当前 tab 的滚动位置(在切换前) | ||
| 225 | + if (activeTab.value) { | ||
| 226 | + tabState.value[activeTab.value].scrollTop = scrollViewScrollTop.value | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + // 切换 tab | ||
| 230 | + activeTab.value = tabId | ||
| 231 | + | ||
| 232 | + // 恢复新 tab 的滚动位置 | ||
| 233 | + scrollViewScrollTop.value = tabState.value[tabId].scrollTop | ||
| 234 | + | ||
| 235 | + // 如果没有搜索过,不执行 | ||
| 236 | + if (!hasSearched.value || !searchKeyword.value.trim()) { | ||
| 237 | + return | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + // 检查当前 tab 是否已有数据,如果有则不重新请求 | ||
| 241 | + const currentData = activeTab.value === 'product' ? products.value : files.value | ||
| 242 | + if (currentData.length > 0) { | ||
| 243 | + console.log('[Search] Tab 已有数据,不重新请求') | ||
| 244 | + return | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + // 如果没有数据,则请求 | ||
| 248 | + console.log('[Search] Tab 无数据,发起请求:', tabId, '页码:', tabState.value[tabId].page) | ||
| 249 | + await performSearch( | ||
| 250 | + searchKeyword.value.trim(), | ||
| 251 | + tabId, | ||
| 252 | + tabState.value[tabId].page, | ||
| 253 | + pageSize, | ||
| 254 | + false | ||
| 255 | + ) | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +/** | ||
| 259 | + * 处理加载更多事件 | ||
| 260 | + * @param {number} page - 下一页页码 | ||
| 261 | + */ | ||
| 262 | +const handleLoadMore = async (page) => { | ||
| 263 | + console.log('[Search] 加载更多,tab:', activeTab.value, '页码:', page) | ||
| 264 | + | ||
| 265 | + if (!activeTab.value) return | ||
| 266 | + | ||
| 267 | + // 更新当前 tab 的 page | ||
| 268 | + tabState.value[activeTab.value].page = page | ||
| 269 | + | ||
| 270 | + // 如果没有搜索过或没有选中 tab,不执行 | ||
| 271 | + if (!hasSearched.value || !activeTab.value || !searchKeyword.value.trim()) { | ||
| 272 | + return | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + // 加载下一页数据 | ||
| 276 | + await performSearch( | ||
| 277 | + searchKeyword.value.trim(), | ||
| 278 | + activeTab.value, | ||
| 279 | + page, | ||
| 280 | + pageSize, | ||
| 281 | + true // 标记为加载更多 | ||
| 282 | + ) | ||
| 283 | +} | ||
| 284 | +``` | ||
| 285 | + | ||
| 286 | +## 改进效果 | ||
| 287 | + | ||
| 288 | +### ✅ 改进后 | ||
| 289 | + | ||
| 290 | +1. **分页状态独立**:每个 tab 都有自己的 page 和 hasMore | ||
| 291 | +2. **滚动位置保持**:切换 tab 后再切回,滚动位置会恢复到原来的位置 | ||
| 292 | +3. **不重复请求**:如果 tab 已经有数据,切换时不会重新请求 | ||
| 293 | +4. **无缝体验**:用户可以在不同 tab 之间自由切换,状态完全保持 | ||
| 294 | + | ||
| 295 | +## 使用场景 | ||
| 296 | + | ||
| 297 | +这个改进方案适用于所有带 Tab 的加载列表场景: | ||
| 298 | + | ||
| 299 | +- 搜索页面(产品/资料) | ||
| 300 | +- 订单列表(待付款/待发货/已完成) | ||
| 301 | +- 消息列表(系统消息/订单消息) | ||
| 302 | +- 任何带 Tab 的分页列表 | ||
| 303 | + | ||
| 304 | +## 实施优先级 | ||
| 305 | + | ||
| 306 | +⚠️ **当前不实施**:客户还未提出明确需求,暂时作为技术储备保存。 | ||
| 307 | + | ||
| 308 | +📝 **何时需要实施**: | ||
| 309 | +- 用户反馈切换 tab 时状态丢失 | ||
| 310 | +- 产品需求明确要求保持 tab 状态 | ||
| 311 | +- 需要提升用户体验时 | ||
| 312 | + | ||
| 313 | +## 参考文件 | ||
| 314 | + | ||
| 315 | +- 搜索页面:`src/pages/search/index.vue` | ||
| 316 | +- LoadMoreList 组件:`src/components/LoadMoreList/index.vue` |
| ... | @@ -110,7 +110,7 @@ function generateWeekHotItem(id) { | ... | @@ -110,7 +110,7 @@ function generateWeekHotItem(id) { |
| 110 | return { | 110 | return { |
| 111 | meta_id: id, | 111 | meta_id: id, |
| 112 | name: `${materialName} ${fileType.name.toUpperCase()}`, | 112 | name: `${materialName} ${fileType.name.toUpperCase()}`, |
| 113 | - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, | 113 | + src: `https://picsum.photos/seed/material-${id}-${fileType.extension}/100/100`, |
| 114 | size: generateRandomSize(), | 114 | size: generateRandomSize(), |
| 115 | read_people_count: generateRandomReadCount(), | 115 | read_people_count: generateRandomReadCount(), |
| 116 | read_people_percent: generateRandomReadPercent(), | 116 | read_people_percent: generateRandomReadPercent(), |
| ... | @@ -188,10 +188,10 @@ function generateMaterialItem(id) { | ... | @@ -188,10 +188,10 @@ function generateMaterialItem(id) { |
| 188 | size: generateRandomSize(), | 188 | size: generateRandomSize(), |
| 189 | extension: fileType.extension, | 189 | extension: fileType.extension, |
| 190 | collected: generateRandomFavorite() === '1', | 190 | collected: generateRandomFavorite() === '1', |
| 191 | - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, | 191 | + src: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`, |
| 192 | - downloadUrl: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, | 192 | + downloadUrl: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100`, |
| 193 | post_date: new Date().toISOString(), | 193 | post_date: new Date().toISOString(), |
| 194 | - value: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}` | 194 | + value: `https://picsum.photos/seed/file-${id}-${fileType.extension}/100/100` |
| 195 | } | 195 | } |
| 196 | } | 196 | } |
| 197 | 197 | ||
| ... | @@ -302,7 +302,7 @@ function generateProductItem(id) { | ... | @@ -302,7 +302,7 @@ function generateProductItem(id) { |
| 302 | id: id, | 302 | id: id, |
| 303 | product_name: productName, | 303 | product_name: productName, |
| 304 | name: productName, | 304 | name: productName, |
| 305 | - cover_image: `https://placehold.co/400x300/4caf50/ffffff?text=${encodeURIComponent(productName.substring(0, 6))}`, | 305 | + cover_image: `https://picsum.photos/seed/product-${id}/400/300`, |
| 306 | recommend: recommend, | 306 | recommend: recommend, |
| 307 | tags: tags, | 307 | tags: tags, |
| 308 | description: '这是一款优质的保险产品,为您的家庭提供全面保障...', | 308 | description: '这是一款优质的保险产品,为您的家庭提供全面保障...', |
| ... | @@ -634,7 +634,7 @@ function generateFavoriteItem(id) { | ... | @@ -634,7 +634,7 @@ function generateFavoriteItem(id) { |
| 634 | meta_id: id, | 634 | meta_id: id, |
| 635 | name: `${materialName}.${fileType.extension}`, | 635 | name: `${materialName}.${fileType.extension}`, |
| 636 | size: generateRandomSize(), | 636 | size: generateRandomSize(), |
| 637 | - src: `https://placehold.co/100x100/e2e8f0/475569?text=${fileType.extension.toUpperCase()}`, | 637 | + src: `https://picsum.photos/seed/favorite-${id}-${fileType.extension}/100/100`, |
| 638 | created_time: formatDate(createDate) | 638 | created_time: formatDate(createDate) |
| 639 | } | 639 | } |
| 640 | } | 640 | } |
| ... | @@ -723,7 +723,7 @@ function generateFeedbackItem(id) { | ... | @@ -723,7 +723,7 @@ function generateFeedbackItem(id) { |
| 723 | if (hasImages) { | 723 | if (hasImages) { |
| 724 | const imageCount = Math.floor(Math.random() * 3) + 1 | 724 | const imageCount = Math.floor(Math.random() * 3) + 1 |
| 725 | for (let i = 0; i < imageCount; i++) { | 725 | for (let i = 0; i < imageCount; i++) { |
| 726 | - images.push(`https://placehold.co/200x200/f3f4f6/9ca3af?text=截图${i + 1}`) | 726 | + images.push(`https://picsum.photos/seed/feedback-${id}-${i}/200/200`) |
| 727 | } | 727 | } |
| 728 | } | 728 | } |
| 729 | 729 | ... | ... |
-
Please register or login to post a comment