uniapp 万条数据不卡顿:我写了个虚拟列表组件 hy-list,原生支持瀑布流

文章目录

上万条数据的列表页,你的页面还卡在滚动掉帧吗?

做电商、社交、资讯类小程序时,长列表性能几乎是每个前端绕不开的坑。数据量一大,DOM 节点堆积成百上千个,滚动直接卡成 PPT。微信自带的 scroll-view 没有虚拟化能力,vant 的 list 组件又只做了无限滚动,真正的 DOM 回收一个都没有。

我在 @hy-app/ui 组件库里写了个 hy-list 虚拟列表组件,专门解决 uniapp 端的长列表性能问题。这篇文章不讲虚的,直接说它解决了什么、怎么用、为什么比同类方案好。


官网:hy-list

示例图:

一、先说结论:它解决了什么问题

我在实际项目中踩过这几个坑,hy-list 逐个干掉了:

1. DOM 爆炸导致滚动卡顿

传统列表有多少数据就渲染多少 DOM。1000 条商品卡片 = 1000 个节点 + 1000 张图片请求。手机内存扛不住,GC 频繁触发,滑动帧率直接掉到 20fps 以下。

2. 瀑布流 + 虚拟滚动的组合几乎没人做

市面上的虚拟列表要么只支持单列等高,要么支持瀑布流但不是真虚拟化。电商场景双列瀑布流是刚需,但做了瀑布流就没法做虚拟滚动------因为列高不固定,你算不出哪些 item 在可视区。

3. 数据加载后 scrollTop 乱跳

这是最恶心的 bug。分页加载新数据后,虚拟列表的撑高容器突然变大,浏览器 scroll anchoring 机制自动补偿滚动位置,导致用户正在看的卡片突然飞走。很多虚拟列表组件都有这个毛病,只是作者没发现。

4. 滚动到底部加载更多不稳定

uniapp H5 端的 scroll-view 原生 scrolltolower 事件有个已知缺陷:用户滑到底部后如果 scrollTop 不再增长,事件就不触发。你得再往上滑一点再往下滑才能触发加载。体验很割裂。


二、hy-list 怎么解决的

核心思路:撑杆 + 绝对定位,彻底绕开 scroll anchoring

大部分虚拟列表用 padding-top + padding-bottom 撑出总高度。这有个致命问题------padding 是文档流的一部分,padding 变化时浏览器会触发 scroll anchoring,自动跳 scrollTop。

hy-list 换了个方案:

复制代码
撑杆(position: relative,height = totalHeight)
  └─ 内容容器(position: absolute,transform: translateY(offsetTop))
  └─ footer(position: absolute,top = totalHeight)

撑杆高度变化时,absolute 定位的内容和 footer 不参与文档流重排,浏览器找不到可 anchor 的内容,scrollTop 稳如老狗。

瀑布流按「行」统一计算

双列模式下,hy-list 把每两个 item 视为一「行」,所有高度计算(startIndex、endIndex、padding)都以行为单位。行号天然是偶数对齐,左右列不会错位。这比按单 item 累加高度再手动分配左右列靠谱得多。

手动触底检测 + emittedListLen 防连锁

不依赖原生的 scrolltolower(它在 H5 上不可靠),而是在 onScroll 里算 totalHeight - scrollTop - viewHeight,小于阈值就主动触发。加载完后用 emittedListLen 记录当前数据量,只有新数据真正到达才允许下一次触发,避免连锁加载。


三、和其他方案对比

能力 scroll-view 原生 vant list z-paging hy-list
DOM 虚拟化(回收不可见节点) 不支持 不支持 支持 支持
双列瀑布流 不支持 不支持 弱支持 原生支持
数据加载后 scrollTop 不跳 会跳 会跳 偶尔跳 不跳
滚动到底稳定触发加载 不稳定 稳定 稳定 稳定
全平台兼容(H5/小程序/App) 原生 部分 支持 支持
自定义渲染程度 完全自定义 仅列表 模板固定 4 种插槽模式

z-paging 是目前 uniapp 生态里最成熟的列表方案,但它的虚拟化是后来加的,瀑布流支持比较弱,而且 API 设计偏重(一个组件管了刷新、空页面、骨架屏,耦合度高)。hy-list 只做一件事------虚拟滚动,其他交给插槽。


四、安装

bash 复制代码
# pnpm
pnpm add @hy-app/ui

# npm
npm install @hy-app/ui

# yarn
yarn add @hy-app/ui

uniapp 项目需要在 pages.json 里配置 easycom(装完就自动注册,不用手动 import):

json 复制代码
{
  "easycom": {
    "autoscan": true,
    "custom": {
      "^hy-(.*)": "@hy-app/ui/components/hy-$1/hy-$1.vue"
    }
  }
}

五、四种用法,覆盖所有场景

用法 1:最简单的单列列表(默认插槽)

你拿到整页数据,自己决定怎么渲染。组件只负责虚拟化。

vue 复制代码
<hy-list
    :list="list"
    container-height="100%"
    :item-height="100"
    :margin-bottom="10"
    :load="loadStatus"
    @scroll-to-lower="loadMore"
>
    <template #default="{ record }">
        <view v-for="item in record" class="my-item" @click="onClick(item)">
            <image :src="item.avatar" />
            <text>{{ item.name }}</text>
        </view>
    </template>
</hy-list>

record 在单列模式下就是 visibleData(当前可视区的数组),双列模式下是 { left: [...], right: [...] }

用法 2:单列 + content 插槽(逐条渲染)

不想自己写 v-for,用 #content 插槽,组件帮你遍历:

vue 复制代码
<hy-list :list="list" :item-height="80" :load="loadStatus" @scroll-to-lower="loadMore">
    <template #content="{ record }">
        <view class="item">
            <text>{{ record.title }}</text>
            <text>{{ record.desc }}</text>
        </view>
    </template>
</hy-list>

用法 3:双列瀑布流(最常用)

电商商品列表标配。设 :line="2",用 #left#right 插槽:

vue 复制代码
<hy-list
    :list="list"
    :item-height="280"
    :margin-bottom="16"
    :line="2"
    :load="loadStatus"
    @scroll-to-lower="loadMore"
>
    <template #left="{ record }">
        <view class="product-card">
            <image :src="record.image" mode="aspectFill" />
            <text class="name">{{ record.name }}</text>
            <text class="price">¥{{ record.price }}</text>
        </view>
    </template>
    <template #right="{ record }">
        <view class="product-card">
            <image :src="record.image" mode="aspectFill" />
            <text class="name">{{ record.name }}</text>
            <text class="price">¥{{ record.price }}</text>
        </view>
    </template>
</hy-list>

组件自动按奇偶索引分配左右列,自动按行计算高度,左右对齐。

用法 4:双列瀑布流 + 自定义列模板(left-list / right-list)

如果你的左右列布局差异很大,或者需要整列级别的控制,用 #left-list#right-list

vue 复制代码
<hy-list :list="list" :line="2" :item-height="280" :load="loadStatus">
    <template #left-list="{ record }">
        <!-- record 是整个左列数组,你自己 v-for -->
        <view v-for="item in record" :key="item.id" class="card-special">
            <!-- 左列特殊布局 -->
        </view>
    </template>
    <template #right-list="{ record }">
        <view v-for="item in record" :key="item.id" class="card-normal">
            <!-- 右列常规布局 -->
        </view>
    </template>
</hy-list>

六、分页加载怎么写

这是配套的分页逻辑模板,直接抄:

ts 复制代码
import { ref, reactive, onMounted } from 'vue'

const list = ref<any[]>([])
const loadStatus = ref<'loadMore' | 'loading' | 'noMore'>('loadMore')

const pagination = reactive({
    current: 1,
    pageSize: 20,
    total: 0,
    hasMore: true
})

const isLoading = ref(false)

const fetchList = async (isRefresh = false) => {
    if (isLoading.value) return

    if (isRefresh) {
        pagination.current = 1
        pagination.hasMore = true
        list.value = []
    }

    isLoading.value = true
    loadStatus.value = 'loading'

    try {
        const res = await api.getList({
            page: pagination.current,
            size: pagination.pageSize
        })

        if (isRefresh) {
            list.value = res.list
        } else {
            list.value.push(...res.list)
        }

        pagination.total = res.total
        pagination.hasMore = list.value.length < res.total
        loadStatus.value = pagination.hasMore ? 'loadMore' : 'noMore'
        pagination.current++
    } catch {
        loadStatus.value = 'loadMore'
    } finally {
        isLoading.value = false
    }
}

const loadMore = () => {
    if (!pagination.hasMore || isLoading.value) return
    fetchList()
}

onMounted(() => fetchList(true))

关键点:loadStatus 有三个值------loadMore(可以加载)、loading(加载中)、noMore(没有更多了)。组件内部根据这个状态决定是否放行下一次触底,你不用自己加锁。


七、API 一览

Props

属性 类型 默认值 说明
list Array \[\] 数据列表
containerHeight String '100%' 容器高度,必须给值
itemHeight String/Number '40px' 单项高度,要和实际一致
marginBottom String/Number 0 单项底部间距
line Number 1 列数,1=单列,2=瀑布流
load String 'loadMore' 加载状态:loadMore/loading/noMore
showDivider Boolean true 显示底部加载提示
keyField String 'id' 唯一标识字段名
padding String/Number 10 单项内边距
borderRadius String/Number '3px' 单项圆角

Events

事件 参数 说明
scrollToLower - 滚动到底部触发
click item 点击列表项触发

Slots

插槽 数据 说明
default record 完全自定义,record 是可视区数据
content record 单列模式逐条渲染
left / right record 瀑布流逐条渲染
left-list / right-list record 瀑布流整列渲染

暴露方法

ts 复制代码
const listRef = ref()

// 滚动到指定索引
listRef.value.scrollToIndex(50)

// 回到顶部
listRef.value.scrollToTop()

// 刷新高度缓存(数据结构变化时调用)
listRef.value.refreshHeightCache()

八、踩坑记录(给想自己写虚拟列表的人)

写这个组件的过程中踩了几个深坑,分享出来:

坑 1:uniapp scroll-view 的 scroll-top 是受控属性

最初我把 scrollTop 绑定到 scroll-top 属性上,又在 @scroll 里写回这个值。结果是滚动回弹------用户每滑一步,scroll-view 都被拉回去。改成「读和写分离」后解决:realScrollTop 只在内部读,不绑到 DOM。

坑 2:H5 端 scrolltolower 的触发条件

翻 uni-h5 源码发现,scrolltolower 的触发条件是 scrollTop + offsetHeight + threshold >= scrollHeight && lastScrollTop < scrollTop。用户滑到底部后如果手没动,scrollTop 不增长,lastScrollTop < scrollTop 永远 false,事件死活不触发。必须加手动检测兜底。

坑 3:scroll anchoring 导致 scrollTop 跳跃

数据加载后 totalHeight 变大,如果用 padding 撑高,浏览器会自动调整 scrollTop 来「保持视觉位置不变」。从 2252 跳到 5192,用户正在看的卡片瞬间消失。换成 absolute 定位 + transform 后,内容不参与文档流重排,问题彻底消失。


最后

如果你觉得这篇文章帮你解决了你现在的问题,那就请给我来个 三连支持一下 ♥️

➡️ 点赞 支持一下

➡️ 收藏 以防找不到

➡️ 评论 我会回访你!

➡️ 关注 不会迷路哦!

你的支持是我持续更新的动力,我们下篇更精彩!🚀🔥

👉 欢迎大家给华玥组件库star。 ✅

相关推荐
2401_841495647 小时前
【数据结构】B*树
数据结构·c++·b树·算法·删除·插入·三分分裂
这是个栗子7 小时前
uni-app 微信小程序开发:常用函数总结(一)
微信小程序·小程序·uni-app·getcurrentpages
不如语冰7 小时前
AI大模型入门-模块导入import
数据结构·人工智能·pytorch·python
岑梓铭7 小时前
《考研408数据结构》第七章(7.1 查找:顺序查找、折半查找、分块查找)复习笔记
数据结构·笔记·考研·408·ds·查找
壹号用户8 小时前
c++入门之list了解及使用
数据结构·list
来一碗刘肉面8 小时前
什么是双端队列
数据结构·链表
流浪0019 小时前
数据结构篇(五):线性表——栈
数据结构·c++·算法
拂拉氏10 小时前
【知识讲解】 链式哈希表的实现与unordered_map和unordered_set的封装
数据结构·哈希算法·散列表
haolin123.10 小时前
数据结构--二叉树
数据结构