前端性能优化之列表虚拟化 (virtualization)

2026年5月27日

💎 加入 E+ 成長計畫 與超過 1000+ 位工程師一同在社群成長,並獲得更多深度的軟體前後端學習資源

在前端性能优化的各种手段中,列表虚拟化 (list virtualization) 是很常用来处理长列表数据的方法。

不论是通知列表、聊天消息、搜索结果、商品列表,还是后台常见的数据表,刚开始使用的时候,数据只有几十条,滚动起来大多会很顺畅。但当数据变成几千条、几万条之后,功能明明没有坏,数据也正确显示,可是页面就开始变慢。滚动的时候会有点卡,滑到中间甚至会有一种画面跟不上手指的感觉。

遇到这种问题时,可能有多个解决切入点,而虚拟化是其中一个。

虚拟化在解决什么问题?为什么需要虚拟化?

要理解虚拟化,需要先从这个方法解决的问题谈起。当浏览器向后端拿数据时,不是拿到数据就结束,而是要进一步把数据变成画面,而这件事本身是有成本的。

如果画面上有一万条数据,我们就真的渲染一万个项目,那浏览器要建立很多 DOM 节点、计算样式、安排布局、处理绘制,还要在用户滚动时持续管理这些东西。问题是,用户同一时间可能只看得到十几行,最多几十行。

明明用户在每一个当下只需要看画面中的十几行数据,却要浏览器同时管理一万行数据的 DOM,要知道每个元素在哪里、长什么样子、尺寸是多少、哪些地方需要重绘,这会造成浏览器的额外负担,显然并不理想。

这里有个重要观念需要记住:数据存在浏览器的内存里,跟数据被渲染成 DOM,有不同的成本。数据在内存里可能只是数组中的一个对象,但一旦变成画面,它就进入浏览器的渲染流程。这个流程包含很多工作:建立元素、计算样式、布局、绘制、合成。单条数据很便宜,但一万条一起出现,就会变成用户感受到的卡顿。

对此,虚拟化的存在就是为了解决这个问题。它的核心想法不是让数据消失,也不是让列表变短,而是画面上只渲染用户现在看得到,或快要看得到的那一小段内容。用户感觉自己在滑一个完整列表,但浏览器实际上每一刻只管理其中一小段。

虚拟化具体在做什么?

不过,具体来说,虚拟化是如何减少浏览器要维护的 DOM 数量呢?

想象画面上有一个高度 600 像素的列表容器。每一行高度 60 像素,所以用户同一时间大概看得到 10 行。实务上为了让滚动更顺,我们可能会在上下多渲染几行作为缓冲,所以画面实际存在的项目可能是 15 到 20 行。

就算整份数据有一万条,虚拟化也只会让浏览器在当下管理这十几二十行。用户往下滑,原本上方离开可视区域的项目会被移除;新的项目进入可视区域,就被渲染出来。用户感觉自己在浏览完整列表,但 DOM 里其实只存在目前附近那一小段。

这就是为什么虚拟化有时也会被称为窗口化 (windowing),因为这就像拿一个小框框在一大卷数据上移动,框框移到哪里,画面就渲染哪里。

要做到虚拟化,实现上通常要做几件事:

  • 要知道总共有多少个项目,这样才能算出整个列表应该有多高。否则滚动条会不正常,用户也不知道下面还有多少内容。
  • 要知道目前滚动到哪里,才能算出现在应该渲染第几条到第几条。
  • 要把这些被渲染出来的项目放在正确的位置。否则明明数据是第 500 条,却出现在画面最上方,看起来就会错乱。

所以从实现角度看,虚拟化的核心是对可视区域的管理。

推荐的虚拟化工具

当提到虚拟化工具,目前在社区中最热门的,莫过于 TanStack Virtual。TanStack Virtual 除了使用上简单,更重要的是跨框架支持,不只支持 React,也支持 Vue、Svelte、Solid、Lit、Angular 等框架。对团队来说,这种概念可以跨项目延续,不会只绑定在某个单一 React 组件 API 上。

简化后,使用 TanStack Virtual 的概念大概会像这样:

function MessageList({ messages }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 64,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const message = messages[virtualItem.index];

          return (
            <div
              key={message.id}
              style={{
                position: "absolute",
                transform: `translateY(${virtualItem.start}px)`,
                height: virtualItem.size,
                width: "100%",
              }}
            >
              {message.title}
            </div>
          );
        })}
      </div>
    </div>
  );
}

可以看到,原本复杂的虚拟化,被用简洁的 API 呈现出来。在上面的例子中,count 告诉虚拟化工具,总共有多少条数据。getScrollElement 则是让虚拟化工具知道哪个容器负责滚动。estimateSize 则是每一行大概多高。

getTotalSize 用来撑出整个列表的总高度,让滚动条看起来像真的。而 getVirtualItems 则是目前真正需要渲染的那些项目。

通过虚拟化,画面看起来像完整列表,但实际 DOM 只包含目前可视区域附近的项目。换句话说,虚拟化不是让列表变短,而是让浏览器永远只处理用户当下需要看的那一段。

不是所有列表都要虚拟化

虽然上面提了虚拟化的优点,也介绍了 TanStack Virtual 这个简单好用的工具;但在实务上,不推荐看到长列表就加虚拟化。

虚拟化本质上是在用复杂度换性能。它让 DOM 数量下降,但也会带来一些额外成本。从软件工程角度来看,重要的不是能不能用,而是这个场景值不值得用。假如今天数据量其实不大。如果列表最多就几十条、一百条,每一行也很简单,那直接渲染通常就很好。这时候加虚拟化,可能只是让程序变难懂。原本一个 map 就能完成的画面,突然多了滚动容器、估算高度、绝对定位、测量尺寸,调试成本反而变高。

此外,如果画面功能依赖完整 DOM,也不适合用虚拟化。举例来说,如果用户期待用浏览器的页面搜索功能搜索整个列表,或需要一次打印完整内容,或某些辅助技术场景需要更完整的内容结构。因为虚拟化只把可视区域附近的项目放进 DOM,所以画面外的内容可能根本还不存在于页面元素里,这时加上虚拟化可能会导致 bugs。

因此,在判断要不要虚拟化时,可以思考:

  • 这个列表的 DOM 数量是否真的可能变大?
  • 用户是否会频繁滚动,而且滚动体感很重要?
  • 加入虚拟化后,复杂度是否比目前的性能问题更值得?

如果答案都是肯定的,虚拟化可能是合理选择。但如果数据量不大、画面很单纯、性能没有明显问题,那反而推荐保持简单。

阅读更多

如果对前端性能优化这个主题感兴趣,我们在 E+ 成长计划中的主题文有谈到更多具体实务的方法。对更深入了解这个主题,以及其他前后端开发、软件工程、AI 工程主题感兴趣的读者,欢迎加入 E+ 成长计划一起成长 (链接)。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們