最近將 Folo 桌面端和移動端中的狀態管理合併到了同一的模塊中,就想着記錄一下相關的設計和踩坑經驗。(很多是從 Innei 的實踐中總結出來的,學習到了很多)
文章大概會有兩到三篇,本文中主要介紹資料庫的選型和整合。
為什麼需要資料庫?#
如果應用較為簡單,一般可以直接使用 TanStack Query / SWR 的 Cache 來持久化請求到的資料,以改善應用首屏加載的加載體驗。但是這樣的話,一般對於快取資料的操作會比較麻煩,也可能缺少類型安全。因此手動控制資料的持久化和預加載,將快取的管理變得和 TanStack Query/SWR 無關,可能長期來看更好維護。
資料庫的選型#
因為在移動端使用了 Expo SQLite,為了保持資料庫 schema 一致,避免寫兩套資料庫操作的程式碼,在桌面端就使用了 SQLite WASM 的方案。或許也可以看看 PGlite。
在瀏覽器中運行 SQLite 一般可以使用以下幾個庫:
- sql.js 已知的第一個在 Web 瀏覽器中直接使用 sqlite3 的程式
- 只支持內存資料庫,除了一次性導入導出整個資料庫文件外,不支持持久化。
- wa-sqlite 已知的第一個的 OPFS 存儲實現 sqlite3 資料庫,支持很多類型的 VFS(來源)。
- SQLite Wasm sqlite3 WebAssembly 的 javascript 包裝
關於這三者的比較相關資訊,可以查看 how is this different from the @rhashimoto/wa-sqlite and sql.js?。從暴露出來的 API 存取級別來看是,SQLite Wasm < wa-sqlite < sql.js,SQLite Wasm 最底層。
最後,SQLocal 是 Folo 桌面端的資料庫方案,因為它基於官方的 SQLite Wasm,由 SQLite 核心團隊構建,在維護方面的表現應該會更好(來源)。
SQLite 在瀏覽器中的運行模式#
SQLite 在瀏覽器中的運行模式主要有三種,在 sqlite3 WebAssembly & JavaScript Documentation 中有詳細的介紹。
- Key-Value VFS (kvvfs):在主 UI 線程中運行,使用如 localStorage 或 IndexedDB 來持久化資料。問題是存儲空間有限,性能相對較差。
- The Origin-Private FileSystem (OPFS):在 Worker 中運行,OPFS 對於瀏覽器的要求相對較高,需要 23 年 3 月之後的瀏覽器版本。
這些運行模式各有優劣,第一種性能較差,存儲空間有限,但對瀏覽器的要求最低,因此仍有很多應用使用它來存儲資料庫到 indexedDB。第二種對於 COOP 和 COEP HTTP 標頭的要求較高,難以滿足,但第三種的並發支持又比較麻煩有限。因此,可以在條件允許的情況下,使用第二種,否則回退到第三種。值得一提的是,PGlite 的文件系統也很相似,在瀏覽器中同樣是 In-memory FS、IndexedDB FS、OPFS AHP FS 三種 (來源)。
前面提到 OPFS SAH 不支持並發,默認情況下,用戶打開兩個窗口時就會出錯。要如何解決呢?需要從多個客戶端中協商出一個可以執行查詢的,然後暫停其他客戶端的使用。PGlite 也有類似的 Multi-tab Worker 實現。目前 SQLocal 還沒有對 OPFS SAH 的支持,相關的 issue 可以查看 Allow using sqlite's OPFS_SAH backend。我基於作者的實現分支進行了一些探索,實現了基礎的支持,但目前測試還未完全通過 (PR)。
所以 Folo 中會使用哪種運行模式呢?在本地使用網頁代理來開發時,由於跨源運行 worker 的限制,會使用 Key-Value VFS;網頁端和桌面端的生產環境中,因為 COOP 和 COEP HTTP 標頭的條件無法滿足,使用 OPFS SAH VFS;
不過桌面端 Electron 中,也可以直接開啟 SharedArrayBuffer
的支持,來使用 OPFS via sqlite3_vfs。
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer")
值得一提的是,由於 Electron 中使用的協議不同,一般是 file://
或是自定義的 app://
,因此為了訪問安全環境的中才有的 API,需要註冊協議。
// https://github.com/getsentry/sentry-electron/issues/661
protocol.registerSchemesAsPrivileged([
{
scheme: "sentry-ipc",
privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
},
{
scheme: "app",
privileges: {
standard: true,
bypassCSP: true,
supportFetchAPI: true,
secure: true,
},
},
])
由於 registerSchemesAsPrivileged
這個 API 最好只被調用一次,所以如果使用了 sentry 的話,推薦將它的的 registerSchemesAsPrivileged
調用給 patch 掉,然後在自己的程式碼中調用。
如何為多端復用程式碼?#
顯然桌面端和移動端的 SQLite Client 是不同的,所以在打包的時候需要為不同的平台導入不同的文件。Folo 的程式碼使用後綴來區分,比如 db.desktop.ts
用於桌面端,db.rn.ts
用於移動端。Vite 可以通過插件來實現(程式碼),Metro 可以通過自定義 resolver.resolveRequest
來實現(程式碼)。
這樣就可以給每個平台提供不同的資料庫實現了。db.ts
中定義類型,db.desktop.ts
和 db.rn.ts
中實現具體邏輯。這裡由於使用了 Drizzle ORM,所以自然用上了 Drizzle 的資料表類型定義,來給資料庫的操作提供一定的類型安全。至於實際的資料庫操作,則和平常寫 Drizzle 的程式碼沒有區別。
// db.ts
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"
import type * as schema from "./schemas"
type DB =
| BaseSQLiteDatabase<"async", any, typeof schema>
| BaseSQLiteDatabase<"sync", any, typeof schema>
export declare const sqlite: unknown
export declare const db: DB
export declare function initializeDB(): void
export declare function migrateDB(): Promise<void>
export declare function exportDB(): Promise<Blob>
資料庫遷移#
- Drizzle Kit 有非常好用的 migrate 工具,可以通過
drizzle-kit generate
命令來生成遷移文件。它和 Expo SQLite 的整合使用已經有完善的文檔來說明,這裡不多贅述。桌面端的遷移可以基於這套方案。 - 因為 migrate 的運行時程式碼並不依賴 Node,所以也可以在 Web 端來運行(程式碼)。
- 由於生成的 SQL 文件引入語句是直接 import 的,所以為了照顧移動端,這裡不使用 Vite 的
?raw
,而是自定義一個插件,將 SQL 文件文本轉成正常的 js 模塊導出(程式碼)。
最後#
這一套下來就能在 Folo 中使用單獨的包來維護資料庫增刪改查相關的邏輯,並且多端的程式碼實現了復用,減少維護的成本和潛在的實現不一致導致的問題。
最後留一個小 Tip,Drizzle ORM 的更新操作處理更新值的時候有些麻煩,需要手寫每一列名,且沒有類型安全,可以創建一個簡單的 helper 函數(來源)。