使用 React 和 CouchDB 构建离线优先应用程序
大约三年前,我在(现已不复存在的)Manifold 博客上发表了一篇关于使用 React 和 CouchDB 创建离线优先应用程序的文章。除了该帖子不再可用的事实之外,考虑到它是如何构建在一个非常旧版本的 React 上的,它也非常过时。然而,我认为这篇文章的主题在今天仍然是一个非常值得关注的问题。
许多应用程序要求其用户具有持续的网络连接以避免丢失他们的工作。有多种策略,其中一些比其他策略更好,以确保用户即使在离线时也能继续工作,方法是在他们重新上线后同步他们的工作。该技术在三年内有了很大的改进,我仍然认为 CouchDB 是构建离线优先应用程序时值得考虑的工具。
再次和我一起探索 CouchDB 及其功能,因为我们构建了一个待读列表,这绝对不是变相的待办事项列表。
什么是 CouchDB?
CouchDB 是一个为同步而构建的 NoSQL 数据库。 CouchDB 引擎可以支持同一个数据库的多个副本(想想数据库服务器),并且可以通过与 git 相似的进程实时同步它们。这使我们能够在世界各地分发我们的应用程序,而不需要数据库成为限制因素。这些副本也不限于服务器。 PouchDB 等 CouchDB 兼容数据库允许您在浏览器或移动设备上同步数据库。这实现了真正的离线优先应用程序,用户在他们自己的本地数据库上工作,该数据库恰好在可能和需要时与服务器同步。同步取决于选择的确切复制协议,并且可以手动触发。使用 PouchDB,当任何更改触发同步时就会发生这种情况。当然,服务器必须启动才能进行同步!如果副本离线,复制将暂停,这将启用我们将在下面讨论的_eventual_一致性。
当您在 CouchDB 中创建文档时,它会创建修订版,以便与副本进行合并和冲突检测。当数据库同步时,CouchDB 会比较修订和更改历史,尝试合并文档,如果不能,则触发合并冲突。
{
"_id":"SpaghettiWithMeatballs",
"_rev":"1–917fa2381192822767f010b95b45325b",
"_revisions":{
"ids":[
"917fa2381192822767f010b95b45325b"
],
"start":1
},
"description":"An Italian-American delicious dish",
"ingredients":[
"spaghetti",
"tomato sauce",
"meatballs"
],
"name":"Spaghetti with meatballs"
}
进入全屏模式 退出全屏模式
所有这些都是通过内置的 REST API 和 Web 界面处理的。 Web 界面可用于管理您的所有数据库及其文档,以及用户帐户、身份验证,甚至文档附件。如果在数据库同步时发生合并冲突,此界面使您能够手动处理这些合并冲突。它还有一个 JavaScript 引擎,用于支持视图和数据验证。

早在 2019 年,CouchDB 就被用来为 CouchApps 提供动力。简而言之,您可以使用 CouchDB 及其 JavaScript 引擎构建整个后端。我是 CouchApps 的忠实粉丝,但 CouchDB 的局限性——以及仅数据库后端——使得 CouchApps 远没有更传统的数据库+应用程序服务器强大。随着我们走向 v4(在撰写本文时),CouchDB 已经更接近于 Firebase 或 Hasura 的替代品,而不是后端的替代品。
那么,我应该把所有东西都切换到 CouchDB 吗?
与软件工程中的一切一样,它_depends_。
CouchDB 为数据一致性不如_eventual_ 一致性重要的应用程序创造了奇迹。 CouchDB 不能保证您的所有实例都将持续同步。它可以保证数据_最终_将保持一致,并且至少有一个实例将始终可用。它被 IBM、联合航空公司、NPM、BBC 和 CERN 的 LHC 科学家(是的,that CERN)等大公司使用或使用过。所有关心可用性和弹性的地方。
在许多其他情况下,CouchDB 也可能对您不利。它不关心确保同步之外的实例之间的数据是一致的,因此不同的用户可能会看到不同的数据。它也是一个 NoSQL 数据库,具有所有优点和缺点。最重要的是,第三方托管有些不一致;您拥有 Cloudant 和 Couchbase,但除此之外,您只能靠自己了。
在选择数据库系统之前,有很多事情需要考虑。如果您觉得 CouchDB 非常适合您,那么是时候系好安全带了,因为您的旅程很棒。
PouchDB 呢?
PouchDB是一个可在浏览器和服务器上使用的 JavaScript 数据库,深受 CouchDB 的启发。得益于出色的 API,它已经是一个强大的数据库,但它与一个或多个数据库同步的能力使其成为支持离线应用程序的明智之选。通过启用 PouchDB 与 CouchDB 同步,我们可以专注于直接在 PouchDB 中写入数据,它最终会负责将这些数据与 CouchDB 同步。我们的用户将继续访问他们的数据,无论数据库是否在线。
构建离线优先应用
现在我们知道了 CouchDB 是什么,让我们使用 CouchDB、PouchDB 和 React 构建一个离线优先的应用程序。在为最初的文章搜索 CouchDB + React 时,我发现了很多待办事项应用程序。我开玩笑说我正在创建一个阅读应用程序,我觉得我很有趣,同时声称要阅读的书籍列表与要完成的任务列表完全不同。为了保持一致性,让我们保持这个笑话继续存在。此外,待读应用与待办应用完全不同。
此应用程序的所有代码都可以在 GitHub 上找到:https://github.com/SavoirBot/definitely-not-a-todo-list。随意跟随代码。
我们需要的第一件事是我们的应用程序的 JavaScript 项目。我们将使用Snowpack作为我们的捆绑器。打开位于项目目录中的终端并输入npx create-snowpack-app react-couchdb --template @snowpack/app-template-minimal。 Snowpack 将为我们的 React 应用程序创建一个骨架并安装所有依赖项。完成工作后,键入cd react-couchdb进入新创建的项目目录。create-snowpack-app在设置项目方面与create-react-app非常相似,但它的侵入性要小得多(您甚至不需要在任何时候使用弹出)。
要完成项目设置,请使用以下命令安装所有依赖项:
npm install react react-dom pouchdb-browser
进入全屏模式 退出全屏模式
有了我们的项目,我们现在需要一个 CouchDB 数据库。为了简单起见,让我们使用docker-compose在docker 容器中启动它,这将允许我们非常轻松地启动和停止它。创建一个docker-compose.yaml文件并将此内容复制到其中:
# docker-compose.yaml
version: '3'
services:
couchserver:
image: couchdb
ports:
- "5984:5984"
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=secret
volumes:
- ./dbdata:/opt/couchdb/data
进入全屏模式 退出全屏模式
该文件定义了一个 CouchDB 服务器,其中包含一些变量来设置管理员用户名和密码。我们还定义了一个卷,它将 CouchDB 数据从容器内部同步到名为dbdata的本地文件夹。这将有助于在我们关闭容器时保留我们的数据。
在您启动此项目的同一文件夹中打开的终端中键入docker compose up -d。拉出后,容器将启动,并使您的 CouchDB 数据库在http://localhost:5984下可用。在浏览器中或使用 curl 访问此 URL 应返回 JSON 欢迎消息。为了使我们的本地应用程序工作,我们必须在我们的数据库上配置 CORS。在浏览器中访问http://localhost:5984/_utils下的 CouchDB 仪表板。使用配置的管理员用户名和密码,然后单击 Settings 选项卡,然后单击 CORS 选项卡,然后单击 Enable CORS 并选择 All domain ( * ) .

为我们的应用配置 PouchDB
对于这个项目,我们将使用一些钩子来配置 PouchDB 并获取我们的待读项目。让我们从配置 PouchDB 本身开始。创建一个名为hooks的目录,然后使用此代码在此目录中创建一个名为usePouchDB.js的文件。
// hooks/usePouchDB.js
import { useMemo } from 'react';
import PouchDB from 'pouchdb-browser';
const remoteUrl = 'http://localhost:5984/reading_lists';
export const usePouchDB = () => {
// Create the local and remote databases for syncing
const [localDb, remoteDb] = useMemo(
() => [new PouchDB('reading_lists'), new PouchDB(remoteUrl)],
[]
);
return {
db: localDb,
};
};
进入全屏模式 退出全屏模式
这个钩子使用来自 React 的useMemo钩子来创建两个新的 PouchDB 实例。第一个实例是本地数据库,安装在浏览器中,名为reading_lists。第二个实例是一个远程实例,它连接到我们的 CouchDB 容器。由于我们只需要应用程序中的本地实例,因此我们只返回一个带有该本地数据库的对象。
现在让我们为这两个数据库配置同步。返回usePouchDB.js并使用这些更改更新代码。
// hooks/usePouchDB.js
import { useMemo, useEffect } from 'react';
import PouchDB from 'pouchdb-browser';
const remoteUrl = 'http://localhost:5984/reading_lists';
export const usePouchDB = () => {
// Previous code omitted for brevity
const [localDb, remoteDb] = useMemo(...);
// Start the sync in a separate effect, cancel on unmount
useEffect(() => {
const canceller = localDb
.sync(remoteDb, {
live: true,
retry: true,
});
return () => {
canceller.cancel();
};
}, [localDb, remoteDb]);
return {
db: localDb,
};
};
进入全屏模式 退出全屏模式
我们添加了一个useEffect挂钩来启动本地和远程数据库之间的双向同步。同步使用live和retry选项,这会导致 PouchDB 与远程数据库保持连接,而不是只同步一次,如果无法同步,则重试。此效果返回一个函数,如果组件在同步时碰巧卸载,该函数将取消同步。
每当 CouchDB 数据库断开连接或不可用时,向我们的用户显示一条小消息会很好。 PouchDB 的同步提供了我们可以监听的事件,例如paused和active,文档中提到可能会在数据库不可用时触发。但是,这些钩子仅与同步数据的行为有关。如果什么都不需要同步,则无论远程数据库的状态如何,同步都会触发paused事件,然后忽略远程数据库的状态。相反,我们需要定期对数据库使用info方法来检查远程数据库的状态。
// hooks/usePouchDB.js
import { useMemo, useEffect, useState } from 'react';
import PouchDB from 'pouchdb-browser';
const remoteUrl = 'http://localhost:5984/reading_lists';
export const usePouchDB = () => {
const [alive, setAlive] = useState(false);
// Previous code omitted for brevity
const [localDb, remoteDb] = useMemo(...);
useEffect(...);
// Create an interval after checking the status of the database for the
// first time
useEffect(() => {
const cancelInterval = setInterval(() => {
remoteDb
.info()
.then(() => {
setAlive(true);
})
.catch(() => {
setAlive(false);
});
}, 1000)
});
return () => {
clearTimeout(cancelInterval);
};
}, [remoteDb]);
return {
db: localDb,
ready,
alive,
};
};
进入全屏模式 退出全屏模式
我们为变量alive添加了状态挂钩,它将跟踪远程数据库是否可用。接下来,我们添加了另一个useEffect挂钩来设置一个间隔,该间隔将每秒调用一次 info 方法来检查数据库是否还活着。和之前的useEffect一样,我们需要确保取消组件卸载的时间间隔,以避免内存泄漏。
获取所有文档
使用我们的 PouchDB 钩子,我们准备创建下一个钩子,用于从本地数据库中获取所有要读取的文档。让我们在hooks目录中创建另一个名为useReadingList.js的文件,用于文档获取逻辑。
// hooks/useReadingList.js
import { useEffect, useState } from 'react';
export const useReadingList = (db, isReady) => {
const [loading, setLoading] = useState(true);
const [documents, setDocuments] = useState([]);
// Function to fetch the data from pouchDB with loading state
const fetchData = () => {
setLoading(true);
db.allDocs({
include_docs: true,
}).then(result => {
setLoading(false);
setDocuments(result.rows.map(row => row.doc));
});
};
// Fetch the data on the first mount, then listen for changes (Also listens to sync changes)
useEffect(() => {
fetchData();
const canceler = db
.changes({
since: 'now',
live: true,
})
.on('change', () => {
fetchData();
});
return () => {
canceler.cancel();
};
}, [db]);
return [loading, documents];
};
进入全屏模式 退出全屏模式
这个钩子做了一些事情。首先,我们创建一些状态变量来保持加载状态和我们获取的文档。接下来,我们定义一个函数来使用allDocs从数据库中获取文档,然后在加载后将文档添加到我们的状态变量中。我们使用allDocs函数的include_docs选项来确保我们获取整个文档。默认情况下,allDocs只会返回 ID 和修订版本。include_docs确保我们获得所有数据。
然后我们创建一个useEffect钩子来启动数据获取过程,然后监听数据库的变化。每当我们通过应用程序更改某些内容,或者同步更改本地数据库中的数据时,都会触发change事件,我们将再次获取数据。live选项确保这在应用程序的整个生命周期中持续发生,或者直到组件卸载时取消侦听器。
放在一起
准备好钩子后,我们现在需要构建 React 应用程序。首先,打开snowpack创建的index.html文件,将<h1>Welcome to Snowpack!</h1>替换为<div id="root"></div>。接下来,将 snowpack 创建的index.js文件重命名为index.jsx并将该文件的内容替换为以下代码:
// index.jsx
import React from 'react';
import { createRoot } from 'react-dom/client';
const App = () => null;
createRoot(document.getElementById('root')).render(<App />);
进入全屏模式 退出全屏模式
您现在可以使用npm run start启动 snowpack 应用程序,这应该会启动应用程序,为您提供在浏览器中打开的 URL,并显示一个空白屏幕(正常,因为我们从我们的应用程序返回null!)。让我们开始构建我们的App组件。
// index.jsx
// rest of the code remove for brevity
import { usePouchDB } from '../hooks/usePouchDB';
import { useReadingList } from '../hooks/useReadingList';
const App = () => {
const { db, ready, alive } = usePouchDB();
const [loading, documents] = useReadingList(db);
return (
<div>
<h1>Definitely not a todo list</h1>
{!alive && (
<div>
<h2>Warning</h2>
The connection with the database has been lost, you can
still work on your documents, we will sync everything once
the connection is re-established.
</div>
)}
{loading && <div>loading...</div>}
{documents.length ? (
<ul>
{documents.map(doc => (
<li key={doc._id}>
{doc.name}
</li>
))}
</ul>
) : (
<div>No books to read added, yet</div>
)}
</div>
);
};
进入全屏模式 退出全屏模式
应用程序加载我们的 PouchDB 钩子,然后我们的钩子加载所有要读取的项目。然后,我们将返回一个基本的 HTML 结构,如果数据库碰巧断开连接,它会显示一条警告消息,当我们获取文档时会显示一条加载消息,最后是从数据库中读取的项目。_id属性是 CouchDB/PouchDB 中的内部唯一 ID 属性,它为我们的列表项提供了完美的key。
显示所有项目非常好,但是为了能够显示任何项目,我们需要一种方法来将新的待读项目添加到我们的数据库中。让我们回到我们的index.jsx文件并在其中添加此代码。
// index.jsx
import React, { useState } from 'react';
// rest of the code remove for brevity
import { usePouchDB } from '../hooks/usePouchDB';
import { useReadingList } from '../hooks/useReadingList';
// Component to add new books with a controlled input
const AddReadingElement = ({ handleAddElement }) => {
const [currentName, setCurrentName] = useState('');
const addBook = () => {
if (currentName) {
// If the currentName has data, clear it and add a new element.
handleAddElement(currentName);
setCurrentName('');
}
};
return (
<div>
<h2>Add a new book to read</h2>
<label htmlFor="new_book">Book name</label>
<input
type="text"
id="new_book"
value={currentName}
onChange={event => setCurrentName(event.target.value)}
/>
<button onClick={addBook}>Add</button>
</div>
);
};
const App = () => {
const { db, ready, alive } = usePouchDB();
const [loading, documents] = useReadingList(db);
const handleAddElement = name => {
// post sends a document to the database and generates the unique ID for us
db.post({
name,
read: false,
});
};
return (
<div>
{/* rest of the code remove for brevity */}
<AddReadingElement handleAddElement={handleAddElement} />
</div>
);
};
进入全屏模式 退出全屏模式
我们在此文件中添加了一个新组件,用于添加要阅读的新书。一个单独的组件有助于使结构更清晰,可以随意将其提取到另一个文件中。该组件使用状态钩子来控制一个输入,然后在点击Add按钮时触发本地数据库上的post方法。
返回您的浏览器并尝试添加一些要阅读的书籍,单击按钮时它们应该会显示在列表中。
最后,能够将书籍设置为已读或删除一些我们不再想要的书籍会很棒。再次打开index.jsx文件并在其中添加此代码。
// index.jsx
// rest of the code remove for brevity
const App = () => {
const { db, ready, alive } = usePouchDB();
const [loading, documents] = useReadingList(db);
// rest of the code remove for brevity
const handleAddElement = name => ...;
// The remove method removes a document by _id and rev. The best way to send
// both is to send the document to the remove method
const handleRemoveElement = element => {
db.remove(element);
};
// The remove method updates a document, replacing all fields from that document.
// like _id and rev, it needs both to find the document.
const handleToggleRead = element => {
db.put({
...element,
read: !element.read,
});
};
return (
<div>
{/* rest of the code remove for brevity */}
{documents.length ? (
<ul>
{documents.map(doc => (
<li key={doc._id}>
<input
type="checkbox"
checked={doc.read}
onChange={() => handleToggleRead(doc)}
id={doc._id}
/>
<label htmlFor={doc._id}>{doc.name}</label>
<button
onClick={() => handleRemoveElement(doc)}
>
Delete
</button>
</li>
))}
</ul>
) : (
<div>No books to read added, yet</div>
)}
{/* rest of the code remove for brevity */}
</div>
);
};
进入全屏模式 退出全屏模式
我们在App中添加了两个函数。 update 方法使用put方法来更新一个文档。本地数据库上的post方法创建一个没有唯一 ID 的文档,并在插入元素后生成它。put既可以更新也可以插入,但是需要一个ID和修订版才能选择文档到put。在我们的例子中,我们使用现有文档使用它,切换read属性。第二个函数对文档使用remove方法,确保 PouchDB 可以找到该文档并将其删除。
最后,我们替换了文档列表以添加一个复选框和一个按钮。当复选框被切换时,更新方法将触发并切换read属性。该按钮将在单击时触发 remove 方法以删除元素。
返回浏览器并尝试切换复选框或删除元素。它应该可以正常工作。
测试离线优先能力
现在,是时候在数据库离线时测试应用程序了。打开你的项目所在的新终端(以免杀死npm run start命令)并输入docker compose stop couchserver。您应该会立即看到警告消息出现在 React 应用程序中。然而,您仍然应该能够与应用程序交互并添加/更改/删除文档。键入docker compose start couchserver以重新启动数据库并在警告消息消失后重新加载页面。您所做的每项更改都应该仍在应用程序中,并且您应该能够在 CouchDB 仪表板中看到更改。
结论
我们现在有一个以离线优先为重点的功能性应用程序。无论数据库的状态如何,我们的用户都可以继续添加要阅读的书籍并设置他们的阅读状态。该消息是一个额外的好处,它可以帮助我们的用户知道在我们正确同步应用程序之前不要清除他们的缓存。
当然,直接从客户端操作数据库可能不是大多数应用程序的最佳解决方案。特别是如果我们在没有从数据库进行任何验证的情况下同步该数据。如果您想在本系列的第二篇文章中实现一个用于在离线优先应用程序中验证和同步数据的后端,请在下面的评论中告诉我。
我很想听听您的想法 - 请评论或分享帖子
我们正在构建 Savoir,因此请留意我们的网站上的功能和更新,网址为 savoir.dev。如果您想订阅更新或 beta 测试,请发送邮件至info@savoir.dev!
Savoir 是法语中知识的意思,发音为 sɑvwɑɹ。
更多推荐
所有评论(0)