返回首页
🎨 前端 / Web

前端大数据量渲染实战 2026:虚拟列表与万行表格的性能工程

列表渲染到几千行,页面开始卡顿;表格撑到上万行,滚动直接掉帧——DOM 的成本从来不是线性的,而大多数性能问题的答案只有一个:只渲染用户看得见的部分。本文系统讲解大数据量渲染的完整工程:从 DOM 成本的真相、分页与无限滚动的适用边界,到虚拟列表的核心原理与最小实现、动态高度的测量与预估修正、虚拟化表格的行列双虚拟化,再到 React 生态选型、交互细节与可访问性,最后给出性能测量方法与落地清单。

虚拟列表 · 性能优化 · React · 前端 · DOM · 长列表 · 表格 · 滚动 · react-window · 可视区
��

今日技术简讯

📰 技术简讯 · 2026-10-16

今日聚合 6 条热门技术内容(中文素材优先)。

🤖 AI / LLM

1. 商务部拟对 AI 相关技术实施出口管制,修订禁止出口限制出口技术目录

  • 链接:https://m.bjnews.com.cn/detail/1792137269129878.html
  • 来源:新京报
  • 摘要:商务部就修订《中国禁止出口限制出口技术目录》答记者问,其中涉及对人工智能相关技术的出口限制。这将直接影响跨境模型合作、开源发布与国内团队的合规策略,开发者需关注最终目录对模型权重、训练框架出口的界定。

2. Reflection AI 开源 501B 参数 MoE 模型 Beam(Apache 2.0)

  • 链接:https://reflection.ai/blog/beam-is-available
  • 来源:Reflection AI 官方博客
  • 摘要:Reflection AI 发布 501B 参数、MoE 架构的开源模型 Beam,采用 Apache 2.0 许可,权重已上架 Hugging Face(含量化版本),宣称可自托管、定制与横向扩展。这是开源模型阵营对闭源的重要回应,关注本地部署与微调的开发者值得实测。

🎨 前端 / Web

3. Expo SDK 58 正式发布

  • 链接:https://expo.dev/changelog/sdk-58
  • 来源:Expo 官方
  • 摘要:Expo SDK 58 上线,跟进 React Native 新架构默认化路线,更新 expo-router、EAS 相关能力与依赖基线。移动端 React 开发者升级前需按官方升级指南同步调整原生依赖,是本周移动前端的必看变更。

4. VS Code 界面现代化改造

  • 链接:https://code.visualstudio.com/blogs/2026/10/15/modern-ui
  • 来源:Visual Studio Code 官方博客
  • 摘要:VS Code 团队介绍对工作台 UI 的现代化改造,涵盖视觉风格、布局细节与渲染路径的更新,兼顾性能与美观。对基于 Web 技术栈构建桌面端应用的团队,其界面演进思路有直接借鉴价值。

⚙️ 后端 / 架构

5. Postgres 19 延期:AI 审查者发现的 bug 多到人类修不完

🚀 独立开发 / OPC

6. 在 AI SaaS 垃圾泛滥时代,用 offline-first 应用赢得用户信任

  • 链接:https://blog.geckocode.pl/2026/10/16/privacy-first-apps.html
  • 来源:Gecko Code(独立开发者博客)
  • 摘要:独立开发者撰文阐述 offline-first + 本地优先架构如何成为独立产品对抗云端 SaaS 的差异化卖点:数据留在本地、断网可用、天然隐私合规。对一人公司是切合当前用户情绪的选型与营销思路。

数据来源:掘金 / InfoQ 中文 / 即刻 / 少数派 / HN 采集日期:2026-10-16 (UTC+8)

��

今日深度文

前端大数据量渲染实战 2026:虚拟列表与万行表格的性能工程

"列表有一万条数据,页面卡死了。"这是前端性能工单里最常见的句式之一。有意思的是,用户的屏幕上同时显示的永远只有几十行——也就是说,一万条数据里,绝大多数 DOM 节点从头到尾没有被真正"看过",却实打实地占着内存、参与着布局计算。虚拟列表(virtualization)解决的就是这个浪费:只渲染可视区域附近的元素,用极小的 DOM 维护一个"看起来完整"的长列表。这篇文章把这个看似简单的技术讲透——因为它在简单和复杂之间,藏着好几层需要认真对待的细节。


一、先算账:DOM 的成本到底在哪

很多优化文章一上来就给方案,但理解成本模型才能做对取舍。浏览器渲染一万行列表,开销分布在四个层面:

大数据量列表的四层成本:

① 内存:每个 DOM 节点在 JS 堆与渲染树中都占内存,
   一万个列表项(每项十来个节点)就是十几万节点,
   内存占用可能达到数百 MB 量级。

② 布局/绘制:首次插入需要完整 layout + paint;
   更关键的是后续任何影响布局的改动
   (插入、删除、动态高度变化)都会触发重排。

③ 事件与响应式开销:现代框架给每个组件实例
   附加响应式依赖、事件监听、生命周期——
   框架的成本往往高于 DOM 本身。

④ 样式匹配:选择器复杂时,新增节点的样式计算
   成本随 DOM 规模上升。

关键结论:**问题不在"数据多",而在"渲染了不需要渲染的东西"。**所以解法的第一步永远不是换更快的框架,而是砍掉不可见的渲染。


二、先做减法:什么时候根本不需要虚拟列表

虚拟列表不是免费午餐——它牺牲了浏览器原生的滚动行为(锚点、Ctrl+F 查找、无障碍树完整性),引入了自己的复杂度。所以先问一句:真的需要吗?

决策顺序(从简到繁):

① 数据量小(百条以内):直接渲染,别折腾。
② 中等量且用户按页消费:传统分页,简单可靠,
   SEO 也友好。
③ 信息流类、用户持续下滑:无限滚动(滚动加载下一页),
   每次只往 DOM 里追加一页。
   注意:无限滚动不停追加,DOM 依然会无限膨胀,
   长会话下照样卡——只是把卡顿推迟了。
④ 真正的巨量数据 + 必须一屏滚到底:虚拟列表。

一个常见的组合拳是"无限滚动 + 虚拟列表":数据按页取,DOM 按可视区渲染,两头都可控。这篇文章的主角是第四种与这个组合。


三、核心原理:三行话讲完虚拟列表

虚拟列表的本质,是一个"滚动驱动的取景器":

虚拟列表三要素:

① 撑起高度:外层容器里放一个"总高占位元素",
   高度 = 全部条目的预估总高度,
   让滚动条的长度和位置与完整列表一致。

② 计算窗口:监听滚动,用 scrollTop 除以行高,
   算出当前可视区起点是第几条(startIndex),
   再加上可视区能容纳的条数得到 endIndex。

③ 平移渲染:只把 [startIndex, endIndex] 的条目
   渲染到一个内层容器,并用 transform: translateY()
   把它平移到正确的位置,视觉上与完整列表无异。

再加一个工程细节:窗口要向上下各多渲染几条(overscan),否则快速滚动时边缘会露出"还没来得及渲染"的空白。


四、动手实现:固定高度的虚拟列表

固定行高是虚拟列表的最简形态,理解它再看动态高度。完整实现不到一百行:

/**
 * 固定行高虚拟列表(React 实现)
 * 原理:总高占位 + 滚动偏移计算窗口 + transform 平移
 */
function FixedSizeList({
  items, rowHeight, height, overscan = 5, renderItem,
}: {
  items: Item[];        // 全量数据
  rowHeight: number;    // 固定行高(像素)
  height: number;       // 容器可视高度
  overscan?: number;    // 上下额外渲染的条数
  renderItem: (item: Item, index: number) => React.ReactNode;
}) {
  const [scrollTop, setScrollTop] = useState(0);

  /** 滚动时记录最新偏移;passive 监听避免阻塞合成线程 */
  const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
    setScrollTop(e.currentTarget.scrollTop);
  };

  // 可视区起点与终点(含 overscan 缓冲)
  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const visibleCount = Math.ceil(height / rowHeight);
  const endIndex = Math.min(items.length - 1, startIndex + visibleCount + overscan * 2);

  // 窗口内条目整体向下平移到绝对位置
  const offsetY = startIndex * rowHeight;

  return (
    <div style={{ overflowY: "auto", height }} onScroll={onScroll}>
      {/* 总高占位:让滚动条与完整列表等长 */}
      <div style={{ height: items.length * rowHeight, position: "relative" }}>
        <div style={{ position: "absolute", top: 0, transform: `translateY(${offsetY}px)` }}>
          {items.slice(startIndex, endIndex + 1).map((item, i) => (
            <div key={startIndex + i} style={{ height: rowHeight }}>
              {renderItem(item, startIndex + i)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

三个容易踩的细节,直接决定手感:

实现细节:

① 用 transform 而不是 top 定位:transform 不触发重排,
   由合成线程处理,滚动流畅度天差地别。

② key 必须用数据 ID 而不是窗口内索引:
   窗口滑动时,同一索引对应的数据在变化,
   用索引做 key 会导致组件复用错乱、状态串位。

③ onScroll 的 setState 频率很高:
   React 18+ 的自动批处理已经缓解大半,
   若仍有压力可配合 requestAnimationFrame 节流,
   保证一帧至多计算一次窗口。

五、动态高度:虚拟列表的真正难点

真实世界的列表项高度往往不等——聊天气泡、动态卡片、可折叠行。这时"scrollTop 除以行高"的公式失效了,因为你还不知道每一行多高。

主流解法是**"预估 + 测量 + 修正"**三段式:

动态高度的工程方案:

① 预估:给每行一个预估高度(estimatedRowHeight),
   用前缀和数组估算每一行的位置。
   → 滚动条长度一开始只是"近似对"的。

② 测量:每个行渲染后用 ResizeObserver 测量真实高度,
   写回高度表。

③ 修正:测量会改变前缀和 → 已滚过的位置会漂移。
   关键技巧:只修正"当前视口以下"的预估,
   或者记录偏移差值补偿 scrollTop,
   保证用户当前看到的内容不跳动。

第三步是所有虚拟列表库的功力所在。简化版的位置修正逻辑如下:

/**
 * 动态高度列表的位置簿记
 * 用预估高度建立前缀和,测量后按差值补偿滚动位置
 */
class DynamicHeights {
  private heights: number[];      // 实测高度(未测量的行为预估值)
  private offsets: number[];      // 前缀和:offsets[i] = 第 i 行的顶部位置

  /**
   * @param estimatedRowHeight 初始预估行高
   * @param count 总条数
   */
  constructor(private estimatedRowHeight: number, count: number) {
    this.heights = new Array(count).fill(estimatedRowHeight);
    this.rebuildOffsets();
  }

  /** 依据高度表重建位置前缀和 */
  private rebuildOffsets(): void {
    this.offsets = [0];
    for (let i = 0; i < this.heights.length; i++) {
      this.offsets[i + 1] = this.offsets[i] + this.heights[i];
    }
  }

  /**
   * 渲染后回写实测高度
   * @param index 行号
   * @param measuredHeight 实测像素高度
   * @param currentScrollTop 当前滚动位置,用于差值补偿
   * @returns 修正后的 scrollTop(若该行在视口上方)
   */
  measure(index: number, measuredHeight: number, currentScrollTop: number): number {
    const delta = measuredHeight - this.heights[index];
    if (delta === 0) return currentScrollTop;

    this.heights[index] = measuredHeight;
    this.rebuildOffsets();

    // 若被测行在当前视口上方,其高度变化会把内容"推走",
    // 必须补偿 scrollTop,否则用户看到的画面会跳动
    if (this.offsets[index] < currentScrollTop) {
      return currentScrollTop + delta;
    }
    return currentScrollTop;
  }

  /** 二分查找:滚动位置对应的第一条可见行号 */
  findStartIndex(scrollTop: number): number {
    let lo = 0, hi = this.offsets.length - 1;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (this.offsets[mid] < scrollTop) lo = mid + 1;
      else hi = mid;
    }
    return lo;
  }
}

**预估高度越准,滚动条和内容跳变越少。**实践中一个有效的技巧:用首屏实际测量出的平均行高作为预估,而不是拍脑袋的固定值。


六、虚拟化表格:行列双向虚拟化

表格比列表多一个维度:列也可能很多(上百列的运营后台报表并不罕见)。完整方案是二维虚拟化——行和列都只渲染窗口:

二维虚拟化的思路:

  行方向:与列表完全一致(scrollTop → 行窗口)
  列方向:横向 scrollLeft → 列窗口,
          总宽占位 + translateX 平移

  合起来:只渲染"可视区矩形"内的单元格。
  1 万行 × 100 列 = 百万单元格,
  实际渲染的可能只有 20 行 × 15 列 ≈ 300 个。

表格特有的两个难题及对策:

/**
 * 虚拟表格的两个关键 CSS 技巧
 */
// ① sticky 表头:行虚拟化时表头要钉住,
//    且与行窗口共享同一个横向滚动偏移
//    <thead style={{ position: "sticky", top: 0 }}>

// ② sticky 首列:列虚拟化时用 position: sticky + left
//    固定关键列(如名称列),滚动横向时保持可见
//    <td style={{ position: "sticky", left: 0, zIndex: 1 }}>

/**
 * 横向列窗口的计算与纵向同理:
 * 用列宽前缀和数组 + scrollLeft 二分求起止列
 */
function findColumnRange(prefixWidths: number[], scrollLeft: number, viewportWidth: number) {
  // start:第一个右边缘越过 scrollLeft 的列
  let start = 0;
  while (prefixWidths[start + 1] <= scrollLeft) start++;
  // end:第一个右边缘覆盖到可视区右缘的列
  let end = start;
  const rightEdge = scrollLeft + viewportWidth;
  while (prefixWidths[end + 1] < rightEdge && end < prefixWidths.length - 2) end++;
  return { start, end };
}

表格虚拟化的另一个现实建议:如果列多但行不算极端多(几千行),可以只虚拟化行、正常渲染列——复杂度减半,效果依然显著。按瓶颈所在决定虚拟化的维度,别为了技术完整而过度工程。


七、React 生态选型:三个主流库怎么选

自己实现适合理解原理和小型场景,生产项目通常直接选库:

React 虚拟化库选型参考:

① @tanstack/react-virtual(原 react-virtual v3)
   框架无关内核 + React 绑定;动态高度支持成熟,
   API 灵活(列表/表格/网格通吃),维护活跃。
   → 新项目的默认首选。

② react-window
   react-virtualized 的轻量重构,固定/可变尺寸列表
   开箱即用,体积小、心智负担低。
   → 需求标准、追求简单时的稳妥选择。

③ react-virtuoso
   开箱即用程度最高:反向滚动(聊天室)、
   分组列表、置顶行、自动恢复滚动位置等
   特殊场景都有内建方案。
   → 聊天流、动态内容多、不想自己处理簿记时的省心之选。

选型口径:标准场景 TanStack Virtual;
聊天室/复杂滚动行为选 Virtuoso;
维护老项目遇到 react-window 也无需强行迁移。

八、交互与可访问性:虚拟列表的代价要还

虚拟列表砍掉的 DOM 也带走了一些浏览器原生能力,需要主动补回来:

必须处理的交互细节:

① 键盘导航:滚动容器要能响应方向键/PageUp/PageDown,
   并同步更新焦点与选中项(虚拟列表不拦,
   但焦点元素被卸载时会丢,需要手动恢复)。

② 锚点跳转:定位到第 N 条(如通知列表跳到某条),
   固定高度直接 scrollTop = N * rowHeight;
   动态高度要依赖前缀和——这正是保留 offsets 数组的
   另一个理由。

③ 查找与无障碍:Ctrl+F 找不到未渲染的内容是虚拟列表
   的固有代价。重要文本场景可考虑"渲染层虚拟 +
   数据层完整"的折中,或为屏幕阅读器提供
   aria-live 的摘要信息。role="list" 与
   aria-rowcount 声明总行数也有帮助。

④ 滚动位置恢复:列表页跳详情再返回,
   要恢复原滚动位置(连同窗口计算状态),
   否则"返回列表回到顶部"会被用户视为故障。

九、测量:先有数字,再谈优化

性能优化的纪律是:**先测量,再动手,改完再测。**长列表场景的三个核心指标:

/**
 * 用 PerformanceObserver 监控长任务
 * 长任务(>50ms)是滚动掉帧的直接元凶
 */
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 超过一帧预算(16.7ms)的渲染工作都值得追查
    if (entry.duration > 50) {
      console.warn("long task:", Math.round(entry.duration), "ms");
    }
  }
});
observer.observe({ entryTypes: ["longtask"] });
测量清单:

  □ Chrome DevTools Performance 面板录一次滚动:
    看掉帧、Long Tasks、Layout/Recalculate Style 占比。
  □ React 项目打开 React DevTools Profiler:
    确认滚动时只有窗口内组件重渲染,
    父组件不要因 scrollTop 状态变化全树重渲。
  □ 内存:Memory 面板对比虚拟化前后堆大小。
  □ 真机验证:低端 Android 设备的体感
    才是"能不能用"的最终标准。

十、落地清单

方案确认
  □ 数据量与用户消费方式评估:分页/无限滚动/虚拟列表
  □ 行高固定还是动态,是否需要列虚拟化
  □ 选型:TanStack Virtual / react-window / Virtuoso

实现要点
  □ key 用数据 ID,绝不用窗口内索引
  □ transform 平移窗口,不用 top/left
  □ 动态高度:预估 + ResizeObserver 实测 + 滚动补偿
  □ overscan 合理设置(过大白渲染,过小露白)
  □ 锚点跳转、键盘导航、滚动位置恢复

质量保障
  □ Performance 面板验证无长任务、滚动满帧
  □ 大数据量(万级)真机过一遍快速滚动
  □ 屏幕阅读器与 aria 属性检查

结语:虚拟列表是一种"诚实的渲染"

虚拟列表的技术含量,不在于那三行核心原理,而在于把"看起来一样"的细节做到位:滚动条长度、位置补偿、键盘与读屏、状态恢复。它代表的其实是一种更诚实的渲染观——只把用户看得见的东西渲染出来,但让整个系统的行为与"全量渲染"无法区分。

当你在某个仪表盘里把十万行表格从"必卡"调到"满帧",你会发现这类优化的方法论可以迁移到几乎所有前端性能问题:先找到"渲染了不需要渲染的东西",再想办法不渲染它。性能工程的大多数胜利,靠的不是更快的机器和框架,而是更少的浪费。

�� 同主题文章

🎨前端 / Web·

View Transitions API 实战 2026:用浏览器原生能力做流畅的页面过渡

从列表点击进入详情,图片像被一只无形的手放大、平移、落位;返回时它又缩回列表原位。这种过去要依赖重量级动画库、还要手动对齐位置的共享元素过渡,如今浏览器原生就能完成。2026 年,View Transitions API 已在所有主流浏览器全面可用,跨文档过渡更被纳入 Interop 2026 互操作性计划。本文系统实战 View Transitions:单页应用同文档过渡的基本原理、view-transition-name 命名与共享元素、自定义关键帧动画、列表到详情的经典场景、多页应用跨文档过渡与 pageswap/pagereveal 事件、React/Vue 框架集成、无障碍与降级,以及性能边界。

View TransitionsCSS前端动画
🎨前端 / Web·

前端状态管理完整实战 2026:从 Redux 到 Signals 再到 Server State

前端状态管理是 React 生态永恒的话题。从 Class 时代的 Redux 到 Hooks 时代的 Context + useReducer,再到 2024 年 Signals 的兴起和 2025 年 Server State 的深度整合,状态管理的范式在持续演进。核心矛盾从未改变:状态在哪里放、怎么同步、组件怎么订阅。本文完整实战 2026 年前端状态管理:状态分类(UI / 服务端 / 全局)、Redux 到 Zustand 的简化路线、Signals 细粒度响应式的原理与迁移、Server State 与本地状态的边界、缓存策略与乐观更新、以及独立开发者的选型决策树。

前端状态管理ReactRedux
🎨前端 / Web·

React Server Components 完整实战 2026:服务端渲染、流式与数据获取的架构重组

React Server Components(RSC)是 React 诞生以来最深刻的架构变革:组件被分为「服务端组件」与「客户端组件」两种,前者只在服务端运行、不占用浏览器 JavaScript 体积、可直接访问数据库,后者保留交互能力。它不是 SSR 的升级版,而是对 React 渲染模型的重新设计。本文完整实战 RSC:服务端组件与客户端组件的分工、数据获取的革命、流式渲染与 Suspense、缓存策略、与客户端状态管理的边界、迁移路径与常见误区。

ReactServer ComponentsRSC

🎨 前端 / Web 分类更多