來源私人檔案系統

檔案系統標準引進了來源私人檔案系統 (OPFS),做為頁面來源的私人儲存空間端點,且對使用者隱藏,可選擇存取效能經過高度最佳化的特殊檔案。

瀏覽器支援

來源私人檔案系統受到新式瀏覽器的支援,並由 Web Hypertext Application Technology Working Group (WHATWG) 在檔案系統 Living Standard 中標準化。

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: 111.
  • Safari: 15.2.

Source

動機

提到電腦上的檔案,你可能會想到檔案階層:檔案以資料夾分類,你可以透過作業系統的檔案總管瀏覽。舉例來說,在 Windows 上,如果使用者名為 Tom,其待辦事項清單可能會位於 C:\Users\Tom\Documents\ToDo.txt。在這個範例中,ToDo.txt 是檔案名稱,UsersTomDocuments 則是資料夾名稱。Windows 上的 `C:` 代表磁碟機的根目錄。

傳統的網路檔案處理方式

如要在網頁應用程式中編輯待辦事項清單,通常會採用以下流程:

  1. 使用者可以使用 <input type="file"> 在用戶端上上傳檔案,或開啟檔案。
  2. 使用者進行變更後,系統會下載產生的檔案,並透過您透過 JavaScript 以程式輔助方式 click() 插入的 <a download="ToDo.txt>
  3. 如要開啟資料夾,您可以在 <input type="file" webkitdirectory> 中使用特殊屬性,雖然名稱為專屬名稱,但幾乎所有瀏覽器都支援這項屬性。

在網路上使用檔案的現代化方式

這個流程並不能代表使用者如何編輯檔案,也代表使用者最終會下載輸入檔案的副本。因此,File System Access API 推出了三種選擇器方法:showOpenFilePicker()showSaveFilePicker()showDirectoryPicker(),這些方法的名稱與功能完全一致。這些功能可啟用以下流程:

  1. 使用 showOpenFilePicker() 開啟 ToDo.txt,並取得 FileSystemFileHandle 物件。
  2. FileSystemFileHandle 物件取得 File,方法是呼叫檔案控點的 getFile() 方法。
  3. 修改檔案,然後在句柄上呼叫 requestPermission({mode: 'readwrite'})
  4. 如果使用者接受權限要求,請將變更內容儲存回原始檔案。
  5. 或者,您也可以呼叫 showSaveFilePicker(),讓使用者選擇新的檔案。(如果使用者選取先前開啟的檔案,該檔案的內容會遭到覆寫)。如要重複儲存,您可以保留檔案句柄,這樣就不必再次顯示檔案儲存對話方塊。

在網路上使用檔案的限制

透過這些方法存取的檔案和資料夾,稱為使用者可見的檔案系統。從網路儲存的檔案 (尤其是可執行檔案) 會標示為網路標記,因此在執行可能有危險的檔案前,作業系統會顯示額外警告。為了提供額外的安全防護,安全瀏覽功能也會保護從網路取得的檔案。為了簡單起見,在本文中,您可以將安全瀏覽視為雲端病毒掃描功能。使用 File System Access API 將資料寫入檔案時,寫入作業並非原地執行,而是使用暫存檔案。除非檔案通過所有安全性檢查,否則不會修改檔案本身。您可以想像,這項作業會讓檔案作業變得相對緩慢,即使在macOS 上也一樣。不過,每個 write() 呼叫都是獨立的,因此在幕後會開啟檔案、尋找指定的偏移量,最後再寫入資料。

以檔案做為處理作業的基礎

同時,檔案也是記錄資料的絕佳方式。舉例來說,SQLite 會將整個資料庫儲存在單一檔案中。另一個例子是圖像處理中使用的mipmaps。Mipmap 是預先計算過的最佳化圖片序列,每個圖片都會以逐漸降低的解析度呈現,因此許多操作 (例如縮放) 的速度會變快。那麼,網頁應用程式要如何取得檔案的優點,卻不必付出以網頁為基礎的檔案處理效能成本呢?答案是原始私人檔案系統

使用者可見的系統與來源私人檔案系統

與使用者可透過作業系統檔案總管瀏覽的公開檔案系統不同,原始私人檔案系統並非供使用者查看,如名稱所示,來源私人檔案系統中的檔案和資料夾為私人檔案,更具體來說,是屬於網站來源的私人檔案。在 DevTools 主控台中輸入 location.origin,即可查看網頁的來源。舉例來說,頁面 https://developer.chrome.com/articles/ 的來源是 https://developer.chrome.com (也就是說,/articles 部分「不是」來源的一部分)。如要進一步瞭解來源理論,請參閱「瞭解『相同網站』和『相同來源』」一文。所有共用相同來源的網頁都可以查看相同來源的私人檔案系統資料,因此 https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ 可以查看與先前範例相同的詳細資料。每個來源都有各自的獨立來源私人檔案系統,也就是說,https://developer.chrome.com 的來源私人檔案系統與 https://web.dev 的來源私人檔案系統完全不同。在 Windows 上,使用者可見檔案系統的根目錄為 C:\\。來源私人檔案系統的等價項目是每個來源的初始空白根目錄,可透過呼叫非同步方法 navigator.storage.getDirectory() 存取。如要比較使用者可見的檔案系統和原始私人檔案系統,請參閱下圖。如圖所示,除了根目錄外,其他所有項目在概念上都相同,並提供檔案和資料夾的階層,可視需要依據資料和儲存空間需求進行排序和安排。

使用者可見的檔案系統和原始私人檔案系統的圖表,其中包含兩個範例檔案階層。使用者可見的檔案系統的進入點是符號硬碟,來源私人檔案系統的進入點則是呼叫「navigator.storage.getDirectory」方法。

來源私人檔案系統的詳細資料

與瀏覽器中的其他儲存機制 (例如 localStorageIndexedDB) 一樣,原始私人檔案系統會受到瀏覽器配額限制。當使用者清除所有瀏覽資料所有網站資料時,也會一併刪除原始私人檔案系統。請呼叫 navigator.storage.estimate(),並在產生的回應物件中查看 usage 項目,瞭解應用程式已使用的儲存空間量,這些資料會依 usageDetails 物件中的儲存空間機制細分,您可以查看 fileSystem 項目。由於使用者無法查看來源私人檔案系統,因此不會顯示權限提示,也不會進行安全瀏覽檢查。

取得根目錄存取權

如要取得根目錄的存取權,請執行下列指令。您會得到空的目錄句柄,具體來說,是 FileSystemDirectoryHandle

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

主執行緒或 Web Worker

使用來源私人檔案系統的方式有兩種:在主執行緒Web Worker 中。Web Workers 無法封鎖主執行緒,這表示在這個情況下 API 可以是同步的,而這類模式通常不允許在主執行緒上執行。同步 API 可避免處理承諾,因此速度可能較快,而且在可編譯為 WebAssembly 的 C 等語言中,檔案作業通常是同步的。

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

如果您需要盡可能快速的檔案作業,或是要處理 WebAssembly,請直接跳至「在 Web Worker 中使用原始私人檔案系統」一節。否則,請繼續閱讀。

在主執行緒上使用來源私人檔案系統

建立新的檔案和資料夾

建立根資料夾後,請分別使用 getFileHandle()getDirectoryHandle() 方法建立檔案和資料夾。傳遞 {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}); const nestedDirectoryHandle = await directoryHandle     .getDirectoryHandle('my first nested folder', {create: true}); 

先前程式碼範例產生的檔案階層。

存取現有檔案和資料夾

如果您知道檔案或資料夾的名稱,請呼叫 getFileHandle()getDirectoryHandle() 方法,並傳入檔案或資料夾名稱,即可存取先前建立的檔案和資料夾。

const existingFileHandle = await opfsRoot.getFileHandle('my first file'); const existingDirectoryHandle = await opfsRoot     .getDirectoryHandle('my first folder'); 

取得與檔案句柄相關聯的檔案以供讀取

FileSystemFileHandle 代表檔案系統中的檔案。如要取得相關聯的 File,請使用 getFile() 方法。File 物件是特定類型的 Blob,可用於 Blob 可用的任何情境。具體來說,FileReaderURL.createObjectURL()createImageBitmap()XMLHttpRequest.send() 都接受 BlobsFiles。如果您要這麼做,從 FileSystemFileHandle 取得 File 會「釋放」資料,讓您可以存取資料,並提供給使用者可見的檔案系統。

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

透過串流寫入檔案

請呼叫 createWritable() 來建立 FileSystemWritableFileStream,然後將內容 write() 串流至檔案。最後,您需要close()串流。

const contents = 'Some text'; // Get a writable stream. const writable = await fileHandle.createWritable(); // Write the contents of the file to the stream. await writable.write(contents); // Close the stream, which persists the contents. await writable.close(); 

刪除檔案和資料夾

呼叫檔案或目錄句柄的特定 remove() 方法,即可刪除檔案和資料夾。如要刪除資料夾及其所有子資料夾,請傳遞 {recursive: true} 選項。

await fileHandle.remove(); await directoryHandle.remove({recursive: true}); 

或者,如果您知道目錄中要刪除的檔案或資料夾名稱,請使用 removeEntry() 方法。

directoryHandle.removeEntry('my first nested file'); 

移動及重新命名檔案和資料夾

使用 move() 方法重新命名及移動檔案和資料夾。你可以同時移動及重新命名,也可以分開進行。

// Rename a file. await fileHandle.move('my first renamed file'); // Move a file to another directory. await fileHandle.move(nestedDirectoryHandle); // Move a file to another directory and rename it. await fileHandle     .move(nestedDirectoryHandle, 'my first renamed and now nested file'); 

解析檔案或資料夾的路徑

如要瞭解特定檔案或資料夾相對於參考目錄的位置,請使用 resolve() 方法,並將 FileSystemHandle 做為引數傳遞。如要取得來源私人檔案系統中檔案或資料夾的完整路徑,請使用根目錄做為透過 navigator.storage.getDirectory() 取得的參考目錄。

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle); // `relativePath` is `['my first folder', 'my first nested folder']`. 

檢查兩個檔案或資料夾句柄是否指向相同的檔案或資料夾

有時您可能有兩個句柄,但不知道它們是否指向相同的檔案或資料夾。如要確認是否發生這種情況,請使用 isSameEntry() 方法。

fileHandle.isSameEntry(nestedFileHandle); // Returns `false`. 

列出資料夾的內容

FileSystemDirectoryHandle非同步疊代器,您可以使用 for await…of 迴圈對其進行疊代。由於它是異步疊代器,因此也支援 entries()values()keys() 方法,您可以根據所需資訊選擇其中一種方法:

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()) {} 

遞迴列出資料夾和所有子資料夾的內容

處理非同步迴圈和與遞迴配對的函式時,很容易出錯。下方的函式可用於列出資料夾及其所有子資料夾的內容,包括所有檔案及其大小。如果您不需要檔案大小,可以透過 directoryEntryPromises.push 簡化函式,不推送 handle.getFile() 承諾,而是直接推送 handle

  const getDirectoryEntriesRecursive = async (     directoryHandle,     relativePath = '.',   ) => {     const fileHandles = [];     const directoryHandles = [];     const entries = {};     // Get an iterator of the files and folders in the directory.     const directoryIterator = directoryHandle.values();     const directoryEntryPromises = [];     for await (const handle of directoryIterator) {       const nestedPath = `${relativePath}/${handle.name}`;       if (handle.kind === 'file') {         fileHandles.push({ handle, nestedPath });         directoryEntryPromises.push(           handle.getFile().then((file) => {             return {               name: handle.name,               kind: handle.kind,               size: file.size,               type: file.type,               lastModified: file.lastModified,               relativePath: nestedPath,               handle             };           }),         );       } else if (handle.kind === 'directory') {         directoryHandles.push({ handle, nestedPath });         directoryEntryPromises.push(           (async () => {             return {               name: handle.name,               kind: handle.kind,               relativePath: nestedPath,               entries:                   await getDirectoryEntriesRecursive(handle, nestedPath),               handle,             };           })(),         );       }     }     const directoryEntries = await Promise.all(directoryEntryPromises);     directoryEntries.forEach((directoryEntry) => {       entries[directoryEntry.name] = directoryEntry;     });     return entries;   }; 

在 Web Worker 中使用來源私人檔案系統

如先前所述,Web Workers 無法封鎖主執行緒,因此在這個情況下允許使用同步方法。

取得同步存取句柄

最快的檔案作業進入點是 FileSystemSyncAccessHandle,您可以呼叫 createSyncAccessHandle(),從一般 FileSystemFileHandle 取得此進入點。

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

同步原地檔案方法

取得同步存取句柄後,您就能存取快速的在原地檔案方法,這些方法全都是同步的。

  • getSize():傳回檔案大小,以位元組為單位。
  • write():將緩衝區的內容寫入檔案 (可選在指定偏移處),並傳回已寫入的位元組數。檢查傳回的寫入位元組數,可讓呼叫端偵測及處理錯誤和部分寫入。
  • read():將檔案內容讀入緩衝區,可選在指定偏移處讀取。
  • truncate():將檔案大小調整為指定大小。
  • flush():確保檔案內容包含透過 write() 完成的所有修改。
  • close():關閉存取句柄。

以下是使用上述所有方法的範例。

const opfsRoot = await navigator.storage.getDirectory(); const fileHandle = await opfsRoot.getFileHandle('fast', {create: true}); const accessHandle = await fileHandle.createSyncAccessHandle();  const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder();  // Initialize this variable for the size of the file. let size; // The current size of the file, initially `0`. size = accessHandle.getSize(); // Encode content to write to the file. const content = textEncoder.encode('Some text'); // Write the content at the beginning of the file. accessHandle.write(content, {at: size}); // Flush the changes. accessHandle.flush(); // The current size of the file, now `9` (the length of "Some text"). size = accessHandle.getSize();  // Encode more content to write to the file. const moreContent = textEncoder.encode('More content'); // Write the content at the end of the file. accessHandle.write(moreContent, {at: size}); // Flush the changes. accessHandle.flush(); // The current size of the file, now `21` (the length of // "Some textMore content"). size = accessHandle.getSize();  // Prepare a data view of the length of the file. const dataView = new DataView(new ArrayBuffer(size));  // Read the entire file into the data view. accessHandle.read(dataView); // Logs `"Some textMore content"`. console.log(textDecoder.decode(dataView));  // Read starting at offset 9 into the data view. accessHandle.read(dataView, {at: 9}); // Logs `"More content"`. console.log(textDecoder.decode(dataView));  // Truncate the file after 4 bytes. accessHandle.truncate(4); 

將檔案從來源私人檔案系統複製到使用者可見的檔案系統

如上所述,您無法將檔案從原始私人檔案系統移至使用者可見的檔案系統,但可以複製檔案。由於 showSaveFilePicker() 只會在主執行緒中公開,不會在 Worker 執行緒中公開,因此請務必在該處執行程式碼。

// On the main thread, not in the Worker. This assumes // `fileHandle` is the `FileSystemFileHandle` you obtained // the `FileSystemSyncAccessHandle` from in the Worker // thread. Be sure to close the file in the Worker thread first. const fileHandle = await opfsRoot.getFileHandle('fast'); try {   // Obtain a file handle to a new file in the user-visible file system   // with the same name as the file in the origin private file system.   const saveHandle = await showSaveFilePicker({     suggestedName: fileHandle.name || ''   });   const writable = await saveHandle.createWritable();   await writable.write(await fileHandle.getFile());   await writable.close(); } catch (err) {   console.error(err.name, err.message); } 

對原始私人檔案系統進行偵錯

在內建開發人員工具支援功能加入前 (請參閱 crbug/1284595),請使用 OPFS Explorer Chrome 擴充功能來偵錯原始私人檔案系統。順帶一提,上方「建立新檔案和資料夾」部分的螢幕截圖是直接從擴充功能擷取。

Chrome 線上應用程式商店中的 OPFS Explorer Chrome 開發人員工具擴充功能。

安裝擴充功能後,請開啟 Chrome 開發人員工具,然後選取「OPFS Explorer」分頁,即可檢查檔案階層。按一下檔案名稱,即可將檔案從原始私人檔案系統儲存到使用者可見的檔案系統,按一下垃圾桶圖示,即可刪除檔案和資料夾。

示範

如要查看原始私人檔案系統的運作情形 (如果您已安裝 OPFS Explorer 擴充功能),請參閱這項技術的示範,該示範會將原始私人檔案系統用作編譯為 WebAssembly 的 SQLite 資料庫的後端。請務必查看 Glitch 上的原始碼。請注意,下方的嵌入版本並未使用來源私人檔案系統後端 (因為 iframe 是跨來源),但在您在個別分頁中開啟示範時,就會使用來源私人檔案系統後端。

結論

根據 WHATWG 指定的來源私人檔案系統,我們決定了在網路上使用及與檔案互動的方式。這項功能可實現使用者無法透過可見檔案系統達成的新用途。所有主要瀏覽器供應商 (Apple、Mozilla 和 Google) 都加入了這個計畫,並共同分享願景。開發原始私人檔案系統需要多方合作,開發人員和使用者的意見回饋對其進度至關重要。我們會持續改進和改善標準,歡迎您針對 whatwg/fs 存放區提出意見回饋,形式可以是問題或提取要求。

特別銘謝

本文由 Austin SullyEtienne NoëlRachel Andrew 審查。主頁橫幅圖片由 Christina Rumpf 提供,取自 Unsplash