react 虚拟滚动列表的实现 —— 固定高度

js 复制代码
import React, { useState, useEffect, useCallback, useRef } from 'react';

// 模拟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) => ({
        id: action === 'refresh' ? `new_${page}_${i}` : startIndex + i, // 刷新时使用新ID
        content: `数据项 ${startIndex + i}`
      }));
      resolve(newData);
    }, 500);
  });
};

const VirtualListWithScrollLoad = () => {
  // 状态管理
  const [dataList, setDataList] = useState([]); // 所有已加载的数据
  const [loading, setLoading] = useState(false); // 是否正在加载(上拉)
  const [refreshing, setRefreshing] = useState(false); // 是否正在下拉刷新
  const [page, setPage] = useState(1); // 当前页码
  const [finished, setFinished] = useState(false); // 数据是否已全部加载完毕
  const containerRef = useRef(null); // 滚动容器Ref

  // 虚拟列表相关参数
  const itemHeight = 60; // 每个列表项的高度(px)
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
  const containerHeight = 400; // 滚动容器可视高度

  // 加载更多数据(上拉)
  const loadMore = useCallback(async () => {
    if (loading || finished) return;
    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);
    }
  }, [loading, finished, page]);

  // 下拉刷新
  const onRefresh = useCallback(async () => {
    if (refreshing) return;
    setRefreshing(true);
    setFinished(false);
    try {
      const newData = await mockFetchData(1, 'refresh'); // 总是请求第一页
      setDataList(newData); // 用新数据替换旧数据
      setPage(2); // 下次加载从第二页开始
    } catch (error) {
      console.error('刷新数据失败:', error);
    } finally {
      setRefreshing(false);
    }
  }, [refreshing]);

  // 计算可视区域
  const calculateVisibleRange = useCallback(() => {
    const scrollTop = containerRef.current?.scrollTop || 0;
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length); // 多渲染2项作为缓冲
    setVisibleRange({ start: startIndex, end: endIndex });
  }, [dataList.length, itemHeight, containerHeight]);

  // 初始化数据和监听滚动
  useEffect(() => {
    loadMore(); // 初始化加载第一页数据
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleScroll = () => {
      calculateVisibleRange(); // 滚动时重新计算可视区域

      // 检查是否滚动到底部(上拉加载)
      const { scrollTop, scrollHeight, clientHeight } = container;
      if (scrollHeight - scrollTop - clientHeight < 100 && !loading && !finished) { // 距离底部100px触发
        loadMore();
      }

      // 检查是否滚动到顶部并下拉(下拉刷新)
      if (scrollTop < -80 && !refreshing) { // 下拉超过80px触发
        onRefresh();
      }
    };

    // 使用节流函数优化性能,避免滚动事件触发过于频繁[10](@ref)
    const throttledScroll = throttle(handleScroll, 100);
    container.addEventListener('scroll', throttledScroll);
    calculateVisibleRange(); // 初始计算一次

    return () => container.removeEventListener('scroll', throttledScroll);
  }, [loadMore, onRefresh, loading, refreshing, finished, calculateVisibleRange]);

  // 渲染可见的项目
  const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);

  return (
    <div
      ref={containerRef}
      style={{
        height: `${containerHeight}px`,
        overflow: 'auto',
        border: '1px solid #ccc',
        position: 'relative'
      }}
    >
      {/* 下拉刷新指示器 */}
      <div style={{ textAlign: 'center', height: refreshing ? '50px' : '0', transition: 'height 0.2s' }}>
        {refreshing && <div>刷新中...</div>}
      </div>

      {/* 虚拟列表容器,其高度撑开滚动条 */}
      <div style={{ height: `${dataList.length * itemHeight}px`, position: 'relative' }}>
        {/* 可视项目的容器,通过定位偏移到正确位置 */}
        <div style={{
          position: 'absolute',
          top: 0,
          transform: `translateY(${visibleRange.start * itemHeight}px)`,
          width: '100%'
        }}>
          {visibleItems.map(item => (
            <div key={item.id} style={{ height: `${itemHeight}px`, borderBottom: '1px solid #eee', display: 'flex', alignItems: 'center' }}>
              {item.content}
            </div>
          ))}
        </div>
      </div>

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

// 简单的节流函数
function throttle(func, delay) {
  let timeoutId;
  return function (...args) {
    if (!timeoutId) {
      timeoutId = setTimeout(() => {
        func.apply(this, args);
        timeoutId = null;
      }, delay);
    }
  };
}

export default VirtualListWithScrollLoad;

关键点解释

1.计算可视区域,渲染可视dom

js 复制代码
 // 计算可视区域
  const calculateVisibleRange = useCallback(() => {
    const scrollTop = containerRef.current?.scrollTop || 0;
    const startIndex = Math.floor(scrollTop / itemHeight);
    console.log('startIndex', startIndex)
    const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length); // 多渲染2项作为缓冲
    setVisibleRange({ start: startIndex, end: endIndex });
  }, [dataList.length, itemHeight, containerHeight]);
js 复制代码
// 渲染可见的项目
  const visibleItems = dataList.slice(visibleRange.start, visibleRange.end + 1);

这个函数是 虚拟列表 的核心计算逻辑,用来确定在当前滚动位置下,哪些列表项需要被渲染到DOM中。(主要获取 startIndexendIndex,截取数据)

js 复制代码
const startIndex = Math.floor(scrollTop / itemHeight);
  • 计算 第一个 可见项的索引

    • scrollTop / itemHeight = 滚动距离 ÷ 每项高度 = 已经滚动了多少个完整的列表项
    • Math.floor() 向下取整,得到第一个可见项的索引

js 复制代码
const endIndex = Math.min(startIndex + Math.ceil(containerHeight / itemHeight) + 2, dataList.length);
  • 计算 最后一个 需要渲染项的索引
    • Math.ceil(containerHeight / itemHeight) = 容器能完整显示多少个项目
    • 2 = 额外渲染2个项目作为缓冲区(提升滚动体验)
    • Math.min(..., dataList.length) = 确保不超过数据总长度

2.上拉加载更多(无限滚动)

js 复制代码
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 100 && 
!loading && !finished) {
  loadMore();
}
解释各个属性:
  • scrollTop :已滚动的距离
  • scrollHeight :内容的总高度
  • clientHeight :容器的可视高度
触发条件:
  • scrollHeight - scrollTop - clientHeight < 100 :距离底部小于100px
  • !loading :当前没有在加载
  • !finished :还有更多数据可以加载
图解:
复制代码
┌─────────────────┐ ← 容器顶部
│                 │
│   可视区域      │ ← clientHeight
│                 │
└─────────────────┘ ← 容器底部
│                 │
│   隐藏内容      │ ← 剩余距离 < 100px 时触发加载
│                 │
└─────────────────┘ ← scrollHeight 总高度

3.用 translateY 把渲染的项目"推"到正确位置

html 复制代码
 <div style={{
  position: 'absolute',
  top: 0,
  transform: `translateY(${visibleRange.start * itemHeight}px)`,
  width: '100%'
}}>
1. 滚动条系统
html 复制代码
<div style={{ height: `${dataList.length * itemHeight}px`, 
position: 'relative' }}>
  • 外层容器高度 = 总数据量 × 每项高度(比如 1000 × 60 = 60000px)
  • 这个高度 只是为了撑开滚动条 ,让滚动条知道"总共有这么多内容"
  • 滚动条本身不知道具体渲染了什么,它只看容器高度
2. 内容渲染系统
html 复制代码
<div style={{ transform: `translateY(${visibleRange.start * 
itemHeight}px)` }}>
  • 我们只渲染可见的几个项目(比如第100-110项)
  • 但这些项目默认会出现在容器的 顶部
  • 用户滚动到第100项时,期望看到第100项,而不是第1项

问题和解决方案

问题: 用户滚动到中间位置,但我们渲染的项目出现在容器顶部

解决: 用 translateY 把渲染的项目"推"到正确位置

具体例子

用户滚动到6000px位置(第100项开始):

makefile 复制代码
容器总高度: 60000px
┌─────────────────┐ ← 0px
│                 │
│   空白区域      │ ← 用户已经滚动过的区域
│                 │
├─────────────────┤ ← 6000px (用户当前看到的位置)
│  第100项        │ ← 我们渲染的内容要出现在这里
│  第101项        │
│  ...            │
└─────────────────┘

如果不用 translateY:

  • 渲染的第100-110项会出现在容器的0px位置
  • 用户看不到,因为已经滚动到6000px了

用了 translateY(6000px):

  • 把渲染的内容向下移动6000px
  • 正好移动到用户当前的可视区域
相关推荐
金梦人生9 小时前
🔥Knife4j vs Swagger:Node.js 开发者的API文档革命!
前端·node.js
Larcher9 小时前
n8n 入门笔记:用零代码工作流自动化重塑效率边界
前端·openai
林希_Rachel_傻希希9 小时前
正则表达式捕获组与全局匹配
前端·javascript
前端赵哈哈9 小时前
那个让我熬夜三天的 “小数点”:一次 URL 踩坑记
前端·chrome·http
karshey9 小时前
【vue】NoticeBar:滚动通知栏组件手动实现(内容、速度、循环间隔可配置)
前端·javascript·vue.js
醉方休9 小时前
React 官方推荐使用 Vite
前端·react.js·前端框架
Dontla9 小时前
React惰性初始化函数(Lazy Initializer)(首次渲染时执行一次,只执行一次,应对昂贵初始化逻辑)(传入一个函数、传入函数)
前端·javascript·react.js
lypzcgf10 小时前
FastbuildAI新建套餐-前端代码分析
前端·智能体平台·ai应用平台·agent平台·fastbuildai
南囝coding10 小时前
Claude Code 插件系统来了
前端·后端·程序员