虚拟列表与大数据渲染:从原理到实现取舍
本文目录
"渲染一万条数据页面卡死"是前端最高频的性能工单之一。直觉解法是分页或无限加载,但很多场景(表格滚动、时间线、聊天记录、日志查看器)就是要在一个滚动容器里放几万行——这时虚拟列表是唯一现实解。这篇从成本模型讲到原理、实现要点、方案选型,最后过一遍容易翻车的边界情况,帮助你判断"什么时候该上虚拟列表、什么时候不该自己写"。
为什么需要虚拟列表:DOM 的真实成本
浏览器处理 DOM 的成本分四层,全部随节点数量线性(或更差)增长:
| 成本层 | 发生时机 | 随节点数增长的表现 |
|---|---|---|
| 内存 | 节点存在即占用 | 万级节点可达数百 MB,低配机直接杀后台 |
| 样式计算与布局 | DOM 变化后 | 节点越多,一次重排的全局成本越高 |
| 绘制 | 布局后 | 滚动触发的绘制区域变大,帧率下降 |
| 事件与代理 | 交互时 | 节点数影响事件命中的计算面 |
关键认知:用户任意时刻只看得见几十行。一万行里渲染在视口内的不到 1%,其余 99% 只产生成本不产生价值。虚拟列表做的事就是把"渲染的行数"与"数据的条数"解耦——无论数据 1 万条还是 100 万条,DOM 里永远只有视口内的几十行加缓冲。
上图是同一份列表数据不同行数下的首渲染耗时示意(全量渲染那条陡增,虚拟列表基本水平)。两条曲线的差别不是"快了一点",而是随数据量增长的斜率——虚拟列表把 O(n) 的渲染成本压成了 O(视口行数)。
原理:只渲染看得见的部分
三步计算构成虚拟列表的全部原理:
总高度 = 行高 × 总行数 (撑出滚动条)
起始下标 = floor(scrollTop / 行高) (当前视口顶部对应第几行)
可见行数 = ceil(容器高度 / 行高) (视口能装下几行)
渲染范围 = [起始下标 - overscan, 起始下标 + 可见行数 + overscan]
两个技术路线解决"总高度怎么撑出来、渲染的行怎么摆到正确位置":
| 路线 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 内边距法 | 容器 padding-top/bottom 分别撑起上方与下方的空间,只渲染中间 | 纯文档流,不需要绝对定位 | 行数变化时调整 padding,方向运算易错 |
| transform 法 | 总高度由一个空占位元素撑,渲染的行放在绝对定位容器里,用 translateY 整体移动到视口位置 | 位移只写一处,动态行高更好处理 | 行元素脱离文档流,需要自己保证布局 |
两种路线的滚动条行为都依赖"总高度真实存在"——总高度算错,滚动条长度就会跳动,用户体验立即穿帮。动态行高问题的本质就是"总高度算不准",这是后文最大的难点。
最小实现:固定行高版
一个 60 行左右的核心实现,覆盖路由外的大部分场景:
interface VirtualListOptions {
rowHeight: number; // 固定行高(px)
overscan?: number; // 视口外多渲染的行数,快速滚动时的缓冲
}
function createVirtualList(container: HTMLElement, options: VirtualListOptions) {
const { rowHeight, overscan = 5 } = options;
const viewport = document.createElement('div'); // 可视区:overflow auto
const spacer = document.createElement('div'); // 占位:撑出总高度
const track = document.createElement('div'); // 行容器:整体 translateY
viewport.style.overflow = 'auto';
viewport.appendChild(track);
container.appendChild(spacer);
container.appendChild(viewport);
let items: unknown[] = [];
let startIndex = 0;
let endIndex = 0;
let rafId = 0;
function render(): void {
const total = items.length;
spacer.style.height = `${total * rowHeight}px`; // 总高度
const scrollTop = viewport.scrollTop;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const visible = Math.ceil(viewport.clientHeight / rowHeight) + overscan * 2;
if (first === startIndex && first + visible === endIndex) return; // 范围没变不重渲
startIndex = first;
endIndex = first + visible;
track.style.transform = `translateY(${startIndex * rowHeight}px)`;
track.innerHTML = ''; // 实际项目用增量 diff 或框架渲染
for (let i = startIndex; i < Math.min(endIndex, total); i++) {
track.appendChild(renderRow(items[i], i)); // renderRow 由业务提供
}
}
viewport.addEventListener(
'scroll',
() => {
cancelAnimationFrame(rafId); // 每帧最多重算一次
rafId = requestAnimationFrame(render);
},
{ passive: true } // 不 preventDefault,passive 提升滚动性能
);
return {
setItems(next: unknown[]): void {
items = next;
viewport.scrollTop = 0; // 数据源整体更换,滚回顶部
render();
},
scrollToIndex(index: number): void {
viewport.scrollTop = index * rowHeight;
},
};
}
从这份骨架能看清三个实现要点:
- 滚动事件里不要直接 render。用
requestAnimationFrame合并,一帧一次;监听器声明passive: true,告诉浏览器不需要 preventDefault,滚动可以不等 JS。 - 范围没变就跳过。慢速滚动时每帧 scrollTop 只变化几像素,多数帧的可渲染范围与上一帧完全相同,提前 return 是最便宜的优化。
- 滚动容器与测量对象必须是同一个元素。
scrollTop与clientHeight都从视口元素上取,取错对象(比如取了外层)是新手第一坑,表现为"翻倍渲染或半屏空白"。
滚动容器的两种结构
实现前先确定列表挂在谁身上滚动:
- 内部容器滚动(上面骨架的做法):列表自己一个固定高度的盒子,
overflow: auto。测量和定位都发生在盒子内部,逻辑最简单,适合工具类界面(日志、表格)。 - 整页滚动(视口是
window):列表要监听 window 的 scroll,并计算自身getBoundingClientRect().top相对视口的偏移。移动端体验更自然(浏览器滚动条、下拉刷新、地址栏收缩都原生兼容),但定位计算要处理"列表开始于页面中部"的偏移,resize 与地址栏高度变化都要重新测量。
动态行高:虚拟列表的分水岭
内容型列表的行高不可能固定:一段文本在宽屏两行、窄屏五行。动态行高下"第 N 行的 scrollTop"没有公式,只有缓存。标准做法是前缀高度数组 + 渲染后测量回填:
class DynamicRowCache {
private heights: number[] = []; // 每行已测量的真实高度
private offsets: number[] = [0]; // 前缀和:offsets[i] = 前 i 行总高度
private estimated: number; // 未测量行的估算高度
/** 二分查找:给定 scrollTop 找到起始行 */
findStartIndex(scrollTop: number, total: number): number {
let lo = 0, hi = total - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (this.offsetOf(mid + 1) <= scrollTop) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** 未测量的行按估算高度兜底,保证滚动条长度始终有值 */
offsetOf(index: number): number {
return index < this.offsets.length
? this.offsets[index]
: this.offsets[this.offsets.length - 1] + (index - (this.offsets.length - 1)) * this.estimated;
}
/** 渲染后回填真实高度,修正 offsets,并触发一次校正渲染 */
measure(index: number, height: number): boolean {
if (Math.abs(this.heights[index] - height) < 0.5) return false;
this.heights[index] = height;
this.rebuildOffsets();
return true; // 返回 true 时调用方应重算可视范围
}
private rebuildOffsets(): void {
this.offsets = [0];
for (let i = 0; i < this.heights.length; i++) {
this.offsets.push(this.offsets[i] + this.heights[i]);
}
}
}
工作流程与坑位:
- 初始用估算高度渲染。估算值来自"已知行的平均高度",冷启动时偏差最大,首屏估算可以取"先渲染再测量"。
- 行渲染完成后测量真实高度(
entry.target.getBoundingClientRect().height或 ResizeObserver,行内容异步加载后还会变高,ResizeObserver 能持续跟踪)。测量值与缓存差超过阈值才触发重算,避免浮点抖抖抖。 - 测量回填会让下方内容位移——真实高度比估算矮时,用户正在看的内容会跳。缓解手段:估算值宁大勿小(宁可短暂留白也不要内容上蹿)、首次测量校正只在视口外安静进行。
- 图片、字体、异步数据都会让行高变化,一切"行高固定"的假设在内容型列表里都会被打破,这是选库时第一个要验证的能力。
快速滚动的白屏处理
惯性滚动一秒划过几千行,视口内容每帧全换,测量与渲染跟不上就出现白屏。三档处理:
| 档位 | 手段 | 效果 |
|---|---|---|
| 基础 | 加大 overscan(10–20 行) | 拖慢一拍再白屏,多数列表够用 |
| 进阶 | 白屏瞬间显示占位行(估算高度的灰色块) | 用户看到"内容正在飞过来"而不是空白 |
| 激进 | 检测滚动速度,高速时降级为占位渲染,停止后再渲染真实内容 | 永不白屏,但实现复杂、依赖速度阈值调参 |
/* 占位行:与真实行高一致,避免占位与真实内容切换时再跳一次 */
.row-placeholder {
height: var(--row-estimated-height);
background: linear-gradient(90deg, #f2f2f2 25%, #e8e8e8 50%, #f2f2f2 75%);
animation: row-sheen 1.2s infinite;
}
@keyframes row-sheen {
from { background-position: -200px 0; }
to { background-position: 200px 0; }
}
另一个常被忽略的细节:overscan 是内存与流畅度的换算。overscan = 20 且行高 40px 时,用户永远有 800px 的"预渲染余量",够大部分惯性滚动;overscan 拉到 50 会显著增加每帧渲染量,慢机器反而更卡。
方案选型:自实现还是用库
| 方案 | 适合 | 成本 |
|---|---|---|
| 自实现(本文骨架级) | 固定行高、结构简单的场景;需要完全掌控细节 | 动态行高/分组/反向滚动要自己补齐,隐性成本高 |
| 成熟虚拟库(如 react-window、TanStack Virtual 等) | 绝大多数业务列表,动态行高、横竖双向、表格布局都有现成实现 | 引入依赖与 API 学习,但生产验证充分 |
CSS content-visibility: auto | 内容不聚焦交互、允许浏览器自由裁剪的页面 | 一行 CSS,但行为由浏览器决定:滚动到未渲染区仍有空白感,且对"跳到第 N 行"类 API 无能为力 |
判断顺序建议:先确认需求是不是"真·万行"(见下节),是的话优先选成熟库,自实现留给"框架栈特殊(原生 JS/老栈)或要求极致定制"的场景。选库时用动态行高 + 快速滚动 + 反向插入(聊天)三条用例实测,这三条是库之间差距最大的地方。
与无限加载的区别:一个常被混淆的问题
两个方案解决的是不同维度的问题,经常被混为一谈:
| 维度 | 无限加载 | 虚拟列表 |
|---|---|---|
| 解决的问题 | 数据从无到有(往数据源追加) | DOM 从多到少(控制渲染量) |
| DOM 数量 | 随加载持续增长,滚到底时已渲染全部 | 恒定在视口行数级别 |
| 触发机制 | IntersectionObserver 观察哨兵元素 | scroll 事件 + 可视区计算 |
| 万行数据表现 | 渐进劣化:越滚越卡 | 恒定性能 |
组合使用是常见形态:虚拟列表管渲染,列表底部放哨兵元素做 IntersectionObserver 触发追加数据。反过来只做无限加载做不做虚拟化,"加载第一百页时页面还剩多少内存"就是定时炸弹。哨兵观察器的具体实现参考站内 IntersectionObserver 一文。
边界情况:上线前必须过的四关
- 键盘导航:方向键在行间移动焦点时,目标行可能不在渲染范围内。焦点移到未渲染行前要先
scrollToIndex让它进入视口,再等下一帧才focus()——否则 focus 落在已删除的节点上直接报错。 - 锚点跳转与搜索定位:
scrollToIndex在动态行高下是估算位置,跳完还要一轮"测量校正 + 二次微调"才能精确停在目标行,把它做成两段式。 - 无障碍:屏幕阅读器需要知道列表总规模。渲染容器上声明
role="list"且aria-setsize给真实总数(而不是 DOM 里的当前行数),否则读屏用户听到的是一个"只有 30 行"的假列表。表格场景优先用真实<table>结构虚拟化行,而不是 div 仿造。 - 滚动位置恢复:从详情页返回列表要还原 scrollTop。在动态行高下还原是"先跳估算位置,等测量完成再校正"的异步过程;并且只有恢复渲染完成后才能设置 scrollTop,设置了没人监听是白设。
注意事项与边界
- 数据源筛选/排序变化时必须整体重置:清缓存、回顶部或按业务保留锚点,残留旧 offsets 是"跳行"事故的直接来源。
scroll事件处理器里禁止同步重布局操作(读offsetHeight同时写 style),强制同步布局会把每帧成本翻几倍。- 移动端 WebView 的惯性滚动事件频率更高,overscan 与 rAF 合并比桌面更关键。
- 行内交互(悬浮菜单、拖拽排序)与虚拟化天然冲突:菜单跟随行存在,行被回收菜单就没了。菜单和拖拽层要挂到容器外的独立层,用坐标对齐而不是嵌进行内。
- 虚拟化后"Ctrl+F 页面搜索"搜不到未渲染内容,需要自建搜索(输入后
scrollToIndex定位)替代。 - 数据量没有上千行时不要上虚拟列表:普通渲染 + CSS
content-visibility已经够用,虚拟化的复杂度是负资产。
总结
虚拟列表的核心是把渲染成本从 O(数据量) 压到 O(视口):原理只有三行公式(总高度、起始下标、可见行数),难点集中在动态行高(估算 + 测量回填 + 二分定位)与快速滚动白屏(overscan + 占位行)。方案上优先成熟库,自实现留给特殊栈和深度定制场景;上线前过键盘导航、锚点跳转、无障碍、位置恢复四关,才是能进生产的虚拟列表。最后记住边界:千行以下的列表,虚拟化不是优化而是负担。
参考
- MDN: content-visibility
- MDN: Element.scrollTop
- MDN: requestAnimationFrame
- web.dev: content-visibility 提升渲染性能
- 站内参考:IntersectionObserver 实战(无限加载哨兵与观察器的完整实现)
- 站内参考:ResizeObserver 与计时器的布局测量实践(行高变化的持续测量)
- 站内参考:Web Worker 实战(十万级数据的排序过滤搬到 Worker)
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。