H5 虚拟列表与长列表渲染
H5 虚拟列表与长列表渲染
列表是页面中最常见的渲染形态。当数据量达到几千条甚至上万条时,一次性渲染全部 DOM 节点会让页面卡顿、内存膨胀,甚至被浏览器强制回收。虚拟列表通过只渲染可视区域内的部分节点,让长列表也能保持流畅。
一、为什么浏览器会卡
每一个 DOM 节点都会占用内存,并且节点过多会让样式计算、布局和绘制成本线性上升。即使节点本身简单,几千个节点叠加后,一次滚动仍可能引发上百毫秒的长任务。
const itemHeight = 48;
const containerHeight = 600;
const itemCount = 10000;
在这个例子中,如果全部渲染,需要接近五十万个像素高度的容器,其中绝大部分节点用户根本看不到。
二、虚拟列表的基本原理
虚拟列表只渲染可视区域附近的少量节点。组件根据滚动偏移量计算当前应该渲染的起始索引和结束索引,再通过一个与完整列表等高的占位容器维持滚动条比例。
function getVisibleRange(
scrollTop: number,
viewportHeight: number,
itemHeight: number,
itemCount: number
): { startIndex: number; endIndex: number } {
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const endIndex = Math.min(
itemCount - 1,
startIndex + visibleCount
);
return { startIndex, endIndex };
}
itemHeight 是关键参数。如果每个条目高度固定且已知,虚拟列表的实现最简单可靠。
三、等高与动态高度
等高列表假设每个条目的渲染高度一致。但在实际业务中,标题可能一行或两行,文本长度不确定,条目高度并不一致。
动态高度虚拟列表需要在渲染前预估高度,并在真实渲染后测量并缓存结果:
interface ItemMeasure {
offsetTop: number;
height: number;
measured: boolean;
}
条目真实高度发生变化后,要更新后续所有条目的偏移量。这个计算本身可能成为新瓶颈,如果条目差异不大,可以用固定高度加上预估缓冲,避免过度复杂的测量。
四、滚动事件的节流与帧调度
滚动事件在浏览器中会高频触发。处理虚拟列表时,应避免在每次事件中做复杂计算和同步读取布局信息,并把 DOM 更新集中到下一帧。
let frameId = 0;
function handleScroll(scrollTop: number): void {
if (frameId > 0) return;
frameId = requestAnimationFrame(() => {
frameId = 0;
updateVisibleItems(scrollTop);
});
}
一帧内多次滚动只触发一次重算,避免重复操作 DOM。需要读取布局信息时,尽量集中在一次批量读取,避免读写交替。
五、上下缓冲区
如果只渲染可视区域内的条目,快速滚动时会先出现空白,再渲染新内容。可以在可视区域上下额外多渲染几个条目作为缓冲。
const overscan = 6;
const bufferedStart = Math.max(0, startIndex - overscan);
const bufferedEnd = Math.min(itemCount - 1, endIndex + overscan);
缓冲区越大,滚动越不容易看到空白,但同时也会增加渲染节点数量。缓冲区大小应根据设备性能和真实滚动体验调整,通常在 4 到 10 之间。
六、占位容器与滚动条
虚拟列表需要一个占位元素撑起与完整列表相同的高度,否则浏览器无法计算正确的滚动条比例。最常见做法是使用一个外层容器保存滚动,内部再放一个总高度的占位元素和绝对定位的渲染层。
.virtual-list {
position: relative;
overflow-y: auto;
height: 600px;
}
.virtual-list-phantom {
height: 480000px;
}
.virtual-list-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
渲染层内部条目使用绝对定位或 transform 摆放在正确位置,避免依赖文档流反复触发回流。
七、索引定位与快速跳转
如果业务需要“回到顶部”或“跳转到某个分组”,直接设置 scrollTop 即可,无需模拟滚动。
function scrollToIndex(index: number, itemHeight: number): void {
container.scrollTop = index * itemHeight;
}
动态高度场景下,索引定位要使用缓存的偏移量。如果目标条目尚未测量过,先根据预估高度估算位置,等真实渲染完成后再修正。
八、图片与子组件的更新
可视区域外的条目被卸载后,组件状态和图片请求也随之取消。图片组件应具备取消加载的能力,否则用户快速滚动时仍会发出大量请求。
function loadImage(element: HTMLImageElement): void {
element.src = element.dataset.src || '';
}
如果列表项包含复杂子组件,应尽量让子组件的状态跟随数据源,而不是保存在组件内部。卸载重建后,业务状态不能丢失。
九、分组与吸附标题
通讯录和商品列表常用分组标题。分组标题的高度与普通条目不同,虚拟列表需要为不同类型的条目分别维护高度或偏移量。
type ListItem =
| { type: 'group'; title: string }
| { type: 'item'; id: string; name: string };
如果产品要求标题吸附在顶部,吸附逻辑应在滚动计算中统一处理,而不是让每个标题各自监听滚动事件。
十、什么时候不需要虚拟列表
虚拟列表会增加复杂度,不是所有长数据都必须使用。如果条目只有几十条,普通渲染通常已经足够流畅。可以先通过真实数据测量滚动帧率和内存占用,确认问题后再引入虚拟化。
常见适合虚拟列表的场景:
- 聊天记录、日志查看器。
- 商品列表、订单列表。
- 通讯录和城市选择。
- 树形结构展开后的扁平列表。
总结
虚拟列表的本质是把渲染范围收敛到用户可见的一小段内容,同时用占位容器保持滚动语义。等高场景优先用固定高度实现,动态高度要控制测量频率并缓存结果。滚动更新集中到帧回调,渲染范围加上下缓冲区,图片和子组件要支持卸载与恢复。先确认性能问题再引入虚拟化,避免为了技术而技术。
- 点赞
- 收藏
- 关注作者
评论(0)