React+Mongoose无限滚动重复取数问题及Range查询替代方案咨询
问题描述
我未使用任何NPM包实现了无限滚动功能,前端运行正常,但后端MongoDB会重复获取相同数据,该如何解决?另外,我了解到skip+limit不适用于大数据集,想知道如何使用range查询替代它。
相关代码
Next.js服务层
export const getMobilePosts = async (skip: any) => { endpoint = config.post.getMobilePosts + `?skip=${skip}`; return Axios.get(endpoint); }
前端数据处理逻辑
try { const { data } = await getMobilePosts(skip); const filterData = await data?.filter(obj => obj.adPromote == "free-ad" || obj.adPromote == "reach-up-ad") .sort((f,p)=>{ if (f.adPromote === 'reach-up-ad' && p.adPromote !== 'reach-up-ad') { return -1; } else if (f.adPromote !== 'reach-up-ad' && f.adPromote === 'reach-up-ad') { return 1; } return 0; }) .map(obj => { return { ...obj, title: `${obj.title} (${obj.features.condition})` } }); setPost([...post, ...filterData]); setSkip(filterData.length); } catch (err) { console.log(err) }
后端Node+Express服务
export async function getMobilePosts(skips: any) { try{ const skip = skips ? Number(skips) : 0; const PER_REQUEST = 5; return await Post.find({expiredAt: {$gte: currentDate},status: 'live'}, "document fields goes here") .sort({_id: -1 }) .skip(skip) .limit(PER_REQUEST) } catch(error: any) { throw new Error(error as any); } }
结果截图


解决方案
一、解决重复获取数据的问题
问题根源
前端的skip更新逻辑错误:每次请求后把skip设为当前返回的filterData.length,而非累计已加载的总条数。比如第一次请求skip=0返回5条,过滤后设skip=5;第二次请求skip=5返回5条,又把skip设为5,第三次请求依然用skip=5,自然会重复获取相同数据。
修复步骤
- 修正skip更新逻辑:用函数式更新累加已加载条数,确保每次请求的skip是累计值:
// 替换原setSkip(filterData.length) setSkip(prevSkip => prevSkip + filterData.length);
- 增加无更多数据判断:当
filterData.length === 0时,禁用滚动加载,避免无效请求:
if (filterData.length === 0) { setHasMore(false); return; }
- 添加loading状态:防止同一时刻多次触发请求导致重复数据:
const [loading, setLoading] = useState(false); const loadMore = async () => { if (loading) return; setLoading(true); try { // 原请求逻辑 } finally { setLoading(false); } }
二、用Range查询替代skip+limit(大数据集优化)
为什么skip+limit不适合大数据集?
MongoDB执行skip(n)时,需要先扫描掉前n条文档才能返回结果,当n很大时,扫描开销急剧上升,性能极差。而Range查询利用索引直接定位目标文档,效率高得多。
改造方案(基于_id字段,天生有序且唯一)
1. Next.js服务层:传递最后一条数据的_id替代skip
export const getMobilePosts = async (lastId: string | null) => { let endpoint = config.post.getMobilePosts; if (lastId) { endpoint += `?lastId=${lastId}`; } return Axios.get(endpoint); }
2. 前端数据处理逻辑:跟踪最后一条数据的_id
// 初始化状态 const [post, setPost] = useState([]); const [lastId, setLastId] = useState<string | null>(null); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const loadMore = async () => { if (!hasMore || loading) return; setLoading(true); try { const { data } = await getMobilePosts(lastId); const filterData = data?.filter(obj => obj.adPromote == "free-ad" || obj.adPromote == "reach-up-ad") .sort((f,p)=>{ if (f.adPromote === 'reach-up-ad' && p.adPromote !== 'reach-up-ad') { return -1; } else if (f.adPromote !== 'reach-up-ad' && p.adPromote === 'reach-up-ad') { return 1; } return 0; }) .map(obj => ({ ...obj, title: `${obj.title} (${obj.features.condition})` })); if (filterData.length === 0) { setHasMore(false); } else { setPost(prev => [...prev, ...filterData]); // 更新lastId为当前最后一条数据的_id setLastId(filterData[filterData.length - 1]._id); } } catch (err) { console.log(err); } finally { setLoading(false); } }
注:原sort逻辑有笔误,
else if里的条件已修正为判断p.adPromote === 'reach-up-ad'。
3. 后端Node+Express服务:用Range查询替代skip
export async function getMobilePosts(req: Request) { try{ const { lastId } = req.query; const PER_REQUEST = 5; const query = { expiredAt: {$gte: currentDate}, status: 'live' }; // 有lastId时,查询比它小的文档(按_id倒序,下一页数据的_id都比当前最后一条小) if (lastId) { query._id = { $lt: lastId }; } return await Post.find(query, "document fields goes here") .sort({_id: -1 }) .limit(PER_REQUEST); } catch(error: any) { throw new Error(error as any); } }
关键注意事项
- 必须使用唯一且有序的字段作为Range查询条件,
_id是最优选择(MongoDB默认生成,包含时间戳,天生有序唯一);如果用createdAt,要确保它唯一,或结合_id一起排序。 - 动态插入新文档时,倒序查询不会影响分页正确性,新文档只会出现在第一页。
内容的提问来源于stack exchange,提问作者asela daskon
相关产品推荐
相关产品推荐

