使用PouchDB创造一款离线优先的Todo app

作为一名网页前端工程师,我们开发时默认用户处于互联网在线状态。然而,在开发桌面应用与移动设备应用时,我们必须考虑离线使用、甚至离线优先的场景。也即是离线优先应用(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生成方法等...欢迎大家讨论指点。

相关推荐
蟾宫曲3 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心3 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455663 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029404 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
魏时烟5 小时前
css文字折行以及双端对齐实现方式
前端·css
2401_882726486 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203986 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github
胡西风_foxww6 小时前
【ES6复习笔记】迭代器(10)
前端·笔记·迭代器·es6·iterator
前端没钱6 小时前
探索 ES6 基础:开启 JavaScript 新篇章
前端·javascript·es6
m0_748255267 小时前
vue3导入excel并解析excel数据渲染到表格中,纯前端实现。
前端·excel