react 虚拟滚动列表的实现 —— react-window实现

50 阅读2分钟
import React, { useState, useEffect, useCallback, useRef } from "react";
import { List, useDynamicRowHeight } from "react-window";

// 模拟API,每次返回20条数据
const mockFetchData = (page, action = "load") => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const startIndex = (page - 1) * 20 + 1;
      const newData = Array.from({ length: 20 }, (_, i) => {
        const id = action === "refresh" ? `new_${page}_${i}` : startIndex + i;
        // 通过重复内容制造不同高度(1-4行),以演示可变高度
        const lines = ((startIndex + i) % 4) + 1;
        const content = `数据项 ${startIndex + i} ` + "内容 ".repeat(lines);
        return { id, content, lines };
      });
      resolve(newData);
    }, 500);
  });
};

const VirtualListWithScrollLoad = () => {
  // 状态管理
  const [dataList, setDataList] = useState([]); // 所有已加载的数据
  const [loading, setLoading] = useState(false); // 是否正在加载(上拉)
  const [page, setPage] = useState(1); // 当前页码
  const [finished, setFinished] = useState(false); // 数据是否已全部加载完毕

  // 列表参数
  const containerHeight = 400; // 列表可视高度

  const listRef = useRef(null);

  // 使用 DOM 实测行高(ResizeObserver驱动)
  const dynamicRowHeight = useDynamicRowHeight({ defaultRowHeight: 60 });

  // 防止重复首次加载(StrictMode/dev)
  const didInitRef = useRef(false);
  //用 inFlightRef 作为“请求互斥锁”,确保在一次请求未完成时,任何再次触发的 loadMore 都会直接返回。
  const inFlightRef = useRef(false); 

  // 加载更多数据(上拉)
  const loadMore = useCallback(async () => {
    console.log("loadMore", page);
    if (inFlightRef.current || loading || finished) return;
    inFlightRef.current = true;
    setLoading(true);
    try {
      const newData = await mockFetchData(page, "load");
      if (newData.length === 0) {
        setFinished(true);
      } else {
        setDataList((prev) => [...prev, ...newData]);
        setPage((prev) => prev + 1);
      }
    } catch (error) {
      console.error("加载数据失败:", error);
    } finally {
      setLoading(false);
      inFlightRef.current = false;
    }
  }, [loading, finished]);

  // 初始化加载(加守卫,避免 StrictMode 在开发环境下触发两次)
  useEffect(() => {
    if (didInitRef.current) return;
    didInitRef.current = true;
    loadMore();
  }, [loadMore]);

  // 监听滚动以触发无限加载(绑定在 List 上)
  const handleScroll = useCallback(
    (e) => {
      const el = e.currentTarget;
      const { scrollTop, scrollHeight, clientHeight } = el;
      if (
        scrollHeight - scrollTop - clientHeight < 100 &&
        !loading &&
        !finished
      ) {
        loadMore();
      }
    },
    [loading, finished, loadMore]
  );

  // 行渲染组件(不要设置固定高度,交由库测量)
  const Row = ({ index, style, items }) => {
    const item = items[index];
    if (!item) return <div style={style} />;
    const lines = item.lines || 1;
    return (
      <div
        style={{
          ...style,
          borderBottom: "1px solid #eee",
          padding: "8px 12px",
          boxSizing: "border-box",
        }}
      >
        <div style={{ fontWeight: 600 }}>数据项 {item.id}</div>
        {Array.from({ length: lines }).map((_, i) => (
          <div key={i}>内容</div>
        ))}
      </div>
    );
  };

  return (
    <div>
      <List
        style={{ height: containerHeight, width: "100%" }}
        overscanCount={5}
        rowCount={dataList.length}
        rowHeight={dynamicRowHeight}
        rowComponent={Row}
        rowProps={{ items: dataList }}
        listRef={listRef}
        onScroll={handleScroll}
      />

      {/* 加载指示器 */}
      <div style={{ textAlign: "center", padding: "10px" }}>
        {loading && <div>加载中...</div>}
        {finished && <div>没有更多数据了</div>}
      </div>
    </div>
  );
};

export default VirtualListWithScrollLoad;