You need to enable JavaScript to run this app. React.js结合useReducer实现表格懒加载偶发加载异常问题
优惠活动
大模型
产品
解决方案
定价
更多

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

完整的加载流程流转:

  1. 触发加载时先锁状态:dispatch({type: 'SET_LOADING', payload: true})
  2. 执行切片/API请求拿到newData
  3. 如果newData为空:解锁状态,标记hasMore=false,直接return
  4. 把newData追加到已加载数据集:dispatch({type: 'APPEND_BOOKS', payload: newData})
  5. 更新分页偏移:dispatch({type: 'SET_OFFSET', payload: stateRef.current.offset + newData.length})
  6. 根据newData的长度判断剩余数据:如果newData长度小于10,标记hasMore=false
  7. 最后解锁状态: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.11.05 09:36:04