React.js结合useReducer实现表格懒加载偶发加载异常问题
核心问题根因
你遇到的两类异常(slice生成的newData偶发为空、刷新后内容不加载、数据偶发不追加),都是懒加载实现中高频踩坑点导致的,和useReducer本身的能力无关,本质是状态流转、事件绑定的边界没有做防护:
- 滚动回调存在闭包陷阱:window绑定的scroll监听器是一次性注册的,不会随着组件重渲染自动同步最新的reducer状态,回调里永远拿注册时的旧offset、isLoading值,重复触发时基于旧范围切片,自然切出空数组
- 加载流程没有统一互斥锁:不管是滚动触发还是按钮点击触发,没有第一时间校验当前是否正在加载,多个触发入口交叉执行时会出现offset覆盖、状态错乱,后到的逻辑把之前追加好的数据覆盖
- 切片边界没有做防护:slice直接用
[offset, offset+10]切数组,当offset超过数组总长度时会返回空数组,没有提前做范围校验 - 状态更新没有生成新引用:reducer里直接修改原loadedBooks数组的引用,React检测不到状态变化,就算数据存进去了,表格也不会重新渲染,看起来像没追加
- 初始加载没有兜底逻辑:完全依赖滚动事件触发第一次加载,当页面初始内容高度不够一屏时,永远触发不到滚动,自然加载不出内容;StrictMode下effect双重挂载时没有清理旧的事件监听器,残留的回调会互相干扰状态
稳定运行的修复方案
1. 明确useReducer的状态结构,统一所有触发入口
先把需要管理的状态边界划清楚,避免零散的state重复定义:
// reducer初始状态 const initialState = { loadedBooks: [], // 已加载到表格的数据集 offset: 0, // 当前分页偏移 isLoading: false, // 全局加载锁 hasMore: true // 是否还有剩余数据 }
所有触发下一批加载的逻辑(滚动到底、点击Load More按钮),必须调用同一个封装好的loadNextBatch方法,不要写两套独立的请求/切片逻辑,避免状态更新不一致。
2. 解决滚动回调的闭包陷阱,同步最新状态到ref
因为window的scroll监听器注册后不会随着组件重渲染自动更新闭包变量,需要把reducer的最新状态同步到可实时读取的ref里,让回调永远拿最新值,而不是注册时的旧值:
const [state, dispatch] = useReducer(bookReducer, initialState); // 状态同步到ref,供全局事件回调读取 const stateRef = useRef(state); useEffect(() => { stateRef.current = state; }, [state]);
3. 给切片逻辑加边界防护,彻底避免空数组问题
不要再直接对目标数组做无边界的slice操作,切片前先做两层校验:
- 先判断当前offset是否已经超过数组总长度,如果超过直接标记
hasMore=false,不执行切片 - 切片的结束索引取「offset+10」和数组总长度的较小值,避免越界
- 切片完成后判断如果newData长度为0,直接跳过追加逻辑,避免把空数组拼到已加载数据里
示例校验逻辑:
const getNextBatch = () => { const {offset} = stateRef.current; const totalBookList = /* 你本地缓存的全量书单/API返回的总数据 */; // 越界校验 if (offset >= totalBookList.length) { dispatch({type: 'SET_HAS_MORE', payload: false}); return []; } // 带边界的切片 const endIndex = Math.min(offset + 10, totalBookList.length); const newData = totalBookList.slice(offset, endIndex); // 空值兜底 if (newData.length === 0) { dispatch({type: 'SET_HAS_MORE', payload: false}); return []; } return newData; }
4. 给加载流程加全局互斥锁,避免重复触发
loadNextBatch方法的第一行必须先读取ref里的最新加载状态、是否有更多数据的标记,满足以下任意一个条件直接return,不执行后续逻辑:
- 当前
isLoading为true - 当前
hasMore为false
完整的加载流程流转:
- 触发加载时先锁状态:
dispatch({type: 'SET_LOADING', payload: true}) - 执行切片/API请求拿到newData
- 如果newData为空:解锁状态,标记
hasMore=false,直接return - 把newData追加到已加载数据集:
dispatch({type: 'APPEND_BOOKS', payload: newData}) - 更新分页偏移:
dispatch({type: 'SET_OFFSET', payload: stateRef.current.offset + newData.length}) - 根据newData的长度判断剩余数据:如果newData长度小于10,标记
hasMore=false - 最后解锁状态:
dispatch({type: 'SET_LOADING', payload: false})
5. 修正reducer的状态更新方式,保证新引用可被检测
所有state更新必须返回全新的对象/数组引用,绝对不要直接修改原数组。比如错误的写法:
// 错误:直接修改原数组,没有新引用 case 'APPEND_BOOKS': state.loadedBooks.push(action.payload); return state;
正确的写法必须生成新的数组和对象引用,让React能检测到状态变化:
// 正确:返回全新的引用 case 'APPEND_BOOKS': return { ...state, loadedBooks: [...state.loadedBooks, ...action.payload] }
6. 加初始加载兜底,解决刷新后不渲染的问题
- 挂载滚动监听器的时候,不要等用户滚动才触发加载,先主动执行一次滚动位置判断:如果当前可视区域已经到底部(包括内容高度不足一屏的情况),直接触发第一次加载
- 事件监听器的cleanup函数必须完整实现,组件卸载时移除scroll事件,避免StrictMode双重挂载时残留多个重复回调,互相干扰状态
示例滚动绑定逻辑:
useEffect(() => { const handleScroll = () => { const {isLoading, hasMore} = stateRef.current; if (isLoading || !hasMore) return; // 判断是否滚到底,留20px的提前量,避免差几像素就不触发的问题 const isBottomReached = window.innerHeight + window.scrollY >= document.body.offsetHeight - 20; if (isBottomReached) { loadNextBatch(); } } window.addEventListener('scroll', handleScroll); // 挂载后主动执行一次判断,触发初始加载,解决内容不足一屏不加载的问题 handleScroll(); // 清理监听器,避免重复绑定 return () => window.removeEventListener('scroll', handleScroll); }, []);
内容的提问来源于stack exchange,提问作者Pratyush Narain
相关产品推荐
相关产品推荐

