太长不看版
问题:切换筛选项时,如果不滚动页面,loading 会一直显示,但滚动后再切换就正常。
原因:
processingData判断逻辑有问题:当数据量刚好等于 pageSize 时,错误地判断finished = false- van-list 的 watch 机制:当
finished从false变成false时不会触发加载 - 两个问题叠加导致切换筛选后没有触发数据加载
解决方案:修改 processingData 的判断逻辑,用 累积数据量 >= total 代替 list.length < pageSize
背景
最近在做会员详情页,有个余额明细的列表,可以切换筛选项(全部/充值/消费)。看起来很简单的功能,结果遇到了一个莫名其妙的问题。
项目技术栈:
- Vue 3 + Vant 4
- 分页加载用的 van-list 组件
问题现象:
- 进入页面,默认显示"全部"筛选的数据(10 条,total=10)
- 不滑动页面,直接点击"充值"或"消费"筛选,一直 loading,没有数据
- 但如果先滑动页面,再切换筛选,正常
第一次遇到这种问题,完全摸不着头脑。为什么滑不滑动页面会有区别?
临时修复
因为要上线,我需要快速解决这个问题。看了代码发现切换筛选时会调用 resetListParams():
const resetListParams = () => {
nextTick(() => {
state.list = []
queryParams.page = 1
scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
state.finished = false
})
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
}
我猜测可能是因为没有触发数据加载,于是加了一行手动调用 getList():
const resetListParams = () => {
nextTick(() => {
state.list = []
queryParams.page = 1
scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
state.finished = false
getList() // 加了这一行
})
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
}
结果:能用,切换筛选正常了。
但我心里还是觉得不踏实:为什么原本的代码不行?滑动页面后就正常?其他用 van-list 的地方会不会也有这个问题?
深入排查
上线后,我打算搞清楚这个问题。
第一步:重现问题
把之前加的 getList() 注释掉,重新测试:
const resetListParams = () => {
nextTick(() => {
state.list = []
queryParams.page = 1
scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
state.finished = false
// getList() // 注释掉,重现问题
})
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
}
问题重现:
- 不滑动页面,直接切换筛选,一直 loading
- 滑动页面后,再切换筛选,正常
第二步:确认请求没发起
打开开发者工具,Network 面板里确实没有新的请求。所以问题不是请求失败,而是根本没发起请求。
第三步:分析为什么滑动就正常
看一下 van-list 的用法:
<van-list
v-model:loading="state.listLoading"
:finished="state.finished"
:offset="30"
@load="getList"
:finished-text="state.list?.length ? '加载完成' : ''"
>
<div
class="member-item"
v-for="item in state.list"
:key="item.orderNo"
>
<!-- 列表项内容 -->
</div>
</van-list>
我知道 van-list 会在滚动到底部时触发 @load 事件加载更多数据。
但现在的问题是:切换筛选后,我已经在 resetListParams() 里设置了 state.finished = false,按理说 van-list 应该要重新加载数据才对。为什么不滚动的话,@load 就不会触发呢?难道 van-list 只能通过滚动来触发加载,没有其他方式吗?
我开始怀疑是 finished 状态的问题。
第四步:检查 finished 的判断逻辑
第一次加载数据的代码:
CustomerApiFetch.customerCardBalanceChangeListPost(params)
.then((res) => {
const { total, list } = res // total=10, list.length=10
const { data, finished } = processingData(state.list, list, queryParams)
state.list = [...data]
state.total = total
state.finished = finished
})
processingData 是项目的公共 hook,用于处理分页数据:
export const processingData = (data, list, param) => {
const newData = param.page === 1 ? list : [...data, ...list]
const finished = list.length < param?.pageSize // 判断逻辑
if (!finished) {
param.page++
}
return { data: newData, finished }
}
发现问题!
当第一次加载时:
list.length = 10(返回 10 条数据)param.pageSize = 10finished = 10 < 10 = false(错误)
但实际上,total 也是 10,说明数据已经全部加载完了,finished 应该是 true 才对!
第五步:理解 van-list 的触发机制
现在我明白了问题的一半:finished 被错误地判断为 false。
但还有一个疑问:为什么滑动页面后就正常了?
我去看了 van-list 的源码(node_modules/vant/lib/list/List.js):
// 第 124 行:监听 props 变化
(0, import_vue.watch)(() => [props.loading, props.finished, props.error], check);
// 第 143 行:监听滚动事件
(0, import_use.useEventListener)("scroll", check, {
target: scroller,
passive: true
});
原来 van-list 有两种触发 check() 的方式:
- watch 监听 props 变化:当
finished、loading、error改变时 - scroll 事件监听:用户滚动时
再看 check() 函数的实现(第 58-85 行):
const check = () => {
(0, import_vue.nextTick)(() => {
if (loading.value || props.finished || props.disabled || props.error ||
(tabStatus == null ? void 0 : tabStatus.value) === false) {
return; // 如果 finished=true,直接返回
}
// 计算是否滚动到边缘
// ...
if (isReachEdge) {
loading.value = true;
emit("update:loading", true);
emit("load"); // 触发 @load 事件
}
});
};
现在全部串起来了!
第六步:对比两种情况
情况 1:滑动页面后切换(正常)
1. 第一次加载
├─ 返回 10 条数据,total=10
├─ processingData 判断:list.length(10) < pageSize(10) = false
└─ state.finished = false(错误判断)
2. 用户滑动页面
├─ 触发 scroll 事件
├─ van-list 调用 check()
├─ 检测到滚动到底部
└─ 触发 @load
3. 第二次加载(page=2)
├─ 返回空数组(因为只有 10 条数据)
├─ processingData 判断:list.length(0) < pageSize(10) = true
└─ state.finished = true(被意外纠正了)
4. 用户切换筛选
├─ resetListParams() 设置 state.finished = false
├─ finished: true 变成 false(状态改变了!)
├─ van-list 的 watch 被触发
└─ 自动触发 @load
情况 2:不滑动直接切换(问题)
1. 第一次加载
├─ 返回 10 条数据,total=10
├─ processingData 判断:list.length(10) < pageSize(10) = false
└─ state.finished = false(错误判断)
2. 用户直接切换筛选(没有滚动)
├─ resetListParams() 设置 state.finished = false
├─ finished: false 变成 false(状态没变!)
├─ van-list 的 watch 不触发(Vue watch 机制:值没变就不触发)
├─ scroll 事件也没有(用户没滚动)
└─ 没有任何方式触发 @load
3. 结果
├─ showLoadingToast() 已经显示
├─ 但没有请求发起
└─ loading 永远不会关闭
搞明白了
根本原因
问题有两个层面:
1. processingData 的判断逻辑有缺陷
const finished = list.length < param?.pageSize
这个判断在以下情况下是错误的:
| 场景 | list.length | pageSize | total | 实际状态 | 判断结果 | 是否正确 |
|---|---|---|---|---|---|---|
| 还有数据 | 10 | 10 | 30 | 未完成 | false | 正确 |
| 刚好加载完 | 10 | 10 | 10 | 已完成 | false | 错误 |
| 最后一页不足 | 5 | 10 | 15 | 已完成 | true | 正确 |
当返回的数据量刚好等于 pageSize,且已经是全部数据时,finished 会被错误地判断为 false。
2. van-list 的触发机制
van-list 的 @load 有两种触发方式:
| 触发方式 | 触发条件 | 使用场景 |
|---|---|---|
| watch 监听 | finished、loading、error 状态改变 | 状态切换(true 和 false 互相切换) |
| scroll 事件 | 用户滚动到底部 | 正常的分页加载 |
当 finished 从 false 变成 false 时:
- watch 不会触发(Vue 的 watch 机制,值没变就不触发)
- scroll 也不会触发(用户没滚动)
- 结果:没有任何方式触发 @load
3. 两个问题叠加
processingData 错误判断
|
v
finished = false(应该是 true)
|
v
用户切换筛选
|
v
resetListParams 设置 finished = false
|
v
finished: false 变成 false(状态没变)
|
v
van-list 的 watch 不触发
|
v
没有 scroll 事件
|
v
@load 不触发
|
v
一直 loading
解决方案对比
方案 1:手动调用 getList()(临时方案)
const resetListParams = () => {
nextTick(() => {
state.list = []
queryParams.page = 1
state.finished = false
getList() // 手动调用
})
showLoadingToast({ ... })
}
优点:
- 快速修复,立即上线
缺点:
- 治标不治本,finished 的判断还是错的
- 其他 10 个使用
processingData的页面也有同样的问题
适用场景:紧急上线,先解决问题
方案 2:修复 processingData 判断逻辑
修改 src/hooks/processingData.ts:
/**
* @function 处理分页数据
* @param { Array } data 保存的数据
* @param { Array } list 接口请求回来的数据
* @param { Object } param 请求接口的分页数据
* @param { Number } total 数据总数
* @return { data } 处理后的数据
* @return { finished } 数据是否全部请求完
*/
export const processingData = (data, list, param, total) => {
const newData = param.page === 1 ? list : [...data, ...list]
const finished = newData.length >= total // 使用累积数据量判断
if (!finished) {
param.page++
}
return { data: newData, finished }
}
然后更新所有 10 个调用的文件,传入 total 参数:
// 修改前
const { data, finished } = processingData(state.list, list, queryParams)
// 修改后
const { data, finished } = processingData(state.list, list, queryParams, total)
优点:
- 从根源解决问题
- 所有使用分页加载的页面都受益
- finished 状态永远准确
- 不需要在 resetListParams 里手动调用 getList()
缺点:
- 需要修改 10 个文件
- 需要测试所有相关页面
适用场景:彻底解决问题,消除技术债
方案 3:强制触发 watch(hack 方案)
const resetListParams = () => {
nextTick(() => {
state.list = []
queryParams.page = 1
// 先设置为 true,再设置为 false,强制触发 watch
state.finished = true
nextTick(() => {
state.finished = false // true 变成 false,触发 van-list 的 watch
})
})
showLoadingToast({ ... })
}
优点:
- 不需要改 processingData
- 不需要改其他文件
- 利用了 van-list 的 watch 机制
缺点:
- 非常 hack,不优雅
- finished 的判断还是错的
- 状态闪烁(true 变成 false)可能有副作用
最终选择
我选择方案 2:修复 processingData 判断逻辑。
踩坑总结
-
公共 hook 的判断逻辑要严谨
list.length < pageSize看起来对,但有边界情况- 应该用
累积数据量 >= total来判断 - 边界情况很容易被忽略
-
了解组件库的触发机制很重要
- 不要只会用,要知道原理
- van-list 的 watch + scroll 两种触发方式
- 状态改变和滚动事件的区别
-
临时方案要知道只是临时的
- 昨晚加 getList() 是为了上线
- 但不能一直用临时方案
- 要找时间深入研究,彻底解决
一些想法
昨晚为了赶上线,我就直接加了 getList() 就完事了。当时就想着"能用就行",但心里总觉得哪里不对。
今天重新看这个问题,发现还挺有意思的:
- processingData 的判断逻辑有问题
- van-list 的 watch 机制
- 两个问题叠加就出现了
要不是"滑动就正常"这个线索,我估计还得调试更久。就是这个奇怪的现象让我发现,滑动前后 finished 的状态不一样,顺着这个思路才找到根本原因。
还有就是看源码真的有用。之前我就只会用 van-list,知道有 finished 和 @load,但完全不知道它内部怎么工作的。看了源码才明白 watch 和 scroll 两种触发方式,也搞清楚了为什么 false 变成 false 不会触发。
关于临时方案和彻底修复,我觉得都需要吧。昨晚的临时方案让我能按时上线,今天的深入研究让我理解了问题本质。不能因为有临时方案就不去研究,也不能因为追求完美就一直不上线。