You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React用户列表滚动懒加载实现遇问题,求正确解决方案

React 用户列表滚动懒加载问题排查与修复

我有一个包含1000个用户的数组,原本通过map方法渲染全部用户,现需实现懒加载:初始加载前50个用户,滚动至底部时加载下一批50个,直至所有用户加载完成。我尝试了两种实现方式,但均存在问题:第一种仅加载前50个后就停止;第二种使用Intersection Observer实现了滚动加载,但存在bug(如共400个用户却仅加载250个)。


无懒加载实现的组件

import { useState, useEffect, useRef } from "react";
import { users } from "../../users/generateUsers.ts";
import UserItem from "../userItem/UserItem.tsx";
import { nanoid } from "nanoid";
import "../userList/UserList.css";

export default function UserList() {
  return (
    <div className="wrapper">
      <div className="content">
        {users.map((user, index) => (
          <div className={`userCard `} key={nanoid()}>
            <span className="card-number">
              {index + 1}
            </span>
            <UserItem
              color={user.color}
              speed={user.speed}
              name={user.name}
              time={user.time}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

懒加载实现第一版(滚动事件监听)

import { useState, useEffect, useRef } from "react";
import { users } from "../../users/generateUsers.ts";
import UserItem from "../userItem/UserItem.tsx";
import { nanoid } from "nanoid";
import "../userList/UserList.css";
import { User } from "../../types/types.ts";

export default function UserList() {
  const batchSize = 50;
  const [loadedUsers, setLoadedUsers] = useState<User[]>([]);
  const [startIndex, setStartIndex] = useState(0);
  const [totalUsers, setTotalUsers] = useState(users.length);
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
  const contentRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    loadMoreUsers();
  }, []);

  const loadMoreUsers = () => {
    const endIndex = Math.min(startIndex + batchSize, totalUsers);
    const nextBatch = users.slice(startIndex, endIndex);

    setLoadedUsers((prevLoadedUsers) => [...prevLoadedUsers, ...nextBatch]);
    setStartIndex(endIndex);
  };

  const handleScroll = () => {
    const contentElement = contentRef.current;
    if (
      contentElement &&
      contentElement.scrollTop + contentElement.clientHeight ===
        contentElement.scrollHeight &&
      startIndex < totalUsers
    ) {
      loadMoreUsers();
    }
  };

  return (
    <div className="wrapper" onScroll={handleScroll}>
      <div className="content" ref={contentRef}>
        {loadedUsers.map((user, index) => (
          <div className={`userCard `} key={nanoid()}>
            <span className="card-number">{index + 1}</span>
            <UserItem
              color={user.color}
              speed={user.speed}
              name={user.name}
              time={user.time}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

使用Intersection Observer的第二版

import { useState, useEffect, useRef } from "react";
import { users } from "../../users/generateUsers.ts";
import UserItem from "../userItem/UserItem.tsx";
import { nanoid } from "nanoid";
import "../userList/UserList.css";

export default function UserList() {
  const batchSize = 50;
  const [loadedUsers, setLoadedUsers] = useState<any[]>([]);
  const [startIndex, setStartIndex] = useState(0);
  const [totalUsers, setTotalUsers] = useState(users.length);
  const [selectedUser, setSelectedUser] = useState<any | null>(null);
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    loadMoreUsers();
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        const target = entries[0];
        if (target.isIntersecting && startIndex < totalUsers) {
          loadMoreUsers();
        }
      },
      { threshold: 0.9 }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => {
      if (sentinelRef.current) {
        observer.unobserve(sentinelRef.current);
      }
    };
  }, [startIndex, totalUsers]);

  const loadMoreUsers = () => {
    const endIndex = Math.min(startIndex + batchSize, totalUsers);
    const nextBatch = users.slice(startIndex, endIndex);

    setLoadedUsers((prevLoadedUsers) => [...prevLoadedUsers, ...nextBatch]);
    setStartIndex(endIndex);
  };

  const handleUserClick = (user: any) => {
    setSelectedUser(user);
  };

  return (
    <div className="wrapper">
      <div className="content">
        {loadedUsers.map((user, index) => (
          <div
            className={`userCard `}
            key={nanoid()}
            onClick={() => handleUserClick(user)}
          >
            <span className="card-number">{index + 1}</span>
            <UserItem
              color={user.color}
              speed={user.speed}
              name={user.name}
              time={user.time}
              selected={selectedUser === user}
            />
          </div>
        ))}
      </div>
      <div ref={sentinelRef} style={{ height: "1px" }} />
    </div>
  );
}

问题排查与修复

第一版问题原因及修复

问题点

  1. 滚动事件绑定错误:onScroll绑定在wrapper上,但如果wrapper未设置overflow: auto和固定高度,滚动条实际属于window,导致事件无法触发。
  2. 滚动判断条件严苛:scrollTop + clientHeight === scrollHeight因浏览器精度问题,可能无法触发。
  3. 闭包问题:handleScroll中读取的startIndex是旧值,导致加载判断失效。

修复后代码

import { useState, useEffect, useRef } from "react";
import { users } from "../../users/generateUsers.ts";
import UserItem from "../userItem/UserItem.tsx";
import { nanoid } from "nanoid";
import "../userList/UserList.css";
import { User } from "../../types/types.ts";

export default function UserList() {
  const batchSize = 50;
  const [loadedUsers, setLoadedUsers] = useState<User[]>([]);
  const [startIndex, setStartIndex] = useState(0);
  const totalUsers = users.length;
  const wrapperRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    loadMoreUsers();
  }, []);

  useEffect(() => {
    const wrapper = wrapperRef.current;
    if (!wrapper) return;

    const handleScroll = () => {
      if (
        wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 10 &&
        startIndex < totalUsers
      ) {
        loadMoreUsers();
      }
    };

    wrapper.addEventListener("scroll", handleScroll);
    return () => wrapper.removeEventListener("scroll", handleScroll);
  }, [startIndex, totalUsers]);

  const loadMoreUsers = () => {
    const endIndex = Math.min(startIndex + batchSize, totalUsers);
    setLoadedUsers(prev => [...prev, ...users.slice(startIndex, endIndex)]);
    setStartIndex(endIndex);
  };

  return (
    <div 
      className="wrapper" 
      ref={wrapperRef} 
      style={{ overflow: "auto", height: "100vh" }}
    >
      <div className="content">
        {loadedUsers.map((user, index) => (
          <div className="userCard" key={nanoid()}>
            <span className="card-number">{index + 1}</span>
            <UserItem
              color={user.color}
              speed={user.speed}
              name={user.name}
              time={user.time}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

第二版问题原因及修复

问题点

  1. 阈值设置过高:threshold: 0.9要求1px高的sentinel元素90%可见,几乎无法触发。
  2. Observer重复创建:每次startIndex变化都重新创建Observer,导致观察逻辑混乱。
  3. 闭包问题:回调中读取的startIndex可能为旧值,导致加载判断错误。

修复后代码

import { useState, useEffect, useRef } from "react";
import { users } from "../../users/generateUsers.ts";
import UserItem from "../userItem/UserItem.tsx";
import { nanoid } from "nanoid";
import "../userList/UserList.css";
import { User } from "../../types/types.ts";

export default function UserList() {
  const batchSize = 50;
  const [loadedUsers, setLoadedUsers] = useState<User[]>([]);
  const [startIndex, setStartIndex] = useState(0);
  const totalUsers = users.length;
  const sentinelRef = useRef<HTMLDivElement>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    loadMoreUsers();
  }, []);

  useEffect(() => {
    if (!observerRef.current) {
      observerRef.current = new IntersectionObserver(
        (entries) => {
          const target = entries[0];
          if (target.isIntersecting && startIndex < totalUsers) {
            loadMoreUsers();
          }
        },
        { threshold: 0.1 }
      );
    }

    const sentinel = sentinelRef.current;
    if (sentinel) observerRef.current.observe(sentinel);

    return () => {
      if (sentinel && observerRef.current) {
        observerRef.current.unobserve(sentinel);
      }
    };
  }, [startIndex, totalUsers]);

  const loadMoreUsers = () => {
    const endIndex = Math.min(startIndex + batchSize, totalUsers);
    setLoadedUsers(prev => [...prev, ...users.slice(startIndex, endIndex)]);
    setStartIndex(endIndex);

    if (endIndex >= totalUsers && sentinelRef.current && observerRef.current) {
      observerRef.current.unobserve(sentinelRef.current);
    }
  };

  return (
    <div className="wrapper">
      <div className="content">
        {loadedUsers.map((user, index) => (
          <div className="userCard" key={nanoid()}>
            <span className="card-number">{index + 1}</span>
            <UserItem
              color={user.color}
              speed={user.speed}
              name={user.name}
              time={user.time}
            />
          </div>
        ))}
      </div>
      <div 
        ref={sentinelRef} 
        style={{ height: "20px", margin: "10px 0", backgroundColor: "transparent" }} 
      />
    </div>
  );
}

内容的提问来源于stack exchange,提问作者Somebody

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 07:37:16