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 / Edge | 86 |
| Firefox | 111 |
| Safari | 15.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 檔案系統、點垃圾桶刪除。

示範
官方 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 的整個賣點就是反過來。
矛盾點
文章內部
- 命名自相矛盾,作者自己也承認。
createSyncAccessHandle()本身是非同步的;而且你是從一個「一般的」handle 取得「同步的」handle。原文用「這可能有點令人困惑」帶過。(推論)真正的心智模型是:Sync形容的是回傳物件之後的操作方式,不是取得它的方式。 - 「所有主要瀏覽器都支援」和後面一堆 Chrome-only 的註記並存。
remove()是 Chrome 實作的(要功能偵測)、資料夾改名與移動 Chrome 還沒實作、OPFS Explorer 是 Chrome 擴充功能。(推論)「支援 OPFS」和「支援完整 API 面」是兩件事,摘要那句容易讓人以為可以放心用全部。 - 版本號和 vault 既有筆記對不上,但兩邊其實都對。 這篇的表格寫 Safari 15.2,OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 寫 Safari 16.4 以上。(推論)差別在講的不是同一件事——OPFS 基本能力(
navigator.storage.getDirectory())是 15.2,createSyncAccessHandle()是 16.4。這篇的相容表講的是前者,那篇講的是後者。兩篇放在一起看必須知道這個切分,否則會以為有一篇寫錯。 - 中文版是機器翻譯,術語不可信。 頁首自己掛了免責聲明。「控制代碼」(handle)、「疊代器」、「來源私有檔案系統」都不是台灣慣用譯法。(推論)這類頁面適合拿來對照 API 名稱,不適合拿來建立術語習慣。
與 vault 既有筆記
- 它補上了 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 缺的「為什麼」。 那篇從 SQLite 的需求往回推(同步 VFS 介面接不上非同步儲存 API),這篇從另一頭解釋 OPFS 為什麼快:user-visible 檔案系統有 Mark of the Web、Safe Browsing、不就地寫入三道機制,而 OPFS 把三道全部拿掉了。(推論)兩篇合起來才完整:一個講「SQLite 需要什麼」,一個講「OPFS 憑什麼給得起」。
- 瀏覽器內 SQLite:把查詢下推到 Worker 的 WASM heap 的「天花板」那一段,這篇的 demo 就是它指向的那條路。 那篇說 sql.js 預設 VFS 是純記憶體的、整份 DB 必須在 WASM heap,要降到 O(page cache) 得換 VFS;這篇的官方 demo 正是 sqlite-wasm + OPFS 後端。(推論)可以互鏈當作「那條路長什麼樣」的實例。
- Worker 與分頁共用同一份記憶體配額 提供了這篇沒講的另一半帳。 這篇說「要快就去 Worker」,理由是同步 API 與不阻塞主執行緒;那篇補的是Worker 不會多給你記憶體。(推論)合起來的判準是:把檔案操作搬進 Worker 換到的是「同步 API + 不阻塞」,不是「更多資源」。
- 配額那段和 Worker 與分頁共用同一份記憶體配額 是同一個形狀的不同資源。 那篇講記憶體配額以 renderer process 為單位;這篇講儲存配額以 origin 為單位、而且和 localStorage/IndexedDB 共用同一份預算。(推論)值得並記:瀏覽器裡有兩套獨立的配額(記憶體 per process、儲存 per origin),兩者都不是「多開一個東西就能繞過」的。
待確認
-
配額到底多大? 文章只說「受瀏覽器配額限制」並教你怎麼查用量,沒有說配額怎麼算、超過會怎樣(丟什麼 error?被 evict 嗎?)。既有筆記提到 iOS Safari 記憶體很緊,儲存配額的情況沒查。
-
flush()的保證到哪裡? 文中寫「確保檔案內容包含所有write()的修改」——是到瀏覽器的 buffer,還是真的落到磁碟(fsync)?崩潰時的保證沒講。 -
同一個檔案能不能有多個 sync access handle? 既有筆記提到
opfs-sahpoolVFS 有「並行與檔案數限制」,這篇完全沒碰並行。 -
remove()的功能偵測 只說了 Chrome 實作,其他瀏覽器的替代做法(removeEntry()?)沒有明講。 -
crbug/1284595(DevTools 內建支援)現在的狀態不明——文章可能已經過時,OPFS Explorer 未必還是必要的。
-
OPFS 受到 瀏覽器儲存空間配額限制,就像任何其他以來源分割(origin-partitioned)的儲存機制一樣(例如 IndexedDB API)。您可以使用
navigator.storage.estimate()來獲取 OPFS 正在使用的儲存空間大小。 -
清除網站的儲存資料會刪除 OPFS。
-
存取 OPFS 中的檔案不需要權限提示和安全性檢查。
-
瀏覽器會將 OPFS 的內容持久化(persist)儲存到磁碟的某個地方,但您無法期望找到一對一對應的已建立檔案。OPFS 本來就不是設計成讓使用者看見的。
抽出的原子筆記
- OPFS 不在使用者的檔案系統裡,所以快,也所以找不到 — 三道檢查都不用做;以及「落到磁碟但不是一對一對應」(來自我貼的 MDN 批註)
- Worker 能用同步 API,是因為它擋不住主執行緒 — 含
createSyncAccessHandle()自己是非同步的那個命名陷阱 - OPFS 吃儲存配額,清網站資料就一起消失 — 以及「草稿區」這個甜蜜點
- 怎麼查 OPFS 裡有哪些檔案 — console script + origin 綁定的兩個前提
既有的 OPFS sync access handle 讓 SQLite 能在瀏覽器只讀用得到的 page 講的是「SQLite 為什麼非它不可」與代價,和上面四篇不重複。