React useContext setState仅首次更新:Firestore邮票监听场景求助
问题:扫码集邮APP的Firestore快照监听器状态更新异常
我开发了一款允许用户扫码集邮的APP,通过Firebase Firestore快照监听器监听用户新增的邮票,希望利用快照的payload将新邮票添加到Context存储的邮票列表中,避免重新拉取全部邮票数据。但该功能仅在APP启动后的第一次操作正常,后续操作会出现异常:
异常表现
- 第一次扫码操作:收到包含新增邮票的快照后,新邮票正常添加到Context状态,控制台打印的邮票总数正确,日志显示添加成功。
- 第二次及之后扫码操作:收到快照后,控制台打印的邮票总数仍是第一次操作前的旧值,新邮票被添加到旧的Context状态中,导致之前的邮票被覆盖。
代码结构
Context 定义
const AuthContextProvider = ({ children }: Props) => { const [user, setUser] = useState<FirebaseAuthTypes.User>(); const [userData, setUserData] = useState<FirestoreUser>(); const [stamps, setStamps] = useState<GetMyStampsResponse>(); const [isLoading, setLoading] = useState(true); // 与问题无关的认证逻辑 const onAuthStateChanged = useCallback( async (response: FirebaseAuthTypes.User | null) => { ... }, [...] ); useEffect(() => { const unsubscribe = auth().onUserChanged(onAuthStateChanged); return unsubscribe; }, [onAuthStateChanged]); return ( <AuthContext.Provider value={{ isLoading, stamps, setStamps, user, userData, reloadUser, }}> {children} </AuthContext.Provider> ); };
useAuth 钩子
import storage from '@react-native-firebase/storage'; import cloneDeep from 'lodash/cloneDeep'; import { useCallback, useContext } from 'react'; const useAuth = () => { const { stamps, setStamps, reloadUser, userData, user, isLoading } = useContext(AuthContext); /** * 通过监听器添加新邮票,避免调用reloadUser(开销更大) */ const addStampsFromListener = useCallback( async (newStamps: FirestoreStamp[]) => { let newStampsState = cloneDeep(stamps); const newStampHasKnownStampcard = newStampsState?.cards.find( c => c.ref.path === newStamps[0].stampcardRef.path, ); console.log('current stamps state', newStampsState?.stamps.length); if (newStampHasKnownStampcard) { newStampsState = { ...newStampsState!, stamps: newStampsState!.stamps.concat(newStamps), }; } else { const [stampcard, business] = await Promise.all([ stampcardApi.getStampcardByRef(newStamps[0].stampcardRef), businessApi.getBusiness(newStamps[0].businessId), ]); const imageUrl = await storage() .ref(stampcard!.stampImage) .getDownloadURL(); const stampcardWithRef: StampCardDataWithRef = { ...stampcard!, imageUrl, businessId: newStamps[0].businessId, ref: newStamps[0].stampcardRef, }; newStampsState = { ...newStampsState, businesses: (newStampsState?.businesses ?? []).concat(business!), cards: (newStampsState?.cards ?? []).concat(stampcardWithRef), stamps: (newStampsState?.stamps ?? []).concat(newStamps), }; } setStamps(newStampsState); return newStampsState; }, [setStamps, stamps], ); ... return { addStampsFromListener, updateStampsFromListener, isLoading, data: userData, logout, reloadUser, stamps, user, }; };
屏幕组件中的监听器使用
const HomeConsumerScreen = () => { const { stamps: stampcards, reloadUser, addStampsFromListener, updateStampsFromListener, } = useAuth(); const subscribeToStamps = useCallback(() => { if (refIsSubscribedToStamps.current) return; // 避免重复订阅 const unsubscribe = stampApi.subscribeToStamps( /** * 新邮票回调 */ querySnapshot => { error && setError(undefined); if (!refIsSubscribedToStamps.current) { // 第一次快照包含用户所有现有邮票,跳过处理 return; } const data = querySnapshot.map(doc => doc.doc.data(), ) as FirestoreStamp[]; addStampsFromListener(data).then(nextState => showDone('newStamp', data, nextState), ); }, /** * 初始化完成回调 */ () => { refIsSubscribedToStamps.current = true; }, /** * 错误回调 */ e => { setError(e.message); console.error('onSnapShot', e); }, ); return unsubscribe; }, [addStampsFromListener, error, showDone, updateStampsFromListener]); useEffect(() => { // 订阅邮票变化 const unsubscribe = subscribeToStamps(); return () => { unsubscribe && unsubscribe(); refIsSubscribedToStamps.current = false; }; // 注:将subscribeToStamps加入依赖会导致监听器反复卸载/挂载,开销大,但能临时解决问题 }, []); };
问题原因
- 闭包捕获旧状态:
addStampsFromListener通过useCallback依赖捕获了stamps状态,第一次订阅监听器时,该函数绑定的是初始的stamps值。后续stamps更新后,监听器回调仍引用旧的addStampsFromListener(因为useEffect依赖为空数组,未重新订阅),导致每次更新都基于旧状态。 useEffect依赖缺失:HomeConsumerScreen中的useEffect依赖为空数组,subscribeToStamps仅在组件首次渲染时执行一次,监听器回调始终使用第一次渲染时的addStampsFromListener,而该函数已过时,无法获取最新的stamps状态。
解决方案
1. 使用函数式更新获取最新状态
修改addStampsFromListener,通过setStamps的函数式更新获取最新状态,避免直接依赖stamps,同时调整异步逻辑的位置:
const addStampsFromListener = useCallback( async (newStamps: FirestoreStamp[]) => { // 先判断是否需要异步获取数据 const hasKnownStampcard = stamps?.cards?.find( c => c.ref.path === newStamps[0].stampcardRef.path ); if (hasKnownStampcard) { // 直接用函数式更新添加邮票 setStamps(prev => ({ ...prev, stamps: [...(prev?.stamps || []), ...newStamps] })); } else { // 异步获取卡片和商家数据 const [stampcard, business] = await Promise.all([ stampcardApi.getStampcardByRef(newStamps[0].stampcardRef), businessApi.getBusiness(newStamps[0].businessId), ]); const imageUrl = await storage() .ref(stampcard!.stampImage) .getDownloadURL(); const stampcardWithRef = { ...stampcard!, imageUrl, businessId: newStamps[0].businessId, ref: newStamps[0].stampcardRef, }; // 函数式更新合并所有数据 setStamps(prev => ({ ...prev, businesses: [...(prev?.businesses || []), business!], cards: [...(prev?.cards || []), stampcardWithRef], stamps: [...(prev?.stamps || []), ...newStamps] })); } }, [setStamps, stamps] // 这里仍保留stamps用于初始判断,后续可进一步优化 );
2. 用Ref保存最新的回调函数
在HomeConsumerScreen中使用useRef保存最新的addStampsFromListener,确保监听器回调能获取到最新的函数版本,避免频繁重新订阅监听器:
const HomeConsumerScreen = () => { const { stamps: stampcards, reloadUser, addStampsFromListener, updateStampsFromListener, } = useAuth(); // 保存最新的addStampsFromListener const latestAddStamps = useRef(addStampsFromListener); useEffect(() => { latestAddStamps.current = addStampsFromListener; }, [addStampsFromListener]); const subscribeToStamps = useCallback(() => { if (refIsSubscribedToStamps.current) return; const unsubscribe = stampApi.subscribeToStamps( querySnapshot => { error && setError(undefined); if (!refIsSubscribedToStamps.current) { return; } const data = querySnapshot.map(doc => doc.doc.data(), ) as FirestoreStamp[]; // 调用Ref中保存的最新函数 latestAddStamps.current(data).then(nextState => showDone('newStamp', data, nextState), ); }, () => { refIsSubscribedToStamps.current = true; }, e => { setError(e.message); console.error('onSnapShot', e); }, ); return unsubscribe; }, [error, showDone, updateStampsFromListener]); // 移除addStampsFromListener依赖 useEffect(() => { const unsubscribe = subscribeToStamps(); return () => { unsubscribe && unsubscribe(); refIsSubscribedToStamps.current = false; }; }, [subscribeToStamps]); // 加入subscribeToStamps依赖,但因其依赖稳定,不会频繁触发重新订阅 };
3. 优化初始快照处理逻辑
确保refIsSubscribedToStamps.current = true仅在初始快照(包含所有现有邮票)处理完成后设置,避免将初始快照误判为新增邮票:
// 修改subscribeToStamps中的初始化回调逻辑 () => { // 确认初始快照已处理完成后再标记为已订阅 refIsSubscribedToStamps.current = true; },
内容的提问来源于stack exchange,提问作者GunnarK
相关产品推荐
相关产品推荐

