2025 年的 Offline-First 应用:CouchDB 和 PouchDB 的实践
[正文内容]
几周前,我们提供了快速简便地托管您自己的 CouchDB 的工具:CouchDB Minihosting! 本周,我们将提供一个可以在该安装上部署的演示应用程序,以便您可以零麻烦地试用该部分。最重要的是,可以将此视为使用 CouchDB 和 PouchDB 进行 Offline-First 开发的最新最佳实践演示应用。我们使用 Svelte 5 以及 Vite 作为构建工具,并使用 Pico.css 来进行样式设计。
Hello Pouchnotes #
Pouchnotes 是一个简单但相当完整的 250 行演示应用程序,允许用户登录并在线或离线做笔记,并自动将它们同步到远程 CouchDB,以及他们登录的任何其他客户端。它是一个实时、多设备的笔记应用程序。
它旨在作为与 CouchDB Minihosting 实例一起部署的演示,因此您可以试用部署工具,并体验 CouchDB 实例及其复制功能。
Pouchnotes 看起来有点像这样:
Pouchnotes 自述文件 将指导您完成必要的设置步骤,并且 CouchDB Minihosting 部署自述文件 将引导您完成实际的部署过程。
Pouchnotes 源代码有大量注释,但如果您对更高级别的演练以及使用 CouchDB 和 PouchDB 的一些通用技巧感兴趣,请继续阅读!
- 我们的技术栈: CouchDB + PouchDB + Vite + Svelte 5 + Pico.css
- Offline-First 应用中的数据流
- PouchDB 和 TypeScript
- 关于将 PouchDB 与 Vite 结合使用的注意事项
- 结论
我们的技术栈: CouchDB + PouchDB + Vite + Svelte 5 + Pico.css#
再一次,我们的技术栈旨在实现简单性和速度:
- PouchDB 提供了一个简洁、类型良好的 CouchDB 接口和一个本地的浏览器内数据库。
- Svelte,现在是版本 5,仍然是我们最喜欢的框架,用于易于阅读、紧凑的演示应用程序。
- Pico.css 是一个无类的 CSS 库,它看起来不错,非常适合此类应用程序,并且节省了大量时间。
- Vite,好吧,它就在名称中。快速构建工具。
- …而 CouchDB 是我们首先来到这里的目的。
Offline-First 应用中的数据流#
像这样的应用程序最关键的方面是数据如何在应用程序内部以及远程数据库和本地数据库之间移动。 让我们从那里开始。
CouchDB <-> PouchDB 数据流#
在 Offline-First 应用程序中,关键在于应用程序在 未连接到网络 时仍然可以工作,即处于离线状态。 为了使这项工作正常进行,我们需要避免直接与任何远程服务器通信,因为它们可能不可用。 这就是 PouchDB 及其本地数据库的用武之地:我们所有的 数据库读取和写入都将转到 本地 数据库。 客户端和服务器之间唯一的的数据交换是通过复制协议发生的:我们让两个数据库 PouchDB 和 CouchDB 通过启动连续复制来自行解决同步问题。 它们在这方面非常擅长,无论网络可用性如何,我们都想借此机会劝阻您尝试自己实现这种事情😅
总结:
- 我们仅从本地 PouchDB 读取和写入。
- 我们在本地 PouchDB 和远程 CouchDB 中的用户数据库之间启动连续的双向复制,这意味着两者最终将保持一致。
- 如果网络出现故障或数据中心发生故障,则复制协议将处理它并继续尝试,直到成功为止。
- 任何用户都可以从多个设备登录,并且这将在所有设备上正常运行。
Offline-First 身份验证#
所有 与远程 DB 的连接都通过本地数据库? 好吧,几乎:我们必须针对单一事实来源进行身份验证,因此该步骤需要连接到远程 CouchDB。 但是,一旦我们有了会话,即使我们无法再访问远程 CouchDB 来验证该会话,我们也会让当前用户访问其本地 DB。 只有当我们 确实 访问了远程 DB 并且它告诉我们该会话无效时,我们才会阻止他们访问其本地数据。
我们需要注意的一点是,我们无法知道上次登录的用户是谁,因为 CouchDB 会话 cookie 不包含用户名,并且我们可能无法询问 CouchDB 该 cookie 属于哪个用户,因为我们可能处于离线状态。 此处常见的解决方法是将用户名保存在 LocalStorage 中,并在用户注销或 CouchDB 明确告知我们该会话无效时立即清除它。
前端内部的数据流#
Svelte 5 提供了一个不错的,低摩擦的反应式 $state()
抽象(他们称这些为 [runes](https://neighbourhood.ie/blog/2025/03/26/https:/svelte.dev/docs/svelte/what-are-runes))。 当 $state
更新时,受其影响的任何 UI 元素都会重新呈现。 这很方便,因此我们将所有笔记都放在其中之一中。
当应用加载时,我们使用 allDocs()
从本地 PouchDB 获取所有笔记,然后将它们放入 $state
中。
我们已经有了初始状态,那么实时更改又如何呢,例如,来自另一个客户端? 为此,CouchDB 和 PouchDB 提供了一个名为更改提要的事件发射器。 每当数据库中的文档更改时,都会触发一个事件。 由于远程数据库和本地数据库通过复制连接,因此我们将 更改侦听器 附加到本地 PouchDB 并对传入的更改做出反应。 每个更改都与此决策树匹配:
- 更改是否影响我们商店中已有的笔记?
- 是: 这是一个删除吗?
- 是: 我们使用
splice()
将笔记从$state
中删除。 它也会从 UI 中消失。 - 否: 这只是一个编辑。 我们将
$state
中的笔记替换为来自更改的笔记。 该笔记将在 UI 中更新。
- 是: 我们使用
- 否: 这是一个新笔记。 我们使用
push()
将其推送到$state
中,它将出现在 UI 中。
- 是: 这是一个删除吗?
我们完成了。 我们可以在多个设备上打开应用,以同一用户身份登录,并且我们所做的所有更改都将传播到所有地方,无论我们是否在线。
我们不会在这里深入探讨冲突这个可能存在的巨大兔子洞,因为我们已经发表了一个广泛的 分为四个部分的博客文章系列,其中详细介绍了如何避免和解决冲突。 对于此应用,我们将依靠“用户即单例”的想法,并依靠每个用户大致了解他们在覆盖自己的数据时的意图。
PouchDB 和 TypeScript#
PouchDB 和 TypeScript 是非常好的朋友,但是很容易忽略它们之间的配合有多好。 通常:大多数 PouchDB 方法(例如 allDocs
)都接受类型参数(尖括号中的位)。 假设您的数据库中只有一种文档类型,我们将像这样告诉 PouchDB:
type Note = {
text: string
type: "note"
}
const allDocsResult = await localDB.allDocs<Note>({include_docs: true})
// ^^^^ 类型参数
请注意,我们正在使用将 type
键添加到每个 CouchDB 文档1 的约定,这只是从长远来看使处理数据变得更加简单。 总之:
现在,allDocsResult
将被键入为 PouchDB.Core.AllDocsResponse<Note>
,因此您将拥有 allDocs
response 的所有预期属性,例如 offset, update_seq, total_rows, rows
,并且 rows
中的所有 docs
将被正确键入为 PouchDB.Core.ExistingDocument<Note & PouchDB.Core.AllDocsMeta>
。 这看起来有点令人生畏,但深入研究 PouchDB 类型定义表明,这完全符合预期:
PouchDB.Core.ExistingDocument<Note>
提供了Note
的类型加上 CouchDB 属性_id
和_rev
PouchDB.Core.AllDocsMeta
提供了可选的 CouchDB 属性_attachments
和_conflicts
,如果您将conflicts: true
作为选项传递给allDocs
,则可以获取它们
PouchDB.Core.ExistingDocument<{}>
可能会成为您最常用的类型,因为这是您定义和从 PouchDB 或 CouchDB 获取的文档,然后在您的应用程序中的某个地方存储或使用它们的方式。 例如,我们的 Svelte 5 $store
rune 用于笔记文档的类型定义如下:
let notes: PouchDB.Core.ExistingDocument<Note>[] = $state([])
如您所见,我们将 整个 CouchDB 文档(包括 _id
和 _rev
)存储在我们的应用状态中。 我们不需要这些值用于 UI,但是如果我们想要修改或删除文档,则 确实 需要它们:如果没有这两个信息,您无法在 CouchDB 中更新文档。 这就是为什么我们的应用状态中不仅有 Note[]
,而且有 PouchDB.Core.ExistingDocument<Note>[]
。
现在,如果您的数据库中只有一种文档类型,那么一切都很好,但是让我们解决一下房间里的大象:
如果我有 不止一种 文档类型怎么办?#
这不适用于 Pouchnotes,也不会在应用代码中,但是在许多 CouchDB 数据库中,您会发现多种类型的文档。 这意味着默认情况下,allDocs
或更改提要之类的方法可能会返回其中的任何一种。 有几种方法可以解决此问题,但最常见的方法是:
1. 请求所有文档类型,并在以后对其进行排序#
假设我们的数据库同时包含 Note
和 Todo
类型的文档,则 allDocs()
可能会返回其中任何一种,因此我们应该将此告知 PouchDB。 一种不错的,可重用的方法是 TypeScript 所有预期文档类型的联合:
type Note = {
text: string
type: "note"
}
type Todo = {
text: string
done: boolean
type: "todo"
}
type AnyDocumentType = Note | Todo
const allDocsResult = await localDB.allDocs<AnyDocumentType>({include_docs: true})
然后,从 allDocsResult
中选择您需要的文档类型的一种方法是:
notes = allDocsResult.rows?.reduce(
(result: PouchDB.Core.ExistingDocument<Note>[], row) => {
if(row.doc?.type === 'note') {
result.push(row.doc)
}
return result
}, []
)
我们在此处使用 reduce()
是因为我们本质上想要同时 map()
和 filter()
:如果我们映射行以将文档移动到新数组中,则我们的结果数组将在文档 不是 note
的地方包含 undefined
,因此我们需要在之后过滤掉所有这些内容,并且让 TypeScript 同意这一点可能会有点棘手。 此 reduce()
将对所有内容进行正确的类型化,而无需断言类型。 对于一种或两种文档类型来说,这是一种很好的方法,但是不能很好地扩展。
因此,让我们修复它,并抽象它以使其 DRY 且更易于使用:
// 首先,为文档定义一个通用类型保护
function isDocOfType<T extends AnyDocumentType>(
doc: AnyDocumentType | undefined,
type: T["type"]
): doc is T {
return !!doc && doc.type === type;
}
// 然后,是上面 reduce 方法的通用版本,它也可以采用类型参数
function getDocumentsByType<T extends AnyDocumentType>(
allDocsResult: PouchDB.Core.AllDocsResponse<AnyDocumentType>,
type: T["type"]
): PouchDB.Core.ExistingDocument<T>[] {
return allDocsResult.rows.reduce((result: PouchDB.Core.ExistingDocument<T>[], row) => {
if (isDocOfType<T>(row.doc, type)) {
result.push(row.doc);
}
return result;
}, []);
}
// 现在我们得到了非常简洁的用法:
const notes = getDocumentsByType<Note>(allDocsResult, 'note')
const todos = getDocumentsByType<Todo>(allDocsResult, 'todo')
经过大量实验,这似乎是目前解决此问题的最简洁的方法,尤其是如果您将这两个辅助函数放入 utils 文件中并且再也不看它们了😅 2
或者:如果您向所有 CouchDB 文档添加 type
值,则可以安全地断言它们的 TypeScript 类型,因为您可以确定它们是它们声称的样子。 有时会不赞成使用带有 as
的显式 TypeScript 断言,但是如果它们解决了您的问题,则可以在此上下文中安全地使用它们。 因此,也可以使用这样的方法:
const justTheDocs = allDocsResult.rows.map(row => row.doc)
const grouped = Object.groupBy(justTheDocs, (doc) => doc?.type || 'untyped')
const notesFromGroup = (grouped.note || []) as PouchDB.Core.ExistingDocument<Note>[]
2. 提前限制您请求的文档类型#
当然,我们也可以在请求文档类型时限制它们。 在应用程序中,我们将文档 type
编码为文档 _id
的前缀,例如 note::2025-03-07T18:52:49.617Z
。 我们可以在 allDocs()
查询中利用这一点:
const allDocsTodoResult = await localDB.allDocs<Note>({
include_docs: true,
startkey: 'note::',
endkey: 'note::\ufff0'
})
这会将结果限制为仅 _id
以 note::
开头的文档3。 这比方法 1 简单得多,并且如果您以个人组件仅处理一种文档类型(或至少很少)的方式封装了您的应用程序,则尤其有用。 例如,如果您需要在高级组件中使用许多不同的文档类型,则方法 1 似乎更合理。
我们不仅限于 allDocs
和主索引 _id
,我们还可以使用 pouchdb-find
插件,并使用查询选择器按类型获取文档。 如果我们正在监听更改提要,则可以使用 selector
选项仅监听来自特定文档类型的更改,前提是我们只关心更改的子集。 通常:这完全取决于您在做什么。
关于将 PouchDB 与 Vite 结合使用的注意事项#
将 PouchDB 与 Vite 结合使用需要两个小的额外步骤:
$ npm i events
- 将
define: { global: "window" }
添加到vite.config.ts
中的defineConfig
对象
结论#
我们希望 Pouchnotes 既是 CouchDB Minihosting 的有用伴侣,又是 CouchDB 和 PouchDB 的 Offline-First 应用程序当前最佳实践的指导性示例。 在过去的十年中,我们构建了许多这样的应用程序,如果您需要有关 Offline-First/Local-First 项目的任何商业帮助,我们随时可以提供帮助。 我们友好的销售团队将很乐意与您 进行通话。
脚注#
- 在讨论像这样的应用程序中的文档时,CouchDB 和 PouchDB 可以互换使用:这两种文档是相同的,并且文档的来源无关紧要。 没有简单的超集名称可以替换“CouchDB 和/或 PouchDB”文档,因此我们只使用其中一个。 ↩
- 您可能会想:“为什么不直接使用
Object.groupBy
?” 好吧,有一个类型安全的版本,您可以在此 [TypeScript Playground](https://neighbourhood.ie/blog/2025/03/26/https:/www.typescriptlang.org/play/#code/C4TwDgpgBAcg9saBeKBvAsAKClRAPYALigGdgAnASwDsBzLHUSYgImoQhawF8ssnoAFQCGAG1EgoKDNijCAtnACu1IlGpL5AIwjkGucBFbAxErpl6Z+hqAEFqIACJwAxpoirBNlPERQAPlAi4iB8mAJQAOLkymAAQiAAPIJQEAQeACYkUABKEC5w5BmJjgA0UAAKMZDkoADSECAAfOWOqenUWVAA1o1wAGZBTVJYqADadVA0QWOOALpzxClpiJ3ZeQVFJeV1wwD8OIJjc1DE1BAAbrqWWP0qLsCUcNRQtDFK8UnLHV0bhcVlSrVXT1RotKBtFaZbK9EADIZYAAUwnI5CWx3KsIAogRyMIHoViIiIKIIPIPGpBABKKTDI7zKnEaKxBLJVrDGQ4cgQYBKcgvFHkAB03IyShcEERyJcLnKlEQ8hpSA5+hwBWoZB6jSkWpAOIo+OAhUR8rJVIA3KqpoNEQBCfEuMawuY0zk4d0Op2NE4oMam+QnYTZYQOS2yHDcVKiEjQN3uuQyr0gOZCsBKEgACxNCotVss8e5vP5CZcYYj5VQkaDUXenzZEKauYsYXVmrISn6-WI9icrncnkMxx1Y30qAExAA5OxEBPyvg1BOEIhyMHuXJ5MIAF40WgT7ilUfjqATkwhWfr5SqYgARgAzAAmO-7w+GSfTiDn+eT8QKaso6AuBmlDiHuB6yGOr7Hu+n4rJO8iUBKchrtQCEfs+4FHiepggOeCiXmoD4ACwAKwkehcwts8ba8p2CReJAOpvCyICIu2nblBkri0lAnEuEKAhUlgrbAOoHDZCgbH9HRhhCu+UAAPTyVAAC0wy+BAxxCVRcCkkKohwLQiITupJCEOe74kIJmDCbg2HiaQNFSSA9EQPx2EKUpqlBNhmnWdpun6YZE7BBIpmfnZVJAA)上试用它。 可悲的是,当AnyDocumentType
区分联合中的类型本身包装在其他类型(例如 PouchDB 泛型)中时,这不能很好地工作。 如果您是 TypeScript 专家并且可以使其工作,请 务必 与我们联系,我们非常感兴趣! ↩ - 您可以在
allDocs()
中进行前缀搜索,即“给我所有_id
以note::
开头的文档” - 通过使用特殊的高 Unicode 字符\ufff0
。 这样做是因为 CouchDB/PouchDB_id
是 按字典顺序排序的。 您也不需要为此搜索构建或指定索引,因为主索引无论如何都在_id
上。 但是,您仅限于前缀搜索,因此必须从_id
的第一个字符开始。 这意味着您将信息编码到_id
中的顺序很重要,您无法搜索 不 在_id
开头的任何内容。 ↩