You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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时间戳类型。

错误原因

  1. 初始startAfter参数非法:lastDocument初始值为null,直接传入startAfter()会触发Firestore参数错误,该方法不接受null或undefined。
  2. 无限订阅循环:lastDocument被加入useEffect依赖数组,每次查询更新lastDocument都会重新触发订阅,导致反复发起查询,列表数据频繁替换。
  3. 数据更新逻辑错误:每次查询结果直接替换gigs状态,而非追加,导致旧数据被清空,出现“无演出/少量演出”的交替情况。
  4. 查询逻辑不一致:无userLocation时的查询未添加排序和分页规则,与有筛选的逻辑不统一。
  5. 冗余状态处理: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.12 19:42:02