在做社区信息流时,用户滑到上千条内容后页面明显掉帧,滚动时甚至能感觉到延迟。产品要求列表必须支持图文混排和高度不固定的卡片,传统的scroll-view一次性渲染几百条DOM节点直接撑爆内存。后来参照一些源码自己实现了一套虚拟列表,把同时存在的DOM节点控制在20个以内,滑动体验才回到丝滑状态。
uniapp虽然组件市场里有现成的虚拟列表插件,但直接引进来有时并不契合业务样式,而且出了问题不好排查。不如自己写一个,核心代码不到一百五十行,理解了以后还能随意扩展。这篇文章把写这个组件的全过程梳理出来,从原理到动态高度处理,最后给一个可以直接在页面里用的完整例子。
虚拟列表到底干了什么
长列表性能瓶颈的根源在于DOM节点太多。列表里有一千条数据,页面就要创建一千个节点,每个节点都占用内存和布局计算资源。小程序环境里这个消耗更明显,因为渲染层和逻辑层分离,频繁的setData还会带来序列化开销。
虚拟列表的思路很直接:只渲染当前看得见的那一小部分数据。假设屏幕能显示10条,那就只创建10个DOM节点。当用户滚动时,根据滚动位置计算出应该显示哪些数据,然后复用已有的节点,把新的数据填充进去,同时调整节点的位置来模拟长列表效果。
具体来说需要做三件事:监听滚动事件获取滚动距离;根据滚动距离和每项高度计算“当前该显示哪几条数据”;把这几条数据渲染出来,并用一个占位容器撑出总高度,让滚动条看起来像是完整列表。核心变量只有三个——visibleData(当前可见的数据片段)、startIndex(起始索引)、totalHeight(总占位高度)。
固定高度的起步版本
先从一个最简单的版本开始:列表每一项高度固定为100px。这样计算起始索引只需要用scrollTop除以100取整。代码量很少,但足以展示虚拟列表的工作流。
在uniapp的pages目录下新建一个页面,模板结构如下:
<template>
<scroll-view
scroll-y
@scroll="onScroll"
:style="{ height: '100vh' }"
>
<!-- 占位容器,撑出总高度 -->
<view :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 可见区域容器,通过transform偏移 -->
<view :style="{ transform: `translateY(${offsetY}px)` }">
<view
v-for="(item, index) in visibleData"
:key="item.id"
style="height: 100px; border-bottom: 1px solid #eee;"
>
{{ item.text }}
</view>
</view>
</view>
</scroll-view>
</template>
这里外层用scroll-view作为滚动容器,设定一个固定高度(通常是页面高度)。里面套一个占位view,高度设置为totalHeight——也就是所有数据的总高度。再里面的view通过transform: translateY向下偏移,确保可见的那几条数据刚好出现在屏幕正确的位置上。
脚本部分的逻辑:
export default {
data() {
return {
allData: [], // 全部数据
visibleData: [], // 当前可见数据
startIndex: 0, // 第一个可见项的索引
offsetY: 0, // 偏移量
totalHeight: 0, // 总占位高度
itemHeight: 100, // 固定高度
visibleCount: 0, // 一屏能显示的条数
};
},
mounted() {
this.allData = this.generateData(1000); // 生成1000条模拟数据
const sysInfo = uni.getSystemInfoSync();
this.visibleCount = Math.ceil(sysInfo.windowHeight / this.itemHeight) + 2; // 多缓存2条
this.totalHeight = this.allData.length * this.itemHeight;
this.updateVisibleData(0);
},
methods: {
generateData(count) {
const list = [];
for (let i = 0; i < count; i++) {
list.push({ id: i, text: `这是第 ${i + 1} 条数据` });
}
return list;
},
onScroll(e) {
const scrollTop = e.detail.scrollTop;
this.updateVisibleData(scrollTop);
},
updateVisibleData(scrollTop) {
// 计算起始索引
const start = Math.floor(scrollTop / this.itemHeight);
this.startIndex = Math.max(0, start);
// 截取可见数据
this.visibleData = this.allData.slice(
this.startIndex,
this.startIndex + this.visibleCount
);
// 偏移量使得第一条可见数据正好在滚动位置的顶部
this.offsetY = this.startIndex * this.itemHeight;
}
}
};
这个版本跑起来,不管allData里有一千条还是一万条,页面实际渲染的DOM始终只有visibleCount个(大概十二三个)。滚动时根据scrollTop实时更新可见数据,配合偏移量让元素位置看起来自然。
但固定高度的限制太强了。真实的业务里,列表项高度往往因为图片尺寸、文字长度、内边距等因素各不相同。接下来就要解决动态高度的问题。
动态高度的核心难点
高度不固定时,不能再用scrollTop / itemHeight来推算当前可见的起点。因为每项高度都不一样,你需要知道每一项的具体高度才能累加出总高度,也才能算出滚动到某个位置时对应到第几条数据。
解决方式是在数据里提前计算好每一项的高度,或者渲染后通过DOM获取真实高度并缓存下来。后者更灵活,也更能适应运营动态内容。实现思路是这样的:给每项数据绑一个预估高度(比如先猜一个150px),然后当这一项被渲染出来之后,通过uni.createSelectorQuery获取它在屏幕上的真实高度,存到一个高度缓存对象里。下一次滚动计算时,就使用缓存里的实际高度来定位。
但是,在渲染后才获取高度意味着:你的列表初始状态是基于预估高度计算的,存在一些位置偏差。随着用户滚动,越来越多的项被实际渲染,缓存越来越全,定位就越来越精准。这个“渐进式精准”的过程在实际使用中几乎无感知,因为偏差通常只有几十像素,肉眼很难察觉。
完整动态高度虚拟列表组件
下面是一个可以直接放到components目录下使用的虚拟列表组件,支持动态高度、无限滚动,并且通过scroll-tolower事件触发数据追加。
// components/VirtualList/VirtualList.vue
<template>
<scroll-view
scroll-y
@scroll="handleScroll"
@scrolltolower="$emit('loadMore')"
:style="{ height: height }"
>
<view :style="{ height: totalHeight + 'px', position: 'relative' }">
<view :style="{ transform: `translateY(${offsetY}px)` }">
<view
v-for="(item, idx) in visibleData"
:key="item._key"
:ref="'item-' + (startIndex + idx)"
>
<slot :item="item" :index="startIndex + idx"></slot>
</view>
</view>
</view>
</scroll-view>
</template>
<script>
export default {
props: {
// 数据源
items: { type: Array, default: () => [] },
// 容器高度,如 '100vh' 或 '600px'
height: { type: String, default: '100vh' },
// 预估每项高度(像素)
estimateHeight: { type: Number, default: 120 },
// 缓冲数量(上下各多渲染几项)
bufferCount: { type: Number, default: 3 },
},
emits: ['loadMore'],
data() {
return {
visibleData: [],
startIndex: 0,
offsetY: 0,
totalHeight: 0,
heightCache: {}, // key: index, value: 测量后的高度
};
},
watch: {
items: {
handler(newVal) {
this.updateTotalHeight();
this.updateVisibleData(this.currentScrollTop || 0);
},
immediate: true,
},
},
methods: {
// 更新总高度(基于缓存中已知高度和预估高度)
updateTotalHeight() {
let total = 0;
for (let i = 0; i < this.items.length; i++) {
total += this.heightCache[i] || this.estimateHeight;
}
this.totalHeight = total;
},
// 通过滚动位置找到起始索引
getStartIndex(scrollTop) {
if (scrollTop <= 0) return 0;
let accumulated = 0;
for (let i = 0; i < this.items.length; i++) {
const h = this.heightCache[i] || this.estimateHeight;
accumulated += h;
if (accumulated > scrollTop) {
return i;
}
}
return this.items.length - 1;
},
// 更新可见数据
updateVisibleData(scrollTop) {
if (!this.items.length) return;
const start = this.getStartIndex(scrollTop);
const buffer = this.bufferCount;
const startIndex = Math.max(0, start - buffer);
this.startIndex = startIndex;
// 计算需要显示的条数:从startIndex起,逐项累加高度直到填满一屏+缓冲
let covered = 0;
let endIndex = startIndex;
const containerHeight = this.getContainerHeight();
while (covered < containerHeight + this.estimateHeight * buffer && endIndex < this.items.length) {
covered += this.heightCache[endIndex] || this.estimateHeight;
endIndex++;
}
// 截取可见数据
this.visibleData = this.items.slice(startIndex, endIndex).map((item, i) => {
// 保证每项有唯一的key
return { ...item, _key: item.id || (startIndex + i) };
});
// 计算偏移量:startIndex之前所有项的高度之和
let offset = 0;
for (let i = 0; i < startIndex; i++) {
offset += this.heightCache[i] || this.estimateHeight;
}
this.offsetY = offset;
// 在下一帧测量刚刚渲染的节点的实际高度
this.$nextTick(() => {
this.measureHeights(startIndex, this.visibleData.length);
});
},
getContainerHeight() {
// 简单处理,从props取数字,或使用固定值
const match = this.height.match(/(d+)/);
return match ? parseInt(match[1]) : 600;
},
// 测量实际高度并更新缓存,同时重新计算总高度和偏移
measureHeights(fromIndex, count) {
const queries = [];
for (let i = 0; i < count; i++) {
const index = fromIndex + i;
if (this.heightCache[index] !== undefined) continue; // 已缓存则跳过
queries.push(this.getNodeHeight(index));
}
Promise.all(queries).then(() => {
this.updateTotalHeight();
// 重新修正偏移量,因为预估高度可能不准
let offset = 0;
for (let i = 0; i < this.startIndex; i++) {
offset += this.heightCache[i] || this.estimateHeight;
}
this.offsetY = offset;
});
},
getNodeHeight(index) {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(this);
query.select(`#item-${index}`).boundingClientRect((rect) => {
if (rect && rect.height > 0) {
this.heightCache[index] = rect.height;
}
resolve();
}).exec();
});
},
handleScroll(e) {
const scrollTop = e.detail.scrollTop;
this.currentScrollTop = scrollTop;
this.updateVisibleData(scrollTop);
},
},
};
</script>
组件接收items数据源,通过插槽让父页面自定义每一项的展示内容。核心计算放在getStartIndex里,根据缓存高度遍历累加找到滚动位置对应的索引。measureHeights则负责在渲染后使用createSelectorQuery获取真实高度,更新缓存后重新计算总高度和偏移量,消除预估误差。
使用时,父页面只需要引入组件并传入数据:
<template>
<VirtualList
:items="feedList"
height="100vh"
estimateHeight="200"
@loadMore="fetchMore"
>
<template v-slot:default="{ item, index }">
<view style="padding: 20px; border-bottom: 1px solid #f0f0f0;">
<image :src="item.avatar" style="width: 40px; height: 40px; border-radius: 50%;"></image>
<text>{{ item.nickname }}</text>
<view>{{ item.content }}</view>
<image v-if="item.image" :src="item.image" mode="widthFix" style="width: 100%;"></image>
</view>
</template>
</VirtualList>
</template>
<script>
import VirtualList from '@/components/VirtualList/VirtualList.vue';
export default {
components: { VirtualList },
data() {
return {
feedList: [],
page: 1,
};
},
methods: {
loadData(page) {
// 模拟接口请求
return new Promise((resolve) => {
setTimeout(() => {
const list = [];
for (let i = 0; i < 20; i++) {
list.push({
id: (page - 1) * 20 + i,
avatar: 'https://picsum.photos/100/100',
nickname: `用户${Math.random().toString(36).substr(2, 6)}`,
content: `这是第${page}页第${i}条动态,内容长度随机。`,
image: i % 3 === 0 ? 'https://picsum.photos/600/300' : '',
});
}
resolve(list);
}, 500);
});
},
async fetchMore() {
this.page++;
const newData = await this.loadData(this.page);
this.feedList = [...this.feedList, ...newData];
},
},
async mounted() {
this.feedList = await this.loadData(1);
},
};
</script>
这样,一个完整的动态高度无限滚动信息流就跑起来了。无论数据加到多少条,屏幕上始终只维持十几个DOM节点,性能表现稳定。加上@loadMore事件,在滚动到底部时自动加载下一页,体验和原生列表几乎一样。
优化细节与坑点
几个在实践中踩过的点值得单独提一下。
高度缓存策略。 只缓存已经测量到的项。对于从未渲染过的项,使用预估高度。预估高度设置得越接近真实平均值,初始时的布局晃动就越小。通常取一个略大于大部分项的高度值可以让内容不至于被截断。
ref的名称需要精确对应。 在measureHeights中我们使用#item-${index}选择器,前提是在模板里给每个可见项设置了对应的id。这里要注意uni.createSelectorQuery在小程序端对动态ref的支持限制,确保选择器能拿到节点。
scroll-view的throttle。 多个平台对scroll事件的触发频率不一致,有些平台可能每秒触发几十次。直接在事件回调里截取数据、操作DOM可能会造成卡顿。建议在回调里做一个简单的节流,比如每16ms(一帧)更新一次。上面代码没有加节流,但在滚动不频繁时影响不大,可以根据实际平台情况自行补上。
数据更新时的处理。 当items发生变化(比如内容增删)时,高度缓存可能需要部分失效。如果只是追加新数据,原有缓存依然有效,只需要重新计算总高度和可见区域即可。如果中间插入或删除数据,缓存的索引会错位,此时最好清空缓存重新测量。这种场景较少见,可以根据业务权衡是否处理。
可以扩展的方向
这个组件作为基础版本能满足大部分长列表场景,但可以根据需求进一步强化:加上下拉刷新功能,在scroll-view外层套一个refresh容器即可;结合骨架屏,在数据加载时展示占位卡片;对于图片密集的列表,可以加入懒加载,只加载进入可视区的图片。这些扩展都不影响虚拟列表本身的逻辑,各司其职。
手写虚拟列表的过程,本质上是在处理“位置计算”和“DOM复用”两个核心问题。一旦这两块搞清楚了,后续无论用什么框架,遇到长列表优化都可以迅速反应出解决思路。

