REACT · Vol.VI · LESSON 38 · 性能优化与部署
虚拟列表与大数据渲染
一万行为什么卡:memo 管不到的地方
上一篇第 37 篇把 memo 讲透了:稳住引用、包住热点组件,无意义的重渲染该跳的都能跳过。但有一类卡顿 memo 救不了——列表本身有 1 万行。第 36 篇的分页管「数据一次拿多少」,本篇管「DOM 一次挂多少」,这是渲染性能的另一半。
先算一笔账。1 万行、每行 6 个节点,就是 6 万个 DOM 节点;每个节点都是实打实的对象,挂着样式、布局盒、事件监听一整套元数据。后果分三层:
- 内存:6 万节点轻松吃掉几十上百 MB,低端机 GC(垃圾回收)压力肉眼可见;
- 首次挂载:解析、创建、插入 6 万节点,fiber 树同步建一遍——首屏白屏;
- reflow:滚动插入新行、窗口 resize、行内展开——任何布局改动都要重算布局树,树越大越慢。第 5 篇说过 diff 的便宜以「树小」为前提;虚拟 DOM 省的是桥接和全量重建,省不出 6 万节点的布局成本。
| 列表规模 | DOM 节点数(按每行 6 个估) | 大致体感(量级示意) |
|---|---|---|
| 100 行 | 约 600 | 无感 |
| 1 000 行 | 约 6 000 | 中端手机滚动开始掉帧 |
| 10 000 行 | 约 60 000 | 首屏明显卡顿,滚动掉帧 |
memo 为什么救不了?回看第 37 篇:memo 优化的是重复渲染,而这里的问题是一次渲染的绝对工作量太大——哪怕只挂载一次,6 万个节点也得实打实建出来。少做无用功,不等于少干活。
一次 SELECT 把 100 万行全部捞进 JVM,new 成对象塞进 List——内存暴涨、GC 频繁、接口超时,典型的反模式。老司机的做法是分页或流式读取(MyBatis 的 Cursor):内存里永远只有当前一批。前端把 1 万行全部变成 DOM 节点,就是同一个反模式的浏览器版;虚拟列表就是前端的「流式读取」——DOM 里永远只有可视区那几十行。
核心原理:只渲染看得见的,外加两行缓冲
虚拟列表(virtual list)的思路一句话说完:数据全集留在 JS 数组里,DOM 只渲染可视区那一小片,滚动时换一片。拆成五个关键动作:
- 数据全集放内存(1 万条 JS 对象,不产生任何 DOM);
- 按 scrollTop 和行高算出可视区行号区间 [start, end];
- 只把切片渲染成 DOM,绝对定位到正确坐标;
- 上下多渲染几行缓冲区(overscan),快速滚动不露白;
- 用「总高元素」把滚动条撑到真实长度,滚动手感与完整列表一致。
关键在「滚动时发生了什么」:不是移动内容,而是内容坐标不动、窗口索引平移。每滚一格,diff 只增删两三行节点,DOM 总量恒定几十个——1 万行的体验,六万分之一的渲染成本。三个部件各司其职:
| 部件 | 职责 | 后端直觉 |
|---|---|---|
| 总高撑高元素 | 撑出真实长度的滚动条,滚动手感正常 | 表的行数 COUNT |
| 索引换算 | scrollTop → start / end 行号 | OFFSET 换算 |
| 切片渲染 | 可视区 ± 缓冲变成 DOM | 当页数据 |
JDBC 流式查询(Cursor)读 100 万行,内存里永远只有当前批,游标往后推一批换一批——虚拟列表一模一样,只是「批」的推进由滚动条驱动。数据一行没少,物化的对象永远只有一小窗。
手写一个 30 行的迷你虚拟列表
原理这么简单,不如直接写一个。固定行高、一屏 600px、1 万行数据:
import { useState, type UIEvent } from "react";
const ROW_H = 40; // 固定行高
const VIEW_H = 600; // 视口高度
const OVERSCAN = 5; // 上下各多渲染 5 行缓冲
const TOTAL = 10_000;
function MiniVirtualList() {
const [scrollTop, setScrollTop] = useState(0);
function handleScroll(e: UIEvent<HTMLDivElement>) {
setScrollTop(e.currentTarget.scrollTop);
}
// 1. scrollTop → 起始行号:除以行高取整,再往前让出缓冲
const start = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
// 2. 可视行数 + 两倍缓冲 = 本次要渲染的行数
const count = Math.ceil(VIEW_H / ROW_H) + OVERSCAN * 2;
const end = Math.min(TOTAL, start + count);
// 3. 切片:只把 [start, end) 这段数据变成 DOM
const rows: number[] = [];
for (let i = start; i < end; i++) rows.push(i);
return (
<div onScroll={handleScroll} style={{ height: VIEW_H, overflowY: "auto" }}>
{/* 4. 内层容器撑出真实滚动条,并充当绝对定位的坐标系 */}
<div style={{ height: TOTAL * ROW_H, position: "relative" }}>
{rows.map((i) => (
<div
key={i}
style={{
position: "absolute",
top: i * ROW_H, // 5. 每行钉在自己的坐标上
left: 0,
right: 0,
height: ROW_H,
lineHeight: "40px",
borderBottom: "1px solid #eee",
}}
>
第 {i + 1} 行
</div>
))}
</div>
</div>
);
}
整条数据流三步:scrollTop 除以行高取整得 start(往前让出缓冲),可视行数加两倍缓冲得 count,切片绝对定位到各自坐标。滚动时 setState 每秒触发几十次,但每次重渲染只剩「算两个索引 + diff 几十个节点」,DOM 只增删两三个——第 37 篇那句「重渲染 ≠ DOM 更新」在这里兑现:函数跑得勤,DOM 动得少。
生产直接用 react-window
手写版帮你建立直觉,生产里别自己造轮子——react-window 把滚动兼容、边界情况都处理好了:
import { FixedSizeList, type ListChildComponentProps } from "react-window";
// 全集已在内存:日志查看器、导入预览这类场景
const data: string[] = Array.from(
{ length: 10_000 },
(_, i) => `第 ${i + 1} 行数据`
);
function Row({ index, style, data }: ListChildComponentProps<string[]>) {
return (
<div style={style}> // style 必须透传:内含定位坐标与行高
{data[index]}
</div>
);
}
export default function BigList() {
return (
<FixedSizeList
height={600} // 视口高度
width="100%"
itemSize={40} // 固定行高
itemCount={data.length}
itemData={data} // 透传给 Row 的数据
overscanCount={5} // 上下缓冲
>
{Row}
</FixedSizeList>
);
}
唯一的坑写在注释里:Row 收到的 style 必须原样透传——撑高、绝对定位、top 偏移全在里面,丢了它列表会叠成一坨。手写版的全部数学,库都替你做了。
换个视角看参数表,似曾相识:itemCount 是总数(COUNT),itemSize 是批大小(size),滚动位置换算成偏移量(OFFSET)——正是第 36 篇的分页三要素,只是全部发生在内存里:没有 SQL、没有网络往返,窗口随便滑、随便回滚。「内存放全集、视图只渲染窗口」,和后端「只查当页」是同一个窗口思想。
FixedSizeList 就是浏览器内的分页游标:COUNT 给出滚动条总长,itemSize 定批大小,scrollTop 换算 OFFSET——零延迟、可回滚、可乱跳。「只查当页」省服务端资源,「只渲染当窗」省浏览器资源,思想同源:数据该在服务端切的用分页(第 36 篇),已在客户端的用虚拟化(本篇)。
行高不固定的场景:react-window 的 VariableSizeList 要预估每行高度、用着别扭;实际项目直接上 react-virtuoso——自动测量真实高度、支持聊天流反向滚动、可配合无限取数。
选型:分页、无限滚动、虚拟列表
本篇和第 36 篇凑齐了「大数据渲染」的三张牌,放一张选型总表:
| 分页(第 36 篇) | 无限滚动(第 36 篇) | 虚拟列表(本篇) | |
|---|---|---|---|
| 数据在哪 | 服务端,按页取 | 服务端,按游标推进 | 浏览器内存,一次到位 |
| DOM 数量 | 一页的量 | 越滚越多,无上限 | 恒定:视口 + 缓冲 |
| 跳转能力 | 跳页、总数、URL 可收藏 | 只能顺序滚,无总数 | 随便滚,scrollToIndex 直达 |
| 后端配合 | 偏移分页接口 | 游标分页接口 | 一次性全量接口 |
| 适用 | 后台管理、报表 | 信息流、评论、feed | 本地大数据:日志查看、大表格、导入预览 |
| 常见组合 | — | 无限滚动 + 虚拟列表(长 feed 标配) | 与前两者叠加,只管渲染层 |
记一条组合拳原则:取数和渲染是两层,可独立选型、叠着用——无限滚动管取数(游标推进),虚拟列表管渲染(DOM 恒定),react-virtuoso 一类库已把两层粘好,是超长信息流的标准解法。
- 卡的根源是 DOM 数量:1 万行 ≈ 6 万节点,内存、首屏挂载、reflow 全线告急;memo 省重复劳动,省不了绝对工作量。
- 原理:全集留内存,按 scrollTop 算出切片 [start, end],只渲染切片 + 上下缓冲(overscan),总高元素撑出真实滚动条,DOM 恒定几十个节点。
- 手写三步:scrollTop 除行高取整减缓冲得 start → 可视行数加两倍缓冲得 count → 切片绝对定位到 i × ROW_H。
- 生产用 react-window:Row 的 style 必须透传;行高不定用 react-virtuoso;超长 feed = 无限滚动取数 + 虚拟列表渲染。
- 选型口诀:管理端分页,信息流无限滚,本地大数据虚拟化;数据在远端又想随便滚,就把取数、渲染拆成两层叠起来。
本章回顾
1 万行卡的不是 React,是 6 万个 DOM 节点带来的内存、挂载和布局成本。虚拟列表釜底抽薪:全集留在内存,视图永远只渲染「可视区 + 缓冲」那几十行,滚动只是索引平移——手写三十行领会精髓,生产交给 react-window / react-virtuoso。至此渲染层的性能牌打完:第 37 篇少做无用功(memo),本篇少渲染无用节点(虚拟化)。但卡还有另一个来源——不在运行时,在加载时:整个应用打成一个 JS 文件,首屏就要下载全部代码,其中九成用不上。下一篇第 39 篇:代码分割与懒加载,让「按需」从数据层延伸到代码层——用不到的代码,一开始就别下载。
Comments · 评论