Firestore快照中如何映射多集合数据组装ToDo结构
问题背景
我日常开发习惯使用SQL作为应用后端,对Firestore的使用不够熟练,在实现复杂数据获取的API调用时遇到了问题。
当前开发模块需要合并三个集合的数据,获取包含参与用户、当前用户为该待办标注的分类的ToDos数组。由于每个参与待办的用户都可以按照自己的偏好标注待办分类,提升了实现复杂度,各集合结构拆分如下:
集合结构
- todo(Firestore数据库文档)
{ title: string, involved: string[], // 存储用户ID involvedCategory: string[] // 分类ID,和involved数组按索引一一对应 }
注:我曾尝试用对象数组替代这两个平行数组,但Firestore不支持在对象数组中按当前用户ID做查询,因此采用了当前结构作为折中方案。
- category(Firestore数据库文档)
{ title: string, color: string }
- user(Firebase身份认证用户)
{ uid: string, displayName: string, photoURL: string, ... }
实现目标
最终需要得到如下结构的ToDo元素数组:
{ id: string, title: string, involved: User[], category?: { title: string, color: string } }
我使用TypeScript开发,已经创建了对应接口配合数据转换器使用,当前代码如下:
import { DocumentData, FirestoreDataConverter, WithFieldValue, QueryDocumentSnapshot, SnapshotOptions, query, collection, where, } from 'firebase/firestore' import { store } from '../firebase' import { useCollectionData } from 'react-firebase-hooks/firestore' import { User } from 'firebase/auth' import { useCategories } from './categories' import { useAuth } from '../contexts/AuthContext' interface ToDo { id: string title: string involved: User[] category?: { title: string color: string } } const converter: FirestoreDataConverter<ToDo> = { toFirestore(todo: WithFieldValue<ToDo>): DocumentData { return {} // 暂未实现 }, fromFirestore( snapshot: QueryDocumentSnapshot, options: SnapshotOptions ): ToDo { const data = snapshot.data(options) return { id: snapshot.id, title: data.title, category: undefined, // 待填充 involved: [], // 待填充 } }, } export function useToDos() { const { currentUser } = useAuth() const { categories } = useCategories() // 转换器中需要用到分类数据 const ref = query( collection(store, 'habits'), where('involved', 'array-contains', currentUser.uid) ).withConverter(converter) const [data] = useCollectionData(ref) return { todos: data, } }
当前遇到的问题:我已有一个可返回用户全部分类的Hook,但无法在useToDos钩子外部调用该Hook;如果在useToDos钩子内部调用该Hook,又会触发无限重渲染问题。
实现方案
不要把关联数据的填充逻辑写在Firestore转换器里,转换器只负责单文档的基础字段转换,关联的用户、分类数据在拿到todo列表后批量拉取填充即可,同时严格遵守React Hook调用规则,通过useEffect依赖项控制触发时机,避免无限重渲染。
具体实现思路
- 拆分原有转换器,只保留单文档自有字段的转换逻辑,不在转换器内做跨文档请求
- 所有Hook统一在
useToDos顶层调用,符合React Hook规则 - 拿到todo基础列表后,先收集所有需要拉取的关联ID,做去重后批量请求,减少Firestore读次数
- 用内存映射的方式把用户、分类数据关联回todo对象,避免重复查询
- 给useEffect设置准确的依赖项,只有当前用户、用户分类列表变化时才重新拉取数据
调整后可运行代码参考
import { DocumentData, FirestoreDataConverter, WithFieldValue, QueryDocumentSnapshot, SnapshotOptions, query, collection, where, getDocs, getDoc, doc } from 'firebase/firestore' import { store } from '../firebase' import { useCollectionData } from 'react-firebase-hooks/firestore' import { User } from 'firebase/auth' import { useCategories } from './categories' import { useAuth } from '../contexts/AuthContext' import { useEffect, useState } from 'react' interface ToDo { id: string title: string involved: User[] category?: { title: string color: string } } // 基础转换器仅处理单文档自有字段,不处理关联数据 const baseConverter: FirestoreDataConverter<Omit<ToDo, 'involved' | 'category'> & {involved: string[], involvedCategory: string[]}> = { toFirestore(todo: WithFieldValue<Omit<ToDo, 'involved' | 'category'>>): DocumentData { return { title: todo.title } }, fromFirestore( snapshot: QueryDocumentSnapshot, options: SnapshotOptions ) { const data = snapshot.data(options) return { id: snapshot.id, title: data.title, involved: data.involved, involvedCategory: data.involvedCategory } }, } export function useToDos() { const { currentUser } = useAuth() const [todos, setTodos] = useState<ToDo[]>([]) const [loading, setLoading] = useState(true) // 顶层调用Hook,符合React规则 const { categories: userCategories } = useCategories() useEffect(() => { if (!currentUser?.uid) return const fetchTodos = async () => { setLoading(true) // 1. 拉取当前用户参与的所有todo基础数据 const todoRef = query( collection(store, 'habits'), // 如果实际集合名为todos请自行修改 where('involved', 'array-contains', currentUser.uid) ).withConverter(baseConverter) const todoSnap = await getDocs(todoRef) const todoBaseList = todoSnap.docs.map(d => d.data()) // 2. 收集所有参与用户ID,去重后批量拉取用户数据 const allUserIds = [...new Set(todoBaseList.flatMap(t => t.involved))] // 以下用户拉取逻辑请根据自己的用户存储位置调整,如果用户数据不存Firestore可替换为对应请求逻辑 const userPromises = allUserIds.map(uid => getDoc(doc(store, 'users', uid))) const userSnaps = await Promise.all(userPromises) const userMap = new Map<string, User>() userSnaps.forEach(snap => { if (snap.exists()) { userMap.set(snap.id, snap.data() as User) } }) // 3. 组装最终todo数据 const finalTodos: ToDo[] = todoBaseList.map(todo => { // 找到当前用户在参与列表中的索引,匹配对应分类ID const userIndex = todo.involved.findIndex(uid => uid === currentUser.uid) const categoryId = userIndex > -1 ? todo.involvedCategory[userIndex]?.replaceAll(' ', '') : undefined // 直接从已拉取的用户全部分类列表中匹配,无需额外请求 const category = categoryId ? userCategories.find(c => c.id === categoryId) : undefined // 映射参与用户列表 const involvedUsers = todo.involved.map(uid => userMap.get(uid)).filter(Boolean) as User[] return { id: todo.id, title: todo.title, involved: involvedUsers, category: category ? { title: category.title, color: category.color } : undefined } }) setTodos(finalTodos) setLoading(false) } fetchTodos() }, [currentUser?.uid, userCategories]) // 精准设置依赖项,避免无限重渲染 return { todos, loading } }
踩坑提示
- Firebase Auth的用户唯一标识字段是
uid不是id,写查询条件时注意不要写错 - 如果遇到
PERMISSION_DENIED报错,先检查传入的文档路径是否正确,分类ID如果存在首尾空格要先做清理,避免路径错误触发权限拦截,清理方法参考:categoryId.replaceAll(' ', '') - 不要在Firestore的converter里做跨文档请求,converter会在每次快照更新时触发,很容易造成重复请求和无限重渲染
- 关联数据拉取前先做ID去重,能大幅减少Firestore的读操作次数,降低使用成本
内容的提问来源于stack exchange,提问作者Bexy-Lyn
相关产品推荐
相关产品推荐

