作为一名网页前端工程师,我们开发时默认用户处于互联网在线状态。然而,在开发桌面应用与移动设备应用时,我们必须考虑离线使用、甚至离线优先的场景。也即是
离线优先应用
(offline-first-app)
PouchDB简介
PouchDB
是一款使用javascript
开发的开源数据库,他可以运行在浏览器中与CouchDB
数据库同步。CouchDB
的特色是能在多个数据库实例中同步数据
。意味着我们可以在前端代码中使用PouchDB
进行数据库操作,并且与远程的CouchDB
同步。由此我们可以在离线环境下操作数据,当恢复网络连接,又能与远程数据库同步。
实战一款离线优先的todo app
1. 安装CouchDB
首先我们安装CouchDB用于同步本地数据至远程,你可以在本地安装或者有云服务器更好。安装流程参见官方文档。我使用docker
安装,供参考。
yaml
# docker-compose.yaml
version: '3'
services:
couchServer:
image: couchdb
ports:
- "5984:5984"
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=admin
volumes:
- ./couchDB-data:/opt/couchdb/data
bash
docker compose up -d
安装完成后,访问http://localhost:5984/_utils,输入你创建的账号密码即可登录web管理页。
CouchDB注意事项
我们在web管理页中创建一个名为test-db 的测试数据库,然后点击Create Document
按钮来创建第一条数据。CouchDB会自动生成_id
字段用于保证数据的唯一性,这是一个UUID。但官方并不建议依赖此id,但我们学习阶段就拿他当唯一id用了。注:如果遇到跨域问题,可以在设置中开启cors,并允许所有域名
创建完成后再查看数据,发现又多了个_rev
字段,且以1-
开头。_rev
意为revision ,是CouchDB用于追踪数据版本的字段。此时更新数据,_rev
就会变为2-
开头。
2. 创建前端界面
现在让我们进入正题,先使用create-react-app或者任意方式创建一个react+tailwind应用。然后安装PouchDB,ts使用第二条命令安装类型定义。
bash
npm install pouchdb-browser --save
npm install @types/pouchdb-browser --save --save-exact
tsx
// App.tsx
import React, { useState } from 'react'
type todoItem = {
content: string
_id: string
}
const App = () => {
const [todoList, setTodoList] = useState<todoItem[]>([{ content: '测试', _id: 'test' }])
return (
<div className="w-screen h-screen bg-slate-200 flex justify-center items-center">
<div className="w-1/2 h-5/6 bg-slate-400 rounded-md shadow-lg overflow-y-scroll">
<h1 className="text-center font-bold text-lg mt-2">Todo Demo</h1>
<button className="bg-green-400 text-slate-100 border-none cursor-pointer rounded-sm ml-5">
添加
</button>
<ul className="mt-2 px-5">
{todoList.map((item, index) => (
<li
key={item.content + index}
className="w-full flex items-center mb-2"
>
<span className="text-md">{index + 1}.</span>
<input
className="mx-2 flex-1 bg-slate-400 border-none outline-none"
defaultValue={item.content}
/>
<button className="bg-red-300 text-slate-100 border-none cursor-pointer rounded-sm">
删除
</button>
</li>
))}
</ul>
</div>
</div>
)
}
export default App
创建连接数据库
diff
import React, { useEffect, useMemo, useState } from 'react'
+ import PouchDB from 'pouchdb-browser'
type todoItem = {
content: string
_id: string
}
const App = () => {
const [todoList, setTodoList] = useState<todoItem[]>([{ content: '测试', _id: 'test' }])
+ // 远程数据库地址
+ const remoteUrl = 'http://localhost:5984/todo-db'
+ const [localDB, remoteDB] = useMemo(
+ () => [
+ new PouchDB('todo-db'),
+ new PouchDB(remoteUrl, {
+ auth: { username: 'admin', password: 'admin' }
+ })
+ ],
+ []
+ )
+ useEffect(() => {
+ const sync = localDB.sync(remoteDB, {
+ live: true,
+ retry: true
+ })
+
+ return () => {
+ sync.cancel()
+ }
+ }, [localDB, remoteDB])
return ......
}
export default App
通过new PouchDB
,我们创建了一个本地的PouchDB数据库与一个远程的同名数据库。使用useMemo
包裹,以防止重复创建。再使用useEffect
进行监听,并且通过sync.cancel()
在组件卸载时关闭连接。localDB.sync
方法将同步本地数据库与远程数据库的数据。
注意:离线优先应用中,我们一切对数据库的操作都在本地数据库中进行,可以将远程数据库理解为一个本地数据库的远程备份。
PouchDB的增删改查
查询
diff
const App = () => {
......
+ const getAllDocs = async () => {
+ try {
+ const allDocs = await localDB.allDocs<todoItem>({
+ include_docs: true
+ })
+ setTodoList(
+ allDocs.rows.map(item => ({
+ content: item.doc?.content || '',
+ _id: item.doc?._id || '' // _id用于在更新时查询数据
+ }))
+ )
+ } catch (err) {
+ console.log('获取所有文档报错', err)
+ }
+ }
useEffect(() => {
const sync = localDB.sync(remoteDB, {
live: true,
retry: true
})
+ getAllDocs()
return () => {
sync.cancel()
}
}, [localDB, remoteDB])
return ......
}
export default App
需要注意的是localDB.allDocs()
方法返回的是promise ,该函数的入参可以指定分页、数据量、排序等,具体参考官方文档。我们将文档的_id
加入了todo list中,这是为了方便后续进行文档更新删除等操作需要通过id来确定具体文档。
新增
ts
const handleAddTodo = async () => {
// 新增id方法需优化
const newItem = { content: '', _id: new Date().toISOString() }
setTodoList(prev => prev.concat(newItem))
try {
await localDB.put(newItem)
} catch (err) {
console.log('添加方法报错', err)
setTodoList(todoList)
}
}
新增文档时,需要前端生成一个唯一id,我们先简单用时间来生成。具体使用时建议使用uuid。
更新
tsx
const onTodoItemChange = async (item: todoItem, value: string) => {
try {
const doc = await localDB.get(item._id)
await localDB.put({ ...doc, content: value })
} catch (err) {
console.log('更新方法报错', err)
}
}
......
<input
onBlur={e => onTodoItemChange(item, e.target.value)}
/>
文档更新时,先通过_id
获取到文档内容,然后将更新的内容写入并调用put
方法更新数据库。
删除
ts
const handleDelete = async (todoItem: todoItem) => {
try {
const itemToDel = await localDB.get(todoItem._id)
await localDB.remove(itemToDel)
setTodoList(prev => prev.filter(item => item._id !== todoItem._id))
} catch (err) {
console.log('删除方法报错', err)
}
}
类似于更新文档,删除时同样先通过_id
获取到文档内容再进行删除。其实remove
方法并不是真的将文档从数据库中删除,实际发生的是给文档添加一个_deleted
属性,并设置为true
。所以,如果使用更新的逻辑将_deleted
设置为true
可以达到一样的效果。
3. 前端优化------将数据库创建连接抽取为hook
以上,我们的应用就完成了。即便关闭远程数据库,依然可以正常操作应用,重新打开之后又能更新数据。最后我们优化下代码,将数据库的创建和连接抽成hook方便复用。
ts
import { useMemo, useEffect } from 'react'
import PouchDB from 'pouchdb-browser'
interface Config {
dbName?: string
}
const usePouchDB = (config?: Config) => {
const remoteUrl = config?.dbName
? `http://localhost:5984/${config.dbName}`
: 'http://localhost:5984/todo-db'
const [localDB, remoteDB] = useMemo(
() => [
new PouchDB(config?.dbName || 'todo-db'),
new PouchDB(remoteUrl, {
auth: { username: 'admin', password: 'admin' }
})
],
[]
)
useEffect(() => {
const sync = localDB.sync(remoteDB, {
live: true,
retry: true
})
return () => {
sync.cancel()
}
}, [localDB, remoteDB])
return {
localDB,
remoteDB
}
}
export default usePouchDB
tsx
//App.tsx
const { localDB, remoteDB } = usePouchDB()
结语
本文简单介绍了使用PouchDB创建离线优先应用的指南,需要优化的点还有很多,如同步冲突处理、多用户权限管理、代码细节优化如敏感数据加密加盐、唯一id生成方法等...欢迎大家讨论指点。