做过小程序商城或社区信息流的同学,应该都有过这种经历:一次性塞两三千条数据到页面里,滑动起来掉帧掉到怀疑人生,尤其在低端安卓机上,那个滚动基本就是三步一卡、五步一顿。要是再把复杂的item塞进去,每滚一屏都要等半天,用户早走了。
这种问题的根源说白了就一句话:我们把太多节点塞给了渲染层。小程序里每次setData传输的数据量直接跟渲染性能挂钩。那有没有一种办法,让列表看起来很长、能正常滚,但实际上只渲染肉眼看得见的那几十条?有,这就是虚拟列表。
今天我就带你手写一个固定行高的虚拟列表组件,专门用于uniapp。它不依赖任何第三方库,复制过去就能用。
虚拟列表的逻辑并不复杂
假设每条消息的高度固定是60px,可视区域高度是600px。那不管我总共有1万条还是10万条,我永远只在屏幕里塞10条,再加一点上下缓冲。用户向下滑动时,我动态更改这10条数据的区间,并且让它们出现在正确的位置。
为了做到看起来像原生列表,需要两个关键点:
- 一个用于撑起滚动高度的“占位层”,高度 = 总条数 × 每条高度。
- 一个通过偏移把可见区域“挪”到当前位置的“内容层”,也叫渲染层。
因为行高固定,计算当前应该显示哪些数据就变得特别简单:只要知道scrollTop,就能算出来头尾索引。
听起来很简单?对,但细节没处理好还是会出现白屏、抖动、末尾空白。所以我们先把固定行高这个前提锁死,等跑通了整个流程,再去扩展动态高度。
先写子组件 virtual-list.vue
我在项目里习惯把这种通用组件放到 components/virtual-list.vue。它接收几个参数:
list:原始列表数据,必须每一项里有一个唯一的key字段(或者用index生成)。itemSize:每条item的固定高度,单位是px。height:scroll-view容器的高度,也是px。不能传百分比,必须是一个数字。buffer:可视区上下的额外缓冲条数。建议给个5或6,这样滑动时不容易看到空白。
下面是完整的组件代码,没有一行多余的样式引入,所有的定位都用内联style搞定:
<template>
<scroll-view
:style="'height:' + height + 'px'"
scroll-y
@scroll="onScroll"
>
<!-- 占位层:撑起滚动高度 -->
<view :style="'height:' + totalHeight + 'px;position:relative'">
<!-- 渲染层:通过translateY移动可视区位置 -->
<view
:style="'position:absolute;top:0;left:0;right:0;transform:translateY(' + topOffset + 'px)'"
>
<view
v-for="item in visibleList"
:key="item.key"
:style="'height:' + itemSize + 'px;box-sizing:border-box'"
>
<slot name="item" :item="item" />
</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
const props = defineProps({
list: {
type: Array,
default: () => []
},
itemSize: {
type: Number,
required: true
},
height: {
type: Number,
required: true
},
buffer: {
type: Number,
default: 6
}
});
const scrollTop = ref(0);
// 首条数据的索引,减去缓冲
const startIndex = computed(() => {
const raw = Math.floor(scrollTop.value / props.itemSize) - props.buffer;
return Math.max(0, raw);
});
// 末尾数据的索引,加缓冲,同时至少保证一屏
const endIndex = computed(() => {
const visibleCount = Math.ceil(props.height / props.itemSize);
const base = Math.floor(scrollTop.value / props.itemSize);
const rawEnd = base + visibleCount + props.buffer;
return Math.min(props.list.length, Math.max(rawEnd, startIndex.value + visibleCount));
});
// 最终真正渲染到页面上的那一小块数据
const visibleList = computed(() => {
return props.list.slice(startIndex.value, endIndex.value);
});
// 撑起滚动高度的总高度
const totalHeight = computed(() => props.list.length * props.itemSize);
// 告诉渲染层偏移多少距离
const topOffset = computed(() => startIndex.value * props.itemSize);
// 列表长度变化后,如果当前位置超过最大可滚动距离,需要强行拉回来
watch(
() => props.list.length,
() => {
const maxTop = Math.max(0, props.list.length * props.itemSize - props.height);
if (scrollTop.value > maxTop) {
scrollTop.value = maxTop;
}
}
);
function onScroll(e) {
scrollTop.value = e.detail.scrollTop;
}
</script>
组件里最核心的其实是 startIndex 和 endIndex 这两个计算属性。它依据滚动距离 scrollTop 算出可视范围,再用 slice切出对应的数据。只要 scrollTop变化,列表就会自动更新。
为什么需要buffer?因为滚动是一个高频且流畅的过程。如果没有缓冲,手指快速划过时,旧内容已经被移出可视区,新内容还在计算,中间就会露出白底。加了缓冲等于预加载了一部分,所以容错性会好很多。
怎么在页面里使用它
下面是一个模拟的页面。我们伪造了8000条消息,然后通过virtual-list来渲染。这是最典型的“消息列表”场景。
<template>
<view class="page">
<virtual-list
:list="msgList"
:item-size="72"
:height="scrollViewHeight"
:buffer="6"
>
<template #item="{ item }">
<view
style="display:flex;justify-content:space-between;align-items:center;height:100%;padding:0 20rpx;border-bottom:1rpx solid #f0f0f0"
>
<text>{{ item.id }}号客服消息</text>
<text class="summary">{{ item.text }}</text>
</view>
</template>
</virtual-list>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onReady } from '@dcloudio/uni-app';
import VirtualList from '@/components/virtual-list.vue';
// 伪造一批消息
const msgList = ref(
Array.from({ length: 8000 }, (_, i) => ({
key: i,
id: i + 1,
text: '这里是第 ' + i + ' 条历史消息,用来测试性能'
}))
);
// 滚动区域的高度 = 窗口高度 - 顶部自定义导航高度
const scrollViewHeight = ref(0);
onReady(() => {
const info = uni.getSystemInfoSync();
// 如果你的项目没自定义导航栏,就不需要减44
scrollViewHeight.value = info.windowHeight - 44;
});
</script>
这里几个细节值得注意一下:
item-size必须是数字,单位是px。如果你设计稿用rpx,那在script里先通过uni.upx2px(144)换算成px传给组件。- 传给组件的列表数组最好带一个唯一的
key字段。否则在v-for里使用index作为key,会导致某些滑动场景状态错乱(比如输入框内容、图片懒加载状态)。 height传一个固定px值,万一你自己搞不清,直接填600也能看效果,但最好让滚动容器占满整个页面。
性能到底提升了多少
可以简单算一笔账:原来一次性渲染8000条view,小程序需要一次性创建8000个节点,光是初始化的耗时就要半秒甚至更多。在低端机上,用户根本等不了。
用虚拟列表以后,每次只显示约可视条数 + 2 × buffer个view。假设你的屏幕能显示10条,buffer是6,那最多渲染22条。无论总数据是8000还是80000,渲染节点永远只有20多个。这对setData的压力几乎可以忽略不计。
尤其是当你把条目的DOM做得复杂一点,比如包含图片、swiper、富文本,这种提升是几何级别的。
几个常见的坑和解决方案
1. scroll-view必须有一个确定的高度
如果你不给scroll-view设高度,它内部的滚动就永远不会触发,因为内容会直接撑开父容器,不会产生滚动。这也是很多同学第一次写完发现不滚的原因。
解决方案很简单:通过uni.getSystemInfoSync()拿到窗口高度,再减去页面其他部分的高度,给组件一个具体px值。
2. 在H5端,e.detail.scrollTop有时是undefined
上面代码是uniapp的标准写法,但个别基础库版本在H5端不传scrollTop,而是直接给一个事件对象。如果你遇到H5端不更新列表,可以加一层判断:
function onScroll(e) {
const st = e.detail?.scrollTop ?? e.scrollTop ?? 0;
scrollTop.value = st;
}
3. 快速滑到底部时,末尾会出现暂时的空白
这是因为endIndex计算里加了buffer,但还是有可能出现“渲染速度跟不上手指滚动速度”的情况。解决办法很简单:把buffer调大一点,比如8或10。当然buffer太大会增加渲染数量,你需要找到一个平衡点。我在项目里一般取8。
4. 使用自定义组件插槽需要注意性能
如果每个item里有图片,你需要自己实现图片懒加载。因为虚拟列表本身会不断根据滚动重建item,图片如果直接给src,它会频繁请求。建议使用uniapp的lazy-load或者配合IntersectionObserver。
固定行高够用吗?
大部分聊天记录、订阅消息、订单列表都可以做成固定行高。但万一有一行文字特别长,换行以后高度不同了,这个组件就会计算出错,出现“重叠”或“空的太多”的问题。
动态高度是虚拟列表的噩梦。处理方法一般有两种:
- 如果每一条内容长度差异不大,就设定一个最大行数,超出部分截断,保证固定行高。
- 真正需要完全展示不同高度的内容,那就得对每一条计算并缓存实际高度,然后在渲染层提前预留好对应位置。这种“动态高度虚拟列表”实现复杂度会翻倍,一般场景也用不上。
所以我的建议是:优先设计成定高。如果确实有少量不同高度,可以给itemSize传一个保守的高度值,再让内容在内部自适应并且垂直居中,视觉上勉强可以接受。
说说这个“缓冲”存在的意义
没有缓冲的虚拟列表,是这样的:你滚到第100条,组件就只显示第100条到110条,然后滚到第101条,组件又立刻丢掉第100条并新增第111条。更新频率太高,反而容易出现闪烁。
有了缓冲,比如前后各6条,当你滚到第100条的时候,实际渲染的是第94条到116条。从第94条滚到第106条,组件都没有触发任何更新,只有退出缓冲范围才会重新slice。这不仅减少了setData次数,也让滚动手感更接近原生。
我想强调的是,虚拟列表不是银弹,它解决的问题主要就是“一次性渲染过多节点”这个场景。如果你的是无限流、瀑布流,那还是优先考虑分页加载,或者把每页的数量控制在两位数。
最终效果
在我的项目里,替换完这个组件后,从3000条消息到8000条消息,滑动时的帧率没有明显区别。原来滚动时页面会卡一下,现在完全不会了。而且代码量并没有增加,只是把原来的v-for换成了virtual-list。
当然,你也可以在组件基础上继续扩展,比如加上滚动加载更多、支持指定key字段、暴露重置scrollTop的方法等。这个小东西看似简单,掌握之后很多长列表的坑都能躲过去。
希望这篇写的uniapp虚拟列表实战对你有帮助。如果你也在项目里试过类似方案,欢迎交流一下你踩到过的奇怪bug。

