File System Access API:簡化本機檔案存取流程

File System Access API 可讓網頁應用程式直接讀取或儲存使用者裝置上的檔案和資料夾變更。

什麼是 File System Access API?

File System Access API 可讓開發人員建構功能強大的網頁應用程式,與使用者裝置上的檔案互動,例如 IDE、相片和影片編輯器、文字編輯器等。使用者授予網頁應用程式存取權後,這個 API 就會允許使用者直接讀取或儲存變更至使用者裝置上的檔案和資料夾。除了讀取及寫入檔案之外,File System Access API 還可開啟目錄並列舉其內容。

如果您曾處理過讀取和寫入檔案的作業,那麼我接下來要分享的內容,您應該會覺得很熟悉。不過,我們還是建議您閱讀這份說明,因為並非所有系統都相同。

在 Windows、macOS、ChromeOS、Linux 和 Android 的大多數 Chromium 瀏覽器上,都支援 File System Access API。值得注意的是,Brave 目前僅在標記後方提供這項功能。

使用 File System Access API

為了展示 File System Access API 的強大功能和實用性,我編寫了一個單一檔案文字編輯器。您可以使用此方法開啟文字檔案、編輯檔案、將變更內容儲存回磁碟,或是開始建立新檔案並將變更內容儲存到磁碟。這並非什麼高級功能,但提供的資訊足以協助您瞭解相關概念。

瀏覽器支援

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: not supported.
  • Safari: not supported.

Source

特徵偵測

如要瞭解系統是否支援 File System Access API,請檢查您感興趣的挑選工具方法是否存在。

if ('showOpenFilePicker' in self) {
  // The `showOpenFilePicker()` method of the File System Access API is supported.
}

立即試用

如要查看 File System Access API 的實際應用情形,請參閱文字編輯器示範。

從本機檔案系統讀取檔案

我要處理的第一個用途是要求使用者選擇檔案,然後從磁碟開啟並讀取該檔案。

請使用者選取要讀取的檔案

File System Access API 的進入點為 window.showOpenFilePicker()。呼叫時,會顯示檔案挑選器對話方塊,並提示使用者選取檔案。使用者選取檔案後,API 會傳回檔案句柄陣列。您可以透過選用的 options 參數,影響檔案挑選工具的行為,例如允許使用者選取多個檔案、目錄或不同檔案類型。如果未指定任何選項,檔案挑選器會允許使用者選取單一檔案。這非常適合文字編輯器。

如同許多其他強大的 API,呼叫 showOpenFilePicker() 時必須在安全的環境中進行,且必須在使用者手勢中呼叫。

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  // Destructure the one-element array.
  [fileHandle] = await window.showOpenFilePicker();
  // Do something with the file handle.
});

使用者選取檔案後,showOpenFilePicker() 會傳回處理常式陣列,在本例中,這是一個含有一個 FileSystemFileHandle 的單一元素陣列,其中包含與檔案互動所需的屬性和方法。

建議您保留檔案句柄的參照,以便日後使用。您需要使用此權限才能儲存檔案變更,或執行任何其他檔案作業。

從檔案系統讀取檔案

有了檔案的句柄,您就可以取得檔案的屬性,或存取檔案本身。我會先讀取內容。呼叫 handle.getFile() 會傳回包含 blob 的 File 物件。如要從 Blob 取得資料,請呼叫其中一個方法 (slice()stream()text()arrayBuffer())。

const file = await fileHandle.getFile();
const contents = await file.text();

只要磁碟上的基礎檔案未變更,FileSystemFileHandle.getFile() 傳回的 File 物件就只能讀取。如果磁碟上的檔案已修改,File 物件就會變得無法讀取,您必須再次呼叫 getFile(),才能取得新的 File 物件來讀取已變更的資料。

全面整合使用

使用者點選「Open」按鈕時,瀏覽器會顯示檔案挑選器。使用者選取檔案後,應用程式會讀取內容並放入 <textarea>

let fileHandle;
butOpenFile.addEventListener('click', async () => {
  [fileHandle] = await window.showOpenFilePicker();
  const file = await fileHandle.getFile();
  const contents = await file.text();
  textArea.value = contents;
});

將檔案寫入本機檔案系統

在文字編輯器中,您可以透過「儲存」和「另存新檔」兩種方式儲存檔案。Save 會使用先前擷取的檔案句柄,將變更內容寫回原始檔案。不過,另存為會建立新檔案,因此需要新的檔案句柄。

建立新檔案

如要儲存檔案,請呼叫 showSaveFilePicker(),這會在「儲存」模式下顯示檔案挑選器,讓使用者挑選要用來儲存的新檔案。對於文字編輯器,我也希望它能自動新增 .txt 擴充功能,因此我提供了一些額外參數。

async function getNewFileHandle() {
  const options = {
    types: [
      {
        description: 'Text Files',
        accept: {
          'text/plain': ['.txt'],
        },
      },
    ],
  };
  const handle = await window.showSaveFilePicker(options);
  return handle;
}

將變更儲存至磁碟

您可以在 GitHub 上找到我的 文字編輯器示範,其中包含所有用於儲存檔案變更的程式碼。核心檔案系統互動內容位於 fs-helpers.js 中。最簡單的做法是,讓這個程序看起來像以下程式碼。我會逐步說明每個步驟。

// fileHandle is an instance of FileSystemFileHandle..
async function writeFile(fileHandle, contents) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Write the contents of the file to the stream.
  await writable.write(contents);
  // Close the file and write the contents to disk.
  await writable.close();
}

將資料寫入磁碟時,會使用 FileSystemWritableFileStream 物件 (WritableStream 的子類別)。請在檔案句柄物件上呼叫 createWritable(),藉此建立串流。呼叫 createWritable() 時,瀏覽器會先檢查使用者是否已授予檔案的寫入權限。如果未授予寫入權限,瀏覽器會提示使用者授予權限。如果未授予權限,createWritable() 就會擲回 DOMException,且應用程式將無法寫入檔案。在文字編輯器中,DOMException 物件會在 saveFile() 方法中處理。

write() 方法會採用字串,這是文字編輯器所需的內容。但也可以使用 BufferSourceBlob。例如,您可以將資料流直接傳送至該項目:

async function writeURLToFile(fileHandle, url) {
  // Create a FileSystemWritableFileStream to write to.
  const writable = await fileHandle.createWritable();
  // Make an HTTP request for the contents.
  const response = await fetch(url);
  // Stream the response into the file.
  await response.body.pipeTo(writable);
  // pipeTo() closes the destination pipe by default, no need to close it.
}

您也可以在串流中執行 seek()truncate(),以更新特定位置的檔案,或調整檔案大小。

指定建議的檔案名稱和起始目錄

在許多情況下,您可能會希望應用程式建議預設檔案名稱或位置。舉例來說,文字編輯器可能會建議預設檔案名稱為 Untitled Text.txt,而非 Untitled。您可以將 suggestedName 屬性傳遞為 showSaveFilePicker 選項的一部分,藉此達成這項目標。

const fileHandle = await self.showSaveFilePicker({
  suggestedName: 'Untitled Text.txt',
  types: [{
    description: 'Text documents',
    accept: {
      'text/plain': ['.txt'],
    },
  }],
});

預設的啟動目錄也是如此。如果您正在建構文字編輯器,建議您在預設 documents 資料夾中啟動檔案儲存或檔案開啟對話方塊,而如果是圖片編輯器,建議您在預設 pictures 資料夾中啟動對話方塊。您可以將 startIn 屬性傳遞至 showSaveFilePickershowDirectoryPicker()showOpenFilePicker 方法,如下所示,藉此建議預設啟動目錄。

const fileHandle = await self.showOpenFilePicker({
  startIn: 'pictures'
});

常見的系統目錄清單如下:

  • desktop:使用者的電腦桌面目錄 (如果有)。
  • documents:使用者建立的文件通常會儲存在這個目錄中。
  • downloads:下載的檔案通常會儲存在這個目錄中。
  • music:音訊檔案通常儲存的目錄。
  • pictures:相片和其他靜態圖片通常會儲存在這個目錄中。
  • videos:通常用來儲存影片或電影的目錄。

除了眾所周知的系統目錄之外,您也可以將現有的檔案或目錄句柄,做為 startIn 的值傳遞。對話方塊就會在同一個目錄中開啟。

// Assume `directoryHandle` is a handle to a previously opened directory.
const fileHandle = await self.showOpenFilePicker({
  startIn: directoryHandle
});

指定不同檔案挑選器的用途

有時應用程式會根據不同用途使用不同的挑選器。舉例來說,富文字編輯器可讓使用者開啟文字檔,但也能匯入圖片。根據預設,每個檔案挑選器都會在系統記得的上次位置開啟。您可以為每種類型的選擇器儲存 id 值,藉此解決這個問題。如果指定 id,檔案挑選器實作會記住該 id 上次使用的獨立目錄。

const fileHandle1 = await self.showSaveFilePicker({
  id: 'openText',
});

const fileHandle2 = await self.showSaveFilePicker({
  id: 'importImage',
});

在 IndexedDB 中儲存檔案句柄或目錄句柄

檔案句柄和目錄句柄可序列化,也就是說,您可以將檔案或目錄句柄儲存至 IndexedDB,或呼叫 postMessage() 在相同頂層來源之間傳送這些句柄。

將檔案或目錄句柄儲存至 IndexedDB 表示您可以儲存狀態,或記住使用者正在處理的檔案或目錄。這樣一來,您就能保留最近開啟或編輯的檔案清單、在開啟應用程式時重新開啟上次使用的檔案、還原先前的作業目錄等等。在文字編輯器中,我會儲存使用者最近開啟的五個檔案清單,方便再次存取這些檔案。

以下程式碼範例說明如何儲存及擷取檔案句柄和目錄句柄。您可以在 Glitch 上查看實際運作情形。(我使用 idb-keyval 程式庫以便簡化說明)。

import { get, set } from 'https://unpkg.com/[email protected]/dist/esm/index.js';

const pre1 = document.querySelector('pre.file');
const pre2 = document.querySelector('pre.directory');
const button1 = document.querySelector('button.file');
const button2 = document.querySelector('button.directory');

// File handle
button1.addEventListener('click', async () => {
  try {
    const fileHandleOrUndefined = await get('file');
    if (fileHandleOrUndefined) {
      pre1.textContent = `Retrieved file handle "${fileHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const [fileHandle] = await window.showOpenFilePicker();
    await set('file', fileHandle);
    pre1.textContent = `Stored file handle for "${fileHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

// Directory handle
button2.addEventListener('click', async () => {
  try {
    const directoryHandleOrUndefined = await get('directory');
    if (directoryHandleOrUndefined) {
      pre2.textContent = `Retrieved directroy handle "${directoryHandleOrUndefined.name}" from IndexedDB.`;
      return;
    }
    const directoryHandle = await window.showDirectoryPicker();
    await set('directory', directoryHandle);
    pre2.textContent = `Stored directory handle for "${directoryHandle.name}" in IndexedDB.`;
  } catch (error) {
    alert(error.name, error.message);
  }
});

儲存的檔案或目錄句柄和權限

由於權限不一定會在工作階段之間保留,因此您應確認使用者是否已使用 queryPermission() 授予檔案或目錄的權限。如果尚未完成,請呼叫 requestPermission() 重新要求。檔案和目錄句柄的運作方式也相同。您需要分別執行 fileOrDirectoryHandle.requestPermission(descriptor)fileOrDirectoryHandle.queryPermission(descriptor)

我在文字編輯器中建立了 verifyPermission() 方法,用於檢查使用者是否已授予權限,並視需要提出要求。

async function verifyPermission(fileHandle, readWrite) {
  const options = {};
  if (readWrite) {
    options.mode = 'readwrite';
  }
  // Check if permission was already granted. If so, return true.
  if ((await fileHandle.queryPermission(options)) === 'granted') {
    return true;
  }
  // Request permission. If the user grants permission, return true.
  if ((await fileHandle.requestPermission(options)) === 'granted') {
    return true;
  }
  // The user didn't grant permission, so return false.
  return false;
}

透過讀取要求要求寫入權限,我減少了權限提示的數量;使用者在開啟檔案時會看到一個提示,並授予讀取和寫入檔案的權限。

開啟目錄並列舉其內容

如要列舉目錄中的所有檔案,請呼叫 showDirectoryPicker()。使用者在挑選器中選取目錄後,系統會傳回 FileSystemDirectoryHandle,讓您列舉及存取目錄的檔案。根據預設,您將可讀取目錄中的檔案,但如果需要寫入權限,可以將 { mode: 'readwrite' } 傳遞至方法。

butDir.addEventListener('click', async () => {
  const dirHandle = await window.showDirectoryPicker();
  for await (const entry of dirHandle.values()) {
    console.log(entry.kind, entry.name);
  }
});

如果您還需要使用 getFile() 存取每個檔案 (例如取得個別檔案大小),請不要依序對每個結果使用 await,而是並行處理所有檔案,例如使用 Promise.all()

butDir.addEventListener('click', async () => {
  const dirHandle = await window.showDirectoryPicker();
  const promises = [];
  for await (const entry of dirHandle.values()) {
    if (entry.kind !== 'file') {
      continue;
    }
    promises.push(entry.getFile().then((file) => `${file.name} (${file.size})`));
  }
  console.log(await Promise.all(promises));
});

建立或存取目錄中的檔案和資料夾

您可以透過目錄使用 getFileHandle()getDirectoryHandle() 方法建立或存取檔案和資料夾。您可以傳入選用的 options 物件,其中鍵為 create,布林值為 truefalse,藉此判斷是否應在檔案或資料夾不存在時建立新檔案或資料夾。

// In an existing directory, create a new directory named "My Documents".
const newDirectoryHandle = await existingDirectoryHandle.getDirectoryHandle('My Documents', {
  create: true,
});
// In this new directory, create a file named "My Notes.txt".
const newFileHandle = await newDirectoryHandle.getFileHandle('My Notes.txt', { create: true });

解析目錄中項目的路徑

處理目錄中的檔案或資料夾時,您可能需要解析問題項目的路徑。您可以使用命名恰當的 resolve() 方法執行這項操作。在解析時,項目可以是目錄的直接或間接子項。

// Resolve the path of the previously created file called "My Notes.txt".
const path = await newDirectoryHandle.resolve(newFileHandle);
// `path` is now ["My Documents", "My Notes.txt"]

刪除目錄中的檔案和資料夾

如果您已取得目錄的存取權,就可以使用 removeEntry() 方法刪除其中的檔案和資料夾。針對資料夾,您可以選擇遞迴刪除,並包含所有子資料夾和其中的檔案。

// Delete a file.
await directoryHandle.removeEntry('Abandoned Projects.txt');
// Recursively delete a folder.
await directoryHandle.removeEntry('Old Stuff', { recursive: true });

直接刪除檔案或資料夾

如果您有權存取檔案或目錄句柄,請在 FileSystemFileHandleFileSystemDirectoryHandle 上呼叫 remove() 來移除該句柄。

// Delete a file.
await fileHandle.remove();
// Delete a directory.
await directoryHandle.remove();

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

您可以在 FileSystemHandle 介面上呼叫 move(),藉此重新命名檔案和資料夾,或將檔案和資料夾移至新位置。FileSystemHandle 有子介面 FileSystemFileHandleFileSystemDirectoryHandlemove() 方法有一個或兩個參數。第一個參數可以是包含新名稱的字串,或是目標資料夾的 FileSystemDirectoryHandle。在後一種情況下,選用的第二個參數是包含新名稱的字串,因此可以一次完成移動和重新命名作業。

// Rename the file.
await file.move('new_name');
// Move the file to a new directory.
await file.move(directory);
// Move the file to a new directory and rename it.
await file.move(directory, 'newer_name');

拖曳式整合

HTML 拖曳和放置介面可讓網頁應用程式接受網頁上拖曳和放置的檔案。在拖曳及放置作業期間,拖曳的檔案和目錄項目會分別與檔案項目和目錄項目建立關聯。如果拖曳的項目是檔案,DataTransferItem.getAsFileSystemHandle() 方法會傳回含有 FileSystemFileHandle 物件的 promise,如果拖曳的項目是目錄,則會傳回含有 FileSystemDirectoryHandle 物件的 promise。以下清單顯示實際運作情形。請注意,拖曳和放置介面的 DataTransferItem.kind"file",適用於檔案和目錄;而 File System Access API 的 FileSystemHandle.kind"file" (適用於檔案) 和 "directory" (適用於目錄)。

elem.addEventListener('dragover', (e) => {
  // Prevent navigation.
  e.preventDefault();
});

elem.addEventListener('drop', async (e) => {
  e.preventDefault();

  const fileHandlesPromises = [...e.dataTransfer.items]
    .filter((item) => item.kind === 'file')
    .map((item) => item.getAsFileSystemHandle());

  for await (const handle of fileHandlesPromises) {
    if (handle.kind === 'directory') {
      console.log(`Directory: ${handle.name}`);
    } else {
      console.log(`File: ${handle.name}`);
    }
  }
});

存取來源私人檔案系統

來源私人檔案系統是儲存端點,顧名思義,該端點是頁面來源的私人端點。雖然瀏覽器通常會透過將來源私人檔案系統的內容儲存至磁碟來實作這項功能,但這類內容「不應」供使用者存取。同樣地,我們預期會存在名稱與來源私人檔案系統子項名稱相符的檔案或目錄。雖然瀏覽器可能會讓您認為有檔案,但在內部 (因為這是原始私人檔案系統),瀏覽器可能會將這些「檔案」儲存在資料庫或任何其他資料結構中。基本上,如果您使用這個 API,請不要預期會在硬碟上找到一對一的建立檔案。取得根目錄 FileSystemDirectoryHandle 的存取權後,您就可以照常操作來源私人檔案系統。

const root = await navigator.storage.getDirectory();
// Create a new file handle.
const fileHandle = await root.getFileHandle('Untitled.txt', { create: true });
// Create a new directory handle.
const dirHandle = await root.getDirectoryHandle('New Folder', { create: true });
// Recursively remove a directory.
await root.removeEntry('Old Stuff', { recursive: true });

Browser Support

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

Source

從原始私人檔案系統存取效能最佳化的檔案

來源私人檔案系統可提供特殊類型檔案的選用存取權,這些檔案經過高度最佳化,可大幅提升效能,例如提供檔案內容的內建及專屬寫入權限。在 Chromium 102 以上版本中,來源私人檔案系統上還有另一種方法可簡化檔案存取:createSyncAccessHandle() (用於同步讀取和寫入作業)。這個屬性會在 FileSystemFileHandle 上公開,但僅限於 Web Workers

// (Read and write operations are synchronous,
// but obtaining the handle is asynchronous.)
// Synchronous access exclusively in Worker contexts.
const accessHandle = await fileHandle.createSyncAccessHandle();
const writtenBytes = accessHandle.write(buffer);
const readBytes = accessHandle.read(buffer, { at: 1 });

聚酯纖維

無法完全為 File System Access API 方法提供 polyfill。

  • showOpenFilePicker() 方法可以使用 <input type="file"> 元素近似表示。
  • showSaveFilePicker() 方法可透過 <a download="file_name"> 元素模擬,但這會觸發程式輔助下載,且不允許覆寫現有檔案。
  • showDirectoryPicker() 方法可透過非標準的 <input type="file" webkitdirectory> 元素模擬。

我們開發了一個名為 browser-fs-access 的程式庫,盡可能使用 File System Access API,並在所有其他情況下改用這些次佳選項。

安全性和權限

Chrome 團隊根據「控管強大網路平台功能的存取權」一文中定義的核心原則,設計並實作了 File System Access API,包括使用者控管和資訊透明度,以及使用者人體工學。

開啟檔案或儲存新檔案

檔案選擇器,可開啟檔案供閱讀
用於開啟現有檔案供讀取的檔案挑選器。

開啟檔案時,使用者會透過檔案挑選器提供讀取檔案或目錄的權限。只有在從安全內容提供時,才能使用使用者手勢顯示開啟檔案挑選器。如果使用者改變心意,可以在檔案挑選器中取消選取,網站就不會取得任何存取權。這與 <input type="file"> 元素的行為相同。

檔案挑選器,可將檔案儲存至磁碟。
用於將檔案儲存到磁碟的檔案挑選器。

同樣地,當網路應用程式要儲存新檔案時,瀏覽器會顯示儲存檔案挑選器,讓使用者指定新檔案的名稱和位置。由於他們將新檔案儲存到裝置 (而非覆寫現有檔案),因此檔案挑選器會授予應用程式寫入檔案的權限。

受限制的資料夾

為保護使用者和他們的資料,瀏覽器可能會限制使用者儲存至特定資料夾的功能,例如 Windows 和 macOS 的 Library 資料夾等核心作業系統資料夾。發生這種情況時,瀏覽器會顯示提示,要求使用者選擇其他資料夾。

修改現有檔案或目錄

網頁應用程式必須取得使用者的明確許可,才能修改磁碟上的檔案。

權限提示

如果使用者想將變更內容儲存至先前授予讀取權的檔案,瀏覽器會顯示權限提示,要求網站將變更內容寫入磁碟。權限要求只能由使用者動作觸發,例如按一下「儲存」按鈕。

儲存檔案前顯示的權限提示。
在瀏覽器獲得現有檔案的寫入權限前,向使用者顯示提示。

或者,編輯多個檔案的網頁應用程式 (例如 IDE),也可以在開啟時要求儲存變更的權限。

如果使用者選擇「取消」,且未授予寫入權限,網路應用程式就無法將變更儲存至本機檔案。應為使用者提供其他方法來儲存資料,例如提供「下載」檔案或將資料儲存至雲端的做法。

透明度

網址列圖示
網址列圖示,表示使用者已授予網站儲存至本機檔案的權限。

使用者授予網頁應用程式權限後,瀏覽器會在網址列中顯示圖示。按一下圖示後,系統會開啟彈出式視窗,顯示使用者已授予存取權的檔案清單。使用者隨時可以選擇撤銷該存取權。

權限持續性

在關閉來源的所有分頁前,網路應用程式可以繼續儲存檔案的變更,而不會顯示提示。關閉分頁後,網站就會失去所有存取權。使用者下次使用網頁應用程式時,系統會再次提示他們存取檔案。

意見回饋

我們想瞭解您使用 File System Access API 的體驗。

請說明 API 設計

API 是否有任何部分無法正常運作?或者,您是否缺少實作想法所需的方法或屬性?對於安全性模型有疑問或意見嗎?

導入時發生問題?

你是否發現 Chrome 實作項目有錯誤?或者實作方式與規格不同?

打算使用 API 嗎?

您是否打算在網站上使用 File System Access API?您的公開支持有助於我們決定功能的優先順序,並向其他瀏覽器供應商顯示支援這些功能的重要性。

實用連結

特別銘謝

File System Access API 規格是由 Marijn Kruisselbrink 撰寫。