前言:当浏览器变成暖手宝
接上回。咱们用 React Hook Form 搞定了复杂的表单校验,用户终于能开心地录入数据了。
但没过两天,后端老哥一脸坏笑地跑来找你:“兄弟,那个日志监控台,由于业务增长,现在大概有 5 万条日志需要一次性展示出来,不仅要展示,还要能实时滚动,你要扛住啊。”
你心里一惊,想着:“5 万条?你这是要谋杀我的 Chrome 吗?”
如果你真的老老实实写个 .map() 把这 5 万个 <div> 渲染出来,哪怕没有任何逻辑,你的浏览器也会瞬间卡死,CPU 风扇开始狂转,电脑变成暖手宝。因为 DOM 节点实在是太重了。
今天,我们要来学一招**“障眼法”** —— 虚拟滚动 (Virtual Scrolling)。我们要学会欺骗用户的眼睛,让他感觉有 10 万条数据,但实际上浏览器只干了 10 条数据的活儿。
核心原理:只画你看见的
什么叫虚拟滚动?
想象一下你坐在一辆飞驰的火车上,窗外是连绵不断的风景(长列表)。 虽然风景有几百公里长,但在任何一个时间点,透过窗户(视口/Viewport),你只能看到眼前这一小块。
浏览器也是一样。 用户屏幕就那么大,一次能看到的列表项可能只有 10 到 20 条。 剩下的 49,980 条数据,用户都没滚到那里,你渲染它干嘛?浪费内存吗?
虚拟滚动的逻辑是:
- 算出列表的总高度(比如 50000 条 * 50px = 2500000px),搞一个空的
div把滚动条撑起来,让用户觉得列表很长。 - 监听滚动事件
onScroll。 - 根据当前的滚动位置(
scrollTop),算出用户现在应该看到哪几条数据(比如第 100-110 条)。 - 只渲染这 10 条数据,并用绝对定位(Absolute)把它们摆在正确的位置上。
- 当用户继续滚动,把移出屏幕的 DOM 销毁(或回收),渲染新进入屏幕的 DOM。
这就是**“移花接木”**。无论数据量是 100 还是 100 万,DOM 节点的数量永远恒定在 20 个左右。性能也就恒定在满帧。
神器登场:react-window
早年间我们用 react-virtualized,那是个全能选手,但太重了。
后来它的作者重写了一个轻量版的,叫 react-window,这才是现在的版本答案。
❌ 这种写法会炸:
const LogList = ({ logs }) => {
// 💀 如果 logs.length 是 50000,这里会生成 50000 个 DOM
return (
<div className="list">
{logs.map(log => (
<div key={log.id} className="row">{log.content}</div>
))}
</div>
);
};
✅ 丝滑写法 (FixedSizeList):
假设每一行高度是固定的(比如 35px)。
// 1. 定义每一行的长相
// 注意:style 是必须的!react-window 靠它来绝对定位
const Row = ({ index, style, data }) => {
const log = data[index];
return (
<div style={style} className="flex items-center border-b px-4">
<span className="text-gray-500 mr-2">#{index + 1}</span>
{log.content}
</div>
);
};
const VirtualLogList = ({ logs }) => {
return (
<List
height={500} // 视口高度(窗口大小)
itemCount={logs.length} // 总数据量(比如 50000)
itemSize={35} // 每一行的高度
width="100%" // 列表宽度
itemData={logs} // 把数据传进去,方便 Row 组件拿
>
{Row}
</List>
);
};
就这么简单。你会发现,不管 logs 有多少条,滚动起来都像德芙一样丝滑。
进阶坑点:当高度不固定怎么办?
现实往往很骨感。PM 说:“有的日志只有一行,有的日志有三行,你不能定死 35px 啊。”
这时候你需要 VariableSizeList。 你需要提供一个 getItemSize 函数,告诉 react-window 每一行有多高。
const getItemSize = (index) => {
// 这就需要你提前算出每一行的高度
// 或者根据字数估算:
return logs[index].content.length > 50 ? 70 : 35;
};
<List
itemSize={getItemSize} // 传函数而不是数字
{...otherProps}
>
{Row}
</List>
老司机的忠告: 动态高度是性能杀手。如果每一行高度都要通过计算 DOM 才能得出来(比如复杂的富文本),那你可能需要更高级的库像 react-virtuoso,它能自动测量高度,但原理更复杂。能用固定高度,尽量忽悠 PM 用固定高度。
最大的痛点:怎么让列表自适应宽高?
react-window 的 List 组件需要明确的 height 和 width 数字。 但我们在写 CSS 布局时,通常喜欢写 flex: 1 或者 height: 100%,让它自动填满父容器。
直接传 100% 给 react-window 是不行的。 你需要配合 react-virtualized-auto-sizer 食用。
const FullScreenList = () => (
<div style={{ flex: 1, height: '100%' }}>
<AutoSizer>
{/* AutoSizer 会算出当前容器的宽(width)和高(height),并传给子组件 */}
{({ height, width }) => (
<List
height={height}
width={width}
itemCount={1000}
itemSize={35}
>
{Row}
</List>
)}
</AutoSizer>
</div>
);
这样,你的虚拟列表就能完美响应式,随着窗口大小改变而改变了。
避坑指南:状态丢失之谜
用了虚拟滚动,新手最容易懵逼的一个 Bug 是: “我在第一行勾选了 Checkbox,往下滚几屏,怎么第 100 行也被勾选了?”
这是因为 DOM 复用。 React Window 为了省资源,当你滚动时,它并没有销毁上面的 div,而是把它挪到了下面,改了改文字内容。 如果你把 Checkbox 的状态(useState)写在 Row 组件内部,那这个状态就会跟着 DOM 跑到下面去。
解决方法: 状态上移。 所有的选中状态、输入框内容,必须存在父组件的 logs 数据里,或者 React Query / Context 里。 Row 组件必须是一个纯展示组件(Stateless Component) ,只根据 props 渲染,绝不能自己持有状态。
总结
前端性能优化有三大法宝:
- 懒加载 (Lazy Loading) :不用的代码别下。
- 防抖节流 (Debounce/Throttle) :频繁的事件别做。
- 虚拟滚动 (Virtual Scrolling) :看不见的 DOM 别画。
掌握了虚拟滚动,你就不再畏惧大数据的展示需求。哪怕后端给你返了 100 万条数据,你也只需要淡淡一笑:“没问题,拿来吧你。”
当然,前提是你不用为了这 100 万条数据去算高度算到头秃。
好了,我要去把那个渲染了 5000 个 Table Row 的报表页面给优化了。
下期预告:客户端渲染(CSR)虽好,但首屏白屏时间长,且对 SEO 不友好。搜索引擎爬虫过来一看,只看到一个空的
<div id="root"></div>,转身就走了。 下一篇,我们来聊聊 “服务端渲染 (SSR) 与 Next.js” 。看看如何让 React 代码在服务器上跑起来,做到“秒开”且被 Google 喜欢。