React Hooks通用无限滚动的useEffect依赖与竞态条件问题
这个场景我太熟悉了——用Intersection Observer做无限滚动时,很容易陷入「依赖项导致回调频繁重建→Effect意外触发→竞态条件」的死循环。咱们不用禁用ESLint,几个优雅的方案就能搞定:
方案1:用函数式更新消除不必要的依赖(最推荐)
你父组件里的loadData依赖posts,本质是为了获取当前列表长度,但直接依赖状态变量会导致每次posts变化时回调都重新创建。改用React状态的函数式更新,就能摆脱对posts的依赖,同时还能修正你代码里直接修改状态数组的错误(直接posts.push违反了React状态不可变的原则哦)。
修改后的父组件回调:
const loadData = useCallback(async () => { if (!hasMore) return; // 模拟异步请求延迟 await new Promise(resolve => setTimeout(resolve, 2000)); // 用函数式更新获取最新的posts状态,避免直接依赖posts setPosts(prevPosts => { const startAmount = prevPosts.length; // 创建新数组,不修改原状态 const newPosts = [...prevPosts]; for (let i = 0; i < 20; ++i) { newPosts.push(`I am post number ${i + startAmount}.`); } return newPosts; }); // 同样用函数式更新判断是否还有更多数据 setHasMore(prevHasMore => { return prevPosts => prevPosts.length + 20 < 100; }); }, [hasMore]); // 现在只依赖hasMore,回调不会频繁重建了
这样loadData的依赖只有hasMore,不会因为posts变化而重新创建,子组件的Effect也就不会被意外触发了。
方案2:用useRef保存回调的最新引用(适合复杂场景)
如果你的加载逻辑必须依赖多个状态,不想重构代码,可以用useRef把最新的getMore回调存起来,子组件里只监听isLoading,调用时用ref.current获取最新的函数:
子组件修改:
import { useRef, useEffect } from 'react'; const InfiniteScroll = ({ getMore, isLoading, setIsLoading, children }) => { const getMoreRef = useRef(); // 每次渲染都更新ref,保存最新的getMore useEffect(() => { getMoreRef.current = getMore; }, [getMore]); useEffect(() => { if (isLoading) { const fetchNextPage = async () => { await getMoreRef.current(); setIsLoading(false); }; fetchNextPage(); } }, [isLoading]); // 只依赖isLoading,getMore变化不会触发这个Effect // 你的Intersection Observer逻辑... return ( <div> {children} {isLoading && <div>加载中...</div>} </div> ); };
这个方案的核心是:useRef的current属性是可变的,不会触发组件重新渲染,我们用它来「缓存」最新的回调函数,这样即使父组件的getMore重建了,子组件的Effect也不会因为依赖变化而触发,只会在isLoading变为true时执行。
方案3:用useReducer统一状态管理(适合复杂状态逻辑)
如果你的组件状态逻辑越来越复杂(比如还要处理加载错误、分页参数等),可以用useReducer把状态更新和业务逻辑封装起来,dispatch函数是稳定的,不会随状态变化而重建:
父组件示例:
const postsReducer = (state, action) => { switch (action.type) { case 'LOAD_MORE': const startAmount = state.posts.length; const newPosts = [...state.posts]; for (let i = 0; i < 20; ++i) { newPosts.push(`I am post number ${i + startAmount}.`); } return { ...state, posts: newPosts, hasMore: newPosts.length < 100, }; default: return state; } }; const ParentComponent = () => { const [state, dispatch] = useReducer(postsReducer, { posts: [], hasMore: true, }); const loadData = useCallback(async () => { if (!state.hasMore) return; await new Promise(resolve => setTimeout(resolve, 2000)); dispatch({ type: 'LOAD_MORE' }); }, [state.hasMore, dispatch]); // 渲染子组件... };
这里loadData只依赖state.hasMore和dispatch,而dispatch是React保证稳定的引用,不会导致回调频繁重建。
额外提醒:避免竞态条件的小技巧
不管用哪个方案,都要在加载函数里加个「防重复触发」的判断,比如在loadData开头检查是否正在加载:
const loadData = useCallback(async () => { if (!hasMore || isLoading) return; // 加个isLoading判断 // ...加载逻辑 }, [hasMore, isLoading]);
这样即使因为某些原因触发了多次加载,也只会执行一次。
内容的提问来源于stack exchange,提问作者IceMetalPunk

