陌上人如玉
公子世无双

5.5 列表优化

5.5 列表优化

一句话:长列表的性能瓶颈有三层——React 渲染(几千个组件函数)、DOM 数量(几千个节点)、布局与绘制;对应手段是「稳定 key + 行组件 memo + 稳定处理函数 + 虚拟滚动 + 避免渲染中重算」。

一、先定位瓶颈在哪一层

三层成本:

① React 层
   · 几千行 → 几千次组件函数调用 + 元素创建 + diff
   · 每次更新都重跑一遍 → 主线程被占满

② DOM 层
   · 几千个 DOM 节点的内存与维护成本
   · 浏览器的「样式计算 + 布局 + 绘制」都要遍历这些节点
   · 这是「滚动掉帧」最常见的原因 ⚠️

③ 布局与绘制层
   · 复杂的行内布局(表格、嵌套 flex)
   · 阴影、渐变、滤镜等绘制成本
   · 图片未设置尺寸 → 布局抖动
判断方法:
·Profiler 显示「组件渲染耗时高」→ ①
· Performance 面板显示「Recalculate Style / Layout / Paint 耗时高」→ ②③
· 滚动时掉帧但没有 JS 执行(Performance 里 Frames 有红条、Main 空闲)→ ②③

二、key 的选择

// ❌ 用 index(列表增删/排序时状态错位,见 4.5)
{items.map((item, i) => <Row key={i} item={item} />)}

// ❌ 用随机值(每次渲染整列重建)
{items.map((item) => <Row key={Math.random()} item={item} />)}

// ✅ 稳定唯一 ID
{items.map((item) => <Row key={item.id} item={item} />)}
为什么 key 对性能如此关键?
① key 正确 → React 能「复用」DOM 与组件实例 → 只更新变化的属性
② key 错误(如随机值)→ React 认为「全是新节点」→ 卸载全部 + 创建全部
   → DOM 操作数量从「几个」变成「几千个」

⚠️ 这是「列表性能」中收益最大、成本最低的一项。

三、行组件 memo + 稳定 props

// ❌ 每个 Row 都收到一个「新建的箭头函数」→ memo 失效
{items.map((item) => (
  <Row key={item.id} item={item} onDelete={() => handleDelete(item.id)} />
))}
// ✅ 方案一:稳定处理函数 + 稳定 props
const Row = React.memo(function Row({ item, onDelete }: RowProps) {
  console.log('Row 渲染', item.id);
  return (
    <li>
      {item.name}
      <button onClick={() => onDelete(item.id)}>删除</button>
      {/* ⭐ 这里的箭头函数是 Row 内部的,不影响 Row 自身的 memo */}
    </li>
  );
});

function List({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
  const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]);

  return (
    <ul>
      {items.map((item) => (
        <Row key={item.id} item={item} onDelete={handleDelete} />
      ))}
    </ul>
  );
}
// ✅ 方案二(更彻底):data-* + 事件委托 → 处理函数引用永久稳定
const RowWithoutCallback = React.memo(function RowWithoutCallback({ item }: { item: Item }) {
  return (
    <li data-id={item.id}>
      {item.name}
      <button>删除</button>
    </li>
  );
});

function List2({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
  const onDeleteRef = useRef(onDelete);
  useEffect(() => { onDeleteRef.current = onDelete; });

  // ⭐ 依赖为空 → 引用永久稳定
  const handleClick = useCallback((e: React.MouseEvent<HTMLUListElement>) => {
    const target = (e.target as HTMLElement).closest('[data-id]');
    if (!target) return;
    onDeleteRef.current(target.getAttribute('data-id')!);
  }, []);

  return (
    <ul onClick={handleClick}>
      {items.map((item) => (
        <RowWithoutCallback key={item.id} item={item} />
        //                    ↑ props 只有 item → memo 永久生效
      ))}
    </ul>
  );
}
// 优点:只有「一个」监听器(事件委托)+ 行组件永远不因回调变化重渲染
// 缺点:模板里看不到「点击处理函数」(可读性略降)
方案 处理函数引用 监听器数量 可读性
内联箭头函数 每次渲染新函数 每行 1 个 ✅ 直观
useCallback + 每行传参 稳定 每行 1 个 ✅
data-* + 事件委托 永久稳定 整列 1 个 ⚠️ 略降

四、避免「渲染中重算」

// ❌ 每次渲染都重新 filter + sort
function List({ items, keyword, sortBy }: Props) {
  const visible = items
    .filter((it) => it.name.includes(keyword))
    .sort((a, b) => (sortBy === 'name' ? a.name.localeCompare(b.name) : b.score - a.score));

  return <ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>;
}

// ✅ 用 useMemo 缓存
function List2({ items, keyword, sortBy }: Props) {
  const visible = useMemo(() => {
    return items
      .filter((it) => it.name.includes(keyword))
      .sort((a, b) => (sortBy === 'name' ? a.name.localeCompare(b.name) : b.score - a.score));
  }, [items, keyword, sortBy]);

  return <ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>;
}
⚠️ 一个常见错误:每次渲染创建「新的数组」
const sorted = [...items].sort();          // ❌ 每次都是新引用
<MemoRow items={sorted} />                 // → memo 失效

✅ 放到 useMemo 里
const sorted = useMemo(() => [...items].sort(), [items]);

五、虚拟滚动(长列表的核心解法)

核心思路:
  只渲染「可视区域内」的几十个元素,
  用一个「占位元素」撑起总高度(保证滚动条长度正确)。

关键计算:
  ① 可视区高度:containerHeight = container.clientHeight
  ② 行高(固定时):rowHeight
  ③ 总高度:totalHeight = rowHeight × itemCount
  ④ 起始索引:startIndex = Math.floor(scrollTop / rowHeight)
  ⑤ 渲染数量:visibleCount = Math.ceil(containerHeight / rowHeight)
  ⑥ 结束索引:endIndex = Math.min(startIndex + visibleCount + overscan, itemCount - 1)
  ⑦ 偏移:offsetY = startIndex × rowHeight
// 固定行高的虚拟列表(简化版,见 11.6 的完整实现)
function VirtualList({ items, rowHeight = 40, overscan = 3 }: VirtualListProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [containerHeight, setContainerHeight] = useState(0);

  // ① 监听容器高度(响应式)
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setContainerHeight(el.clientHeight));
    ro.observe(el);
    setContainerHeight(el.clientHeight);
    return () => ro.disconnect();
  }, []);

  // ② 滚动监听(用 rAF 节流,避免高频 setState)
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
    const top = e.currentTarget.scrollTop;
    requestAnimationFrame(() => setScrollTop(top));
  }, []);

  // ③ 计算可视范围
  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const endIndex = Math.min(
    items.length - 1,
    Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan
  );
  const visible = items.slice(startIndex, endIndex + 1);

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{ height: '100%', overflow: 'auto', position: 'relative' }}
    >
      {/* 占位元素:撑起总高度,保证滚动条正确 */}
      <div style={{ height: items.length * rowHeight, position: 'relative' }}>
        {/* 渲染层:整体偏移到正确位置 */}
        <div style={{ transform: `translateY(${startIndex * rowHeight}px)` }}>
          {visible.map((item, i) => (
            <Row key={item.id} item={item} style={{ height: rowHeight }} />
          ))}
        </div>
      </div>
    </div>
  );
}
虚拟滚动的三个关键点:

① 用「占位元素」撑高度(否则滚动条只有几十行的高度)
② 用「transform: translateY」偏移渲染层(比 top/absolute 更省布局计算)
③ overscan(多渲染几行)避免「快速滚动时白屏」

⚠️ 不等高的行怎么办?
  需要用「高度缓存 + 前缀和 + 二分查找 + 滚动锚定」,
  复杂度明显上升 → 生产上建议用成熟库(react-window / react-virtuoso / TanStack Virtual)。
库 特点
react-window 轻量、API 简单、固定/变量行高
react-virtualized 功能全(表格、网格),体积大、维护缓慢
react-virtuoso 支持不等高、反向滚动、分组,体验好
@tanstack/react-virtual headless(只管逻辑,样式自定),灵活
antd 的 Table/VirtualList 项目已用 antd 时可直接用
// react-window 的用法(生产环境推荐)
import { FixedSizeList } from 'react-window';

function List({ items }: { items: Item[] }) {
  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={40}
      itemData={items}
    >
      {({ index, style, data }) => (
        <div style={style}>
          {data[index].name}
          {/* ⚠️ 注意:必须把 style 应用到最外层元素 */}
        </div>
      )}
    </FixedSizeList>
  );
}

六、分页与分批渲染

// ① 分页(最传统、最省资源)
function PaginatedList() {
  const [page, setPage] = useState(1);
  const { data, isLoading } = useQuery({
    queryKey: ['items', page],
    queryFn: () => api.getItems({ page, size: 20 }),
    placeholderData: (prev) => prev,            // 翻页时保留上一页,避免闪白
  });
  // ...
}
// ② 分批渲染(先渲染前 N 条,滚动/按钮触发更多)
function IncrementalList({ items }: { items: Item[] }) {
  const [visibleCount, setVisibleCount] = useState(50);
  const visible = useMemo(() => items.slice(0, visibleCount), [items, visibleCount]);

  const loadMore = useCallback(() => {
    setVisibleCount((c) => Math.min(c + 50, items.length));
  }, [items.length]);

  return (
    <>
      <ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>
      {visibleCount < items.length && <button onClick={loadMore}>加载更多</button>}
    </>
  );
}
三种策略的取舍:
· 分页    → 资源最省,但用户需要「翻页」
· 分批    → 简单,但「一次性 DOM 仍然会增长到全量」(不解决根本问题)
· 虚拟滚动 → 最彻底(DOM 始终只有几十个),但实现复杂、对「行高不定」支持难

七、其它优化技巧

/* ① content-visibility:让浏览器跳过「屏幕外元素」的渲染工作 */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 40px;      /* 声明一个预估高度,避免滚动条跳动 */
}
/* ⚠️ 兼容性与滚动条准确性需要验证(不适合「必须精确」的场景) */

/* ② contain:隔离布局与样式计算的影响范围 */
.list-item {
  contain: layout style paint;
}

/* ③ 避免行内「昂贵样式」 */
.item { box-shadow: 0 2px 8px rgba(0,0,0,.1); }         /* 大量阴影 → 绘制成本高 */
.item { will-change: transform; }                        /* ⚠️ 滥用会导致内存暴涨 */

/* ④ 图片设置宽高(避免布局抖动) */
.item img { width: 40px; height: 40px; object-fit: cover; }
// ⑤ 只给「可视区域」的行绑定复杂逻辑(如 IntersectionObserver 懒加载图片)
function LazyImage({ src, alt }: { src: string; alt: string }) {
  const [visible, setVisible] = useState(false);
  const ref = useRef<HTMLImageElement>(null);

  useEffect(() => {
    const io = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting) {
        setVisible(true);
        io.disconnect();
      }
    });
    if (ref.current) io.observe(ref.current);
    return () => io.disconnect();
  }, []);

  return <img ref={ref} src={visible ? src : PLACEHOLDER} alt={alt} loading="lazy" />;
}
// ⑥ 滚动监听要「节流」(否则每次滚动都触发 setState)
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
  const top = e.currentTarget.scrollTop;
  if (rafRef.current) return;
  rafRef.current = requestAnimationFrame(() => {
    setScrollTop(top);
    rafRef.current = 0;
  });
}, []);

八、常见坑

// ① 用 index 作 key(增删/排序时全量重建或状态错位)
// ② 用随机值作 key(灾难级)
// ③ 每行传内联箭头函数(memo 失效)
{items.map((it) => <MemoRow key={it.id} onDelete={() => del(it.id)} />)}     // ❌

// ④ 渲染中做 filter/sort/map(每次渲染都重算)
// ⑤ 每次渲染创建新数组(让下游 memo 失效)
const visible = items.filter(...);       // ❌ 新数组
<MemoList items={visible} />             // → memo 失效

// ⑥ 滚动监听没有节流(每次滚动都 setState → 每秒几十次渲染)
// ⑦ 虚拟滚动忘写「占位元素」(滚动条高度错误)
// ⑧ 虚拟滚动忘了「overscan」(快速滚动白屏)
// ⑨ 用「分批渲染」以为解决了性能问题(DOM 仍然会增长到全量)
// ⑩ 行内容太重(复杂 SVG、阴影、渐变)→ 绘制成本高
// ⑪ 图片没有宽高 → 加载后布局抖动
// ⑫ 在列表里用「深层嵌套的 flex/grid」→ 布局计算成本上升
// ⑬ 列表容器在「滚动时同时改样式」(读取尺寸 + 修改样式 → 布局抖动)
const h = el.offsetHeight;               // 读
el.style.height = `${h}px`;              // 写 → 触发 layout
// ⑭ 给所有元素加 will-change(内存暴涨、合成层过多)
// ⑮ 认为「虚拟滚动是万能的」(它不解决「单元格内容复杂」的问题)

九、面试延伸

  1. 「长列表卡顿怎么优化?」
    分三层:① React 层——用「稳定唯一 key」(让 React 正确复用节点)、给行组件加 React.memo 并保证 props 引用稳定(处理函数用 useCallback 或改用「data-* + 事件委托」)、不要在渲染中做 filter/sort(用 useMemo);② DOM 层——虚拟滚动(只渲染可视区域的几十个节点,用占位元素撑高度),把 DOM 从「一万」降到「几十」;③ 布局/绘制层——简化行内样式(少用阴影/滤镜)、图片设定宽高、必要时用 content-visibility: auto。先判断瓶颈在哪一层(Profiler 看渲染耗时,Performance 看 Layout/Paint)。

  2. 「虚拟滚动的原理是什么?」
    核心是「只渲染可视区域的元素,用占位元素撑起总高度」。计算过程:startIndex = floor(scrollTop / rowHeight)、visibleCount = ceil(containerHeight / rowHeight)、offsetY = startIndex × rowHeight。渲染时用「一个高度为 itemCount × rowHeight 的占位 div」保证滚动条长度正确,再用 transform: translateY(offsetY) 把实际渲染的几十行偏移到正确位置。再加 overscan(多渲染几行)避免快速滚动时白屏。

  3. 「虚拟滚动有什么难点?」
    三个:① 不等高的行——必须「测量并缓存每行高度 → 维护高度前缀和 → 二分查找定位 startIndex → 处理滚动锚定」,复杂度显著上升,生产上建议用成熟库(react-virtuoso、@tanstack/react-virtual);② 滚动性能——onScroll 触发极频繁,必须用 requestAnimationFrame 节流,避免每次滚动都 setState;③ 可访问性与搜索——Ctrl+F 找不到未渲染的内容,屏幕阅读器也可能读不到,需要额外处理(如 aria-*、提供「全部加载」的途径)。

  4. 「为什么 key 对列表性能这么重要?」
    因为 key 决定 React「复用还是重建」节点。key 正确时,列表更新只需少量 DOM 操作(只更新变化的属性);key 错误时(如随机值),React 会认为「所有元素都是新的」→ 卸载全部旧节点 + 创建全部新节点 → DOM 操作从「几个」变成「几千个」。对于长列表,这是最严重的性能问题;同时它还会导致「组件状态与输入内容错位」(见 4.5)。

  5. 「为什么说『每行传内联箭头函数』会毁掉列表性能?」
    因为它让 React.memo 失效——每次父组件渲染都会创建新的函数对象,行组件的 props 浅比较失败,于是每一行都会重新渲染。对 1000 行的列表就是 1000 次无意义的渲染。解法有三种:① 用 useCallback 稳定处理函数并「只传 id」(行内自己调用);② 用「data-* + 事件委托」(整列只有一个监听器、处理函数永久稳定);③ 把处理函数通过 Context 提供(如果层级深)。方案②通常是长列表的最优解。

  6. 「排序/过滤的结果每次都重建数组有问题吗?」
    有。因为「新数组 = 新引用」→ 传给 memo 子组件时浅比较失败 → 子组件全部重渲染。此外「每次渲染都执行 filter/sort」本身是浪费(尤其数据量大时)。所以要:① 用 useMemo 缓存「排序/过滤的结果」(依赖 items 与「排序/过滤条件」);② 如果结果是给 memo 列表用的,缓存后引用稳定,memo 才能生效;③ 数据量极大时(10 万+),考虑把排序放到 Web Worker 或服务端。

  7. 「分页、分批、虚拟滚动怎么选?」
    按需求与成本:① 分页——资源最省,适合「数据本身适合分页浏览」(表格、后台列表),缺点是用户要翻页;② 分批渲染(先渲染 50 条、滚动加载更多)——实现简单,但DOM 仍会增长到全量,只适合「几千条以内」;③ 虚拟滚动——最彻底(DOM 始终只有几十个),适合「上万条」与「无限滚动」,但实现复杂、对不等高支持难。经验法则:< 200 条直接渲染;200~2000 条用分批或简单优化;> 2000 条用虚拟滚动。

  8. 「滚动监听为什么必须节流?」
    因为 scroll 事件触发极其频繁(一次滚动可能触发上百次)。如果每次都在回调里 setState,就会「每帧多次渲染」,直接拖垮性能。正确做法:① 用 requestAnimationFrame 节流(保证「每帧最多更新一次」);② 或改用 IntersectionObserver(不监听滚动事件,由浏览器在「元素进入视口」时通知——这是无限滚动的更优方案);③ 在 onScroll 里不要读布局属性(offsetHeight 等),否则会触发强制同步布局。

一句话速记

列表优化三层:React 层(稳定唯一 key、行组件 memo + 稳定处理函数(useCallback 或 data-* + 事件委托)、渲染中不 filter/sort(用 useMemo,避免新数组让 memo 失效));DOM 层(虚拟滚动——占位元素撑高度 + translateY 偏移 + overscan,不等高用成熟库);布局层(简化行内样式、图片定宽高、content-visibility);滚动监听必须用 rAF 节流(更优是 IntersectionObserver);策略选择——< 200 条直接渲染、几千条分批、上万条虚拟滚动。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.5 列表优化

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫