來源私人檔案系統

檔案系統標準引進來源私人檔案系統 (OPFS) 作為網頁來源專用的儲存端點,使用者無法選擇這種儲存端點,以提供高度最佳化效能的特殊檔案類型。

瀏覽器支援

新式瀏覽器支援來源私人檔案系統,並且根據檔案系統生活標準中的 Web Hypertext Application Technology Working Group (WHATWG) 標準化。

瀏覽器支援

  • 86
  • 86
  • 111
  • 15.2

來源

動機

思考電腦上的檔案時,您不妨思考一下檔案階層:檔案是以資料夾分類,可透過作業系統的檔案總管進行探索。以 Windows 為例,使用者的「待辦事項」清單可能位於 C:\Users\Tom\Documents\ToDo.txt 中。在此範例中,ToDo.txt 是檔案名稱,而 UsersTomDocuments 是資料夾名稱。Windows 上的 `C:` 代表磁碟的根目錄。

以傳統方式在網路上處理檔案

如要在網頁應用程式中編輯待辦事項清單,以下是一般的流程:

  1. 使用者將檔案上傳至伺服器,或使用 <input type="file"> 在用戶端上開啟檔案。
  2. 使用者做出變更,然後下載產生的檔案,以及您透過 JavaScript 以程式輔助方式編寫的 <a download="ToDo.txt> 插入的 <a download="ToDo.txt>click()
  3. 為了開啟資料夾,您在 <input type="file" webkitdirectory> 中使用特殊屬性,即使屬性名稱仍支援通用瀏覽器。

在網路上處理檔案的新方式

此流程無法反映使用者編輯檔案的方式,且是指使用者最終下載了輸入檔案的「副本」。因此,File System Access API 引入了三種挑選器方法,包括 showOpenFilePicker()showSaveFilePicker()showDirectoryPicker(),專門用於其名稱所代表的意義。可依照下列方式啟用流程:

  1. 使用 showOpenFilePicker() 開啟 ToDo.txt,並取得 FileSystemFileHandle 物件。
  2. 呼叫檔案控制代碼的 getFile() 方法,從 FileSystemFileHandle 物件取得 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 (也就是說,/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 中。網路工作處理序無法封鎖主執行緒,也就是說,在此情境中 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;
  };

使用網路工作處理序中的原始私人檔案系統

如前文所述,網路工作處理程序無法封鎖主執行緒,因此在此結構定義中可以使用同步方法。

取得同步存取控點

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

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() 只會在主執行緒上公開,但不會在工作站執行緒中公開,因此請務必在當中執行程式碼。

// 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 網站上。