虚拟列表实现原理
场景题:页面需要展示数万条数据,不能依赖传统分页,如何保证首屏和滚动都流畅?请说明虚拟列表的实现原理。
直接把数万条数据映射成 DOM,成本并不只发生在首次渲染:浏览器还要长期维护庞大的 DOM 树,每次样式计算、布局和绘制都会受到影响。分批渲染虽然能把首屏的长任务拆开,但最终仍会留下数万个节点。
虚拟列表(Virtual List / Windowing)的核心不是“更快地创建所有节点”,而是:
保留完整数据,只渲染视口附近的一小段 DOM;滚动时不断移动这个渲染窗口。
它优化的是 DOM 数量,和分页、无限滚动解决的问题不同:
实际项目中,虚拟列表经常和无限滚动组合使用:虚拟列表负责控制 DOM 数量,无限滚动负责按需请求数据。
一、定高虚拟列表
假设列表有 100000 项,每项固定高 50px,视口高 500px。屏幕一次只能看到约 10 项,因此没有必要创建 10 万个节点。
实现需要三层结构:
滚动容器(固定高度,overflow: auto)
└── 占位层(高度 = 数据总数 × 每项高度,用来撑出正确滚动条)
└── 渲染层(通过 transform 移到当前窗口,只包含视口附近的节点)
1. 计算可见区间
滚动时可以根据 scrollTop 直接得到窗口边界:
const firstVisibleIndex = Math.floor(scrollTop / itemHeight);
const lastVisibleIndex = Math.ceil(
(scrollTop + viewportHeight) / itemHeight
);
这里把 lastVisibleIndex 约定为切片的右开边界,即数据范围是 [firstVisibleIndex, lastVisibleIndex)。
只渲染恰好可见的项目容易在快速滚动时短暂露白,通常会在上下额外渲染几项,这个缓冲区称为 overscan:
const startIndex = Math.max(0, firstVisibleIndex - overscan);
const endIndex = Math.min(itemCount, lastVisibleIndex + overscan);
const visibleItems = items.slice(startIndex, endIndex);
2. 撑开总高度
如果只留下十几个节点,滚动条也会变得很短,用户将无法滚动到后面的数据。因此需要一个占位层模拟完整列表高度:
const totalHeight = itemCount * itemHeight;
占位层只是一个高盒子,不包含对应数量的 DOM。
3. 把渲染窗口移动到正确位置
切片后的第一项原本应该出现在 startIndex * itemHeight 的位置,所以要把整个渲染层向下移动:
const offsetY = startIndex * itemHeight;
通常使用 transform: translateY(...) 定位渲染层;也可以让每一项绝对定位到自己的 top。滚动过程中,真实 DOM 数量大致保持为“可见项数 + 两侧缓冲项数”。
二、一个可直接运行的原生 JavaScript Demo
下面的示例只使用 HTML、CSS 和原生 JavaScript,不依赖框架、第三方库或构建工具。把完整代码保存为 virtual-list.html,直接用浏览器打开即可运行。
原生 JavaScript 定高虚拟列表
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>原生 JavaScript 虚拟列表</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px 16px;
color: #1f2937;
background: #f5f7fa;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.demo {
max-width: 760px;
margin: 0 auto;
padding: 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgb(0 0 0 / 8%);
}
h1 {
margin: 0 0 8px;
font-size: 22px;
}
.description,
#status {
color: #6b7280;
font-size: 14px;
}
.toolbar {
display: flex;
gap: 8px;
margin: 16px 0;
}
button {
padding: 7px 12px;
color: #fff;
background: #2563eb;
border: 0;
border-radius: 6px;
cursor: pointer;
}
button:hover {
background: #1d4ed8;
}
#viewport {
position: relative;
height: 400px;
overflow-y: auto;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
contain: strict;
scrollbar-gutter: stable;
}
/* 只负责撑开完整列表高度,从而生成正确的滚动条 */
#phantom {
position: relative;
width: 100%;
}
/* 真实渲染的窗口,通过 transform 移动到当前数据所在位置 */
#render-window {
position: absolute;
top: 0;
right: 0;
left: 0;
will-change: transform;
}
.row {
display: flex;
align-items: center;
height: 50px;
padding: 0 16px;
border-bottom: 1px solid #e5e7eb;
background: #fff;
}
.row:nth-child(even) {
background: #f9fafb;
}
#status {
min-height: 20px;
margin: 12px 0 0;
}
</style>
</head>
<body>
<main class="demo">
<h1>100,000 条数据的虚拟列表</h1>
<p class="description">
滚动区域中始终只有十几个真实 DOM 节点。
</p>
<div class="toolbar">
<button type="button" data-index="0">回到顶部</button>
<button type="button" data-index="50000">跳到中间</button>
<button type="button" data-index="99999">跳到底部</button>
</div>
<div
id="viewport"
role="grid"
aria-label="十万条数据的虚拟列表"
aria-rowcount="100000"
tabindex="0"
>
<div id="phantom">
<div id="render-window" role="rowgroup"></div>
</div>
</div>
<p id="status" aria-live="polite"></p>
</main>
<script>
const TOTAL = 100_000;
const ITEM_HEIGHT = 50;
const OVERSCAN = 5;
// 示例保留了完整数据;虚拟化只减少 DOM,不会减少数据本身的内存。
const items = Array.from({ length: TOTAL }, (_, index) => ({
id: index + 1,
title: `这是第 ${index + 1} 条数据`,
}));
const viewport = document.querySelector("#viewport");
const phantom = document.querySelector("#phantom");
const renderWindow = document.querySelector("#render-window");
const status = document.querySelector("#status");
// 占位层模拟完整列表高度,保证滚动条的长度和位置正确。
phantom.style.height = `${TOTAL * ITEM_HEIGHT}px`;
let animationFrameId = 0;
let previousStart = -1;
let previousEnd = -1;
function render() {
const scrollTop = viewport.scrollTop;
const viewportHeight = viewport.clientHeight;
const firstVisibleIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const lastVisibleIndex = Math.ceil(
(scrollTop + viewportHeight) / ITEM_HEIGHT,
);
const startIndex = Math.max(0, firstVisibleIndex - OVERSCAN);
const endIndex = Math.min(TOTAL, lastVisibleIndex + OVERSCAN);
// 同一个数据窗口内滚动时,不需要重复创建 DOM。
if (startIndex === previousStart && endIndex === previousEnd) {
return;
}
previousStart = startIndex;
previousEnd = endIndex;
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
const row = document.createElement("div");
row.className = "row";
row.setAttribute("role", "row");
row.setAttribute("aria-rowindex", String(index + 1));
row.dataset.id = String(items[index].id);
row.textContent = items[index].title;
fragment.appendChild(row);
}
// 渲染窗口移动到 startIndex 原本应该出现的位置。
renderWindow.style.transform = `translateY(${startIndex * ITEM_HEIGHT}px)`;
renderWindow.replaceChildren(fragment);
status.textContent =
`可见数据:${firstVisibleIndex + 1}~${Math.min(lastVisibleIndex, TOTAL)};` +
`实际渲染:${startIndex + 1}~${endIndex};` +
`真实 DOM:${renderWindow.childElementCount} 个`;
}
// 将高频 scroll 事件合并为每个动画帧至多更新一次。
function scheduleRender() {
if (animationFrameId) return;
animationFrameId = requestAnimationFrame(() => {
animationFrameId = 0;
render();
});
}
function scrollToIndex(index) {
const safeIndex = Math.max(0, Math.min(index, TOTAL - 1));
viewport.scrollTop = safeIndex * ITEM_HEIGHT;
scheduleRender();
}
viewport.addEventListener("scroll", scheduleRender, { passive: true });
document.querySelector(".toolbar").addEventListener("click", (event) => {
const button = event.target.closest("button[data-index]");
if (!button) return;
scrollToIndex(Number(button.dataset.index));
});
render();
</script>
</body>
</html>
以这个示例为例,视口可见 8 项,overscan 上下各补 5 项,页面通常只需要维护十几个列表节点。滚动事件通过 requestAnimationFrame 合帧,只有可见区间发生变化时才重新生成节点。数据数组仍然完整保留在内存中,所以虚拟列表减少的是 DOM 和渲染开销,并不会自动解决海量数据本身的内存占用。
三、为什么要设置 overscan?
滚动事件、框架状态更新和浏览器绘制并不是完全同步的。如果窗口只包含刚好可见的项目,快速拖动滚动条时,新节点可能来不及提交,视口边缘就会露出空白。
overscan 用少量额外 DOM 换取滚动稳定性:
- 缓冲太小:快速滚动时更容易白屏或闪烁。
- 缓冲太大:失去虚拟化收益,单次更新也更重。
- 常见做法:按项目数或像素设置缓冲,并根据滚动方向给前方更多缓冲。
滚动回调里应只读取必要的布局信息,避免读写交错。若一次滚动会触发大量业务计算,可以用 requestAnimationFrame 合并为每帧一次更新,而不是随意设置一个很大的节流间隔,否则窗口会明显跟不上滚动位置。
四、不定高列表如何实现?
定高列表能用除法直接定位。不定高列表中,第 i 项的起点取决于它前面所有项目的高度:
offset[i] = height[0] + height[1] + ... + height[i - 1]
生产级实现通常包含以下步骤:
- 预估高度:未渲染的项目先使用一个估算值,得到初始总高度和偏移量。
- 测量真实高度:节点渲染后用
ResizeObserver 获取实际尺寸,并写入高度缓存。
- 维护前缀偏移:高度变化后,重新计算受影响项目的
offset 和列表总高度。大规模场景可使用树状数组等结构维护前缀和。
- 查找起始项:
offset 是递增数组,可以根据 scrollTop 二分查找第一个与视口相交的项目,将线性查找降为 O(log n)。
- 修正滚动锚点:如果视口上方某项的实测高度和估值不同,要把差值补偿到
scrollTop,否则用户看到的内容会突然跳动。
伪代码如下:
// offsets[i] 表示第 i 项顶部到列表顶部的距离
const startIndex = binarySearchFirstItem(offsets, scrollTop);
const endIndex = binarySearchFirstItem(
offsets,
scrollTop + viewportHeight,
);
// 节点尺寸变化后更新缓存
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.getAttribute("data-index"));
heightCache.set(index, entry.contentRect.height);
recomputeOffsetsFrom(index);
}
});
resizeObserver.observe(rowElement);
不定高虚拟列表最难的不是“测量”,而是测量结果返回后仍要保持视觉位置稳定。图片、折叠面板、异步文本都会改变行高;能固定高度时应优先固定,图片也最好预留宽高比。
五、工程中的常见陷阱
1. 列表项状态丢失
滚出窗口的节点会被卸载,节点内部未提升的输入状态、展开状态可能丢失。状态应按稳定的业务 id 存在列表外部,key 也不要使用渲染窗口内的局部下标。
2. 可访问性和键盘操作
屏幕阅读器无法感知未挂载的节点,Tab 焦点也可能随着节点卸载而丢失。需要补充正确的 aria-rowcount、aria-rowindex 等语义,并在键盘导航到窗口外项目时先滚动、再恢复焦点。
3. 滚动到指定项
定高列表可以直接设置 scrollTop = index * itemHeight。不定高列表只能基于缓存偏移定位;未测量区域通常先用估值跳转,测量后再校准。
4. 数据变化导致位置跳动
在列表头部插入、删除数据,或者聊天记录向上加载历史消息时,应记录更新前后的滚动锚点,让同一条内容在视口中的相对位置保持不变。
5. 组件渲染本身仍然很重
虚拟列表只限制节点数量。如果单个列表项包含复杂计算、大图或频繁变化的全局状态,仍可能掉帧。还需要稳定 props、缓存昂贵计算、合理拆分状态,并避免在滚动回调中执行重逻辑。
六、什么时候不该使用虚拟列表?
虚拟化会增加高度测量、焦点管理、滚动定位、SSR 和自动化测试的复杂度。如果只有几十或几百个简单节点,普通列表可能更可靠。是否使用应以性能测量为依据,而不是只看数据条数。
在业务中通常优先使用成熟方案,例如 React 生态的 react-window、TanStack Virtual,Vue 生态的 vue-virtual-scroller。手写实现适合理解原理;涉及不定高、反向列表、二维表格、吸顶元素和无障碍能力时,更应该采用经过验证的库。
高频面试题剖析
Q:虚拟列表是如何只渲染少量节点,却仍然保持正确滚动条和滚动位置的?
回答思路:
- 先讲目标:全量数据仍然保留,但 DOM 只渲染视口附近的窗口,节点数量稳定在几十个左右。
- 讲清三层结构:外层是可滚动视口;占位层用“总条数 × 单项高度”撑出完整滚动条;渲染层只放切片数据,再通过
translateY 移到正确位置。
- 给出核心计算:定高时用
Math.floor(scrollTop / itemHeight) 算起始下标,用 (scrollTop + viewportHeight) / itemHeight 算结束下标,并在两侧加入 overscan 防止快速滚动露白。
- 展开不定高难点:先估高,渲染后通过
ResizeObserver 实测并缓存;维护高度前缀和,通过二分查找可见区间;修正视口上方的高度误差以避免滚动跳动。
- 补充工程边界:虚拟列表解决 DOM 规模,不负责数据分页;还要处理稳定 key、行内状态、焦点、滚动锚定和单行渲染成本。生产环境优先选择成熟库。