Origin Private File System(web.dev)

web.dev|Thomas Steiner|審稿:Austin Sully、Etienne Noël、Rachel Andrew 〈來源私有檔案系統〉——OPFS 的完整 API 導覽。

這個中文頁是機器翻譯

頁首自己掛著「Google 會運用 AI 技術將內容翻譯成你偏好的語言,但可能會出錯」。譯名和台灣慣用不一致,讀的時候要換回來: 來源私有檔案系統 = Origin Private File System(OPFS)、控制代碼 = handle、疊代器 = iterator、位移 = offset。 我在下面一律用英文原詞。

媒體

3 張圖 + 1 個內嵌 demo,都用原始 source 放在對應位置。

統整

vault 裡已經有 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page(從 souffle MR 抽出來的),那篇講的是「為什麼 SQLite 非它不可」。這篇補的是另外三塊:OPFS 為什麼存在(user-visible 檔案操作為什麼慢)、完整的 API 面、以及主執行緒/Worker 的分工。

一句話

OPFS 讓 Web 應用程式在專屬的 origin 虛擬檔案系統中儲存及操控檔案,包括低階檔案操控、逐位元組存取,以及檔案串流。所有主要瀏覽器都支援。

瀏覽器版本
Chrome / Edge86
Firefox111
Safari15.2

由 WHATWG 在 File System Living Standard 標準化。⚠️ 版本號和 vault 既有筆記有出入,見矛盾點 3。

動機:user-visible 的檔案操作為什麼慢

這一段是 vault 既有筆記沒有的,也是整篇的立論基礎。

網頁處理檔案的老流程是 <input type="file"> 上傳/<a download> 下載,使用者最後拿到的是輸入檔案的副本。File System Access API 用三個 picker 改善了這件事:

showOpenFilePicker()
showSaveFilePicker()
showDirectoryPicker()

但透過這些方法拿到的檔案位於 user-visible 檔案系統,而那裡有三道安全機制:

機制做什麼
Mark of the Web從網路儲存的檔案(尤其是可執行檔)會被標記,OS 執行前會多跳一次警告
Safe Browsing文中的比喻:把它想成雲端病毒掃描
不就地寫入用 File System Access API 寫檔時,系統不是就地寫入,而是先寫暫存檔;除非通過所有安全檢查,原檔不會被修改

後果:

儘管盡可能改善,例如在 macOS 上,檔案作業的速度仍相對緩慢。而且每個 write() 呼叫仍是獨立的——在幕後會開啟檔案、seek 到指定 offset,最後才寫入資料。

為什麼要用「檔案」這個抽象

檔案是記錄資料的絕佳方式。舉例來說,SQLite 會把整個資料庫儲存在單一檔案中。另一個例子是圖像處理的 mipmap——預先計算的圖片最佳化序列,每張都是前一張的漸進式低解析度表示法,可加快縮放之類的作業。

問題因此成形:網頁應用程式怎麼享有檔案的好處,同時避開網頁式檔案處理的效能成本? 答案就是 OPFS。

user-visible vs origin private

user-visible 檔案系統的進入點是「符號硬碟」(Windows 是 C:\),OPFS 的進入點是呼叫 navigator.storage.getDirectory()。

  • 私有的對象是 origin,不是使用者。location.origin 是什麼,能看到的 OPFS 就是哪一份。
  • 同 origin 的所有頁面共用同一份 OPFS:https://developer.chrome.com/articles/ 和 https://developer.chrome.com/docs/... 看到的是同一份;https://web.dev 的則完全不同。
  • 除了根目錄之外,其他一切在概念上都相同——你照樣可以自由安排資料夾階層。

配額、清除、以及「沒有的東西」

  • 和 localStorage、IndexedDB 一樣,OPFS 受瀏覽器配額限制。
  • 使用者清除所有瀏覽資料或網站資料時,OPFS 一併被刪除。
  • 查用量:navigator.storage.estimate() → 回應物件的 usage,並在 usageDetails 裡看 fileSystem 那一項。

關鍵的兩個「沒有」:

由於使用者看不到 OPFS,因此不會出現權限提示,也不會進行 Safe Browsing 檢查。

(推論)這正是前面那三道安全機制的鏡像——OPFS 快,是因為它把那三道全部拿掉了;它拿得掉,是因為檔案根本不進使用者的檔案系統。

取得根目錄

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

主執行緒 or Web Worker(原連結指向的那一節)

使用 OPFS 的方式有兩種:在主執行緒或 Web Worker 中。 Web Worker 無法封鎖主執行緒,因此在這個環境中 API 可以是同步的——這是主執行緒上通常不允許的模式。 同步 API 可避免處理 Promise,因此速度較快;而且在可編譯為 WebAssembly 的 C 這類語言中,檔案作業通常本來就是同步的:

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

如果需要盡快完成檔案作業,或是處理 WebAssembly,就用 Worker。

主執行緒的 API(非同步)

建立——{create: true} 表示不存在就建:

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});

讀——FileSystemFileHandle.getFile() 拿到 File,而 File 是一種 Blob:

const file = await fileHandle.getFile();
console.log(await file.text());

File 可用於任何 Blob 可用的情境——FileReader、URL.createObjectURL()、createImageBitmap()、XMLHttpRequest.send() 都接受。從 handle 取得 File 會「釋放」資料,所以你能存取它、並提供給 user-visible 檔案系統。

寫——串流三部曲,close() 才是真正持久化的那一步:

const writable = await fileHandle.createWritable();
await writable.write('Some text');
await writable.close();   // Close the stream, which persists the contents.

刪除:

await fileHandle.remove();
await directoryHandle.remove({recursive: true});
directoryHandle.removeEntry('my first nested file');   // 知道名字時

快速提示:await (await navigator.storage.getDirectory()).remove({recursive: true}) 是清除整個 OPFS 最快的方法。 注意:remove() 是 Chrome 實作的,可用 'remove' in FileSystemFileHandle.prototype 做功能偵測。

移動/改名——同一個方法:

await fileHandle.move('my first renamed file');                    // 改名
await fileHandle.move(nestedDirectoryHandle);                      // 搬
await fileHandle.move(nestedDirectoryHandle, 'renamed and nested'); // 一起做

⚠️ Chrome 尚未實作資料夾的改名與移動。而且你無法把檔案從 OPFS「移動」到 user-visible 檔案系統——只能複製。

路徑與比對:

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// ['my first folder', 'my first nested folder']
 
fileHandle.isSameEntry(nestedFileHandle);   // 兩個 handle 是不是同一個東西

列出內容——FileSystemDirectoryHandle 本身就是 async iterator:

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

遞迴列出的完整實作原文有給(getDirectoryEntriesRecursive),骨架是:

for await (const handle of directoryHandle.values()) {
  if (handle.kind === 'file')      { /* push handle.getFile() 的 promise */ }
  else if (handle.kind === 'directory') { /* 遞迴下去,push 該 promise */ }
}
const directoryEntries = await Promise.all(directoryEntryPromises);

處理非同步迴圈和遞迴配對的函式時,很容易出錯。 不需要檔案大小的話可以簡化——直接 push handle 而不是 handle.getFile() 的 promise。

Worker 的 API(同步)

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

注意:這可能有點令人困惑——你實際上是從一般的 FileSystemFileHandle 取得同步的 FileSystemSyncAccessHandle。另請注意,儘管方法名稱裡有 Sync,createSyncAccessHandle() 本身是非同步的。

拿到之後的六個方法全部同步:

方法做什麼
getSize()回傳檔案大小(bytes)
write(buf, {at})寫入並回傳寫入的 byte 數——檢查它可以偵測錯誤與部分寫入
read(buf, {at})讀進 buffer
truncate(n)調整檔案大小
flush()確保內容包含所有 write() 的修改
close()關閉 handle
let size = accessHandle.getSize();                     // 0
accessHandle.write(textEncoder.encode('Some text'), {at: size});
accessHandle.flush();
size = accessHandle.getSize();                         // 9
accessHandle.write(textEncoder.encode('More content'), {at: size});
accessHandle.flush();
size = accessHandle.getSize();                         // 21
 
const dataView = new DataView(new ArrayBuffer(size));
accessHandle.read(dataView);                           // "Some textMore content"
accessHandle.read(dataView, {at: 9});                  // "More content"
accessHandle.truncate(4);

read() 和 write() 的第一個參數是 ArrayBuffer 或 ArrayBufferView(例如 DataView)。你無法直接操控 ArrayBuffer 的內容,必須建立 typed array(Int8Array)或 DataView 來讀寫。

從 OPFS 複製到 user-visible 檔案系統

因為 showSaveFilePicker() 只在主執行緒可用,這段一定要在主執行緒跑(而且要先在 Worker 裡把檔案關掉):

const fileHandle = await opfsRoot.getFileHandle('fast');
const saveHandle = await showSaveFilePicker({ suggestedName: fileHandle.name || '' });
const writable = await saveHandle.createWritable();
await writable.write(await fileHandle.getFile());
await writable.close();

偵錯

在內建 DevTools 支援到位之前(crbug/1284595),用 OPFS Explorer Chrome 擴充功能。裝好後在 DevTools 選「OPFS Explorer」分頁就能檢查階層、點檔名把檔案存到 user-visible 檔案系統、點垃圾桶刪除。

|757x579

示範

官方 demo 就是把 OPFS 當成編譯為 WebAssembly 的 SQLite 資料庫後端:https://googlechrome.github.io/samples/sqlite-wasm-opfs/

注意:內嵌版本不會使用 OPFS 後端(因為 iframe 是跨來源),在個別分頁開啟才會。

反覆出現的關鍵字

  • origin:整篇的界線單位。私有的對象是 origin 不是使用者,配額、清除、隔離全都以它為單位。文中特地花一段解釋 location.origin 是什麼——因為搞錯這個就會搞錯資料的共用範圍。
  • user-visible:全篇的對照組。所有 OPFS 的優點都是「相對於 user-visible 檔案系統」定義的,快也是、缺乏權限提示也是。
  • 同步(synchronous):Worker 那一節的核心,也是 API 存在的理由。文中把「同步」和「WebAssembly/C」綁在一起講——因為那些語言的檔案作業本來就是同步的。
  • handle:FileSystemDirectoryHandle / FileSystemFileHandle / FileSystemSyncAccessHandle 三層。你操作的從頭到尾都是 handle 而不是路徑字串,這是整個 API 的形狀。
  • {create: true} / {recursive: true} / {at: offset}:三個 option 幾乎撐起整個 API 的變化。
  • 不是就地寫入:解釋 user-visible 為什麼慢的關鍵詞,而 OPFS 的整個賣點就是反過來。

矛盾點

文章內部

  1. 命名自相矛盾,作者自己也承認。 createSyncAccessHandle() 本身是非同步的;而且你是從一個「一般的」handle 取得「同步的」handle。原文用「這可能有點令人困惑」帶過。(推論)真正的心智模型是:Sync 形容的是回傳物件之後的操作方式,不是取得它的方式。
  2. 「所有主要瀏覽器都支援」和後面一堆 Chrome-only 的註記並存。 remove() 是 Chrome 實作的(要功能偵測)、資料夾改名與移動 Chrome 還沒實作、OPFS Explorer 是 Chrome 擴充功能。(推論)「支援 OPFS」和「支援完整 API 面」是兩件事,摘要那句容易讓人以為可以放心用全部。
  3. 版本號和 vault 既有筆記對不上,但兩邊其實都對。 這篇的表格寫 Safari 15.2,OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 寫 Safari 16.4 以上。(推論)差別在講的不是同一件事——OPFS 基本能力(navigator.storage.getDirectory())是 15.2,createSyncAccessHandle() 是 16.4。這篇的相容表講的是前者,那篇講的是後者。兩篇放在一起看必須知道這個切分,否則會以為有一篇寫錯。
  4. 中文版是機器翻譯,術語不可信。 頁首自己掛了免責聲明。「控制代碼」(handle)、「疊代器」、「來源私有檔案系統」都不是台灣慣用譯法。(推論)這類頁面適合拿來對照 API 名稱,不適合拿來建立術語習慣。

與 vault 既有筆記

  1. 它補上了 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 缺的「為什麼」。 那篇從 SQLite 的需求往回推(同步 VFS 介面接不上非同步儲存 API),這篇從另一頭解釋 OPFS 為什麼快:user-visible 檔案系統有 Mark of the Web、Safe Browsing、不就地寫入三道機制,而 OPFS 把三道全部拿掉了。(推論)兩篇合起來才完整:一個講「SQLite 需要什麼」,一個講「OPFS 憑什麼給得起」。
  2. 瀏覽器內 SQLite:把查詢下推到 Worker 的 WASM heap 的「天花板」那一段,這篇的 demo 就是它指向的那條路。 那篇說 sql.js 預設 VFS 是純記憶體的、整份 DB 必須在 WASM heap,要降到 O(page cache) 得換 VFS;這篇的官方 demo 正是 sqlite-wasm + OPFS 後端。(推論)可以互鏈當作「那條路長什麼樣」的實例。
  3. Worker 與分頁共用同一份記憶體配額 提供了這篇沒講的另一半帳。 這篇說「要快就去 Worker」,理由是同步 API 與不阻塞主執行緒;那篇補的是Worker 不會多給你記憶體。(推論)合起來的判準是:把檔案操作搬進 Worker 換到的是「同步 API + 不阻塞」,不是「更多資源」。
  4. 配額那段和 Worker 與分頁共用同一份記憶體配額 是同一個形狀的不同資源。 那篇講記憶體配額以 renderer process 為單位;這篇講儲存配額以 origin 為單位、而且和 localStorage/IndexedDB 共用同一份預算。(推論)值得並記:瀏覽器裡有兩套獨立的配額(記憶體 per process、儲存 per origin),兩者都不是「多開一個東西就能繞過」的。

待確認

  • 配額到底多大? 文章只說「受瀏覽器配額限制」並教你怎麼查用量,沒有說配額怎麼算、超過會怎樣(丟什麼 error?被 evict 嗎?)。既有筆記提到 iOS Safari 記憶體很緊,儲存配額的情況沒查。

  • flush() 的保證到哪裡? 文中寫「確保檔案內容包含所有 write() 的修改」——是到瀏覽器的 buffer,還是真的落到磁碟(fsync)?崩潰時的保證沒講。

  • 同一個檔案能不能有多個 sync access handle? 既有筆記提到 opfs-sahpool VFS 有「並行與檔案數限制」,這篇完全沒碰並行。

  • remove() 的功能偵測 只說了 Chrome 實作,其他瀏覽器的替代做法(removeEntry()?)沒有明講。

  • crbug/1284595(DevTools 內建支援)現在的狀態不明——文章可能已經過時,OPFS Explorer 未必還是必要的。

  • OPFS 受到 瀏覽器儲存空間配額限制,就像任何其他以來源分割(origin-partitioned)的儲存機制一樣(例如 IndexedDB API)。您可以使用 navigator.storage.estimate() 來獲取 OPFS 正在使用的儲存空間大小。

  • 清除網站的儲存資料會刪除 OPFS。

  • 存取 OPFS 中的檔案不需要權限提示和安全性檢查。

  • 瀏覽器會將 OPFS 的內容持久化(persist)儲存到磁碟的某個地方,但您無法期望找到一對一對應的已建立檔案。OPFS 本來就不是設計成讓使用者看見的。

抽出的原子筆記

既有的 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 講的是「SQLite 為什麼非它不可」與代價,和上面四篇不重複。