React Native Firestore分页异常求助:数据闪烁且startAfter传undefined报错
Firestore分页实现问题排查与修复
问题描述
我正在开发一个基于React Native + Firestore的演出信息应用,gigs集合存储演出数据。自定义hook useGigs.tsx负责获取数据,在List.tsx中渲染列表。添加分页逻辑后,列表出现异常:交替显示无数据、少量数据、全新列表,最终崩溃抛出错误:
ERROR FirebaseError: Function startAfter() called with invalid data. Unsupported field value: undefined
补充:dateAndTime字段为Firestore时间戳类型。
错误原因
- 初始
startAfter参数非法:lastDocument初始值为null,直接传入startAfter()会触发Firestore参数错误,该方法不接受null或undefined。 - 无限订阅循环:
lastDocument被加入useEffect依赖数组,每次查询更新lastDocument都会重新触发订阅,导致反复发起查询,列表数据频繁替换。 - 数据更新逻辑错误:每次查询结果直接替换
gigs状态,而非追加,导致旧数据被清空,出现“无演出/少量演出”的交替情况。 - 查询逻辑不一致:无
userLocation时的查询未添加排序和分页规则,与有筛选的逻辑不统一。 - 冗余状态处理:
List.tsx中额外维护了一层gigs状态,增加了不必要的复杂度,容易导致数据不同步。
解决方案
- 条件添加
startAfter:仅当lastDocument存在时,才在查询中加入startAfter条件。 - 分页数据追加:更新
gigs状态时,将新查询到的数据追加到现有数组中,而非直接替换。 - 统一查询规则:无论是否有
userLocation,都保持排序(按dateAndTime)和分页(limit 10)的逻辑一致。 - 移除冗余状态:
List.tsx直接使用useGigs返回的数据,无需额外维护gigs状态。 - 手动触发分页:添加“滚动加载更多”逻辑,避免自动循环查询。
修正后的代码
useGigs.tsx
import { useState, useEffect, useCallback } from 'react' import { collection, onSnapshot, query, where, orderBy, startAfter, limit } from 'firebase/firestore' import { db } from '../firebase' import { GigObject } from '../routes/homeStack' export interface Time { nanoseconds: number seconds: number; } export const useGigs = (userLocation?: string) => { const [gigs, setGigs] = useState<GigObject[]>([]) const [lastDocument, setLastDocument] = useState<FirebaseFirestore.QueryDocumentSnapshot | null>(null); const [isLoading, setIsLoading] = useState(false); const [hasMore, setHasMore] = useState(true); // 构建查询逻辑抽离 const buildQuery = useCallback(() => { let baseQuery = collection(db, 'gigs'); // 统一添加排序规则 baseQuery = query(baseQuery, orderBy('dateAndTime')); // 添加地点筛选 if (userLocation && userLocation.trim() !== "") { baseQuery = query(baseQuery, where('city', '==', userLocation)); } // 添加分页条件:仅当lastDocument存在时才用startAfter if (lastDocument) { baseQuery = query(baseQuery, startAfter(lastDocument), limit(10)); } else { // 初始查询直接取前10条 baseQuery = query(baseQuery, limit(10)); } return baseQuery; }, [userLocation, lastDocument]); // 初始加载或地点变更时重置数据 useEffect(() => { setGigs([]); setLastDocument(null); setHasMore(true); }, [userLocation]); // 加载数据逻辑 const loadGigs = useCallback(async () => { if (isLoading || !hasMore) return; setIsLoading(true); const gigQuery = buildQuery(); const unsubscribe = onSnapshot(gigQuery, (querySnapshot) => { const newGigs = querySnapshot.docs.map(doc => ({ id: doc.id, tickets: doc.data().tickets || "", venue: doc.data().venue || "Unknown Venue", dateAndTime: doc.data().dateAndTime || { seconds: 0, nanoseconds: 0 }, isFree: doc.data().isFree || false, image: doc.data().image || "", genre: doc.data().genre || "", gigName: doc.data().gigName || "", blurb: doc.data().blurb || "", location: doc.data().location || { longitude: 0, latitude: 0 }, address: doc.data().address || "", gigName_subHeader: doc.data().gigName_subHeader || "", links: doc.data().links || [], ticketPrice: doc.data().ticketPrice || "", likes: doc.data().likes || 0, likedGigs: doc.data().likedGigs || [], savedGigs: doc.data().savedGigs || [], city: doc.data().city || "Unknown City", })); // 追加数据而非替换 setGigs(prev => [...prev, ...newGigs]); // 更新lastDocument和hasMore const newLastDoc = querySnapshot.docs[querySnapshot.docs.length - 1]; setLastDocument(newLastDoc); // 返回数据不足10条则标记无更多数据 setHasMore(newGigs.length === 10); setIsLoading(false); }, (error) => { console.error('加载演出数据失败:', error); setIsLoading(false); }); return unsubscribe; }, [buildQuery, isLoading, hasMore]); // 初始加载 useEffect(() => { const unsubscribe = loadGigs(); return unsubscribe; }, [loadGigs]); return { gigs, loadGigs, isLoading, hasMore }; }
优化后的List.tsx(核心逻辑)
import { FC, useContext, useState } from "react"; import { StyleSheet, Text, View, TouchableOpacity, FlatList } from "react-native"; import { useGigs } from "../hooks/useGigs"; import GigsByDay from "./GigsByDay"; import GigsByWeek from "./GigsByWeek"; import { listProps } from "../routes/homeStack"; import { getGigsToday, getGigsThisWeek } from "../util/helperFunctions"; import { format } from "date-fns"; import { AuthContext } from "../AuthContext"; import { useGetUser } from "../hooks/useGetUser"; import { useLocation } from "../LocationContext"; type ListScreenNavigationProp = listProps["navigation"]; interface Props { navigation: ListScreenNavigationProp; } const ListByDay: FC<Props> = ({ navigation }): JSX.Element => { const [showWeek, setShowByWeek] = useState<boolean>(false); const currentDateMs: number = Date.now(); const { selectedLocation } = useLocation(); const { user } = useContext(AuthContext) || {}; const userDetails = useGetUser(user?.uid); const locationToUse = user && userDetails?.userLocation ? userDetails.userLocation : selectedLocation; // 直接使用hook返回的数据和方法 const { gigs, loadGigs, isLoading, hasMore } = useGigs(locationToUse); const gigsToday = getGigsToday(gigs, currentDateMs); const gigsThisWeek = getGigsThisWeek(gigs, currentDateMs); const formattedDay = format(new Date(currentDateMs), 'EEEE'); const formattedWeek = format(new Date(currentDateMs), 'LLLL do Y'); // 渲染加载更多提示 const renderFooter = () => { if (!isLoading) return null; return ( <View style={{ paddingVertical: 20 }}> <Text>加载中...</Text> </View> ); }; const gigsToRender = showWeek ? ( Object.keys(gigsThisWeek).length === 0 ? ( <Text style={{ fontFamily: 'LatoRegular', marginLeft: '7%' }}>No gigs this week</Text> ) : ( <GigsByWeek gigsThisWeek_grouped={gigsThisWeek} navigation={navigation} /> ) ) : ( gigsToday?.length === 0 ? ( <Text style={{ fontFamily: 'LatoRegular', marginLeft: '7%' }}>No gigs today</Text> ) : ( // 使用FlatList实现滚动加载更多 <FlatList data={gigsToday} renderItem={({ item }) => <GigsByDay navigation={navigation} gigsFromSelectedDate={[item]} />} keyExtractor={(item) => item.id} onEndReached={() => { if (hasMore) loadGigs(); }} onEndReachedThreshold={0.1} ListFooterComponent={renderFooter} /> ) ); const showDate = !showWeek ? ( <View testID="gigMapHeader" style={styles.headerText}> <Text style={styles.headerText_main}>{formattedDay}</Text> <Text style={styles.headerText_sub}>{formattedWeek}</Text> </View> ) : null; return ( <View style={{ flex: 1 }}> <View style={styles.buttonContainer}> <TouchableOpacity onPress={() => setShowByWeek(false)} style={!showWeek ? styles.touchable : styles.selected} > <Text style={!showWeek ? styles.buttonText : styles.buttonTextSelected}>Gigs Today</Text> </TouchableOpacity> <TouchableOpacity onPress={() => setShowByWeek(true)} style={showWeek ? styles.touchable : styles.selected} > <Text style={showWeek ? styles.buttonText : styles.buttonTextSelected}>Gigs this Week</Text> </TouchableOpacity> </View> {showDate} <View style={styles.listContainer}> {gigsToRender} </View> </View> ); };
内容的提问来源于stack exchange,提问作者Josh Simon
相关产品推荐
相关产品推荐

