在长短列表的交互场景中,“一次性把所有数据都渲染出来”是最简单也是最危险的做法。当列表项达到数千甚至数十万条时,DOM 节点的创建、布局和绘制会急剧消耗内存与 CPU,造成页面卡顿甚至崩溃。于是两类优化手段应运而生:无限滚动(解决“加载体验”问题)和虚拟列表(解决“渲染性能”问题)。
31.5.1 无限滚动:按需加载更多数据
无限滚动(Infinite Scroll)的思路很直接:不一次性加载全部数据,而是每次只请求一小段,当用户滚动到接近列表底部时,自动触发下一段数据的加载,将新数据追加到列表中,形成“永远滚不到头”的体验。
核心实现步骤
- 监听滚动事件:在列表的滚动容器(可以是
window或一个固定高度的div)上绑定scroll事件。 - 判断是否触底:比较“滚动距离 + 容器可视高度”与“内容总高度”的差值,当差值小于某个阈值(例如 100px)时,认为用户已经接近底部。
- 防抖处理:
scroll事件触发非常频繁,需要用防抖或节流来控制回调执行频率,避免无谓计算。 - 加载状态管理:正在加载时阻止重复请求;所有数据加载完毕时停止监听。
基础代码示例(Vue/React 通用逻辑)
// 滚动容器上的逻辑(以原生 JS 为例)
let isLoading = false;
let currentPage = 1;
let hasMore = true;
const container = document.getElementById('scroll-container');
container.addEventListener('scroll', () => {
if (isLoading || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = container;
// 距离底部 100px 时触发
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore();
}
});
async function loadMore() {
isLoading = true;
try {
const newItems = await fetchData(currentPage);
if (newItems.length === 0) {
hasMore = false;
return;
}
appendItems(newItems); // 将新数据渲染到列表中
currentPage++;
} catch (error) {
// 处理错误,允许重试
} finally {
isLoading = false;
}
}
注意事项
- 快速滚动时可能漏触发:一些移动端浏览器在惯性滚动时可能不会持续触发
scroll事件,可使用Intersection Observer作为更可靠的触底检测手段。 - 数据一致性:如果列表支持筛选、排序,重置状态时要清空列表并从第一页重新加载。
- SEO 与无障碍:无限滚动通常不利于搜索引擎抓取和辅助技术,对内容型网站需谨慎使用,或提供分页作为降级方案。
31.5.2 虚拟列表:只渲染可视区域内的节点
虚拟列表(Virtual List)解决的是另一个维度的问题:即便所有数据已经在前端(或者单纯数据量巨大),也不可能让成千上万条 DOM 同时存在。虚拟列表的核心思想是按需渲染——只创建当前视口内可见的那些列表项,其余部分用空白占位来维持滚动条高度。
核心原理拆解
- 定高与不定高
- 固定高度:每项高度一致,实现最为简单。
- 动态高度:每项高度不固定,需要提前缓存或预估高度,并在渲染后进行修正,实现更复杂。
- 可视区域计算
scrollTop:容器已滚动的距离。containerHeight:容器可视高度。itemHeight:单项高度(固定高度可用,动态需累加缓存)。
通过 startIndex = Math.floor(scrollTop / itemHeight) 和 endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight) 确定当前应渲染的起止索引。
- 偏移量与占位
- 列表整体高度 = 总数据量 × 单项高度(或动态累加值),用一个占位
div撑开滚动条。 - 可见项通过
transform: translateY(startIndex * itemHeight)或padding-top定位到正确位置。
固定高度虚拟列表简化实现(Vue 3 组合式)
<template>
<div class="virtual-container"
ref="containerRef"
@scroll="onScroll"
:style="{ height: containerHeight + 'px', overflow: 'auto' }">
<!-- 占位元素,设定总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 可见项容器,通过偏移定位 -->
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleItems"
:key="item.id"
:style="{ height: itemHeight + 'px' }">
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
list: Array,
itemHeight: Number,
containerHeight: Number
});
const containerRef = ref(null);
const scrollTop = ref(0);
const totalHeight = computed(() => props.list.length * props.itemHeight);
const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight)
);
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight) + 1 // 额外多渲染一项做缓冲
);
const visibleItems = computed(() =>
props.list.slice(startIndex.value, startIndex.value + visibleCount.value)
);
const offsetY = computed(() =>
startIndex.value * props.itemHeight
);
function onScroll() {
scrollTop.value = containerRef.value.scrollTop;
}
</script>
动态高度虚拟列表的难点
- 需要维护一个存储每项预估高度、实际高度及累计偏移量的数组。
- 当某项渲染完成后,通过
ResizeObserver或nextTick获取其真实高度并更新缓存,同时重新计算后面所有项的偏移量。 - 滚动时通过二分查找在累积偏移量数组中找到
startIndex,效率需保证。
工程中一般不会手动从头写,成熟的库如 vue-virtual-scroller、react-window、react-virtualized 等已经完美处理了固定/动态高度、横向/纵向、网格等多种场景。
31.5.3 无限滚动 + 虚拟列表的协同
在实际业务中,无限滚动和虚拟列表常常需要组合使用:用无限滚动分页请求数据,用虚拟列表保证已加载数据的高性能渲染。关键是处理好以下交互:
- 触底加载新数据后,需要平滑地将新数据追加到数据池(而不是替换整个数组),虚拟列表自动感知数据长度变化,保持当前滚动位置不跳动。
- 如果数据总量尚未确定(如搜索结果),虚拟列表的总高度需要动态增长,占位元素的高度要随数据累积而更新。
- 加载中的 loading 指示器可以放置在列表底部,作为最后一行的特殊渲染项,同时避开虚拟列表的切片逻辑。
31.5.4 避坑要点
- 滚动位置抖动:数据加载后追加到尾部容易保持位置,但在顶部插入数据(如聊天消息的上拉加载)会导致内容向下偏移,需手动调整
scrollTop。 - 快速滑动白屏:如果可视区域内的节点来不及渲染,用户会看到短暂的白屏。增大渲染缓冲区域(上下各多渲染几个额外项)可以有效缓解。
- 列表项内有交互或表单:虚拟列表在滚动时是会销毁和创建 DOM 节点的,如果项内有未保存的状态(如输入框内容、展开/折叠状态),状态会丢失。需要将这些状态提升到列表数据中,用“状态外置”的方式保存。
- 避免频繁触发 scroll 计算:采用
requestAnimationFrame或框架带来的onScroll节流机制,确保更新速率与屏幕刷新率同步。
虚拟列表和无限滚动看似是两个独立技巧,实际在长列表场景中互补成为一对黄金搭档。掌握了它们,你就可以在看似不可能的数据量下,依然给用户提供丝滑的浏览体验。