首页 / 文章 / 在 Next.js 中使用 Firebase Auth 和 Firestore,无需扁平集合结构

在 Next.js 中使用 Firebase Auth 和 Firestore,无需扁平集合结构

仅守护一个 Firebase 应用实例,让身份验证辅助函数抛出类型化的错误,将训练记录按用户分类存储,并通过模拟器验证写入操作。

1049 词

继承自 Firebase + Next.js 的模板通常存在两个常见问题:其一是一个扁平的 workouts 集合,其中存储着所有账户的文档;其二是在捕获错误时仅记录日志并返回 undefined,导致调用方无法得知写入操作失败。仅支持单次登录的演示版本可以规避这两个问题,但实际生产环境则无法做到。

这款健身应用的认证与追踪功能核心已基于 Firebase JS SDK 12.17.1 重新构建,并通过 Firebase 模拟器进行测试,从而能够实现端到端的写入操作验证。以下说明重点在于尽早修正架构问题。

仅使用一个 Firebase 实例,而非五个

一个常见的错误是在被多个路由导入的模块顶部调用 initializeApp。由于 Next.js 的热重载机制以及服务器端与客户端代码包的分离,该模块可能会被加载两次;第二次调用时就会报错称默认应用已存在。应采取以下措施加以保护:

import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_SENDER_ID!,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
};const app = getApps().length ? getApp() : initializeApp(firebaseConfig);export const auth = getAuth(app);
export const db = getFirestore(app);

getApps().length ? getApp() : initializeApp(...) 就是整个保护逻辑。应将配置放在 NEXT_PUBLIC_ 环境变量中,而非硬编码——并非因为 Web Firebase 的配置是机密信息(它本就设计为会传输到浏览器中),而是因为开发和生产环境应使用独立的项目,无需通过修改源代码来切换。

旧教程会将 "your-api-key" 这样的字符串直接粘贴到文件中。这本身并非安全漏洞,但这种习惯最终会导致真实的生产环境配置被放入公共仓库中。

能返回调用方可用信息的认证机制

采用这种实现方式。注意它没有做的是:捕获错误后返回 undefined。

import {
  createUserWithEmailAndPassword,
  type User,
} from "firebase/auth";
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import { auth, db } from "./firebase";
export async function registerUser(
  email: string,
  password: string,
): Promise<User> {
  const cred = await createUserWithEmailAndPassword(auth, email, password);
  return cred.user;
}

旧版本的辅助函数会将调用封装在try/catch结构中,打印注册错误信息后返回undefined。UI代码随后会等待registerUser(...)的返回值,并尝试访问可能不存在的user.uid属性——因此,错误的密码或重复的邮箱地址永远无法以标准的认证错误形式呈现,而会在之后尝试读取undefined属性时才暴露问题,此时距离真正的故障点已经很远了。

建议直接拒绝请求。Firebase认证接口会抛出类型明确的错误(如auth/email-already-in-use、auth/weak-password等)。应将这些错误代码转换为易于理解的文字提示。数据处理函数应保持诚实:要么成功返回,要么抛出异常。

从第一天起就按用户划分训练计划

这一改动在原型之外尤为重要。旧版本的示例会创建一个包含userId字段的扁平结构workouts集合:

// what everyone copies — one collection for the whole app
addDoc(collection(db, "workouts"), { userId, ...workout });

通过“my workouts”进行查询时会扫描随着整个用户群规模扩大而不断增长的集合,因此所有操作都必须通过安全规则依据userId进行过滤。建议使用子集合,这样每个用户的训练记录都可以存储在独立的文档中:

export type WorkoutInput = {
  type: string;
  durationMinutes: number;
  caloriesBurned: number;
};
export async function addWorkoutSession(
  userId: string,
  workout: WorkoutInput,
): Promise<string> {
  const ref = await addDoc(collection(db, "users", userId, "workouts"), {
    ...workout,
    createdAt: serverTimestamp(),
  });
  return ref.id;
}

collection(db, “users”, userId, “workouts”)会定位到该用户的私有子集合。这样一来,无论是读取还是写入操作,安全规则都可以将request.auth.uid与{uid}路径段进行比对,从而确保每个查询都仅在对应用户的文档范围内进行。

有两点值得强调。建议使用 serverTimestamp() 而非 new Date():客户端时钟(或恶意客户端)可能会生成错误的时间戳;serverTimestamp() 是 Firestore 设置的哨兵值,会在数据提交时被替换为服务器时间,因此无法被伪造。将数据载荷的类型定义为 WorkoutInput 而非 any,可以捕捉到字段名拼写错误,否则这些错误只有在图表无法正常显示时才会显现。

验证数据确实已被写入

切勿信任从未在模拟器上运行过的 Firebase 代码——SDK 可能会接受真实安全规则会拒绝的请求。应将 SDK 指向本地模拟器,并完整执行整个流程:注册、写入数据、再读取回来:

import { getAuth, connectAuthEmulator, createUserWithEmailAndPassword } from "firebase/auth";
import { getFirestore, connectFirestoreEmulator, addDoc, getDocs, collection, serverTimestamp } from "firebase/firestore";
connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true });
connectFirestoreEmulator(db, "127.0.0.1", 8080);const cred = await createUserWithEmailAndPassword(auth, email, "s3cret-pass");
const ref = await addDoc(
  collection(db, "users", cred.user.uid, "workouts"),
  { type: "run", durationMinutes: 32, caloriesBurned: 410, createdAt: serverTimestamp() },
);
const snap = await getDocs(collection(db, "users", cred.user.uid, "workouts"));

通过模拟器运行器执行相同的路径后,会生成一个具体的认证UID、一个写入ID以及一份回读文档,该文档的createdAt字段为真实的Firestore时间戳(seconds/nanoseconds),而非占位值——这证明了服务器已填充了该字段。错误的路径或字段类型会在笔记本电脑上直接导致失败,而不会在部署之后出现问题。

工具相关注意事项:Firebase CLI现在要求使用Java 21或更高版本。在干净的笔记本电脑上若安装了较旧版本的JRE,会在任何应用代码运行之前因版本错误而阻止模拟器运行器的启动。请先升级JDK,然后再尝试。

下一步该怎么做

身份验证加上正确作用域的写入操作是基础。完整的应用通常会使用onAuthStateChanged来处理已登录用户的界面状态,以特定格式映射身份验证错误代码,并通过getDocs、orderBy("createdAt", "desc")以及limit功能来展示操作历史记录。这些功能依然依赖于早期做出的两个决策:在保护机制后仅初始化一次Firebase,以及将训练记录文档嵌套在对应用户下。若遗漏其中任何一点,后续功能都会受到影响。