移动端 H5 里最常见的卡顿场景,就是长列表:订单记录、聊天消息、商品列表、数据明细,动辄上千条。直接把所有节点渲染进 DOM,Android 低端机上列表滑动会明显掉帧,iOS 上内存也会被大量离屏节点吃满。虚拟滚动(窗口化渲染)的思路是:只渲染可视区域附近的一小批节点,其余用空白占位,滚动时按需重建。本文讲清楚它为什么有效、实现时有哪些必须处理的坑,以及一套可直接抄走的移动端方案。
一个 1000 行的列表,如果每个 li 里还有图片、按钮、多层嵌套,DOM 节点轻松过万。两个成本随节点数线性上涨:
虚拟滚动把"渲染全部"改成"渲染视口内的 20~40 条",理论上滚动性能与数据总量无关。
// 1. 数据总量与行高
const TOTAL = 5000; // 总条数
const ROW_H = 64; // 固定行高(px)
// 2. 视口信息
const viewportH = window.innerHeight;
const overscan = 6; // 上下额外多渲染的行数(缓冲)
// 3. 计算渲染区间
function getRange(scrollTop) {
const start = Math.max(0, Math.floor(scrollTop / ROW_H) - overscan);
const end = Math.min(TOTAL - 1, Math.ceil((scrollTop + viewportH) / ROW_H) + overscan);
return [start, end];
}外层容器高度固定、overflow 滚动;内层用一个大高度 div 撑起总滚动高度;中间只放 [start, end] 区间的行。滚动时重算区间、替换行内容。
固定行高(每一行都是同样高度)时,实现最简单:滚动位置除以行高就能算出起始索引,性能也最好。移动端大部分列表(订单、消息、记录)都能用固定行高。
// 简易固定行高虚拟列表
class VirtualList {
constructor(container, { total, rowH, render }) {
this.c = container;
this.total = total;
this.rowH = rowH;
this.render = render;
this.spacer = document.createElement('div');
this.spacer.style.height = total * rowH + 'px';
this.holder = document.createElement('div');
this.c.appendChild(this.spacer);
this.c.appendChild(this.holder);
this.c.addEventListener('scroll', () => this.update(), { passive: true });
this.update();
}
update() {
const [start, end] = getRange(this.c.scrollTop, this.rowH, this.c.clientHeight, this.total);
this.holder.style.transform = `translateY(${start * this.rowH}px)`;
this.holder.innerHTML = '';
for (let i = start; i <= end; i++) this.holder.appendChild(this.render(i));
}
}关键点:holder 用 transform: translateY 定位,而不是 top,避免触发重排。
真实业务里很多列表高度不固定(标题折行、内容长短不一)。做法是"先给一个预估高度撑住滚动条,渲染后实测高度再修正":
// 位置表:记录每行的预估/实测高度
positions = []; // {top, height, index}
function estimate(i) {
positions[i] = positions[i] || { top: i * estH, height: estH };
return positions[i];
}
// 行渲染完成后回填真实高度,修正后续所有行位置
function correct(i, realH) {
const pos = positions[i];
const diff = realH - pos.height;
if (diff === 0) return;
pos.height = realH;
for (let j = i + 1; j < positions.length; j++) positions[j].top += diff;
}不定高时滚动到中后段容易出现"白屏闪烁",因为预估高度和实际累计高度有偏差,渲染区间被算错。修正表可以缓解,但要注意:每次修正都要从当前行往后重算偏移,数据量大时累积成本高,建议配合二分查找定位起始行。
scroll 事件在 iOS 上频率低且不连续,一定要用 requestAnimationFrame 节流更新,否则滚动停住后列表才慢慢补齐。loading="lazy" 或 IntersectionObserver 都行,避免把图片 URL 直接塞给离屏行。data-index 定位。坑 | 现象 | 解法 |
|---|---|---|
未用 rAF 节流 | iOS 上滚动后列表补得慢 | scroll 回调里 requestAnimationFrame |
固定行高套用变高内容 | 列表上下错位 | 换不定高方案+位置表修正 |
图片直连离屏行 | 反复请求、流量暴增 | loading="lazy" / IntersectionObserver |
事件绑在行上 | 行重建后点击失效 | 容器事件委托 + data-index |
列表里有 input | 滚动后输入内容丢失 | 重建前保存 value 或排除该行 |
选择虚拟滚动方案时,先用数据画像决定行高模型:90% 以上行高等高就上固定行高,简单且性能最好;只有内容长度差异大的才上预估+修正。组件化封装时把"数据源、行渲染函数、滚动容器"三件事解耦,方便复用在订单、消息、日志等多个页面。上生产前用低端 Android 真机做一次长列表滚动实测,关注帧率和内存曲线。如果团队不想从零造轮子,也可以直接采用社区成熟的虚拟列表库,但要确认它支持移动端触摸滚动和 iOS 惯性,而不是只在桌面端表现良好。
虚拟滚动解决的是"数据量大"和"DOM 数量多"的根本矛盾,是移动端长列表性能优化的标准答案。它的难点不在原理,而在滚动节流、行高修正、图片懒加载这些工程细节。希望这份落地清单能帮你少踩几次坑,一次做对。
以上为通用前端技术实践分享,具体功能以各平台官方实时文档为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。