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

防抖后滚动事件仍多次触发,分页加载API调用异常排查

滚动触底加载组件的API重复调用问题

我开发了一个滚动触底加载更多数据的React组件,每次加载12张图片,但目前每次滚动会触发约10次API调用,远超预期。存在两个关联问题:

  • 初始加载时API调用page1正常,但首次滚动时仍调用page1(初始加载后page已设为2),后续滚动加载正常;
  • 已使用防抖和isLoading状态控制,为何仍多次触发API调用?

组件代码

import debounce from 'lodash.debounce';
import useStore from "../utils/store.js"; // zustand store
import React, {useEffect, useState} from "react";
import {fetchItems} from "../utils/rest.js";
import MyComponent from "./MyComponent.jsx";

const Layout = () => {
 const setItems = useStore((state) => state.setItems);
 const page = useStore((state) => state.page);
 const setPage = useStore((state) => state.setPage);

 const [isLoading, setIsLoading] = useState(false); // hook or zustand state, same outcome

 const debouncedFetchItems = debounce(() => fetchItems(page), 50);

 const updatePageAndItems = (newItems) => {
  setPage();
  setItems(newItems);
 };

 const handleScroll = () => {
  const offset = 1000;
   if (!isLoading && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - offset) {
    setIsLoading(true);
     const promise = debouncedFetchItems();
     promise?.then(newItems => {
      updatePageAndItems(newItems)
      setIsLoading(false)
     });
   }
 };

 useEffect(() => {
   fetchItems(page).then(newItems => updatePageAndItems(newItems));
 }, []);

 useEffect(() => {
  window.addEventListener('scroll', handleScroll);
 },[page]);

 return (
   <MyComponent /> {/* This component loads data based on using the global state value from items */}
 );
};

export default Layout;

Store代码

import { create } from "zustand";
import { combine } from 'zustand/middleware'

const initialValues = {
  items: [],
  page: 1,
}

const useStore = create(
  combine(
    { ...initialValues },
    (set) => ({
      setItems: (newItems) => set((state) => ({ items: [...state.items, ...newItems] })),
      setPage: () => set((state) => ({ page: state.page + 1 })),
    })
  ),
)

export default useStore;

注:尝试过无依赖的useEffect配置,不会重复触发API,但page始终为1;使用page作为依赖时能获取更新后的page,但会多次触发API。更新后的useEffect配置如下:

useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => {
    window.removeEventListener('scroll', handleScroll);
  }
 }, [page]);

问题分析与修复方案

核心问题原因

  1. 闭包导致首次滚动page值错误:debouncedFetchItems在组件初始化时创建,捕获了初始page=1的闭包值,即便后续page更新为2,首次滚动调用的还是旧的防抖函数,使用的依然是page=1。
  2. 防抖失效引发多次调用:每次组件渲染(比如page变化时)都会重新创建新的debouncedFetchItems实例,原来的防抖逻辑失效;同时handleScroll没有用useCallback包裹,每次page变化会绑定新的滚动事件,旧的事件处理器可能未被正确清理,导致多个处理器同时触发。
  3. 滚动事件绑定时机错误:将page作为滚动事件的依赖,每次page更新都会重新绑定滚动事件,而handleScroll内部的逻辑没有做好防抖控制,进一步加剧了重复调用。

修复后的代码

import debounce from 'lodash.debounce';
import useStore from "../utils/store.js";
import React, {useEffect, useState, useCallback, useMemo} from "react";
import {fetchItems} from "../utils/rest.js";
import MyComponent from "./MyComponent.jsx";

const Layout = () => {
 const setItems = useStore((state) => state.setItems);
 const page = useStore((state) => state.page);
 const setPage = useStore((state) => state.setPage);

 const [isLoading, setIsLoading] = useState(false);

 // 用useMemo确保page变化时更新防抖函数,捕获最新page值
 const debouncedFetchItems = useMemo(() => {
  return debounce(async () => {
    const newItems = await fetchItems(page);
    updatePageAndItems(newItems);
    setIsLoading(false);
  }, 150); // 延长防抖时间,避免快速滚动触发
 }, [page]);

 const updatePageAndItems = useCallback((newItems) => {
  setPage();
  setItems(newItems);
 }, [setPage, setItems]);

 // 用useCallback包裹handleScroll,避免不必要的重新创建
 const handleScroll = useCallback(() => {
  const offset = 1000;
  if (!isLoading && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - offset) {
    setIsLoading(true);
    debouncedFetchItems();
  }
 }, [isLoading, debouncedFetchItems]);

 // 初始加载:只在组件挂载时执行一次
 useEffect(() => {
  const loadInitialData = async () => {
    const newItems = await fetchItems(page);
    updatePageAndItems(newItems);
  };
  loadInitialData();
 }, []);

 // 滚动事件监听:只在组件挂载时绑定一次,依赖handleScroll(useCallback确保稳定)
 useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => {
    window.removeEventListener('scroll', handleScroll);
    // 清理防抖定时器,避免组件卸载后仍执行
    debouncedFetchItems.cancel();
  };
 }, [handleScroll]);

 return (
   <MyComponent />
 );
};

export default Layout;

关键修复点说明

  • 用useMemo创建防抖函数:依赖page,确保page更新时生成新的防抖函数,捕获最新的page值,解决首次滚动page错误的问题。
  • handleScroll用useCallback包裹:依赖isLoading和debouncedFetchItems,确保函数引用稳定,避免滚动事件重复绑定。
  • 调整防抖时间:从50ms延长到150ms,更符合滚动场景的防抖需求,减少触发次数。
  • 清理防抖定时器:在组件卸载时调用debouncedFetchItems.cancel(),避免内存泄漏和不必要的API调用。
  • 滚动事件仅绑定一次:不再依赖page,而是依赖稳定的handleScroll引用,避免每次page变化都重新绑定事件。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 12:22:38