阅读视图

发现新文章,点击刷新页面。

深挖 van-list:一次分页加载问题的完整排查

太长不看版

问题:切换筛选项时,如果不滚动页面,loading 会一直显示,但滚动后再切换就正常。

原因

  1. processingData 判断逻辑有问题:当数据量刚好等于 pageSize 时,错误地判断 finished = false
  2. van-list 的 watch 机制:当 finishedfalse 变成 false 时不会触发加载
  3. 两个问题叠加导致切换筛选后没有触发数据加载

解决方案:修改 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 = 10
  • finished = 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() 的方式

  1. watch 监听 props 变化:当 finishedloadingerror 改变时
  2. 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 监听 finishedloadingerror 状态改变 状态切换(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 判断逻辑


踩坑总结

  1. 公共 hook 的判断逻辑要严谨

    • list.length < pageSize 看起来对,但有边界情况
    • 应该用 累积数据量 >= total 来判断
    • 边界情况很容易被忽略
  2. 了解组件库的触发机制很重要

    • 不要只会用,要知道原理
    • van-list 的 watch + scroll 两种触发方式
    • 状态改变和滚动事件的区别
  3. 临时方案要知道只是临时的

    • 昨晚加 getList() 是为了上线
    • 但不能一直用临时方案
    • 要找时间深入研究,彻底解决

一些想法

昨晚为了赶上线,我就直接加了 getList() 就完事了。当时就想着"能用就行",但心里总觉得哪里不对。

今天重新看这个问题,发现还挺有意思的:

  • processingData 的判断逻辑有问题
  • van-list 的 watch 机制
  • 两个问题叠加就出现了

要不是"滑动就正常"这个线索,我估计还得调试更久。就是这个奇怪的现象让我发现,滑动前后 finished 的状态不一样,顺着这个思路才找到根本原因。

还有就是看源码真的有用。之前我就只会用 van-list,知道有 finished@load,但完全不知道它内部怎么工作的。看了源码才明白 watch 和 scroll 两种触发方式,也搞清楚了为什么 false 变成 false 不会触发。

关于临时方案和彻底修复,我觉得都需要吧。昨晚的临时方案让我能按时上线,今天的深入研究让我理解了问题本质。不能因为有临时方案就不去研究,也不能因为追求完美就一直不上线。

我整理了一份 Vue 性能优化指南(给AI用的)

为什么做这个

说实话,这个项目是我自己用的。

工作这几年,遇到的性能问题基本都是类似的坑:接口瀑布流、bundle 越来越大、响应式乱用。每次踩完坑修好了,过段时间换个项目又踩一遍。

后来想着,干脆整理一份文档,自己查方便,也能给 AI 编码助手看(我现在用 Claude Code),这样审代码的时候能提前发现问题。

整理完发现,好像也可以分享出来,说不定有人也遇到过这些问题。

我踩过的坑

这几年写 Vue 项目(Vue 2/3 + Nuxt 都有),踩过不少坑:

接口请求变成瀑布流 一个 await 接一个 await,明明能并行的请求硬是串行了。用户抱怨页面慢,一查发现 3 个接口排队等了 750ms。

bundle 体积失控 每次加需求就往里塞代码,没人关心打包结果。等到首屏白屏 3 秒了,才发现 JavaScript 已经 300KB+。

响应式系统滥用 大对象直接 ref(),上千条商品数据,每个字段都变成响应式。渲染一卡一卡的,还以为是组件写得不好。

这些问题不是什么高深的优化,就是基本功。但忙起来就容易忽略,等出问题再改成本就高了。

怎么说呢,优化要分轻重

我发现很多人(包括以前的我)做性能优化会搞错重点。

举个例子:页面有 600ms 的请求等待时间,结果花一周优化 computed 缓存。首屏加载了 300KB 的 JavaScript,结果去优化循环少跑几次。

其实应该先解决大问题:

  1. 先干掉请求瀑布流 - 能并行就并行,该预加载就预加载
  2. 再砍 bundle 体积 - 代码分割、动态导入、tree-shaking
  3. 然后才是组件和响应式优化 - 减少不必要的渲染

我按这个思路把规则分成了 10 个类别,从 CRITICALLOW,总共 46 条。先把影响大的问题解决了,那些微优化可以慢慢来。

里面有什么

10 个类别,46 条规则:

  • 消除异步瀑布流(CRITICAL)
  • 包体积优化(CRITICAL)
  • 服务端性能(HIGH)
  • 客户端数据获取(HIGH)
  • 响应式系统优化(MEDIUM-HIGH)
  • 渲染性能(MEDIUM)
  • Vue 2 特定优化(MEDIUM)
  • Vue 3 特定优化(MEDIUM)
  • JavaScript 性能(LOW-MEDIUM)
  • 高级模式(LOW)

每条规则的格式:

  • 影响等级(CRITICAL / HIGH / MEDIUM / LOW)
  • 错误示例(我以前写过的错误代码)
  • 正确示例(后来改成什么样)
  • Vue 2/3 兼容性说明

举几个我踩过的坑

坑 1:不需要的 await 也在阻塞代码

以前写过这样的代码:

async function handleRequest(userId: string, skipProcessing: boolean) {
  // 即使 skipProcessing=true,也会等待 userData
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    // 立即返回,但前面已经浪费时间等待了
    return { skipped: true }
  }

  // 只有这个分支使用 userData
  return processUserData(userData)
}

问题是,即使 skipProcessing=true,还是会去请求 userData。白白浪费时间。

后来改成这样:

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    return { skipped: true }
  }

  // 只在需要时才获取数据
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

其实很简单,但之前就是没注意到。

坑 2:大对象别直接用 ref

1000 条商品数据,每条 10+ 个字段,以前直接 ref()

<script setup lang="ts">
import { ref } from 'vue'

// 1000 个商品,每个商品 10+ 字段,全部变成响应式
const products = ref<Product[]>([])

async function loadProducts() {
  products.value = await fetchProducts()
  // Vue 会递归遍历所有对象,添加响应式代理
}
</script>

渲染的时候卡得要命。后来发现应该用 shallowRef

<script setup lang="ts">
import { shallowRef } from 'vue'

// 只有数组本身是响应式的,内部对象保持普通对象
const products = shallowRef<Product[]>([])

async function loadProducts() {
  // 替换整个数组触发更新,无需深度响应式
  products.value = await fetchProducts()
}
</script>

shallowRef 只让数组本身响应式,内部对象保持普通对象。更新时替换整个数组就能触发响应,省了大量性能开销。

几个真实案例(我遇到过的)

案例 1:别对同一个数组循环多次

之前接手一个项目,发现同一个商品列表循环了 5 次:

// 错误:5 次独立遍历
const discounted = products.filter(p => p.discount > 0)
const inStock = products.filter(p => p.stock > 0)
const featured = products.filter(p => p.featured)
const totalValue = products.reduce((sum, p) => sum + p.price, 0)
const avgPrice = totalValue / products.length

看着就难受。后来改成一次循环:

// 正确:一次遍历
const stats = products.reduce((acc, product) => {
  if (product.discount > 0) acc.discounted.push(product)
  if (product.stock > 0) acc.inStock.push(product)
  if (product.featured) acc.featured.push(product)
  acc.totalValue += product.price
  return acc
}, { discounted: [], inStock: [], featured: [], totalValue: 0 })

const avgPrice = stats.totalValue / products.length

商品少的时候看不出来,数据一多性能差距就很明显了。

案例 2:独立的请求不要排队

用户详情页,三个互不依赖的接口,结果在串行调用:

// 错误:串行:总耗时 = 300ms + 200ms + 250ms = 750ms
const user = await fetchUser(userId)          // 300ms
const posts = await fetchUserPosts(userId)    // 200ms
const comments = await fetchUserComments(userId) // 250ms

改成并行之后:

// 正确:并行:总耗时 = max(300ms, 200ms, 250ms) = 300ms
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchUserPosts(userId),
  fetchUserComments(userId)
])

总耗时从 750ms 降到 300ms,页面快了一半多。这种优化投入产出比最高。

案例 3:长列表用 CSS content-visibility

1000+ 条评论的页面,初始渲染很慢:

<!-- 错误:所有评论立即渲染 -->
<div v-for="comment in comments" :key="comment.id">
  <CommentCard :comment="comment" />
</div>

后来加上 content-visibility

<!-- 正确:浏览器跳过屏幕外的渲染 -->
<div
  v-for="comment in comments"
  :key="comment.id"
  class="comment-item"
>
  <CommentCard :comment="comment" />
</div>

<style>
.comment-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;
}
</style>

浏览器会跳过屏幕外的渲染,初始加载快了 5-10 倍,滚动也流畅多了。这个 CSS 属性真的好用。

怎么用

直接看

# 克隆仓库
git clone https://github.com/ursazoo/vue-best-practices.git

# 安装依赖
npm install

# 构建 AGENTS.md
npm run build

克隆下来,直接看 rules/ 目录下的规则文件。每个文件都是独立的,包含问题说明、代码示例和解决方案。

也可以看构建好的 AGENTS.md,把所有规则整合在一起,方便搜索。

集成到 AI 编码助手

如果你也在用 Claude Code、Cursor 这类 AI 工具写代码,可以集成进去:

npx add-skill vue-best-practices

AI 审查代码的时候,如果发现性能问题(比如请求瀑布流、过度响应式),会参考这些规则给出优化建议。我现在就是这么用的,挺方便。

Vue 2 还是 Vue 3

都支持。每条规则都标注了版本兼容性:

  • Vue 2 & 3 通用:基础的性能优化技巧
  • Vue 3 Only:用了 <script setup>shallowRefSuspense 等新特性
  • Vue 2 Only:针对 Vue 2 的特定优化(比如 Object.freeze()

老项目也能用,新项目能用得更充分。

项目地址

GitHub: github.com/ursazoo/vue…

欢迎贡献

这是个开源项目。如果你在生产环境踩过坑、有更好的优化方案,欢迎提 Issue 或 PR。

特别是:

  • 实际项目中遇到的性能问题
  • 现有规则的改进建议
  • Vue/Nuxt 新版本的优化技巧

项目信息

  • 46 条规则,10 个类别
  • 按影响程度排序(先解决大问题)
  • 支持 Vue 2/3 和 Nuxt
  • 适配 AI 编码助手

希望对你有帮助。

❌