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

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依赖项控制触发时机,避免无限重渲染。

具体实现思路

  1. 拆分原有转换器,只保留单文档自有字段的转换逻辑,不在转换器内做跨文档请求
  2. 所有Hook统一在useToDos顶层调用,符合React Hook规则
  3. 拿到todo基础列表后,先收集所有需要拉取的关联ID,做去重后批量请求,减少Firestore读次数
  4. 用内存映射的方式把用户、分类数据关联回todo对象,避免重复查询
  5. 给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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.27 16:01:08