MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

React 长列表渲染的性能优化方法

2023-07-135.9k 阅读

React 长列表渲染性能问题剖析

在 React 应用开发中,长列表渲染是常见场景,比如展示大量商品列表、用户列表等。然而,随着列表数据量的增加,性能问题逐渐凸显。主要体现在以下几个方面:

初始渲染性能

  1. 大量 DOM 节点创建:React 在渲染列表时,会为每个列表项创建对应的 DOM 节点。假设一个列表有 1000 条数据,就会创建 1000 个 DOM 元素。浏览器在创建和渲染这么多 DOM 节点时,需要消耗大量的内存和计算资源,导致初始渲染速度变慢,页面出现卡顿。
  2. 虚拟 DOM 计算开销:React 通过虚拟 DOM 来高效更新实际 DOM。但在长列表场景下,首次渲染时构建虚拟 DOM 树本身就是一个复杂且耗时的操作。虚拟 DOM 树要精确映射真实 DOM 结构,数据量越大,构建过程越慢。例如,对每个列表项的属性、样式等进行计算和构建虚拟节点,这一过程的时间复杂度会随着列表长度增加而显著上升。

滚动性能

  1. 频繁重新渲染:当列表滚动时,可能会触发 React 组件的重新渲染。例如,如果列表项依赖的某些状态发生变化,即使只是滚动操作,React 也会重新计算虚拟 DOM 差异并更新真实 DOM。在长列表中,这种频繁的重新渲染会严重影响滚动的流畅性,给用户带来糟糕的体验。
  2. 事件处理开销:滚动事件通常会绑定一些处理函数,如监听滚动位置、加载更多数据等。在长列表中,频繁触发的滚动事件会导致这些处理函数频繁执行,增加计算开销。比如,每次滚动触发加载更多数据的逻辑,会涉及网络请求、数据处理以及 DOM 更新等一系列操作,进一步加重性能负担。

基于窗口化技术的优化

窗口化技术是解决长列表渲染性能问题的有效手段,它只渲染当前视口可见的列表项,大大减少了需要渲染的 DOM 节点数量,从而提升性能。

固定大小列表项的窗口化实现

  1. 原理:对于列表项高度固定的情况,窗口化实现相对简单。我们可以根据视口高度和列表项高度计算出当前视口能显示的列表项数量,即窗口大小。同时,记录当前视口起始位置对应的列表项索引。只渲染这个窗口范围内的列表项,当滚动发生时,更新起始索引和窗口内的列表项。
  2. 代码示例
import React, { useState, useRef, useEffect } from'react';

const ListItem = ({ item }) => {
  return <div>{item}</div>;
};

const FixedSizeList = ({ data, itemHeight }) => {
  const containerRef = useRef(null);
  const [startIndex, setStartIndex] = useState(0);
  const viewportHeight = window.innerHeight;
  const visibleItemCount = Math.floor(viewportHeight / itemHeight);

  useEffect(() => {
    const handleScroll = () => {
      const scrollTop = containerRef.current.scrollTop;
      const newStartIndex = Math.floor(scrollTop / itemHeight);
      setStartIndex(newStartIndex);
    };
    containerRef.current.addEventListener('scroll', handleScroll);
    return () => {
      containerRef.current.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const visibleData = data.slice(startIndex, startIndex + visibleItemCount);

  return (
    <div
      ref={containerRef}
      style={{ height: '100vh', overflowY: 'auto' }}
    >
      {visibleData.map((item, index) => (
        <ListItem key={index + startIndex} item={item} />
      ))}
    </div>
  );
};

const data = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);
const itemHeight = 50;

const App = () => {
  return <FixedSizeList data={data} itemHeight={itemHeight} />;
};

export default App;

在上述代码中,FixedSizeList 组件通过 useRef 创建一个容器引用 containerRef,用于监听滚动事件。useState 记录当前视口起始索引 startIndex。通过计算 viewportHeightitemHeight 得出 visibleItemCount,即窗口大小。在 useEffect 中绑定和解除滚动事件监听,每次滚动时更新 startIndex。最后,通过 data.slice 获取当前窗口内的可见数据并渲染。

可变大小列表项的窗口化实现

  1. 原理:当列表项高度可变时,不能简单地根据视口高度计算窗口大小。需要维护一个列表项高度的缓存数组,记录每个列表项的高度。在滚动时,根据滚动位置和缓存数组来确定当前视口的起始索引和结束索引。
  2. 代码示例
import React, { useState, useRef, useEffect } from'react';

const ListItem = ({ item }) => {
  return <div>{item}</div>;
};

const VariableSizeList = ({ data }) => {
  const containerRef = useRef(null);
  const [startIndex, setStartIndex] = useState(0);
  const [endIndex, setEndIndex] = useState(0);
  const itemHeights = useRef(new Array(data.length).fill(0));

  useEffect(() => {
    const measureItemHeights = () => {
      const items = containerRef.current.children;
      for (let i = 0; i < items.length; i++) {
        itemHeights.current[i] = items[i].offsetHeight;
      }
    };
    if (containerRef.current) {
      measureItemHeights();
    }
  }, []);

  useEffect(() => {
    const handleScroll = () => {
      const scrollTop = containerRef.current.scrollTop;
      let cumulativeHeight = 0;
      let newStartIndex = 0;
      while (cumulativeHeight < scrollTop && newStartIndex < data.length) {
        cumulativeHeight += itemHeights.current[newStartIndex];
        newStartIndex++;
      }
      newStartIndex = Math.max(0, newStartIndex - 1);

      let newEndIndex = newStartIndex;
      cumulativeHeight = 0;
      while (cumulativeHeight < window.innerHeight && newEndIndex < data.length) {
        cumulativeHeight += itemHeights.current[newEndIndex];
        newEndIndex++;
      }
      newEndIndex = Math.min(data.length, newEndIndex);

      setStartIndex(newStartIndex);
      setEndIndex(newEndIndex);
    };
    containerRef.current.addEventListener('scroll', handleScroll);
    return () => {
      containerRef.current.removeEventListener('scroll', handleScroll);
    };
  }, []);

  const visibleData = data.slice(startIndex, endIndex);

  return (
    <div
      ref={containerRef}
      style={{ height: '100vh', overflowY: 'auto' }}
    >
      {data.map((item, index) => (
        <ListItem key={index} item={item} />
      ))}
    </div>
  );
};

const data = Array.from({ length: 1000 }, (_, i) => `Item ${i}`);

const App = () => {
  return <VariableSizeList data={data} />;
};

export default App;

在这段代码中,VariableSizeList 组件通过 itemHeights 记录每个列表项的高度。measureItemHeights 函数在组件挂载时测量每个列表项高度。在滚动事件处理函数 handleScroll 中,通过遍历 itemHeights 数组,根据滚动位置计算 startIndexendIndex,从而确定当前视口可见的列表项范围并渲染。

虚拟列表库的应用

使用 react - virtualized

  1. 简介react - virtualized 是一个广泛使用的 React 虚拟列表库,它提供了多种虚拟列表组件,如 ListTable 等,支持固定大小和可变大小列表项的渲染,并且在性能优化方面做了很多工作。
  2. 固定大小列表使用示例
import React from'react';
import { List } from'react - virtualized';

const rowRenderer = ({ index, key, style }) => {
  return (
    <div key={key} style={style}>
      Item {index}
    </div>
  );
};

const App = () => {
  const rowCount = 1000;
  const rowHeight = 50;

  return (
    <List
      height={window.innerHeight}
      rowCount={rowCount}
      rowHeight={rowHeight}
      rowRenderer={rowRenderer}
      width={300}
    />
  );
};

export default App;

在上述代码中,通过 react - virtualizedList 组件,只需提供 rowCount(列表项总数)、rowHeight(列表项高度)和 rowRenderer(渲染单个列表项的函数),就可以轻松实现固定大小列表的虚拟渲染。List 组件内部会根据视口大小和滚动位置,高效地渲染可见的列表项。 3. 可变大小列表使用示例

import React from'react';
import { VariableSizeList } from'react - virtualized';

const rowHeight = ({ index }) => {
  // 这里可以根据业务逻辑返回不同高度,示例简单返回固定值 + 索引
  return 50 + index;
};

const rowRenderer = ({ index, key, style }) => {
  return (
    <div key={key} style={style}>
      Item {index}
    </div>
  );
};

const App = () => {
  const rowCount = 1000;

  return (
    <VariableSizeList
      height={window.innerHeight}
      rowCount={rowCount}
      rowHeight={rowHeight}
      rowRenderer={rowRenderer}
      width={300}
    />
  );
};

export default App;

对于可变大小列表,react - virtualized 提供了 VariableSizeList 组件。通过 rowHeight 函数动态计算每个列表项的高度,VariableSizeList 组件会根据滚动位置和这些高度信息,精确地渲染可见的列表项,实现高效的可变大小列表渲染。

使用 react - window

  1. 简介react - window 是另一个轻量级的虚拟列表库,它同样专注于高性能的列表渲染。react - window 提供了 FixedSizeListVariableSizeList 等组件,与 react - virtualized 类似,但在 API 设计和实现细节上有所不同。
  2. 固定大小列表使用示例
import React from'react';
import { FixedSizeList } from'react - window';

const Row = ({ index }) => {
  return <div>Item {index}</div>;
};

const App = () => {
  const data = Array.from({ length: 1000 }, (_, i) => i);
  const rowHeight = 50;

  return (
    <FixedSizeList
      height={window.innerHeight}
      itemCount={data.length}
      itemSize={rowHeight}
      renderItem={Row}
      width={300}
    />
  );
};

export default App;

在这个示例中,react - windowFixedSizeList 组件使用 itemCount 表示列表项总数,itemSize 表示列表项高度,renderItem 函数用于渲染单个列表项。通过这种方式,FixedSizeList 组件实现了固定大小列表的高效虚拟渲染。 3. 可变大小列表使用示例

import React from'react';
import { VariableSizeList } from'react - window';

const getItemSize = (index) => {
  // 这里可以根据业务逻辑返回不同高度,示例简单返回固定值 + 索引
  return 50 + index;
};

const Row = ({ index }) => {
  return <div>Item {index}</div>;
};

const App = () => {
  const data = Array.from({ length: 1000 }, (_, i) => i);

  return (
    <VariableSizeList
      height={window.innerHeight}
      itemCount={data.length}
      getItemSize={getItemSize}
      renderItem={Row}
      width={300}
    />
  );
};

export default App;

对于可变大小列表,react - windowVariableSizeList 组件通过 getItemSize 函数获取每个列表项的高度,然后根据滚动位置和这些高度信息,高效地渲染可见的列表项,实现可变大小列表的高性能渲染。

其他优化方法

减少不必要的重新渲染

  1. 使用 React.memo:React.memo 是一个高阶组件,用于对函数式组件进行浅比较优化。对于列表项组件,如果其 props 没有发生变化,就不会重新渲染。例如:
const ListItem = React.memo(({ item }) => {
  return <div>{item}</div>;
});

在上述代码中,ListItem 组件通过 React.memo 包裹,只有当 item prop 发生变化时,ListItem 组件才会重新渲染,避免了因父组件重新渲染导致列表项不必要的重新渲染。 2. 使用 useCallback 和 useMemouseCallback 用于缓存函数,useMemo 用于缓存值。在列表渲染中,如果列表项依赖的某些函数或值频繁变化,会导致列表项不必要的重新渲染。通过 useCallbackuseMemo 可以避免这种情况。例如:

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

const ListItem = React.memo(({ item, handleClick }) => {
  return (
    <div onClick={handleClick}>
      {item}
    </div>
  );
});

const App = () => {
  const [count, setCount] = useState(0);
  const data = useMemo(() => Array.from({ length: 1000 }, (_, i) => `Item ${i}`), []);
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      {data.map((item, index) => (
        <ListItem key={index} item={item} handleClick={handleClick} />
      ))}
    </div>
  );
};

export default App;

在这个示例中,data 通过 useMemo 缓存,只有当依赖数组(这里为空数组,即不会因任何依赖变化而重新计算)变化时才会重新生成。handleClick 函数通过 useCallback 缓存,只有 count 变化时才会重新生成。这样可以确保 ListItem 组件不会因为 count 变化导致 handleClick 函数引用变化而不必要地重新渲染。

优化数据获取和处理

  1. 分页加载:在长列表场景中,一次性获取大量数据会增加网络请求负担和初始渲染时间。分页加载是一种常见的优化方式,每次只获取当前页面需要展示的数据。例如,在 React 应用中,可以使用 axios 等库进行分页请求:
import React, { useState, useEffect } from'react';

const ListItem = ({ item }) => {
  return <div>{item}</div>;
};

const App = () => {
  const [page, setPage] = useState(1);
  const [data, setData] = useState([]);
  const itemsPerPage = 10;

  useEffect(() => {
    const fetchData = async () => {
      const response = await axios.get(`/api/data?page=${page}&limit=${itemsPerPage}`);
      setData(response.data);
    };
    fetchData();
  }, [page]);

  return (
    <div>
      {data.map((item, index) => (
        <ListItem key={index} item={item} />
      ))}
      <button onClick={() => setPage(page + 1)}>Next Page</button>
    </div>
  );
};

export default App;

在上述代码中,通过 page 状态控制当前页码,itemsPerPage 定义每页显示的列表项数量。每次点击 “Next Page” 按钮,page 增加,触发 useEffect 重新请求数据,实现分页加载。 2. 数据预处理:在渲染列表前,对数据进行预处理可以减少渲染时的计算开销。例如,对于需要进行复杂格式化的数据,可以在获取数据后提前进行格式化。假设列表项需要展示日期,且日期格式需要转换:

import React, { useState, useEffect } from'react';

const ListItem = ({ item }) => {
  return <div>{item.formattedDate}</div>;
};

const App = () => {
  const [data, setData] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const response = await axios.get('/api/data');
      const preprocessedData = response.data.map(item => {
        const date = new Date(item.date);
        item.formattedDate = date.toLocaleDateString();
        return item;
      });
      setData(preprocessedData);
    };
    fetchData();
  }, []);

  return (
    <div>
      {data.map((item, index) => (
        <ListItem key={index} item={item} />
      ))}
    </div>
  );
};

export default App;

在这个示例中,获取数据后,通过 map 方法对每个数据项的日期进行格式化处理,将格式化后的日期存储在 formattedDate 属性中,渲染时直接使用,减少了在 ListItem 组件渲染时进行日期格式化的计算开销。

优化 CSS 和 DOM 操作

  1. 避免频繁重排和重绘:重排和重绘是浏览器渲染过程中的性能瓶颈。在长列表渲染中,应尽量避免频繁改变元素的样式属性,尤其是那些会触发重排的属性,如 widthheightmargin 等。如果必须改变这些属性,可以通过 requestAnimationFrame 等方法批量处理,减少重排和重绘的次数。例如:
import React, { useRef, useEffect } from'react';

const App = () => {
  const listRef = useRef(null);

  useEffect(() => {
    const updateStyles = () => {
      const list = listRef.current;
      const items = list.children;
      const newWidth = window.innerWidth * 0.8;
      requestAnimationFrame(() => {
        for (let i = 0; i < items.length; i++) {
          items[i].style.width = newWidth + 'px';
        }
      });
    };
    window.addEventListener('resize', updateStyles);
    return () => {
      window.removeEventListener('resize', updateStyles);
    };
  }, []);

  return (
    <ul ref={listRef}>
      {/* 列表项 */}
    </ul>
  );
};

export default App;

在上述代码中,当窗口大小改变时,通过 requestAnimationFrame 将改变列表项宽度的操作延迟到浏览器下一次重排和重绘之前执行,避免了因频繁改变宽度导致的多次重排和重绘。 2. 使用 CSS 硬件加速:对于一些复杂的动画或过渡效果,可以使用 CSS 硬件加速来提升性能。例如,通过 transform 属性实现动画效果,浏览器会将相关元素提升到 GPU 进行渲染,提高渲染效率。在列表项的动画效果中,可以这样应用:

.list - item {
  transition: transform 0.3s ease - in - out;
  will - change: transform;
}

.list - item:hover {
  transform: scale(1.1);
}

在上述 CSS 代码中,will - change: transform 提示浏览器提前准备好对 transform 属性变化的优化,当 list - item 元素被鼠标悬停时,transform: scale(1.1) 触发的动画效果会利用 GPU 加速,提升动画的流畅性,尤其在长列表中,这种优化对用户体验的提升更为明显。

服务器端渲染和静态站点生成

  1. 服务器端渲染(SSR):在长列表场景中,服务器端渲染可以在服务器端生成 HTML 页面,将初始的列表数据直接渲染到页面中。这样,用户在首次加载页面时,可以更快地看到列表内容,提高了首屏渲染速度。在 React 中,可以使用 Next.js 等框架实现服务器端渲染。例如,在 Next.js 项目中,创建一个列表页面:
import React from'react';
import axios from 'axios';

const ListPage = ({ data }) => {
  return (
    <div>
      {data.map((item, index) => (
        <div key={index}>{item}</div>
      ))}
    </div>
  );
};

export async function getServerSideProps() {
  const response = await axios.get('/api/data');
  return {
    props: {
      data: response.data
    }
  };
}

export default ListPage;

在上述代码中,getServerSideProps 函数在服务器端执行,获取列表数据并传递给 ListPage 组件。Next.js 会在服务器端渲染这个页面,将包含列表数据的 HTML 返回给客户端,提高页面加载性能。 2. 静态站点生成(SSG):静态站点生成是在构建时生成 HTML 页面。对于长列表场景,如果列表数据相对稳定,使用静态站点生成可以进一步提升性能。在 React 中,可以使用 Gatsby 等框架实现静态站点生成。例如,在 Gatsby 项目中,创建一个列表页面:

import React from'react';
import { graphql } from 'gatsby';

const ListPage = ({ data }) => {
  const items = data.allMarkdownRemark.nodes;
  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>{item.frontmatter.title}</div>
      ))}
    </div>
  );
};

export const query = graphql`
  query {
    allMarkdownRemark {
      nodes {
        frontmatter {
          title
        }
      }
    }
  }
`;

export default ListPage;

在这个示例中,Gatsby 使用 GraphQL 查询获取数据,并在构建时生成包含列表内容的静态 HTML 页面。用户访问页面时,直接加载静态 HTML,大大提高了加载速度,尤其适合数据更新不频繁的长列表场景。

通过以上多种性能优化方法的综合应用,可以显著提升 React 长列表渲染的性能,为用户提供流畅、高效的使用体验。在实际项目中,应根据具体需求和场景,选择合适的优化策略。