永遠不要把整個保管箱載入 Svelte state
閱讀時間 7 分鐘 作者 NT²
本機保管箱可以保存數千筆結構化項目,卻不必變成巨大的記憶體內陣列。 耐用的真實資料留在 SQLite。介面只持有一分頁的輕量列表列, 並透過虛擬列表渲染。
永遠不要把整個保管箱載入 Svelte state
主張很單純:互動式保管箱列表,絕不能把每一筆項目都載入 Svelte state。
SQLite 可以保存一萬筆結構化資產。這不代表解鎖後的介面就該持有一萬個響應式物件、一萬份已解密的 payload 封套,或一萬個 DOM 節點。耐用的真實來源在保管箱資料庫。列表表面應該請求分頁的輕量列、在記憶體中維持有界視窗,並只虛擬化使用者實際看得見的部分。
這聽起來理所當然——直到你把捷徑做成產品。捷徑是:一個回傳全部列的函式、一個塞進響應式 state 的陣列,再加一個迴圈整個陣列的模板。第一天完全能跑。一旦保管箱不再只是 demo,它就會變成錯誤的架構。
限制:全表 UI state 會因錯誤的理由失敗
保管箱列表不是只有四十筆項目的行銷截圖。它是長期存在的本機資料庫,帶有篩選、搜尋、類別計數、建立與更新的變動、垃圾桶與封存生命週期,以及在使用者開啟紀錄前都應保持密封的加密 payload。
如果「解鎖」意味著「把整張表讀進記憶體」,成本會一起到來。
記憶體隨保管箱大小成長,而不是隨可視區域成長。 看起來很小的列表列,仍然帶有識別碼、類別、標題、時間戳,往往還有預覽字串。把這個成本乘上每一筆作用中的項目,再讓陣列在響應式執行環境中保持熱態;若再「為了方便」附上已解密欄位,成本就不再只是理論。瀏覽器等於在持有第二份閣樓副本。
響應式會放大 SQL 早已完成的工作。 當介面擁有完整集合時,每一次篩選變更、排序變更、軟重新整理或插入,都可能再次走完巨大陣列。資料庫其實已經用索引查詢回答過問題。在 JavaScript 裡再答一次,是主執行緒重複支付的勞動。
解鎖延遲會與保管箱大小成正比。 使用者期待開啟保管箱像開啟本機應用,而不是像匯入試算表。若第一次畫面必須等待每一列,等於對每次工作階段課稅——包括只需要一組憑證的那些工作階段。
DOM 壓力來自同一個錯誤。 就算記憶體免費,繪製數千個列表儲存格仍然是工作。當根因是「我們掛載了整個結果集」時,捲動卡頓就不是樣式問題。
便利會軟化安全姿態。 列表檢視不需要密文或已解開的內容金鑰。它需要的是足以導覽的中繼資料。若預設列表路徑為每一筆項目解密或保留沉重 payload,產品就把解鎖時間與 RAM 花在捲動表面根本不會顯示的資料上。
失敗模式不是 SQLite 存不下大型保管箱。失敗模式是把 UI state 層當成資料庫。
本機優先不是「把所有東西都留在分頁裡」。它的意思是:裝置擁有真實來源,而客戶端每一層只持有自己職責所需的資料。
設計:在 SQL 分頁、在 state 開窗、在 DOM 虛擬化
NT² 把三項職責分開。
- SQLite 回答計數與分頁查詢。 篩選、搜尋文字、類別與生命週期條件變成一條查詢。repository 回傳一頁列表列,外加總數與
hasMore旗標。預設頁大小是五十列。查詢路徑可以在文字比對占優勢時使用全文搜尋,或在類別等條件足夠時使用一般資料表篩選。 - UI state 持有列表列的滑動視窗。 解鎖或篩選變更後,state 把第一頁載入
listRows。捲到接近底部時請求下一頁並附加。常駐視窗有上限,避免無限捲動在另一個名字下悄悄重建全表陣列。建立、更新與刪除若與目前視窗相交,就修補視窗,而不是每次變更都強制完整重載。 - 列表元件虛擬化可視區域。 視窗中只有可見切片會變成 DOM。捲動位置與「再載入」閾值決定何時向 state 再要一頁。使用者體驗到的是連續列表;執行環境體驗到的是有界工作集。
列的形狀與分頁同樣重要。
列表列刻意很薄:識別碼、類別、標題、更新時間,以及可選的預覽。它不是加密 payload,也不是附件密文。開啟項目是另一條路徑,只解密該項目需要的內容。捲動表面保持為密封儲存之上的導覽索引。
flowchart LR
UI[虛擬列表 UI]
State[分頁列表 state]
Repo[SQLite 分頁查詢]
DB[(vault.sqlite)]
UI -->|捲動 篩選 搜尋| State
State -->|計數 + 分頁| Repo
Repo --> DB
這條管線還有第二個刻意的不對稱。
備份與匯出仍然需要完整遍歷。把保管箱移到另一台裝置,或建立可攜快照,是對完整資料集的批次工作。那條路徑可以掃描每一筆項目。它不是互動列表路徑。把兩者搞混,就是「暫時」的全表載入再次變成架構的方式:有人重用匯出輔助函式來填滿儀表板,只因為它已經回傳一切。
因此規則必須尖銳。全表列舉是備份與類似批次工作的過渡工具。已解鎖的列表永遠是分頁的。
類別計數與搜尋遵循同一紀律。計數是聚合查詢,不是對已灌水宇宙做 array.length。搜尋會防抖,並為目前篩選集合回傳分頁。介面從來不需要把每一筆相符列都放進記憶體,才能感覺「可搜尋」。
取捨:更多 state 機械,更清楚的規模行為
分頁比 const items = await listAll() 需要更多程式碼。
state 層必須追蹤位移、視窗起點、篩選身分、軟重新整理與硬重設,以及是否已有另一頁在飛行中。虛擬化需要穩定列高或仔細測量。變更必須決定:改動的項目是否屬於目前視窗、是否應前置插入,或只需更新計數。篩選變更會使視窗失效。天真的「永遠從零重載」是正確的,但可能顯得閃爍;能保留脈絡的軟重新整理更舒服,也更難。
我們接受這份複雜度。
換回的是與人們使用保管箱方式相符的規模行為。開啟應用大致只付第一頁的成本,而不是整段閣樓歷史。需要時,捲動再付下一頁。搜尋重新查詢資料庫,而不是在可能已過期的巨大客戶端快取上篩選。記憶體與工作集相關。主執行緒少花時間配置與 diff 使用者看不見的物件。
還有一項誠實的取捨。我們不宣稱列表「已全部載入」。總數來自 SQL。捲動條與再載入行為反映的是更大集合上的視窗。對本機資料庫產品而言,這種誠實勝過假裝瀏覽器分頁裡有一份完整的 JavaScript 陣列保管箱。
工程師有時擔心分頁會讓離線本機資料感覺像緩慢的網路 API。實務上剛好相反。本機分頁查詢很便宜。真正感覺慢的,是灌入太多,再要求響應式系統擁有它們。讓 SQL 靠近、讓視窗變小,才是本機優先保管箱隨成長仍保持回應的方式。
我們拒絕什麼
把拒絕講清楚,架構也會更清楚。
我們拒絕用全表載入驅動保管箱 UI。 若功能需要新篩選,就延伸查詢路徑並消費分頁。它不該把「全部列」輔助函式重新扶正為列表的真實來源。
我們拒絕把備份列舉當成列表架構。 匯出可以走遍一切。儀表板不行。為了重用而把批次路徑接到互動 state,是穿著重用外衣的回歸。
我們拒絕為了「速度」把已解密的項目 payload 留在列表 state。 捲動的速度來自薄列、索引與虛擬化。開啟時再解密。工作階段鎖定時再次密封。
我們拒絕無界的常駐視窗。 沒有上限的無限捲動,會悄悄重建全表記憶體壓力。使用者探索時視窗可以成長,但必須保持有界。
我們拒絕讓 DOM 成為資料庫。 就算資料已經在本機,掛載每一個儲存格也不能取代分頁。
這些拒絕不是反便利。它們是結構化保管箱在第三年——當項目數不再可愛、開始變成現實——仍然可用的方式。
讓閣樓留在磁碟,讓列表成為視窗
本機優先保管箱仍然需要普通的產品美德:快速解鎖、平穩捲動、不卡頓的搜尋,以及感覺即時的篩選。這些美德不是靠把閣樓塞進 Svelte state 得來的。它們來自:讓 SQLite 繼續當閣樓、讓列表 state 繼續當視窗,並讓 DOM 只渲染眼睛用得上的部分。
這與把保管箱資料庫放在真正以檔案為導向的瀏覽器儲存、以及不靠強制伺服器往返也能保持產品可用,是同一條技術棧故事。儲存層持有耐用結構。查詢層為它分頁。UI 層拒絕變成第二個、更弱的資料庫。
若想了解大型保管箱仍保持可用的產品感受,請讀一萬個項目,仍然是保管箱。本系列稍早:為什麼選擇 PWA 本地優先、零伺服器的 Vault,以及為什麼保管箱 SQLite 放在 OPFS,而非 IndexedDB。
最後更新 2026-08-19
相關故事
- 標題用 FTS5;篩選勝出時走表掃描
閱讀時間 7 分鐘
- 為什麼我們將保管箱的 SQLite 資料庫放在 OPFS,而非 IndexedDB
閱讀時間 8 分鐘
- 為什麼選擇 PWA 本地優先、零伺服器的 Vault
閱讀時間 5 分鐘