使用 Browser-fs-access 程式庫讀取及寫入檔案和目錄

瀏覽器已能處理檔案和目錄很長一段時間。File API 提供可在網路應用程式中代表檔案物件的功能,以及以程式輔助方式選取檔案物件和存取其資料的功能。不過,只要仔細觀察,就會發現「金玉其燦」的背後,其實是「金玉其臭」。

處理檔案的傳統方式

開啟檔案

開發人員可以透過 <input type="file"> 元素開啟及讀取檔案。最簡單的開啟檔案方式,如以下程式碼範例所示。input 物件會提供 FileList,在下方範例中,這個物件只包含一個 FileFile 是特定類型的 Blob,可用於 Blob 可用的任何情境。

const openFile = async () => {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.addEventListener('change', () => {
      resolve(input.files[0]);
    });
    input.click();
  });
};

開啟目錄

如要開啟資料夾 (或目錄),您可以設定 <input webkitdirectory> 屬性。除此之外,其他所有操作都與上述相同。儘管 webkitdirectory 名稱中帶有供應商前置字串,但不僅可用於 Chromium 和 WebKit 瀏覽器,也適用於以 EdgeHTML 為基礎的舊版 Edge 和 Firefox。

儲存 (或下載) 檔案

傳統上,您只能下載檔案來儲存檔案,這項功能是透過 <a download> 屬性運作。在 Blob 中,您可以將錨點的 href 屬性設為 blob: 網址,您可以透過 URL.createObjectURL() 方法取得該網址。

const saveFile = async (blob) => {
  const a = document.createElement('a');
  a.download = 'my-file.txt';
  a.href = URL.createObjectURL(blob);
  a.addEventListener('click', (e) => {
    setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
  });
  a.click();
};

問題

下載方法的一大缺點是無法執行傳統的開啟→編輯→儲存流程,也就是無法覆寫原始檔案。相反地,每次「儲存」時,系統會在作業系統的預設「下載」資料夾中建立原始檔案的新副本

File System Access API

檔案系統存取權 API 可讓開啟和儲存作業變得更簡單。這項功能還可啟用真實儲存功能,也就是說,您不僅可以選擇檔案儲存位置,還可以覆寫現有檔案。

開啟檔案

使用 File System Access API 時,只要呼叫一次 window.showOpenFilePicker() 方法,即可開啟檔案。這個呼叫會傳回檔案句柄,您可以透過 getFile() 方法取得實際的 File

const openFile = async () => {
  try {
    // Always returns an array.
    const [handle] = await window.showOpenFilePicker();
    return handle.getFile();
  } catch (err) {
    console.error(err.name, err.message);
  }
};

開啟目錄

呼叫 window.showDirectoryPicker() 即可開啟目錄,讓使用者在檔案對話方塊中選取目錄。

儲存檔案

儲存檔案的方式也非常簡單。您可以透過 createWritable() 從檔案句柄建立可寫入的串流,然後呼叫串流的 write() 方法來寫入 Blob 資料,最後呼叫其 close() 方法來關閉串流。

const saveFile = async (blob) => {
  try {
    const handle = await window.showSaveFilePicker({
      types: [{
        accept: {
          // Omitted
        },
      }],
    });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
    return handle;
  } catch (err) {
    console.error(err.name, err.message);
  }
};

推出 browser-fs-access

雖然 File System Access API 非常實用,但尚未廣泛提供

File System Access API 的瀏覽器支援表格。所有瀏覽器都標示為「不支援」或「待定」。
File System Access API 的瀏覽器支援表格。 (來源)

因此,我認為 File System Access API 是漸進式增強功能。因此,我希望在瀏覽器支援時使用這個方法,如果不支援,則使用傳統方法;同時,絕不會讓使用者不必要地下載不支援的 JavaScript 程式碼。browser-fs-access 程式庫是我對這項挑戰的解答。

設計理念

由於 File System Access API 日後仍可能有所變更,因此 browser-fs-access API 並未以該 API 為基礎。也就是說,該程式庫不是 polyfill,而是 ponyfill。您可以 (靜態或動態) 專門匯入所需的任何功能,盡可能縮小應用程式大小。可用的方法包括 fileOpen()directoryOpen()fileSave()。在內部,程式庫功能會偵測是否支援 File System Access API,然後匯入對應的程式碼路徑。

使用 browser-fs-access 程式庫

這三種方法都很直覺易用。您可以指定應用程式接受的 mimeTypes 或檔案 extensions,並設定 multiple 標記,允許或禁止選取多個檔案或目錄。詳情請參閱 browser-fs-access API 說明文件。下列程式碼範例說明如何開啟及儲存圖片檔案。

// The imported methods will use the File
// System Access API or a fallback implementation.
import {
  fileOpen,
  directoryOpen,
  fileSave,
} from 'https://unpkg.com/browser-fs-access';

(async () => {
  // Open an image file.
  const blob = await fileOpen({
    mimeTypes: ['image/*'],
  });

  // Open multiple image files.
  const blobs = await fileOpen({
    mimeTypes: ['image/*'],
    multiple: true,
  });

  // Open all files in a directory,
  // recursively including subdirectories.
  const blobsInDirectory = await directoryOpen({
    recursive: true
  });

  // Save a file.
  await fileSave(blob, {
    fileName: 'Untitled.png',
  });
})();

示範

您可以在 Glitch 的示範中查看上述程式碼的實際運作情形。其原始碼也同樣可供使用。基於安全性考量,跨來源子框架不得顯示檔案挑選器,因此無法將該示範嵌入本文。

實際使用的 browser-fs-access 程式庫

在空閒時間,我會為名為 Excalidraw可安裝 PWA 做出一點貢獻,這個白板工具可讓您輕鬆繪製手繪風格的圖表。這項功能完全回應式,可在各種裝置上順利運作,從小型手機到大螢幕電腦皆適用。也就是說,無論是否支援 File System Access API,它都需要處理所有不同平台上的檔案。因此,它非常適合用於瀏覽器-fs-access 程式庫。

舉例來說,我可以在 iPhone 上開始繪圖,然後將圖片儲存至 iPhone 的「下載」資料夾 (技術上來說是「下載」),接著在電腦上開啟檔案 (從手機傳輸檔案後),修改檔案,並覆寫檔案或將其儲存為新檔案。

iPhone 上的 Excalidraw 繪圖。
在 iPhone 上啟動 Excalidraw 繪圖作業,但該裝置不支援檔案系統存取 API,但可將檔案儲存 (下載) 至「下載」資料夾。
在電腦上的 Chrome 中修改的 Excalidraw 繪圖。
在支援 File System Access API 的桌面上開啟及修改 Excalidraw 圖表,這樣就能透過 API 存取檔案。
覆寫原始檔案,並套用修改內容。
使用對原始 Excalidraw 繪圖檔案所做的修改,覆寫原始檔案。瀏覽器會顯示對話方塊,詢問我是否同意。
將修改內容儲存至新的 Excalidraw 繪圖檔案。
將修改內容儲存至新的 Excalidraw 檔案。原始檔案則會保持不變。

實際程式碼範例

以下是瀏覽器-fs-access 實際用於 Excalidraw 的範例。這段摘錄內容取自 /src/data/json.ts。特別值得注意的是,saveAsJSON() 方法如何將檔案句柄或 null 傳遞至 browser-fs-access 的 fileSave() 方法,這會導致在提供句柄時覆寫,或在未提供時儲存至新檔案。

export const saveAsJSON = async (
  elements: readonly ExcalidrawElement[],
  appState: AppState,
  fileHandle: any,
) => {
  const serialized = serializeAsJSON(elements, appState);
  const blob = new Blob([serialized], {
    type: "application/json",
  });
  const name = `${appState.name}.excalidraw`;
  (window as any).handle = await fileSave(
    blob,
    {
      fileName: name,
      description: "Excalidraw file",
      extensions: ["excalidraw"],
    },
    fileHandle || null,
  );
};

export const loadFromJSON = async () => {
  const blob = await fileOpen({
    description: "Excalidraw files",
    extensions: ["json", "excalidraw"],
    mimeTypes: ["application/json"],
  });
  return loadFromBlob(blob);
};

UI 考量事項

無論是在 Excalidraw 或應用程式中,UI 都應配合瀏覽器的支援情況。如果支援 File System Access API (if ('showOpenFilePicker' in window) {}),您可以除了顯示「Save」按鈕外,也顯示「Save As」按鈕。下方螢幕截圖顯示 iPhone 和 Chrome 電腦版上,Excalidraw 主應用程式工具列的回應式差異。請注意,iPhone 上缺少「Save As」按鈕。

iPhone 上的 Excalidraw 應用程式工具列,只有「Save」按鈕。
iPhone 上的 Excalidraw 應用程式工具列,僅有「Save」按鈕。
Chrome 電腦版上的 Excalidraw 應用程式工具列,其中有「Save」和「Save As」按鈕。
Chrome 上的 Excalidraw 應用程式工具列,其中包含「Save」和「Save As」按鈕,且後者處於聚焦狀態。

結論

從技術層面來說,所有新式瀏覽器都能處理系統檔案。在支援 File System Access API 的瀏覽器上,您可以允許真正的檔案儲存和覆寫 (而非僅下載),並讓使用者在任何地方建立新檔案,藉此改善使用體驗,同時在不支援 File System Access API 的瀏覽器上保留功能。browser-fs-access 會處理漸進式增強功能的細微差異,並盡可能簡化程式碼,讓您更輕鬆地處理這些問題。

特別銘謝

本文由 Joe MedleyKayce Basques 審查。感謝 Excalidraw 的貢獻者,感謝他們在專案上的工作,以及審查我的提取要求。主頁橫幅:Unsplash 上的 Ilya Pavlov 提供。