虚拟列表实现原理

场景题:页面需要展示数万条数据,不能依赖传统分页,如何保证首屏和滚动都流畅?请说明虚拟列表的实现原理。

直接把数万条数据映射成 DOM,成本并不只发生在首次渲染:浏览器还要长期维护庞大的 DOM 树,每次样式计算、布局和绘制都会受到影响。分批渲染虽然能把首屏的长任务拆开,但最终仍会留下数万个节点。

虚拟列表(Virtual List / Windowing)的核心不是“更快地创建所有节点”,而是:

保留完整数据,只渲染视口附近的一小段 DOM;滚动时不断移动这个渲染窗口。

它优化的是 DOM 数量,和分页、无限滚动解决的问题不同:

方案主要解决的问题数据量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]

生产级实现通常包含以下步骤:

  1. 预估高度:未渲染的项目先使用一个估算值,得到初始总高度和偏移量。
  2. 测量真实高度:节点渲染后用 ResizeObserver 获取实际尺寸,并写入高度缓存。
  3. 维护前缀偏移:高度变化后,重新计算受影响项目的 offset 和列表总高度。大规模场景可使用树状数组等结构维护前缀和。
  4. 查找起始项offset 是递增数组,可以根据 scrollTop 二分查找第一个与视口相交的项目,将线性查找降为 O(log n)
  5. 修正滚动锚点:如果视口上方某项的实测高度和估值不同,要把差值补偿到 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-rowcountaria-rowindex 等语义,并在键盘导航到窗口外项目时先滚动、再恢复焦点。

3. 滚动到指定项

定高列表可以直接设置 scrollTop = index * itemHeight。不定高列表只能基于缓存偏移定位;未测量区域通常先用估值跳转,测量后再校准。

4. 数据变化导致位置跳动

在列表头部插入、删除数据,或者聊天记录向上加载历史消息时,应记录更新前后的滚动锚点,让同一条内容在视口中的相对位置保持不变。

5. 组件渲染本身仍然很重

虚拟列表只限制节点数量。如果单个列表项包含复杂计算、大图或频繁变化的全局状态,仍可能掉帧。还需要稳定 props、缓存昂贵计算、合理拆分状态,并避免在滚动回调中执行重逻辑。

六、什么时候不该使用虚拟列表?

虚拟化会增加高度测量、焦点管理、滚动定位、SSR 和自动化测试的复杂度。如果只有几十或几百个简单节点,普通列表可能更可靠。是否使用应以性能测量为依据,而不是只看数据条数。

在业务中通常优先使用成熟方案,例如 React 生态的 react-window、TanStack Virtual,Vue 生态的 vue-virtual-scroller。手写实现适合理解原理;涉及不定高、反向列表、二维表格、吸顶元素和无障碍能力时,更应该采用经过验证的库。


高频面试题剖析

Q:虚拟列表是如何只渲染少量节点,却仍然保持正确滚动条和滚动位置的?

回答思路:

  1. 先讲目标:全量数据仍然保留,但 DOM 只渲染视口附近的窗口,节点数量稳定在几十个左右。
  2. 讲清三层结构:外层是可滚动视口;占位层用“总条数 × 单项高度”撑出完整滚动条;渲染层只放切片数据,再通过 translateY 移到正确位置。
  3. 给出核心计算:定高时用 Math.floor(scrollTop / itemHeight) 算起始下标,用 (scrollTop + viewportHeight) / itemHeight 算结束下标,并在两侧加入 overscan 防止快速滚动露白。
  4. 展开不定高难点:先估高,渲染后通过 ResizeObserver 实测并缓存;维护高度前缀和,通过二分查找可见区间;修正视口上方的高度误差以避免滚动跳动。
  5. 补充工程边界:虚拟列表解决 DOM 规模,不负责数据分页;还要处理稳定 key、行内状态、焦点、滚动锚定和单行渲染成本。生产环境优先选择成熟库。