---
title: 文件管理器虚拟滚动：从零实现列表与缩略图双模式
date: "2015-10-17 22:30"
tags: ["架构"]
published: true
description: 在命令式选择状态模型之后，用原生 JavaScript 为文件管理器实现固定尺寸虚拟滚动，同时支持列表与缩略图模式，说明窗口计算、overscan、绝对定位与视图切换。
---

在[命令式选择状态模型](./10-10-多选交互的命令模式与单向数据流.md)确定后，文件管理器还需要处理上万个文件。若将所有项都渲染为 DOM 节点，滚动和布局成本会随数据量增长。本文用原生 JavaScript 实现固定尺寸虚拟滚动，并同时支持列表（list）和缩略图（grid）视图。

## 实现边界

实现不依赖第三方虚拟列表库，也不依赖 React。它只解决窗口计算、节点复用、绝对定位和视图切换；选择状态由前文定义的状态模型保存，虚拟滚动只在每次渲染时将状态投射到当前窗口中的节点。

## 整体思路

虚拟滚动的核心思想很简单：**只渲染用户能看到的那一小部分**。

具体做法如下：

1. 外层一个有限高度的 `viewport`，设置 `overflow: auto`。
2. 内层一个 `placeholder`，高度撑满所有数据的总高度，用来撑起滚动条。
3. 真正渲染的 `visibleItems` 通过绝对定位（`position: absolute`）贴到 viewport 内对应的位置。

当用户滚动时，根据 `scrollTop` 反算出当前应该显示哪些项，只渲染这一小撮。

## scrollTop 到 visible range 的映射

这是整个方案中最基础、也最关键的一步。假设：

- 每项高度固定为 `itemHeight`
- 容器可视区域高度为 `viewportHeight`
- 当前滚动位置为 `scrollTop`
- 总数据量为 `totalItems`

那么当前可见范围（不考虑 overscan）的起止索引：

```javascript
var startIndex = Math.floor(scrollTop / itemHeight);
var endIndex = Math.min(
  totalItems - 1,
  Math.ceil((scrollTop + viewportHeight) / itemHeight) - 1
);
```

这个计算非常直白：起始索引就是"滚动过的高度"除以单项高度向下取整；结束索引就是"滚动过的高度 + 可视高度"除以单项高度再向下取整，但不会超过最后一项。

List 模式的完整计算函数：

```typescript
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）**：

```typescript
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 可视项数相关的值：

```javascript
var overscan = Math.max(5, Math.ceil(viewportHeight / itemHeight));
```

这样如果可视区域本身比较大，overscan 也随之增大，不会在快速长距离滚动时出问题。

## 绝对定位与占位容器

决定了渲染哪些项之后，下一步是如何把这些项放到正确的位置。我们的 DOM 结构是：

```html
<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` 的总高度：

```javascript
var totalHeight = totalItems * itemHeight;
```

每一个被渲染出来的 item，通过绝对定位贴到它应该在的位置：

```javascript
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 和绝对定位几何。文章中的原生实现仍可独立运行，不依赖函数式组件。

```typescript
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 宽度和卡片最小宽度：

```typescript
function calcColsPerRow(viewportWidth: number, minCardWidth: number, gap: number): number {
  return Math.max(1, Math.floor((viewportWidth + gap) / (minCardWidth + gap)));
}
```

给定列数之后，行索引和行内偏移：

```typescript
function indexToRowCol(index: number, colsPerRow: number): { row: number; col: number } {
  return {
    row: Math.floor(index / colsPerRow),
    col: index % colsPerRow
  };
}
```

然后计算可见行范围：

```typescript
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
  };
}
```

渲染时每个卡片的位置：

```typescript
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

这是最容易出问题的地方。列表模式切到缩略图模式时，以下几个量会变化：

1. `itemHeight`（列表项高）→ `cardHeight + gap`（卡片行高）
2. 容器宽度的变化（列表不需要考虑宽度，网格需要实时计算列数）
3. **总高度**变了——列表是 `totalItems * itemHeight`，网格是 `totalRows * (cardHeight + gap)`
4. `scrollTop` 的语义也变了

处理视图切换的关键步骤：

```typescript
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：

```javascript
var resizeTimer;
window.addEventListener('resize', function () {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(function () {
    vs.recalculate();
    vs.render();
  }, 150);
});
```

容器宽度变化后，应根据当前逻辑锚点重新计算 `scrollTop`，使同一数据项仍处于接近原来的视口位置。

## 如何避免滚动和选择状态耦合

这是一个架构层面的坑。刚开始的时候，选择状态（哪些文件被选中了）是存在 DOM 节点上的——`el.classList.contains('selected')`。结果窗口滚动、节点复用的时候，差点把选中状态弄丢。

**核心原则：选择状态必须与 DOM 节点解耦**。数据驱动一切，DOM 只是状态的投影。

```typescript
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();
  }
}
```

渲染时根据选择状态来投射：

```typescript
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` 定位当前项。节点池复用时无需为每个节点反复注册和移除监听器：

```javascript
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`：

```javascript
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`

```javascript
var t0 = performance.now();
// ... do renderRange ...
console.log('renderRange:', performance.now() - t0, 'ms');
```

`renderRange` 的耗时应结合当前设备的帧预算观察。若某次渲染接近或超过单帧预算，需要检查 DOM 操作、`renderItem` 的计算量和 overscan 配置。

### 3. 内存与 DOM 节点数

最直接的工具：Chrome DevTools → Performance → Memory，或者直接数节点数：

```javascript
console.log('active DOM nodes:', placeholder.children.length);
```

在虚拟化模式下，节点数应该稳定在 `visibleCount ~ visibleCount + overscan * 2`，不随 `totalItems` 增长。

若浏览器提供 `performance.memory`，可用它观察 JS heap 的变化，辅助排查 DOM 泄漏：

```javascript
if (performance.memory) {
  console.log('heap used:', performance.memory.usedJSHeapSize / 1024 / 1024, 'MB');
}
```

### 滚动性能剖面

为了精确定位慢在哪里，我写了一个简单的统计器，记录每次 scroll 事件的处理耗时：

```typescript
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 节点会被复用销毁）。

可在每次渲染后主动检测区间变更：

```typescript
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 后的列数重算。

几点经验总结：

1. **数据和 DOM 分离**。选择状态存在 `Set` 或 `Map` 中，节点只投射当前状态。
2. **设置 overscan**。它减少快速滚动时窗口切换造成的可见空白，但取值需结合节点成本测量。
3. **视图切换保持逻辑锚点**。记录当前可视区域中间的数据索引，切换后重新定位。
4. **测量实际页面**。以节点数、渲染耗时和 Performance 录制结果判断瓶颈。
5. **明确固定尺寸边界**。变高项和瀑布流需要不同的布局与高度索引策略。

原生实现只需要数据源和 `renderItem(index, el)` 回调即可支持列表和网格。后续的[文件选择交互](./10-17-云盘文件选择交互实现.md)在此窗口模型上计算框选命中与键盘导航。
