列表缓存实战:提升前端性能的关键技巧与踩坑经验

倩倩 交互 阅读 2,268
赞 20 收藏
二维码
手机扫码查看
反馈

列表滚动卡成PPT?缓存没做对

上周改一个商品列表页,用户反馈“滑动像在翻PPT”,一屏一卡。我本地跑起来也确实,手指一划,画面停顿半秒才跟上——典型的滚动性能问题。第一反应:是不是没做虚拟滚动?但仔细一看,列表总共就20条数据,根本用不着那么重的方案。后来折腾了半天才发现,问题出在列表项的缓存策略上

列表缓存实战:提升前端性能的关键技巧与踩坑经验

一开始以为是渲染问题,结果白忙活

我先以为是 React 重新渲染太频繁,加了 React.memo 包裹列表项组件,结果没卵用。又怀疑是 CSS 动画或 transform 干扰,把所有动画关掉,还是卡。甚至试了给列表容器加 will-change: transform,照样卡顿。这时候我已经有点烦躁了,明明数据量不大,怎么这么卡?

打开 Chrome DevTools 的 Performance 面板录了一次滚动,发现每次滚动都会触发大量 DOM 节点的 reflow 和 repaint。奇怪的是,列表项的 key 是唯一的(用的 id),理论上不应该重复创建节点。但仔细看火焰图,发现每次滚动后,整个列表都在重新 mount/unmount——这不对劲!

踩坑:key 写错了位置

翻代码发现,我在 map 里是这么写的:

{list.map(item => (
  <div key={item.id}>
    <MyListItem data={item} />
  </div>
))}

看起来没问题对吧?但问题在于,MyListItem 组件内部其实还有一层逻辑:根据 item.type 渲染不同类型的子组件。而这些子组件没有自己的 key,导致 React 在 diff 时无法正确复用 DOM 节点。更糟的是,某些子组件里还有条件渲染,比如:

// MyListItem 内部
{item.type === 'A' ? <TypeAComponent /> : <TypeBComponent />}

当列表滚动时,即使外层 div 的 key 正确,但内部组件结构变化,React 会认为整个子树都变了,于是直接卸载再重建。这就解释了为什么每次滚动都触发大量 mount/unmount。

这里我踩了个大坑:以为只要外层有 key 就行,其实嵌套组件的 key 也要合理设置,尤其是动态类型组件。

解决方案:稳定 key + 缓存组件实例

改法其实很简单,两步:

  • 确保每个列表项的根元素有稳定且唯一的 key(这个我本来就有)
  • 给内部动态组件也加上 key,而且 key 要包含类型信息

改成这样:

{list.map(item => (
  <div key={item.id}>
    {item.type === 'A' ? (
      <TypeAComponent key={A-${item.id}} data={item} />
    ) : (
      <TypeBComponent key={B-${item.id}} data={item} />
    )}
  </div>
))}

加了 key={A-${item.id}} 之后,React 就能区分不同类型的组件实例,即使切换类型也不会误判为同一个节点。滚动卡顿立马消失。

但我觉得还不够稳,因为如果未来加更多类型,容易漏掉 key。所以最后我封装了一个统一的 render 函数:

const renderItem = (item) => {
  const ComponentMap = {
    A: TypeAComponent,
    B: TypeBComponent,
    // ...其他类型
  };
  const Component = ComponentMap[item.type];
  if (!Component) return null;
  
  return <Component key={${item.type}-${item.id}} data={item} />;
};

// 使用
{list.map(item => (
  <div key={item.id}>
    {renderItem(item)}
  </div>
))}

这样新增类型时,只要注册到 ComponentMap 里,key 自动带上类型前缀,不容易出错。

额外优化:用 useMemo 缓存列表项 props

虽然卡顿解决了,但滚动时还是有点小抖动。我注意到 MyListItemdata prop 每次都传整个 item 对象,而父组件状态更新时(比如筛选条件变化),即使 item 本身没变,引用也会变,导致子组件不必要的 re-render。

于是给每个 item 的 props 做了缓存:

const getItemProps = useCallback((item) => {
  return {
    id: item.id,
    name: item.name,
    price: item.price,
    // 只取需要的字段,避免传整个对象
  };
}, []);

// 渲染时
<TypeAComponent 
  key={A-${item.id}} 
  {...getItemProps(item)} 
/>

或者更彻底一点,用 useMemo 缓存整个列表的 props 数组:

const memoizedList = useMemo(() => {
  return list.map(item => ({
    ...item,
    key: ${item.type}-${item.id}
  }));
}, [list]);

不过这个要看具体场景,如果 list 本身变化频繁,useMemo 可能反而增加开销。我这里 list 更新不频繁,加了之后 FPS 从 50+ 提升到稳定 60。

核心代码就这几行

总结下来,解决列表缓存问题的关键就三点:

  1. key 必须唯一且稳定,尤其在动态组件中要包含类型标识
  2. 避免传递大对象作为 prop,只传必要字段
  3. 用 React.memo + useMemo 减少无效渲染

完整示例代码:

import React, { memo, useMemo, useCallback } from 'react';

const TypeAComponent = memo(({ id, name }) => {
  return <div>A: {name} (ID: {id})</div>;
});

const TypeBComponent = memo(({ id, price }) => {
  return <div>B: ${price} (ID: {id})</div>;
});

const ListItem = ({ item }) => {
  const ComponentMap = {
    A: TypeAComponent,
    B: TypeBComponent,
  };

  const Component = ComponentMap[item.type];
  if (!Component) return null;

  // 只传递必要字段
  const props = useMemo(() => {
    if (item.type === 'A') {
      return { id: item.id, name: item.name };
    } else {
      return { id: item.id, price: item.price };
    }
  }, [item]);

  return <Component key={${item.type}-${item.id}} {...props} />;
};

const List = ({ list }) => {
  return (
    <div>
      {list.map(item => (
        <div key={item.id} style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
          <ListItem item={item} />
        </div>
      ))}
    </div>
  );
};

改完后还有个小问题

上线后发现,当列表项高度不一致时(比如 TypeA 高 50px,TypeB 高 100px),快速滚动偶尔还是会轻微卡顿。查了下是因为浏览器 layout 计算成本高。这个问题其实属于另一个范畴了(需要固定高度或使用 ResizeObserver),但当前业务场景下影响不大,就没深究。毕竟列表总共就几十条,用户不会疯狂上下滑动。

所以这个方案不是 100% 完美,但在简单列表场景下,够用、简单、有效

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更自动化的 key 生成方式?或者在高度不一致时如何低成本优化?这类问题其实挺常见的,后续我打算再写一篇关于“非均匀列表滚动优化”的实战记录。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论