Firestore是如何存储数据的,以及如何使用它来执行CRUD操作
大多数应用程序最终都需要存储和操作数据。如果你使用Firebase进行开发,那么这些数据就会存储在Firestore中——这是谷歌提供的一种灵活且可扩展的NoSQL文档数据库。 但在能够自信地创建、读取、更新或删除数据之前,你首先需要了解Firestore实际上是如何组织信息的。Firestore的结构与SQL数据库不同,如果将其视为SQL数据库来使用,那么最终结果很可能就是得到一个结构混乱、难以查询的数据模型。 在本教程中,你将学习 Firestore的NoSQL数据模型是如何工作的,然后通过使用Firebase Web SDK(v9及以上版本)来构建一个简单的任务管理应用程序,从而练习各种
大多数应用程序最终都需要存储和操作数据。如果你使用Firebase进行开发,那么这些数据就会存储在Firestore中——这是谷歌提供的一种灵活且可扩展的NoSQL文档数据库。
但在能够自信地创建、读取、更新或删除数据之前,你首先需要了解Firestore实际上是如何组织信息的。Firestore的结构与SQL数据库不同,如果将其视为SQL数据库来使用,那么最终结果很可能就是得到一个结构混乱、难以查询的数据模型。
在本教程中,你将学习 Firestore的NoSQL数据模型是如何工作的,然后通过使用Firebase Web SDK(v9及以上版本)来构建一个简单的任务管理应用程序,从而练习各种CRUD操作。学完之后,你将能够安全地添加任务、查询任务、更新嵌套字段和数组,以及删除数据,而不会留下任何孤立的子集合。
目录
先决条件
在开始之前,请确保你具备以下条件:
Node.js v18或更高版本(运行
node --version即可确认版本)一个Google账户,用于创建Firebase项目(本教程使用免费的Spark计划即可)
对JavaScript有基本的了解,包括
async/await以及ES模块的概念需要一个代码编辑器和终端环境
你不需要事先具备使用Firebase或NoSQL数据库的经验,因为本教程会从基础开始帮助你掌握这些知识。
Firestore的数据组织结构
如果你有关系型数据库(如SQL数据库)的使用经验,首先需要摒弃的是那种具有固定数据结构和外键关联的表格概念。Firestore是一种面向文档的NoSQL数据库,它通过两个核心概念来组织数据:集合和文档。
集合是一个用于存储文档的命名容器。例如
tasks、users或orders等。文档是集合中的单个记录,每个文档都由一个唯一的ID标识。数据以键值对的形式存储在文档中,这种结构类似于JSON对象。
这里有一个常常让许多新手犯错的地方:文档并不需要拥有相同的字段。一个任务文档可以包含dueDate字段,而另一个文档则可以没有这个字段。Firestore在数据库层面并不会强制要求数据结构必须遵循某种规范,这一责任由你的应用程序代码来承担。
嵌套与子集合
文档可以包含两种类型的嵌套数据:
映射,即直接嵌套在文档内部的对象(例如,
metadata字段中可能包含{ priority, dueDate }这样的内容)子集合,也就是嵌套在特定文档下的完整集合结构(例如,每个任务都可以拥有自己的
comments子集合)
这样就会形成一种类似树的结构:
tasks (collection)
└── taskId (document)
├── title: "Article title"
├── completed: false
├── tags: ["writing", "firebase"]
├── metadata: { priority: "high", dueDate: }
└── comments (subcollection)
└── commentId (document)
├── text: "CRUD Article"
└── createdAt: >
支持的数据类型
Firestore文档可以存储多种原生数据类型。其中你最常使用的是:
| 类型 | 示例 |
|---|---|
string |
"Write CRUD article" |
number |
42 |
boolean |
true |
array |
["writing", "firebase"] |
map |
{ priority: "high" } |
timestamp |
Timestamp.now() |
reference |
指向另一份文档的引用 |
geopoint |
经纬度坐标对 |
在编写CRUD代码之前了解这些内容的重要性
你之后编写的每一条CRUD操作都会依赖于这种数据结构:
创建操作意味着将一份文档添加到某个集合中,系统会自动生成或指定一个唯一的ID。
读取操作意味着通过ID获取单份文档,或者根据查询条件获取多份文档。
更新操作意味着修改现有文档中的字段内容,包括嵌套的映射和数组。
删除操作意味着移除一份文档,不过Firestore不会自动清理其对应的子集合(这一点在第六步中会经常被忽略)。
在建立了相应的思维模型之后,让我们开始创建项目并编写代码吧。
步骤1 – 设置您的Firebase项目
请访问Firebase控制台,然后创建一个新项目。
点击“添加项目”,为该项目起一个名称(例如:
crud-tasks-demo),然后按照设置向导的指示进行操作(在本教程中,使用Google Analytics是可选的)。项目创建完成后,打开左侧侧边栏,点击“数据库和存储”,再选择“Firestore”。
点击“创建数据库”。请选择一个离您较近的位置;对于本教程来说,建议先选择“测试模式”,这样在尚未配置安全规则的情况下,您也可以正常读写数据。
注意:测试模式下,您的数据库会在30天内对任何用户开放。在没有设置适当的Firestore安全规则之前,切勿将应用程序发布到生产环境。我们将在“调试”部分进一步讨论这一点。
现在,您应该看到一个空的Firestore数据库,它已经准备好接收您的第一个数据集合了。
步骤2 – 初始化SDK
创建一个新的项目文件夹,并安装Firebase Web SDK:
mkdir firestore-crud-demo && cd firestore-crud-demo
npm init -y
npm install firebase
从Firebase控制台的“项目设置 – 一般信息 – 您的应用程序 – Web应用程序”中获取您的项目配置信息(如果还没有注册Web应用程序,请先进行注册)。
创建一个名为firebase-config.js的文件:
// firebase-config.js
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOURPROJECT_ID.firebaseapp.com",
projectId: "YOUR PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.appspot.com",
messagingSenderId: "YOUR_SENDER_ID",
appId: "YOUR_APP_ID",
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
从现在开始,所有的CRUD示例都会从这个文件中导入db>变量。在实际项目中,请将真实的配置信息放在版本控制之外(可以使用环境变量来存储这些信息)。
步骤3 – 创建:添加任务
Firestore提供了两种创建文档的方法:让Firestore自动生成ID,或者您自己指定ID。
使用addDoc()自动生成ID
// create-task.js
import { collection, addDoc, Timestamp } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function createTask() {
try {
const docRef = await addDoc(collection(db, "tasks"), {
title: "编写CRUD文章",
completed: false,
tags: ["writing", "firebase"],
metadata: {
priority: "high",
dueDate: Timestamp.fromDate(new Date("2026-09-15")),
},
createdAt: Timestamp.now(),
});
console.log("任务创建成功,其ID为:", docRef.id);
} catch (error) {
console.error("创建任务时出现错误:", error);
}
}
createTask();
使用setDoc()自定义文档ID
当您希望自行控制文档ID时,可以使用这种方法,例如将其与另一个系统中的ID进行匹配。
import { doc, setDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function createTaskWithId(taskId) {
await setDoc(doc(db, "tasks", taskId), {
title: "Review pull request",
completed: false,
tags: ["code-review"],
});
}
createTaskWithId("task-001");
将文档添加到子集合中
若要在特定任务下添加评论,首先需要引用该任务的父文档:
import { collection, addDoc, Timestamp } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function addComment(taskId, text) {
await addDoc(collection(db, "tasks", taskId, "comments"), {
text,
createdAt: Timestamp.now(),
});
}
addComment("task-001", "First draft done");
步骤4 – 阅读:查询任务信息
获取单个文档
import { doc, getDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getTask(taskId) {
const snapshot = await getDoc(doc(db, "tasks", taskId));
if (snapshotexists()) {
console.log(snapshot.id, snapshot.data());
} else {
console.log("没有找到该任务。");
}
}
getTask("task-001");
获取整个集合
import { collection, getDocs } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getAllTasks() {
const snapshot = await getDocs(collection(db, "tasks"));
snapshot.forEach((doc) => {
console.log(doc.id, doc.data());
});
}
getAllTasks();
使用查询进行过滤
import { collection, query, where, orderBy, limit, getDocs } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function getUrgentPendingTasks() {
const q = query(
collection(db, "tasks"),
where("completed", "==", false),
orderBy("metadata.priority"),
limit(10)
);
const snapshot = await getDocs(q);
snapshot.forEach((doc) => console.log(doc.id, doc.data()));
}
getUrgentPendingTasks();
注意:当在一个字段上使用where()进行过滤,在另一个字段上使用orderBy()进行排序时,通常需要创建一个复合索引。Firestore会在控制台中显示错误信息,并提供创建该索引的详细说明。更多相关内容请参阅“调试”章节。
使用onSnapshot()实现实时更新
您不必一次性获取所有数据,而是可以订阅实时变化。这对于需要在各种设备上即时更新的任务列表来说非常有用:
import { collection, onSnapshot } from "firebase/firestore";
import { db } from "./firebase-config.js";
const unsubscribe = onSnapshot(collection(db, "tasks"), (snapshot) => {
snapshot.docChanges().forEach((change) => {
console.log/change.type, change.doc.id, change.doc.data());
});
});
// 当您不再需要接收更新时,调用unsubscribe()方法(例如,组件被卸载时)
步骤 5 – 更新:修改任务
使用updateDoc()进行部分更新
与setDoc()不同,updateDoc()只会修改您指定的字段,文档中的其他内容保持不变。
import { doc, updateDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function completeTask(taskId) {
await updateDoc(doc(db, "tasks", taskId), {
completed: true,
});
}
completeTask("task-001");
使用点表示法更新嵌套字段
要修改metadata对象中的某个属性,您无需重新编写整个对象:
await updateDoc(doc(db, "tasks", "task-001"), {
"metadata.priority": "low",
});
安全地更新数组
在并发环境中,直接覆盖数组字段是危险的。应使用arrayUnion()和arrayRemove()方法来操作数组:
import { doc, updateDoc, arrayUnion, arrayRemove } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function addTag(taskId, tag) {
await updateDoc(doc(db, "tasks", taskId), {
tags: arrayUnion(tag),
});
}
async function removeTag(taskId, tag) {
await updateDoc(doc(db, "tasks", taskId), {
tags: arrayRemove(tag),
});
}
arrayUnion()会去除重复的值,而arrayRemove()则会删除所有匹配的条目。这两种操作都在服务器端原子性地执行。
步骤 6 – 删除:移除任务
删除文档
import { doc, deleteDoc } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function deleteTask(taskId) {
await deleteDoc(doc(db, "tasks", taskId));
}
deleteTask("task-001");
子集合的陷阱
前面提到过一个需要注意的问题:删除tasks/task-001并不会自动删除其对应的comments子集合。这些评论文档仍然存在于数据库中,只是通过用户界面无法访问它们,除非您知道它们的路径。
要正确地进行清理操作,首先需要删除子集合中的文档,然后再删除父集合中的文档:
import { collection, getDocs, doc, deleteDoc, writeBatch } from "firebase/firestore";
import { db } from "./firebase-config.js";
async function deleteTaskWithComments(taskId) {
const commentsRef = collection(db, "tasks", taskId, "comments");
const commentsSnapshot = await getDocs(commentsRef);
const batch = writeBatch(db);
commentsSnapshot.forEach((commentDoc) => {
batch.delete(commentDoc.ref);
});
batch.delete(doc(db, "tasks", taskId));
await batch.commit();
}
deleteTaskWithComments("task-001");
writeBatch() 将多个删除操作合并为一次原子性操作。这些操作要么全部成功,要么全部失败。
删除单个字段
如果你只想删除某个字段而不想删除整个文档,可以使用 deleteField() 方法:
import { doc, updateDoc, deleteField } from "firebase/firestore";
import { db } from "./firebase-config.js";
await updateDoc(doc(db, "tasks", "task-001"), {
metadata: deleteField(),
});
调试常见问题
FirebaseError: 缺少或权限不足
你的安全规则阻止了该请求的执行。如果你仍处于测试模式,请检查30天的有效期是否已经到期(过了这个期限,规则会自动恢复为拒绝所有请求)。对于正式发布的应用程序,请在Firestore设置中查看规则配置,然后检查这些规则是否与你实际进行读写操作的路径相匹配,包括子集合;子集合也需要单独设置规则。
函数 addDoc() 被调用时传入了无效数据。不支持的值:undefined
与null不同,undefined值是被Firestore明确拒绝的。这种情况通常发生在表单字段为空时,你直接将其传递给写入操作。在进行写入操作之前,请先过滤掉所有值为undefined的字段,或者将它们的值设置为null。
该查询需要索引才能执行
当你在不同的字段上同时使用where()和orderBy()>时,就会出现这种错误。Firestore无法利用自动创建的索引来执行这样的查询。错误信息中会提供一个链接,通过这个链接可以在控制台中预先生成所需的复合索引。点击该链接,等待一两分钟让索引生成完成,然后再重新执行查询即可。
读取操作次数过多导致配额警告
无论是在循环中多次调用getDoc(),还是直接获取整个集合的数据,每次操作都会被计为一次读取。为了避免不必要的数据传输,请不要为了在客户端进行过滤而先获取整个集合,而是应该使用where()在查询阶段就完成过滤操作;对于那些可能会无限增长的数据集,还需要使用limit()>来限制查询结果的数量。
删除后剩余的子集合
如果您发现认为自己已经删除的文档仍在占用存储空间,或者仍然出现在导出结果中,请检查被删除文档路径下是否存在子集合。正如步骤6中所说明的,deleteDoc()方法永远不会触发级联删除操作,因此清理这些剩余的子集合始终是您的责任。
总结
现在,您已经对Firestore的数据结构有了清晰的认识,并且也通过使用Web SDK v9+亲自体验了所有的CRUD操作。以下是这些内容的简要回顾:
| 操作类型 | 关键函数 |
|---|---|
| 创建 | addDoc(), setDoc() |
| 读取 | getDoc(), getDocs(), query(), onSnapshot() |
| 更新 | updateDoc(), arrayUnion(), arrayRemove() |
| 删除 | deleteDoc(), deleteField(), writeBatch() |
当您熟练掌握了这些基础知识之后,接下来可以尝试以下几个方向进行进一步学习:
事务处理,用于那些必须同时成功或失败的操作(例如在两个用户之间传递任务)。
批量写入操作,正如步骤6中所介绍的,当您需要原子性地修改多份文档时,这种操作非常有用。
复合索引,可以帮助您实现更复杂的过滤和排序功能。
分页处理,通过使用
startAfter()方法,可以分批加载大量数据,而无需一次性全部加载。
如果您还没有这样做的话,建议在针对数据编写查询语句之前,先重新思考如何对数据进行建模。在建模阶段做出的决策(比如是否需要嵌套数据或使用子集合),会直接影响到后续使用哪些CRUD操作会更加自然、高效。
相关文章
了解人工智能软件开发生命周期流程——构建智能代理功能的完整指南
也许你可以理解这样的场景:本周,你用了同样的说明四次向别人解释人工智能模型的使用方法。 你反复讲解过团队是如何构建演示文稿的框架的,哪些检查步骤需要在部署之前完成,以及为什么测试数据库并不是文档中提到的那个。 每次你都要把这些内容重新写一遍,每次智能助手也能完成得不错,但每次新的会话开始时,一切都得从零开始。 而这正是 智能助手技能 所要解决的问题。 技能 实际上就是一个文件夹,其中只包含一个名为 Skill.md 的文件。智能助手在启动时会阅读其中的一行总结内容,而只有当真正需要时才会打开完整的说明文件。你只需把解释内容编写一次,将其与代码一起提交,那么团队中的每个智能助手就能访问这些信息,
阅读全文
Cloudflare Workers能够接收入站的TCP连接,而gRPC则是首批被支持使用的协议之一。
现在,Cloudflare Workers可以通过通过Spectrum路由的新connect(socket)处理程序来接收传入的TCP连接,从而结束了此前对HTTP使用的八年限制。使用任何语言编写的容器都可以使用全双工的gRPC协议进行通信;而Cloudflare Workers则可以通过自动进行的gRPC-to-web转换机制来实现单向通信或服务器端流式传输功能。目前所有这些功能都还处于私人测试阶段。 作者:Steef-Jan Wiggers
阅读全文
如何测试Flutter应用程序:单元测试、组件测试、黄金标准测试以及集成测试详解
第一次在技术面试中被问到“你的测试覆盖范围是多少?”时,我并没有一个令人满意的答案。 那时我已经发布了几款真正的Flutter应用程序,它们可以正常运行,用户也在使用它们。但我的测试工作其实非常有限——仅仅是为某个定价功能编写了少量的单元测试而已,并没有其他测试内容。 几个月后,我对其中一个任务完成流程进行了重构,这个修改在代码差异对比中看起来完全没问题,但却破坏了用户真正关心的一个功能:当用户将某项任务标记为已完成时,该任务并不会从错误的列表中移除。虽然没有任何程序崩溃,也没有任何错误日志被记录下来,但用户却开始不再信任这款应用程序。而我直到有用户把这个问题告诉了一位也是测试人员的朋友,才发
阅读全文
如何从大型语言模型中获取可靠的结构化数据
大多数关于如何调用语言模型的教程都会在 JSON.parse(response.content) 这行代码处结束。这段代码在处理前十个测试用例时确实可以有效运行。但当你开始实际应用时,会在第400次左右的一次请求中遇到问题:模型可能会返回一个它自己编造出来的日期,或者当你的数据结构应该包含5个元素时却返回8个数组项,又或者返回一个格式完全正确但实际上缺少某个字段的JSON对象。 我在开发Temploracraft这个简历工具时遇到了这样的问题。这个工具会接收用户上传的文档,并将其转换成应用程序可以编辑的结构化数据。 输入的数据确实具有很大的不可预测性:有些是两列结构的PDF文件,有些表格其实并
阅读全文