文章目录
-
- 一、先说结论:它解决了什么问题
- [二、hy-list 怎么解决的](#二、hy-list 怎么解决的)
-
- [核心思路:撑杆 + 绝对定位,彻底绕开 scroll anchoring](#核心思路:撑杆 + 绝对定位,彻底绕开 scroll anchoring)
- 瀑布流按「行」统一计算
- [手动触底检测 + emittedListLen 防连锁](#手动触底检测 + emittedListLen 防连锁)
- 三、和其他方案对比
- 四、安装
- 五、四种用法,覆盖所有场景
-
- [用法 1:最简单的单列列表(默认插槽)](#用法 1:最简单的单列列表(默认插槽))
- [用法 2:单列 + content 插槽(逐条渲染)](#用法 2:单列 + content 插槽(逐条渲染))
- [用法 3:双列瀑布流(最常用)](#用法 3:双列瀑布流(最常用))
- [用法 4:双列瀑布流 + 自定义列模板(left-list / right-list)](#用法 4:双列瀑布流 + 自定义列模板(left-list / right-list))
- 六、分页加载怎么写
- [七、API 一览](#七、API 一览)
- 八、踩坑记录(给想自己写虚拟列表的人)
- 最后
上万条数据的列表页,你的页面还卡在滚动掉帧吗?
做电商、社交、资讯类小程序时,长列表性能几乎是每个前端绕不开的坑。数据量一大,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。 ✅