详解Electron中如何使用SQLite存储笔记
夜焱辰 人气:0前言
上一篇,我们使用 remirror 实现了一个简单的 markdown 编辑器。接下来,我们要学习如何去存储这些笔记。
当然了,你也可以选择不使用数据库,不过若是你以后需要将该应用上架到 mac Apple Store ,就需要考虑这个了。因为上架 mac 应用需要启用 sandbox,当你第一次访问笔记中的媒体文件时,都要打开选择文件的弹窗,通过让用户主动选择来授权访问沙箱外的媒体文件。不过,如果你的媒体文件在第一次选择插入文档时复制到 sandbox 中,以后访问优先从沙箱容器中读取,那是不需要授权的。虽然我也可以这么做,但这里考虑到后面的功能,还是选择使用数据库,当需要导出笔记时再从数据库中导出。
数据库的选择
Electron
应用中常使用的数据库是 SQLite
、IndexedDB
,IndexedDB
是在前端网页中去操作。有的文章里说 IndexedDB
的性能会比 SQLite
更好,大家看实际场景去选择使用。大多数桌面应用或者 App 需要使用数据库的时候一般都是用 SQLite
。
npm 上有两个最常用的 sqlite3
库,一是 better-sqlite3 ,一是 node-sqlite ,两种各有特点。前者是同步的 api ,执行速度快,后者是异步 api ,执行速度相对慢一点。值得注意的是,后者的编译支持 arm 机器,而且由于出的比较早,和其他库配合使用很方便。
安装
安装 node-sqlite
// 仓库名是 node-sqlite, package 名是 sqlite3 yarn add sqlite3
借助 Knex.js
简化数据库操作
Knex.js是为Postgres,MSSQL,MySQL,MariaDB,SQLite3,Oracle和Amazon Redshift设计的 SQL 查询构建器
安装 Knex.js
yarn add knex
创建表
现在,我们要开始设计数据库结构了。我们大概需要 3 张表,笔记本表,笔记表,还有一个媒体文件表。sqlite 支持 blob 数据类型,所以你也可以把媒体文件的二进制数据存到数据库中。这里我们就简单的记个 id ,把媒体文件存到沙箱内。
我们确定一下三张表的表名,notebooks
, notes
, media
, 然后看一下该如何使用 Knex.js
创建表
import { app } from "electron"; import knex, { Knex } from "knex"; import { join } from "path"; import { injectable } from "inversify"; @injectable() export class LocalDB { declare db: Knex; async init() { this.db = knex({ client: "sqlite", useNullAsDefault: true, connection: { filename: join(app.getPath("userData"), "local.db"), }, }); // 新建表 await this.sync(); } async sync() { // notebooks await this.db.schema.hasTable("notebooks").then((exist) => { if (exist) return; return this.db.schema.createTable("notebooks", (table) => { table.bigIncrements("id", { primaryKey: true }); table.string("name"); table.timestamps(true, true); }); }); // notes await this.db.schema.hasTable("notes").then((exist) => { if (exist) return; return this.db.schema.createTable("notes", (table) => { table.bigIncrements("id", { primaryKey: true }); table.string("name"); table.text("content"); table.bigInteger("notebook_id"); table.timestamps(true, true); }); }); // media await this.db.schema.hasTable("media").then((exist) => { if (exist) return; return this.db.schema.createTable("media", (table) => { table.bigIncrements("id", { primaryKey: true }); table.string("name"); table.string("local_path"); // 本地实际路径 table.string("sandbox_path"); // 沙盒中的地址 table.bigInteger("note_id"); table.timestamps(true, true); }); }); } }
这里我用了一个 IOC 库 inversify, 后面遇到 injectable
、inject
、ioc.get
等写法都是和这个有关,这里我就不多介绍了,具体用法可以看文档或其他文章。
注意:三张表中,note
和 media
都一个外键,这里我简化了,并没有用 api 去创建。
Service
数据库表创建完了,接下来我们为表的操作写相关服务,这一块我是参考传统后端 api 的设计去写的,有 Service
(数据库) 和 Controller
(业务),以 Notebook 为例:
import { inject, injectable } from "inversify"; import { LocalDB } from "../db"; interface NotebookModel { id: number; name: string; create_at?: string | null; update_at?: string | null; } @injectable() export class NotebooksService { name = "notebooks"; constructor(@inject(LocalDB) public localDB: LocalDB) {} async create(data: { name: string }) { return await this.localDB.db.table(this.name).insert(data); } async get(id: number) { return await this.localDB.db .table<NotebookModel>(this.name) .select("*") .where("id", "=", id) .first(); } async delete(id: number) { return await this.localDB.db.table(this.name).where("id", "=", id).delete(); } async update(data: { id: number; name: string }) { return await this.localDB.db .table(this.name) .where("id", "=", data.id) .update({ name: data.name }); } async getAll() { return await this.localDB.db.table<NotebookModel>(this.name).select("*"); } }
Service
只负责数据库的连接和表中数据的增删改查。
Controller
Controller
可以通过接入 Service
操作数据库,并做一些业务上的工作。
import { inject, injectable } from "inversify"; import { NotebooksService } from "../services/notebooks.service"; import { NotesService } from "../services/notes.service"; @injectable() export class NotebooksController { constructor( @inject(NotebooksService) public service: NotebooksService, @inject(NotesService) public notesService: NotesService ) {} async create(name: string) { await this.service.create({ name, }); } async delete(id: number) { const row = await this.service.get(id); if (row) { const notes = await this.notesService.getByNotebookId(id); if (notes.length) throw Error("delete failed"); await this.service.delete(id); } } async update(data: { id: number; name: string }) { return await this.service.update(data); } async getAll() { return await this.service.getAll(); } }
业务
如何创建笔记本?
我们先来实现创建笔记本,之后的删除笔记本,更新笔记本名称等等,依葫芦画瓢就行。我们在界面上添加一个创建按钮。
点击后就会出现这样一个弹窗,这里 UI 库我是用的 antd 做的。
看一下这个弹窗部分的逻辑
import { Modal, Form, Input } from "antd"; import React, { forwardRef, useImperativeHandle, useState } from "react"; interface CreateNotebookModalProps { onCreateNotebook: (name: string) => Promise<void>; } export interface CreateNotebookModalRef { setVisible: (visible: boolean) => void; } export const CreateNotebookModal = forwardRef< CreateNotebookModalRef, CreateNotebookModalProps >((props, ref) => { const [modalVisible, setMoalVisible] = useState(false); const [form] = Form.useForm(); const handleOk = () => { form.validateFields().then(async (values) => { await props.onCreateNotebook(values.name); setMoalVisible(false); }); }; useImperativeHandle(ref, (): CreateNotebookModalRef => { return { setVisible: setMoalVisible, }; }); return ( <Modal visible={modalVisible} title="创建笔记本" onCancel={() => setMoalVisible(false)} onOk={handleOk} cancelText="取消" okText="确定" destroyOnClose > <Form form={form}> <Form.Item label="笔记本名称" name="name" rules={[ { required: true, message: "请填写名称", }, { whitespace: true, message: "禁止使用空格", }, { min: 1, max: 100, message: "字符长度请保持在 1-100 之间" }, ]} > <Input /> </Form.Item> </Form> </Modal> ); });
外部提供的 onCreateNotebook
的实现:
const handleCreateNotebook = async (name: string) => { await window.Bridge?.createNotebook(name); const data = await window.Bridge?.getNotebooks(); if (data) { setNotebooks(data); } };
上面出现的 Bridge
是我在第一篇中讲的 preload.js
提供的对象,它可以帮我们和 electron
主进程通信。
接来写,我们具体看一下 preload
和 主进程部分的实现:
// preload.ts import { contextBridge, ipcRenderer, MessageBoxOptions } from "electron"; contextBridge.exposeInMainWorld("Bridge", { showMessage: (options: MessageBoxOptions) => { ipcRenderer.invoke("showMessage", options); }, createNotebook: (name: string) => { return ipcRenderer.invoke("createNotebook", name); }, getNotebooks: () => { return ipcRenderer.invoke("getNotebooks"); }, });
实际还是用 ipcRenderer
去通信,但是这种方式更好
// main.ts import { ipcMain } from "electron" ipcMain.handle("createNotebook", async (e, name: string) => { return await ioc.get(NotebooksController).create(name); }); ipcMain.handle("getNotebooks", async () => { return await ioc.get(NotebooksController).getAll(); });
总结
最后,我们来看一下这部分的完整交互:
这一篇,我们主要学习了如何在 Elctron
使用 SQLite
数据库,并且简单完成了 CRUD
中的 C
。相关代码在 Github 上,感兴趣的同学可以自行查看。
加载全部内容