每個分頁一個已解鎖保管箱——刻意如此
閱讀時間 7 分鐘 作者 NT²
在兩個瀏覽器分頁開啟同一個保管箱很平常。讓兩個分頁同時寫入同一份本機 SQLite 檔案則否。NT² Vault 選出一個寫入者,並讓其他已解鎖分頁當跟隨者。
每個分頁一個已解鎖保管箱——刻意如此
主張很直白:對同一個保管箱,同一時間只能有一個瀏覽器分頁可以寫入。
其他分頁可以保持已解鎖。它們可以顯示列表、開啟項目,並在寫入者變更資料時跟著更新。它們絕不能再開一條會變更同一份 Origin Private File System 資料庫的 SQLite 連線、推送互相競爭的同步批次,或發明一條繞過寫入者的「背景寫入幫手」。多開分頁的便利是真實需求;多寫入者的本機儲存,則是把本應是使用者真實來源的保管箱搞壞的快捷方式。
這條規則不是 UI 偏好,而是本機優先 PWA 的並行邊界——其耐久儲存是瀏覽器裡以檔案為後端的 SQLite 資料庫。
限制:同一個 OPFS 檔案上的兩個寫入者
現代保管箱應用會邀請人開啟多於一個分頁。比對兩個項目、一邊開著設定一邊瀏覽列表、把詳情釘住同時在別處搜尋——瀏覽器讓這一切很便宜。OPFS 上的本機 SQLite 則不然。
保管箱資料庫是來源私有檔案系統下的真實檔案,透過預期受控存取的 WASM SQLite 引擎開啟。單一頁內的並行 UI 動作已經序列化,以免虛擬檔案系統看到交錯的變更。那種序列化假設資料庫連線只有一個擁有者。第二個也「擁有」保管箱的已解鎖分頁,就是第二個擁有者。兩個擁有者意味著交錯提交、互相競爭的日誌,以及看起來像神秘損毀、而不是清楚產品錯誤的失敗模式。
即便檔案系統奇蹟般撐住,產品仍然會錯。
列表與詳情狀態會分歧。 分頁 A 存了標題;分頁 B 記憶體裡還是昨天的列,稍後又覆寫回去。使用者會怪「同步」;真正的 bug 是兩個寫入者在沒有領導者的情況下共用同一個磁碟檔。
雲端同步會重複套用。 可選的盲副本同步,必須讓邊緣對每個裝置只有一條一致的推送/拉取故事。兩個分頁各自對同一保管箱身分跑同步迴圈,可能競速游標、重複勞動,或把遠端批次合併兩次。多分頁協調與跨裝置同步是不同問題;把它們塌成「每個已解鎖分頁都可以寫」,兩邊都會更難。
安全姿態會在便利底下變軟。 解鎖材料在工作階段期間存在記憶體裡。第二條寫入路徑會誘惑捷徑:雙方都當寫入者的共用 worker、安靜的背景提交,或「就這一次變更」跳過寫入者檢查。一旦某個版本依賴它們,捷徑就會變成永久架構。
失敗模式不是瀏覽器不能開多分頁,而是把每個已解鎖分頁都當成平等的資料庫同儕。
本機優先意味著裝置擁有真相。它不意味著每個文件脈絡都擁有寫入鎖。
設計:一個寫入者,跟隨者走 BroadcastChannel
NT² Vault 把誰可以提交與誰可以觀察分開。
解鎖之後,分頁向瀏覽器的 Web Locks API 請求一把以該保管箱身分為範圍的鎖。若鎖可用,該分頁成為寫入分頁:它擁有 SQLite 變更、領域提交,以及可選的雲端同步迴圈。若鎖已被持有,該分頁成為跟隨者:可解鎖閱讀、禁止寫入,並訂閱即時更新。
跟隨者不開啟第二條寫入路徑。它們不發明平行的儲存庫。它們接收寫入者在成功提交後發布的同一組領域事件信封,透過以該保管箱命名的 BroadcastChannel。跟隨者上的投影套用這些信封的方式,與寫入者自己的 UI 相同——更新分頁式列表視窗、詳情介面,以及例如鎖定這類工作階段訊號——而不重新撰寫那次變更。
角色變更時,寫入者選舉是明確的。需要編輯的跟隨者使用在此分頁中編輯。那會轉移寫入鎖,並發布轉移事件,讓其他分頁更新角色。沒有只因取得焦點就安靜升級。焦點不是權威。
唯讀強制不只是橫幅。命令路徑在變更之前會斷言目前分頁是寫入者。同步推送與完整同步只在寫入者上執行。跟隨者做投影;它們不會自行合併遠端副本批次。跨裝置同步仍是該裝置上持有寫入角色者的工作。
在寫入分頁內部,SQLite 存取仍然序列化。並行點擊與重疊的領域呼叫透過一條鎖鏈排隊,讓 WASM VFS 看到有序的工作。多分頁政策與單一分頁序列化互補:前者決定哪個文件擁有檔案;後者讓該擁有者在並行 UI 下保持誠實。
flowchart LR
W[寫入分頁]
F1[跟隨分頁]
F2[跟隨分頁]
Lock[每個保管箱一把 Web Lock]
DB[(OPFS 中的 vault.sqlite)]
BC[BroadcastChannel]
W -->|持有| Lock
W -->|提交| DB
W -->|發布信封| BC
BC -->|投影| F1
BC -->|投影| F2
F1 -.->|唯讀| DB
F2 -.->|唯讀| DB
心智模型保持很小:一個保管箱身分、一個寫入者、多個觀察者。
把同一套保管箱 UI 嵌進單一行程的桌面殼層,不需要 BroadcastChannel 傳輸;當沒有多個瀏覽器文件競用 OPFS 時,行程內匯流排就夠了。瀏覽器 PWA 才是多分頁寫入者選舉真正重要的地方,因為使用者自然會對同一個來源儲存開啟多個已解鎖文件。
取捨:便利要付出角色成本
單一寫入者的多分頁並非免費。
同一個保管箱在第二個分頁解鎖時,使用者會看到唯讀橫幅。要編輯就得切換角色,或回到寫入分頁。相較於安靜的資料遺失,這是很小的產品稅,但仍是稅。工程師必須把寫入者檢查穿過每一條變更路徑——包括那些很誘人的:匯入、清空垃圾桶、替換附件、寫入保管箱設定區段的設定項,以及同步觸發。少一次斷言,就等於重建第二條寫入路徑。
BroadcastChannel 在來源內是盡力而為的傳遞。跟隨者會對事件去重並忽略自己的回音,但若某個分頁背景很久,寫入者鎖定或轉移時仍需謹慎處理工作階段。事件信封必須不含明文機密;跟隨者投影的是「什麼變了」的事實,而不是它們不該保留的解密內容。
還有一種誘惑是「修正」多分頁:讓每個分頁都當寫入者,只靠樂觀 UI 協調。那會把衝突搬進使用者的資料。我們拒絕這筆交易。另一種誘惑是共用 worker,讓兩個分頁都把它當成隱形共同寫入者。那只是把第二條寫入路徑換了地方。規則不是「把寫入者藏起來」,而是「每個保管箱只有一個提交擁有者」。
我們得到的比失去的更清楚。OPFS 上的 SQLite 保持一致。分頁式列表可從投影事件更新,而不必每個跟隨者整表重載。可選的邊緣同步在裝置端只有一個作者。除錯時有角色可查:寫入者或跟隨者,而不是「不知怎地兩邊都是」。
我們拒絕什麼
把拒絕寫清楚,架構會更清楚。
我們拒絕第二條寫入路徑。 沒有跟隨者端的儲存庫提交、沒有安靜的背景變更器,也沒有對同一保管箱身分「UI 唯讀但 worker 可寫」的逃生口。
我們拒絕多寫入者的 OPFS SQLite。 並行的已解鎖分頁不會各自對同一保管箱檔案開啟會變更的資料庫連線。單一寫入者內部的序列化,不是兩位寫入者的許可證。
我們拒絕跟隨者驅動的雲端同步。 拉取與合併副本批次是寫入者的責任。跟隨者投影本機信封;它們不會在同一裝置上變成同一保管箱的第二個同步引擎。
我們拒絕安靜的寫入者升級。 角色變更是明確的。跟隨者不會只因取得焦點,或使用者在已停用欄位上打字,就變成寫入者。
我們拒絕把 BroadcastChannel 當命令匯流排。 跟隨者不會透過頻道對寫入者送出變更命令,來代替持有鎖。頻道向外承載已提交的事實,讓觀察者保持最新。
我們拒絕用「同步會修好」掩蓋損毀。 多分頁競態是用戶端並行 bug。盲邊緣副本搬運密文;它們不是兩個本不該存在的本機寫入者的衝突解決服務。
這些拒絕讓瀏覽器保管箱在人們做平常的事——再開一個分頁——時仍然誠實。
單一寫入者,是本機優先得以成立的方式
裝置上的私密保管箱必須像應用程式一樣好用,包括多分頁瀏覽,卻不能假裝瀏覽器是多主資料庫。選出寫入者、讓跟隨者保持唯讀、再透過 BroadcastChannel 向外扇出,就是 NT² Vault 畫下的那條線。耐久檔案只有一個擁有者。觀察可以共享。變更不行。
若想理解這項選擇背後的本機優先產品立場,請讀為什麼選擇 PWA 本地優先、零伺服器的 Vault。若想知道為什麼保管箱資料庫本身放在 OPFS 而不是 IndexedDB,見為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB。若想了解互動列表如何維持分頁視窗、而非整表載入,同時跟隨者仍能投影更新,見永遠不要把整個保管箱載入 Svelte state。
最後更新 2026-08-29
相關故事
- 標題用 FTS5;篩選勝出時走表掃描
閱讀時間 7 分鐘
- 永遠不要把整個保管箱載入 Svelte state
閱讀時間 7 分鐘
- 為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB
閱讀時間 8 分鐘