來源私人檔案系統

檔案系統標準會導入來源私有檔案系統 (OPFS),做為頁面來源專用的儲存端點,使用者無法查看,並提供特殊檔案的選用存取權,這類檔案經過高度效能最佳化。

瀏覽器支援

來源私有檔案系統支援新式瀏覽器,並由 Web 超文字應用程式技術工作組 (WHATWG) 在檔案系統即時標準中標準化。

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 導入了三種挑選器方法:

這些選項可啟用下列流程:

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

在網頁上處理檔案的限制

透過這些方法存取的檔案和資料夾位於所謂的「使用者可見」檔案系統中。從網路儲存的檔案 (尤其是可執行檔) 會標示網路標記,因此在執行可能危險的檔案前,作業系統會顯示額外的警告訊息。此外,為了提供額外的安全防護,從網路取得的檔案也會受到安全瀏覽保護。簡單來說,在本文脈絡中,您可以將安全瀏覽視為雲端病毒掃描。使用 File System Access API 將資料寫入檔案時,系統不會就地寫入,而是使用暫時性檔案。除非檔案通過所有安全性檢查,否則不會遭到修改。

因此,儘管我們盡可能改善,例如在 macOS 上,檔案作業的速度仍相對緩慢。不過,每個 write() 呼叫仍是獨立的,因此在幕後會開啟檔案、尋找指定位移,最後寫入資料。

以檔案做為處理基礎

同時,檔案也是記錄資料的絕佳方式。舉例來說,SQLite 會將整個資料庫儲存在單一檔案中。另一個例子是圖像處理中使用的 mipmap。mipmap 是預先計算的圖片最佳化序列,每個圖片都是前一個圖片的漸進式低解析度表示法,因此可加快許多作業 (例如縮放)。那麼,網頁應用程式如何才能享有檔案的優點,同時避免網頁式檔案處理的效能成本?答案是來源私有檔案系統

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

與使用者可見的檔案系統不同 (可透過作業系統的檔案總管瀏覽,並讀取、寫入、移動及重新命名檔案和資料夾),原始私人檔案系統不適合使用者查看。顧名思義,來源私有檔案系統中的檔案和資料夾是私有的,更具體來說,是網站來源的私有檔案。在開發人員工具控制台中輸入 location.origin ,即可找出網頁的來源。舉例來說,網頁 https://developer.chrome.com/articles/ 的來源是 https://developer.chrome.com。如要進一步瞭解來源理論,請參閱「瞭解『相同網站』和『相同來源』」。

凡是共用相同來源的網頁,都能看到相同來源的私有檔案系統資料,因此 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 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,請跳至「在 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,而不是 handle.getFile() 承諾。

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 Worker 無法封鎖主執行緒,因此允許使用同步方法。

取得同步存取控制代碼

最快速的檔案作業進入點是從一般 FileSystemFileHandle 呼叫 createSyncAccessHandle() 取得的 FileSystemSyncAccessHandle

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 資料庫後端。請務必查看 GitHub 上的原始碼。請注意,內嵌版本不會使用來源私有檔案系統後端 (因為 iframe 是跨來源),但您在個別分頁中開啟示範時,就會使用。

結論

WHATWG 指定的來源私有檔案系統,改變了我們在網路上使用及互動檔案的方式。這項技術支援了許多新用途,而這些用途是使用者可見檔案系統無法實現的。Apple、Mozilla 和 Google 等主要瀏覽器供應商都已加入,並分享共同願景。開發來源私人檔案系統需要大量協作,開發人員和使用者的意見回饋對進展至關重要。

我們會持續改善標準,歡迎透過 Issue 或 Pull Request 的形式,在 whatwg/fs 存放區提供意見回饋。

特別銘謝

本文由 Austin SullyEtienne NoëlRachel Andrew 審查。