Next.js使用Firestore onSnapshot+useEffect报空值读取错误
问题根因
你的报错和异常行为来自5个明确的逻辑疏漏,和状态初始值无关:
- Firestore 快照回调未做存在性校验:
onSnapshot触发时如果目标文档不存在、权限校验失败、路径错误,doc.data()会直接返回null,你未做校验就把null写入poolData状态,后续读取属性时必然触发空指针错误。 - React 闭包陈旧值问题:
useEffect传入空依赖数组[],导致内部注册的定时器回调永远捕获组件首次渲染时的poolData初始值,哪怕后续实时监听器更新了poolData,定时器里拿到的还是旧值,时序错乱时就会读到null触发报错。 - 资源泄漏未清理:你注册了两个Firebase实时监听器,但effect的清理函数只清除了定时器,没有调用监听器返回的unsub方法,组件卸载后监听器仍在后台运行,会持续触发无效状态更新,甚至引发内存泄漏和异常。
- 动态值时序错误:Next.js 首次hydration阶段
router.query.slug初始值为undefined,Firebase Auth初始化完成前auth.currentUser也可能为null,你直接用这两个未就绪的值拼接文档路径建监听,实际监听的是不存在的无效路径,必然返回空快照。 - 无效冗余逻辑:代码中调用的
getFirstPoolSnap()未做任何定义,属于无效代码,不会解决任何问题。
修复方案
按以下逻辑调整代码即可解决问题:
- 等
slug和auth.currentUser都初始化完成后,再注册Firestore监听器,不要在动态值为null/undefined时发起无效监听。 - 所有
onSnapshot回调内先判断文档是否存在,再读取data()更新状态。 - 用
useRef存储最新的poolData值,保证定时器回调永远能读到最新的状态值,避开闭包陈旧值问题。 - 清理函数内必须调用所有Firebase监听器的取消方法,避免资源泄漏。
- 定时器逻辑内增加空值守卫,
poolData未就绪时直接跳过计算,不要执行后续属性读取逻辑。
修正后的完整代码:
import { useRouter } from 'next/router'; import { useState, useEffect, useRef } from 'react'; import { doc, onSnapshot } from 'firebase/firestore'; import { auth, db } from '@/your-firebase-config-path'; // 替换为实际的Firebase配置导入路径 export default function PoolPage() { const router = useRouter(); const slug = router.query.slug; const [timerDisplay, setTimerDisplay] = useState(0); const [finalTime, setFinalTime] = useState(0); const [tokenState, setTokenState] = useState(0); const [open, setOpen] = useState(true); const [currentlyWinning, setCurrentlyWinning] = useState("Nobody"); const [poolData, setPoolData] = useState(null); // 用ref存储最新poolData,供定时器回调读取,避开闭包陈旧值问题 const poolDataRef = useRef(null); // 同步ref值,保证ref中永远是最新状态 useEffect(() => { poolDataRef.current = poolData; }, [poolData]); useEffect(() => { // 路由参数、用户信息未就绪时直接退出,不建立无效监听 if (!slug || !auth.currentUser?.uid) return; const profile = `/profile/${auth.currentUser.uid}`; // 监听pool文档变更 const unsubPoolListener = onSnapshot(doc(db, "pools", slug), (doc) => { // 先校验文档存在性,避免读取null的data if (!doc.exists()) { setPoolData(null); return; } const data = doc.data(); setPoolData(data); }); // 监听用户token变更 const unsubTokenListener = onSnapshot( doc(db, "users", auth.currentUser.uid), (doc) => { if (!doc.exists()) return; setTokenState(doc.data().tokens ?? 0); } ); // 时间计算逻辑 const getPoolTime = () => { const latestPoolData = poolDataRef.current; // 数据未就绪直接跳过计算 if (!latestPoolData) return; const localTime = Math.floor(Date.now() / 1000); const gbTimeLeft = latestPoolData.expTimestamp.toMillis() / 1000 - localTime; const finalTimeLeft = latestPoolData.finalTime.toMillis() / 1000 - localTime; setOpen(latestPoolData.open); setTimerDisplay(gbTimeLeft < 0 || finalTimeLeft < 0 ? 0 : Math.floor(gbTimeLeft)); setFinalTime(finalTimeLeft < 0 || gbTimeLeft < 0 ? 0 : Math.floor(finalTimeLeft)); setCurrentlyWinning(latestPoolData.lastSetterName ?? "Nobody"); }; const interval = setInterval(getPoolTime, 1000); // 组件挂载、数据就绪后立即执行一次计算,无需等待1秒首屏才显示内容 getPoolTime(); // 统一清理所有副作用 return () => { clearInterval(interval); unsubPoolListener(); unsubTokenListener(); }; }, [slug, auth.currentUser?.uid]); // 正确声明依赖项,值变化时自动重建监听 return ( // 原有组件JSX逻辑 <div></div> ) }
额外优化建议
- 时间戳计算逻辑可以进一步优化:在
onSnapshot拿到最新poolData时直接计算基准剩余时间,单独的1s定时器只做时间值递减,不需要每次都重复读取Firestore时间戳做计算,性能更好。 - 涉及Firebase Auth的逻辑建议搭配
onAuthStateChanged监听用户登录状态,不要直接读取auth.currentUser的瞬时值,从根源上避免用户未登录时的空值问题。
内容的提问来源于stack exchange,提问作者CondorW
相关产品推荐
相关产品推荐

