在命令式选择状态模型确定后,文件管理器还需要处理上万个文件。若将所有项都渲染为 DOM 节点,滚动和布局成本会随数据量增长。本文用原生 JavaScript 实现固定尺寸虚拟滚动,并同时支持列表(list)和缩略图(grid)视图。
实现边界
实现不依赖第三方虚拟列表库,也不依赖 React。它只解决窗口计算、节点复用、绝对定位和视图切换;选择状态由前文定义的状态模型保存,虚拟滚动只在每次渲染时将状态投射到当前窗口中的节点。
整体思路
虚拟滚动的核心思想很简单:只渲染用户能看到的那一小部分。
具体做法如下:
- 外层一个有限高度的
viewport,设置overflow: auto。 - 内层一个
placeholder,高度撑满所有数据的总高度,用来撑起滚动条。 - 真正渲染的
visibleItems通过绝对定位(position: absolute)贴到 viewport 内对应的位置。
当用户滚动时,根据 scrollTop 反算出当前应该显示哪些项,只渲染这一小撮。
scrollTop 到 visible range 的映射
这是整个方案中最基础、也最关键的一步。假设:
- 每项高度固定为
itemHeight - 容器可视区域高度为
viewportHeight - 当前滚动位置为
scrollTop - 总数据量为
totalItems
那么当前可见范围(不考虑 overscan)的起止索引:
var startIndex = Math.floor(scrollTop / itemHeight);
var endIndex = Math.min(
totalItems - 1,
Math.ceil((scrollTop + viewportHeight) / itemHeight) - 1
);这个计算非常直白:起始索引就是”滚动过的高度”除以单项高度向下取整;结束索引就是”滚动过的高度 + 可视高度”除以单项高度再向下取整,但不会超过最后一项。
List 模式的完整计算函数:
interface VisibleRange {
startIndex: number;
endIndex: number;
offsetY: number; // 第一项相对于 placeholder 顶部的偏移
visibleCount: number;
}
function calcVisibleRange(
scrollTop: number,
viewportHeight: number,
itemHeight: number,
totalItems: number
): VisibleRange {
var startIndex = Math.floor(scrollTop / itemHeight);
var endIndex = Math.min(
totalItems - 1,
Math.floor((scrollTop + viewportHeight) / itemHeight)
);
return {
startIndex: Math.max(0, startIndex),
endIndex: endIndex,
offsetY: startIndex * itemHeight,
visibleCount: endIndex - startIndex + 1
};
}overscan 缓冲区
如果严格按照上面的 startIndex / endIndex 来渲染,用户快速滚动时会出现”白屏”——新项还没来得及渲染,但已经进入了可视区。解决办法是给可见范围两端各加一个缓冲区(overscan):
function calcVisibleRangeWithOverscan(
scrollTop: number,
viewportHeight: number,
itemHeight: number,
totalItems: number,
overscan: number // 两端额外渲染的项数
): VisibleRange {
var startIndex = Math.floor(scrollTop / itemHeight);
var endIndex = Math.min(
totalItems - 1,
Math.floor((scrollTop + viewportHeight) / itemHeight)
);
// 扩展缓冲区
startIndex = Math.max(0, startIndex - overscan);
endIndex = Math.min(totalItems - 1, endIndex + overscan);
return {
startIndex: startIndex,
endIndex: endIndex,
offsetY: startIndex * itemHeight,
visibleCount: endIndex - startIndex + 1
};
}根据经验,overscan 设为 5 ~ 10 项在绝大多数场景下表现良好。太小会白屏,太大又浪费 DOM 节点。我的做法是取一个与 viewport 可视项数相关的值:
var overscan = Math.max(5, Math.ceil(viewportHeight / itemHeight));这样如果可视区域本身比较大,overscan 也随之增大,不会在快速长距离滚动时出问题。
绝对定位与占位容器
决定了渲染哪些项之后,下一步是如何把这些项放到正确的位置。我们的 DOM 结构是:
<div class="viewport" style="overflow:auto; height:600px;">
<div class="placeholder" style="position:relative; height:总高度;">
<!-- 只有 visibleCount 个 item 被渲染到这里 -->
<div class="item" style="position:absolute; top:0px; height:32px;">...</div>
<div class="item" style="position:absolute; top:32px; height:32px;">...</div>
...
</div>
</div>其中 placeholder 的总高度:
var totalHeight = totalItems * itemHeight;每一个被渲染出来的 item,通过绝对定位贴到它应该在的位置:
item.style.position = 'absolute';
item.style.top = (index * itemHeight) + 'px';
item.style.height = itemHeight + 'px';
item.style.left = '0';
item.style.right = '0';为什么不直接 transform: translate3d?本实现使用绝对定位和 top,因为它与数据索引的坐标换算直接对应。是否改用 transform 应由实际性能剖面决定;两种方式都不改变窗口计算和节点复用策略。
完整的 list 模式渲染函数:
下面用节点池展示原生实现。当前配套 Demo 采用 React 实现相同的窗口协调,只保留可见区的组件;两者共享 range、overscan 和绝对定位几何。文章中的原生实现仍可独立运行,不依赖函数式组件。
interface VirtualScrollConfig {
viewportEl: HTMLElement;
placeholderEl: HTMLElement;
itemHeight: number;
totalItems: number;
overscan: number;
renderItem: (index: number, el: HTMLElement) => void;
}
classListViewVirtualScroll {
private config: VirtualScrollConfig;
private viewportHeight: number = 0;
private cacheEls: HTMLElement[] = []; // 复用的 DOM 节点池
constructor(config: VirtualScrollConfig) {
this.config = config;
this.viewportHeight = config.viewportEl.clientHeight;
config.viewportEl.addEventListener('scroll', this.onScroll.bind(this));
this.onScroll(); // 初始渲染
}
private onScroll() {
var scrollTop = this.config.viewportEl.scrollTop;
var range = calcVisibleRangeWithOverscan(
scrollTop,
this.viewportHeight,
this.config.itemHeight,
this.config.totalItems,
this.config.overscan
);
// 设置 placeholder 总高度
this.config.placeholderEl.style.height =
(this.config.totalItems * this.config.itemHeight) + 'px';
this.renderRange(range);
}
private renderRange(range: VisibleRange) {
var cfg = this.config;
// DOM 节点复用:只创建不够用的节点
while (this.cacheEls.length < range.visibleCount) {
var el = document.createElement('div');
el.className = 'list-item';
el.style.position = 'absolute';
el.style.height = cfg.itemHeight + 'px';
el.style.left = '0';
el.style.right = '0';
cfg.placeholderEl.appendChild(el);
this.cacheEls.push(el);
}
// 隐藏多余的节点
for (var i = range.visibleCount; i < this.cacheEls.length; i++) {
this.cacheEls[i].style.display = 'none';
}
// 更新每个节点的位置和内容
for (var j = 0; j < range.visibleCount; j++) {
var itemIndex = range.startIndex + j;
var elNode = this.cacheEls[j];
elNode.style.display = '';
elNode.style.top = (itemIndex * cfg.itemHeight) + 'px';
cfg.renderItem(itemIndex, elNode);
}
}
}网格模式的二维扩展
缩略图模式与列表模式的核心区别在于:数据不再是沿着一维线性排列,而是变成了二维网格。每行有 colsPerRow 个卡片,每个卡片有自己的 cardWidth 和 cardHeight。
计算列数需要根据 viewport 宽度和卡片最小宽度:
function calcColsPerRow(viewportWidth: number, minCardWidth: number, gap: number): number {
return Math.max(1, Math.floor((viewportWidth + gap) / (minCardWidth + gap)));
}给定列数之后,行索引和行内偏移:
function indexToRowCol(index: number, colsPerRow: number): { row: number; col: number } {
return {
row: Math.floor(index / colsPerRow),
col: index % colsPerRow
};
}然后计算可见行范围:
interface GridRange {
startRow: number;
endRow: number;
startIndex: number;
endIndex: number;
totalRows: number;
offsetY: number;
}
function calcGridVisibleRange(
scrollTop: number,
viewportHeight: number,
viewportWidth: number,
cardHeight: number,
cardWidth: number,
gap: number,
totalItems: number,
overscanRows: number
): GridRange {
var colsPerRow = Math.max(1, Math.floor((viewportWidth + gap) / (cardWidth + gap)));
var rowHeight = cardHeight + gap;
var totalRows = Math.ceil(totalItems / colsPerRow);
var startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - overscanRows);
var endRow = Math.min(
totalRows - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscanRows
);
return {
startRow: startRow,
endRow: endRow,
startIndex: startRow * colsPerRow,
endIndex: Math.min(totalItems - 1, (endRow + 1) * colsPerRow - 1),
totalRows: totalRows,
offsetY: startRow * rowHeight
};
}渲染时每个卡片的位置:
for (var i = range.startIndex; i <= range.endIndex; i++) {
var row = Math.floor(i / colsPerRow);
var col = i % colsPerRow;
var top = row * (cardHeight + gap);
var left = col * (cardWidth + gap);
elNode.style.top = top + 'px';
elNode.style.left = left + 'px';
elNode.style.width = cardWidth + 'px';
elNode.style.height = cardHeight + 'px';
}视图切换时的 layout metrics
这是最容易出问题的地方。列表模式切到缩略图模式时,以下几个量会变化:
itemHeight(列表项高)→cardHeight + gap(卡片行高)- 容器宽度的变化(列表不需要考虑宽度,网格需要实时计算列数)
- 总高度变了——列表是
totalItems * itemHeight,网格是totalRows * (cardHeight + gap) scrollTop的语义也变了
处理视图切换的关键步骤:
function switchView(newView: 'list' | 'grid', vs: VirtualScroll) {
// 1. 先记住当前视图下的"逻辑位置"——当前可视区域中间那一项的数据索引
var currentMidIndex = vs.getFirstVisibleItemIndex() +
Math.floor(vs.getVisibleCount() / 2);
// 2. 切换配置
vs.setViewMode(newView);
// 3. 重新计算布局参数
vs.recalculate();
// 4. 将之前中间那一项重新定位到可视区中央
// 这需要反算新的 scrollTop
var newScrollTop = indexToScrollTop(currentMidIndex, newView);
vs.viewportEl.scrollTop = newScrollTop;
// 5. 重新渲染
vs.render();
}
function indexToScrollTop(index: number, view: ViewMode): number {
if (view.type === 'list') {
return index * view.itemHeight;
} else {
var colsPerRow = calcColsPerRow(view.viewportWidth, view.cardWidth, view.gap);
var row = Math.floor(index / colsPerRow);
return row * (view.cardHeight + view.gap);
}
}还有一个坑:viewport 宽度变化时(比如拖拽侧边栏),网格模式的列数要跟着变。我的做法是监听 resize 事件,但加一个 debounce:
var resizeTimer;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
vs.recalculate();
vs.render();
}, 150);
});容器宽度变化后,应根据当前逻辑锚点重新计算 scrollTop,使同一数据项仍处于接近原来的视口位置。
如何避免滚动和选择状态耦合
这是一个架构层面的坑。刚开始的时候,选择状态(哪些文件被选中了)是存在 DOM 节点上的——el.classList.contains('selected')。结果窗口滚动、节点复用的时候,差点把选中状态弄丢。
核心原则:选择状态必须与 DOM 节点解耦。数据驱动一切,DOM 只是状态的投影。
class FileSelectionManager {
private selectedSet: Set<number> = new Set(); // 用数据索引而非 DOM 引用
toggle(index: number) {
if (this.selectedSet.has(index)) {
this.selectedSet.delete(index);
} else {
this.selectedSet.add(index);
}
}
isSelected(index: number): boolean {
return this.selectedSet.has(index);
}
getSelectedIndices(): number[] {
return Array.from(this.selectedSet);
}
clear() {
this.selectedSet.clear();
}
}渲染时根据选择状态来投射:
function renderItem(index: number, el: HTMLElement, selection: FileSelectionManager) {
// 先更新内容
el.textContent = files[index].name;
// 再同步选择状态
if (selection.isSelected(index)) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
}这样无论 DOM 节点怎么复用、怎么销毁重建,选择状态永远跟着索引走,不会因为滚动而丢失。
事件可通过委托统一挂在 placeholder 上,再通过 data-index 定位当前项。节点池复用时无需为每个节点反复注册和移除监听器:
placeholder.addEventListener('click', function (e) {
var target = e.target.closest('.list-item');
if (!target) return;
var index = parseInt(target.getAttribute('data-index'), 10);
selection.toggle(index);
});但这里还有一个问题:closest 在万级节点下性能怎么样?实际上没问题,因为 DOM 节点只有 visibleCount 个(通常 30~60 个),不是几万个。
性能测量
做完实现之后,需要数据说话。我主要关注三个指标:
1. 帧率(FPS)
滚动时的帧率是最直接的体感指标。Chrome DevTools → Rendering → Frame Rendering Statistics 可以快速看。更精确的做法是 hook requestAnimationFrame:
var frameCount = 0;
var lastTime = performance.now();
function measureFPS() {
frameCount++;
var now = performance.now();
if (now - lastTime >= 1000) {
console.log('FPS:', frameCount);
frameCount = 0;
lastTime = now;
}
requestAnimationFrame(measureFPS);
}
requestAnimationFrame(measureFPS);帧率会受浏览器版本、设备、单项内容和滚动方式影响,应以当前页面的 Performance 录制结果为准。虚拟滚动的验收重点是实际 DOM 节点数不随 totalItems 线性增长,以及滚动处理未持续占满单帧预算。
2. 单次渲染耗时 renderRange
var t0 = performance.now();
// ... do renderRange ...
console.log('renderRange:', performance.now() - t0, 'ms');renderRange 的耗时应结合当前设备的帧预算观察。若某次渲染接近或超过单帧预算,需要检查 DOM 操作、renderItem 的计算量和 overscan 配置。
3. 内存与 DOM 节点数
最直接的工具:Chrome DevTools → Performance → Memory,或者直接数节点数:
console.log('active DOM nodes:', placeholder.children.length);在虚拟化模式下,节点数应该稳定在 visibleCount ~ visibleCount + overscan * 2,不随 totalItems 增长。
若浏览器提供 performance.memory,可用它观察 JS heap 的变化,辅助排查 DOM 泄漏:
if (performance.memory) {
console.log('heap used:', performance.memory.usedJSHeapSize / 1024 / 1024, 'MB');
}滚动性能剖面
为了精确定位慢在哪里,我写了一个简单的统计器,记录每次 scroll 事件的处理耗时:
class ScrollPerfTracker {
private logs: number[] = [];
track(fn: () => void): void {
var t0 = performance.now();
fn();
var delta = performance.now() - t0;
this.logs.push(delta);
if (this.logs.length > 200) {
this.logs.shift();
}
}
report() {
var sorted = this.logs.slice().sort(function (a, b) { return a - b; });
var avg = sorted.reduce(function (a, b) { return a + b; }, 0) / sorted.length;
var p95 = sorted[Math.floor(sorted.length * 0.95)];
var max = sorted[sorted.length - 1];
console.table({ avg: avg.toFixed(2), p95: p95.toFixed(2), max: max.toFixed(2) });
}
}配套 Demo 将数据量设为 1 万条,以便在常见笔记本上直接验证列表与网格两种窗口计算。窗口算法不依赖总条数;在固定尺寸、固定 overscan 的前提下,实际挂载节点数由视口大小和每行列数决定,而不由 totalItems 决定。
已知边界与局限
这次实现做了很多”固定尺寸”的假设,这也意味着它有一些明确的边界:
1. 变高项(variable height items)不适用
整套计算都基于”每项高度已知且固定”这个前提。如果现实场景中项高不固定——比如列表项根据描述文字长短自动变高——那 scrollTop / itemHeight 这套映射就行不通。
变高项有两种常见解决思路:
- 预估 + 修正:先给一个
estimatedHeight,渲染出来后再measure实际高度,缓存修正。这种做法需要维护一个 height map,并且在数据变化时可能要 invalidate。 - 只支持固定项高——也就是我们现在的做法,把变高项视为”不支持”,业务层去消化(比如列表模式固定行高、网格模式卡片等高)。
对于文件管理器这种场景,文件名长度有限、图标尺寸固定,固定项高是一个合理假设。如果将来需求扩展,支持变高项的成本也不会太高——前提是架构上把 calcHeight(index) 做成可替换的策略。
2. 不支持跨度的行内自适应
网格模式依赖”每行列数固定”这个假设。如果未来要做瀑布流(masonry layout),需要重新设计布局算法。
3. scroll-linked 动画不支持
因为 DOM 节点会随滚动被复用、销毁,CSS position: sticky 在虚拟化列表内不可用。同样,“监听某一项滚动到可视区触发动画”这种需求,需要在 calcVisibleRange 回调里额外做区间判断,不能依赖 IntersectionObserver(它观察的 DOM 节点会被复用销毁)。
可在每次渲染后主动检测区间变更:
function checkViewportChange(
prevRange: VisibleRange,
currRange: VisibleRange,
onEnter: (index: number) => void,
onLeave: (index: number) => void
) {
// 新进入可视区的
for (var i = currRange.startIndex; i <= currRange.endIndex; i++) {
if (i < prevRange.startIndex || i > prevRange.endIndex) {
onEnter(i);
}
}
// 离开可视区的
for (var j = prevRange.startIndex; j <= prevRange.endIndex; j++) {
if (j < currRange.startIndex || j > currRange.endIndex) {
onLeave(j);
}
}
}4. overscan 带来的内存开销
overscan 会使实际驻留 DOM 节点数高于刚好可见的数量。具体内存开销取决于节点结构、样式、图片和监听器,应使用浏览器内存工具测量。
总结
虚拟滚动由可视范围计算、按需渲染和绝对定位组成。需要重点处理视图切换时的 scrollTop 保持、选择状态与 DOM 解耦,以及容器 resize 后的列数重算。
几点经验总结:
- 数据和 DOM 分离。选择状态存在
Set或Map中,节点只投射当前状态。 - 设置 overscan。它减少快速滚动时窗口切换造成的可见空白,但取值需结合节点成本测量。
- 视图切换保持逻辑锚点。记录当前可视区域中间的数据索引,切换后重新定位。
- 测量实际页面。以节点数、渲染耗时和 Performance 录制结果判断瓶颈。
- 明确固定尺寸边界。变高项和瀑布流需要不同的布局与高度索引策略。
原生实现只需要数据源和 renderItem(index, el) 回调即可支持列表和网格。后续的文件选择交互在此窗口模型上计算框选命中与键盘导航。

