Skip to content
虚拟列表:DOM 复杂度从 O(N) 到 O(1)
概述
全量渲染一万个列表项时,DOM 节点数量、内存占用以及重排(reflow)的成本随数据量线性增长,交互的响应延迟也会同步增加。虚拟列表的核心是把 DOM 节点数量与数据总量解耦:只渲染当前可视区域内的节点,不可见区域用占位空间替代。渲染复杂度从 O(N) 降至 O(visibleCount),visibleCount 由容器高度和每个 item 的高度决定,与数据总量无关。
基本概念
虚拟列表的 DOM 结构由三层组成:
┌─────────────────────────┐
│ scroll container │ ← overflow: auto,固定高度
│ │
│ ┌─────────────────────┐ │
│ │ spacer │ │ ← 撑开滚动条,高度 = totalCount × itemHeight
│ │ [不可见区域] │ │
│ ├─────────────────────┤ │ ← transform: translateY(startIndex × itemHeight)
│ │ item 7 .. item 12 │ │ ← 只渲染可视区域 + buffer 内的节点
│ ├─────────────────────┤ │
│ │ [不可见区域] │ │
│ └─────────────────────┘ │
└─────────────────────────┘三个数值控制渲染范围:
visibleCount = Math.ceil(containerHeight / itemHeight)startIndex = Math.floor(scrollTop / itemHeight)endIndex = startIndex + visibleCount + buffer
buffer 是额外多渲染的几个节点,用于避免快速滚动时出现短暂白屏。一般设为 2 到 5。
工作原理
浏览器渲染一帧大致经过 JS/CSS → Style → Layout → Paint → Composite 几个阶段。不同 DOM 操作触发的阶段不同,成本差距显著:
| 操作 | 触发阶段 | 典型一帧成本 |
|---|---|---|
修改 top / left / width / height | Layout → Paint → Composite | 5–15ms |
修改 color / background / box-shadow | Paint → Composite | 1–5ms |
修改 transform / opacity | Composite only | 0.1–0.5ms |
虚拟列表在滚动时执行的是平移。transform: translateY(offsetY) 把可见区域作为 GPU 纹理进行位移,只要合成层已建立,就不会触发布局和绘制。建立合成层的常见方式有 will-change: transform、3D transform(translateZ(0)),以及现代浏览器中 transform 的隐式提升。没有独立合成层时,transform 的变化仍然会触发 paint。
读取 scrollTop 会强制同步布局。如果在读取前存在尚未 flush 的 DOM 修改,浏览器必须同步完成布局计算。因此虚拟列表的 scroll handler 应当遵循“先批量读取,再批量写入”的顺序,避免 read-write-read 交替导致的布局抖动(layout thrashing):
js
// 布局抖动:读 → 写 → 读 → 写
onScroll() {
const st = container.scrollTop; // 读
viewport.style.transform = '...'; // 写(脏标记)
const h = container.clientHeight; // 读 → 强制 layout flush
spacer.style.height = '...'; // 写
}
// 批量读 → 批量写
onScroll() {
const st = container.scrollTop; // 读
const h = container.clientHeight; // 读
// --- 读数全部完成 ---
viewport.style.transform = '...'; // 写
spacer.style.height = '...'; // 写
}scroll 事件的触发频率与合成线程的协作方式也直接相关。传入 { passive: true } 告知浏览器该 handler 不会调用 preventDefault(),滚动行为可以在合成线程上启动,不必等待主线程 JS 执行完毕,用户感知到的滚动更即时。
基本用法
定高列表
item 高度固定时实现最直接。关键逻辑在滚动回调中:根据 scrollTop 计算新的 startIndex,仅当 startIndex 真正发生变化时才更新 DOM。
js
class VirtualList {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleCount = Math.ceil(container.clientHeight / itemHeight) + 3;
this.startIndex = 0;
this.spacer = document.createElement('div');
this.viewport = document.createElement('div');
container.appendChild(this.spacer);
container.appendChild(this.viewport);
this.render();
container.addEventListener('scroll', () => this.onScroll(), { passive: true });
}
onScroll() {
if (this.ticking) return;
this.ticking = true;
requestAnimationFrame(() => {
const newStart = Math.floor(this.container.scrollTop / this.itemHeight);
if (newStart !== this.startIndex) {
this.startIndex = newStart;
this.updateVisibleRange();
}
this.ticking = false;
});
}
updateVisibleRange() {
const end = Math.min(this.startIndex + this.visibleCount, this.items.length);
const offsetY = this.startIndex * this.itemHeight;
const totalHeight = this.items.length * this.itemHeight;
this.spacer.style.height = totalHeight + 'px';
this.viewport.style.transform = `translateY(${offsetY}px)`;
this.renderItems(this.startIndex, end);
}
renderItems(start, end) {
const fragment = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const node = this.pool.get(i) || this.createItemNode(i);
this.updateNodeContent(node, this.items[i]);
fragment.appendChild(node);
}
this.viewport.innerHTML = '';
this.viewport.appendChild(fragment);
}
}updateVisibleRange 设置了 spacer 的高度撑开滚动条,并用 transform 将可视区平移到正确位置。renderItems 虽然用了 DocumentFragment,但仍通过 innerHTML = '' 整体置换可视区节点。这意味着每次滚动都会销毁并重建所有可见节点。
更高效的做法是让离开可见区的 DOM 节点进入复用池,新进入的 item 从池中取出节点更新内容,避免反复创建和销毁。
js
class DOMPool {
constructor(createFn) {
this.pool = [];
this.createFn = createFn;
}
acquire() {
return this.pool.pop() || this.createFn();
}
release(node) {
node.removeAttribute('data-key');
node.textContent = '';
this.pool.push(node);
if (this.pool.length > 20) this.pool.length = 20;
}
}池容量需要设置上限。滚动停止后堆积的 DOM 节点是纯粹的内存开销,会延长 GC 扫描时间。
动态高度
item 高度不固定时,需要先给未渲染的 item 一个预估高度,渲染后通过 ResizeObserver 获取真实高度并存入缓存,后续计算偏移量时优先使用缓存值。
startIndex 的计算从定高时的 O(1) 退化到按累加高度查找。如果每次重新计算累加高度,开销不可取:
js
const heightCache = new Map();
const estimatedHeight = 80;
function getOffset(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += heightCache.get(i) ?? estimatedHeight;
}
return offset;
}优化分两步。第一步,维护前缀和数组,在高度更新时只重算受影响的后缀。第二步,如果列表需要支持插入或删除(非尾部追加),前缀和的单点更新会退化为 O(N),此时可以用树状数组(Fenwick Tree)将单点更新和前缀和查询都控制在 O(log N)。
有了高效的位置查询,可以通过二分查找确定 startIndex:
js
function findStartIndex(scrollTop) {
let lo = 0, hi = items.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (getOffset(mid) <= scrollTop) lo = mid + 1;
else hi = mid;
}
return Math.max(0, lo - 1);
}getOffset 为 O(1) 时,二分是 O(log N);如果前缀和尚未建好,仍然用到 O(N) 的累加,则整体变成 O(N log N)。冷启动阶段可以利用滚动位置的局部性,用指数搜索(exponential search)逐步跳跃定位。
高度变化需要被持续追踪。图片加载、内容展开都可能让 item 高度改变,ResizeObserver 适合处理这种情况:
js
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = entry.target.dataset.index;
const newHeight = entry.contentRect.height;
if (heightCache.get(index) !== newHeight) {
heightCache.set(index, newHeight);
invalidateOffsetCacheFrom(index);
scheduleUpdate();
}
}
});ResizeObserver 的回调在 layout 之后、paint 之前执行,不会像 getBoundingClientRect() 那样强制同步布局。高度更新后应只标记脏区间,在下一帧的 rAF 中批量重算前缀和,避免每变化一次就触发大量计算。
框架实现
react-window:惰性元数据计算
react-window 在 createListComponent.js 中维护 instanceProps:
js
instanceProps = {
itemMetadataMap: { [index: number]: { offset: number, size: number } },
lastMeasuredIndex: -1,
estimatedItemSize: 50,
};getItemMetadata(index) 采用惰性、只向前的策略:
- 若
index <= lastMeasuredIndex,直接从itemMetadataMap[index]取值(O(1)); - 若
index > lastMeasuredIndex,从lastMeasuredIndex + 1开始向前连续计算到 index,依次调用itemSize(i),递推offset[i] = offset[i-1] + size[i-1],并更新lastMeasuredIndex。
每个 index 的偏移只计算一次,永久缓存。滚动越快,未测量区间的计算越密集,但回滚时直接命中缓存。findNearestItem(scrollOffset) 混合使用二分查找(已测量区间)和指数搜索(超出已测量区间)。总高度的估值 getEstimatedTotalSize() 由已测量部分的精确高度与未测量部分的估算值加总而成,随 lastMeasuredIndex 增长逐渐收敛。
vue-virtual-scroller:三池模型
vue-virtual-scroller 的 RecycleScroller 维护三个数据结构:
pool[]— 当前可见的 view 数组,直接渲染到模板;$_views—Map<key, view>,按 item key 做 O(1) 查找;$_unusedViews—Map<type, view[]>,按组件类型分组的复用池。
view 对象结构:
js
view = {
item: {}, // 数据对象(响应式)
position: 0, // translateY 像素值
nr: { // 非响应式元数据(Object.defineProperty)
id: uid++,
index: number,
used: boolean,
key: any,
type: string,
}
};nr 中的属性在滚动期间高频变更,使用 Object.defineProperty 声明为非响应式,以避免不必要的依赖追踪和 watcher 触发。
updateVisibleItems() 分四个阶段:
- 计算可视范围:基于
scrollTop加 buffer(默认 200px)确定[startIndex, endIndex]。滚动距离不足一个 item 高度时跳过。 - 回收离屏 view:遍历
pool,对不在范围中的 view 放入对应 type 的$_unusedViews池,并将其position设为 -9999 移出屏幕。 - 获取/复用 view:遍历目标区间,先按 key 在
$_views中查找(同 key 复用),未命中则从$_unusedViews[type]取(跨 key 复用),仍未命中才创建新 view。 - 排序:debounce 300ms 后按 index 排序 pool 数组。
类型分池的意义在于不同类型 item 的 DOM 结构不同,同类型复用可以避免 DOM 结构重建和组件销毁/创建的开销。与 react-window 依赖 React reconciliation 通过稳定 key 实现 DOM 复用的思路不同,vue-virtual-scroller 在框架层之上自建了三池管理系统,对 DOM 生命周期有更精确的控制。
帧预算与 buffer 选择
60fps 下每帧预算约为 16.6ms。虚拟列表在滚动期间需要完成读取 scrollTop、计算 startIndex、更新 DOM。DOM 更新的耗时取决于 item 复杂度。
在一个复杂 item(含头像、标题、描述、时间戳等)中,单次 DOM 更新大约耗时 0.1–0.3ms。若 visibleCount + 2 × buffer = 20,总操作时间约 6ms,占帧预算 36%,处于安全范围。buffer 增大会增加单帧 DOM 操作量:
| buffer | 额外帧预算消耗 | 效果 |
|---|---|---|
| 0 | — | 快速滚动必然白屏 |
| 2 | ~20% | 适度滚动无白屏 |
| 5 | ~50% | 极快速滚动无白屏 |
| 10 | ~100% | 可能掉帧,白屏概率极低 |
一般场景 2–5 已足够。在惯性滚动(fling)末段,移动端速度可能达到每帧 10–20 个 item,这时需要增大 buffer 并降低 item 复杂度(例如使用骨架屏)。
通过 rAF 节流,DOM 更新频率锁定在每帧一次,不会生成额外帧。实时 FPS 可用以下方式测量:
js
let lastTime = performance.now();
let frames = 0;
let fps = 60;
function measureFPS() {
frames++;
const now = performance.now();
if (now >= lastTime + 1000) {
fps = Math.round((frames * 1000) / (now - lastTime));
frames = 0;
lastTime = now;
}
requestAnimationFrame(measureFPS);
}虚拟列表应做到:慢速滚动 60fps,快速滚动 55–60fps。低于此区间时,需依次检查 RAF 节流是否生效、是否存在布局抖动、item 组件是否过重、buffer 是否过大。
对 INP(Interaction to Next Paint)的影响也需要注意。scroll 事件的 handler 如果直接执行 DOM 操作,可能使一次交互的处理链路超过 200ms。正确的做法是将 DOM 写入全部放在 rAF 回调中,scroll handler 本身只做轻量读取和 rAF 调度,耗时控制在 1ms 以内。
注意点
滚动条跳动:不定高场景下,预估高度与真实高度差距较大,或同一 item 频繁变化高度(如折叠/展开),会导致总高度估算不准,滚动条长度在滚动过程中变化。定高方案不受此影响。
搜索跳转定位:通过搜索功能跳转到尚未渲染的 item 时,浏览器无法执行 scroll-into-view。需要先计算出目标 item 的位置,设置 startIndex 并渲染该 item,再执行滚动。
键盘导航焦点丢失:用户按上下箭头导航时,焦点可能落在未渲染的 item 上。应在 keydown handler 中拦截导航键,手动调用 scrollToIndex 确保 DOM 存在后再移动焦点。
无障碍访问:屏幕阅读器只能读到当前渲染的 DOM 片段,需要 ARIA 属性补充上下文:
html
<div role="list" aria-rowcount="{totalCount}" aria-label="结果列表">
<div role="listitem" aria-rowindex="{index + 1}">{content}</div>
</div>异步内容高度突变:图片等异步资源加载完毕会改变 item 高度,如果没有更新高度缓存,后续 item 的位置会出现跳动。ResizeObserver + 脏区间标记的方式可解决该问题。
移动端差异:iOS Safari 在惯性滚动期间不触发 scroll 事件,只在手指触摸期间和惯性结束后触发。如果 buffer 不够大,用户在惯性滚动末段会看到白屏。需增大 buffer,或通过 touchmove 辅助。Android Chrome 行为接近桌面端,但设备性能普遍较低,item 组件应尽量精简。移动端 -webkit-overflow-scrolling: touch 会创建新的层叠上下文,影响合成层行为,需要单独测试。
与 CSS content-visibility: auto 的关系:该属性允许浏览器自动跳过屏幕外元素的渲染工作,但 DOM 节点本身仍然存在,内存不会被释放。虚拟列表通过不创建屏幕外节点来控制内存占用,二者可以叠加使用,但虚拟列表已经控制了 DOM 数量,content-visibility 带来的额外收益有限。
应用
部署前验证
- rAF 节流已生效(Performance 面板确认每帧至多一次 DOM 更新)。
- scroll 监听器使用了
{ passive: true }。 - 读写分离,不存在 read-write-read 交替。
- buffer 取值合理(2–5,移动端视滚动速度适当加大)。
startIndex未变时跳过 DOM 操作。- 容器已形成独立合成层(
will-change: transform或等效手段)。 - 图片等异步内容有初始占位尺寸。
ResizeObserver回调有 debounce,高度更新后批量重算前缀和。scrollToIndex逻辑针对未渲染的目标 index 做了处理。- 必要的 ARIA 属性已补充。
滚动卡顿排查
- 使用 DevTools Performance 面板录制滚动,确认掉帧发生的阶段(JS、Layout、Paint、Composite)。
- JS 阶段耗时高:检查 rAF 回调中是否重复计算前缀和、是否跳过了
startIndex不变的帧。 - Layout 阶段耗时高:排查布局抖动,检查是否使用了
top而非transform定位,ResizeObserver回调中是否触发了 layout。 - Paint 阶段耗时高:检查合成层是否生效,item 是否包含
box-shadow、filter、backdrop-filter等昂贵绘制属性。 - Composite 阶段耗时高:检查是否每个 item 都单独设置了
will-change: transform导致合成层数量失控(layer explosion)。
