骗过用户的眼睛:如何用 react-window 渲染 10 万条数据,让浏览器不再假死

68 阅读6分钟

前言:当浏览器变成暖手宝

接上回。咱们用 React Hook Form 搞定了复杂的表单校验,用户终于能开心地录入数据了。

但没过两天,后端老哥一脸坏笑地跑来找你:“兄弟,那个日志监控台,由于业务增长,现在大概有 5 万条日志需要一次性展示出来,不仅要展示,还要能实时滚动,你要扛住啊。”

你心里一惊,想着:“5 万条?你这是要谋杀我的 Chrome 吗?”

如果你真的老老实实写个 .map() 把这 5 万个 <div> 渲染出来,哪怕没有任何逻辑,你的浏览器也会瞬间卡死,CPU 风扇开始狂转,电脑变成暖手宝。因为 DOM 节点实在是太重了。

今天,我们要来学一招**“障眼法”** —— 虚拟滚动 (Virtual Scrolling)。我们要学会欺骗用户的眼睛,让他感觉有 10 万条数据,但实际上浏览器只干了 10 条数据的活儿。

93590b032a71966aa078dd45cb894587(1).jpg

核心原理:只画你看见的

什么叫虚拟滚动?

想象一下你坐在一辆飞驰的火车上,窗外是连绵不断的风景(长列表)。 虽然风景有几百公里长,但在任何一个时间点,透过窗户(视口/Viewport),你只能看到眼前这一小块。

浏览器也是一样。 用户屏幕就那么大,一次能看到的列表项可能只有 10 到 20 条。 剩下的 49,980 条数据,用户都没滚到那里,你渲染它干嘛?浪费内存吗?

虚拟滚动的逻辑是:

  1. 算出列表的总高度(比如 50000 条 * 50px = 2500000px),搞一个空的 div 把滚动条撑起来,让用户觉得列表很长。
  2. 监听滚动事件 onScroll
  3. 根据当前的滚动位置(scrollTop),算出用户现在应该看到哪几条数据(比如第 100-110 条)。
  4. 只渲染这 10 条数据,并用绝对定位(Absolute)把它们摆在正确的位置上。
  5. 当用户继续滚动,把移出屏幕的 DOM 销毁(或回收),渲染新进入屏幕的 DOM。

这就是**“移花接木”**。无论数据量是 100 还是 100 万,DOM 节点的数量永远恒定在 20 个左右。性能也就恒定在满帧。

yihuajiemuxiaotu.jpg

神器登场: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-windowList 组件需要明确的 heightwidth 数字。 但我们在写 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 渲染,绝不能自己持有状态。


总结

前端性能优化有三大法宝:

  1. 懒加载 (Lazy Loading) :不用的代码别下。
  2. 防抖节流 (Debounce/Throttle) :频繁的事件别做。
  3. 虚拟滚动 (Virtual Scrolling) :看不见的 DOM 别画。

掌握了虚拟滚动,你就不再畏惧大数据的展示需求。哪怕后端给你返了 100 万条数据,你也只需要淡淡一笑:“没问题,拿来吧你。”

当然,前提是你不用为了这 100 万条数据去算高度算到头秃。

好了,我要去把那个渲染了 5000 个 Table Row 的报表页面给优化了。

lg_90841_1619336946_60851ef204362.png


下期预告:客户端渲染(CSR)虽好,但首屏白屏时间长,且对 SEO 不友好。搜索引擎爬虫过来一看,只看到一个空的 <div id="root"></div>,转身就走了。 下一篇,我们来聊聊 “服务端渲染 (SSR) 与 Next.js” 。看看如何让 React 代码在服务器上跑起来,做到“秒开”且被 Google 喜欢。