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 的大多數 Chromium 瀏覽器上,都支援 File System Access API。值得注意的是,Brave 目前僅在標記後方提供這項功能。我們已在 crbug.com/1011535 環境中持續支援 Android。

使用 File System Access API

為了展示 File System Access API 的強大功能和實用性,我編寫了一個單一檔案文字編輯器。您可以使用此方法開啟文字檔案、編輯檔案、將變更內容儲存回磁碟,或是開始建立新檔案並將變更內容儲存到磁碟。雖然這種做法很實用,但可讓您理解概念。

瀏覽器支援

瀏覽器支援

  • Chrome:86。
  • Edge:86。
  • Firefox:不支援。
  • Safari:不支援。

資料來源

特徵偵測

如要瞭解系統是否支援 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。只要在 showSaveFilePicker 選項中傳遞 suggestedName 屬性即可。

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/idb-keyval@5.0.2/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 });

瀏覽器支援

  • Chrome:86。
  • Edge:86,
  • Firefox:111。
  • Safari:15.2。

資料來源

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

來源私人檔案系統可提供特殊類型檔案的選用存取權,這些檔案經過高度最佳化,可大幅提升效能,例如提供檔案內容的內建及專屬寫入權限。在 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 實作項目有錯誤?或者實作方式與規格不同?

  • 前往 https://new.crbug.com 回報錯誤。請務必盡可能提供詳細資訊、重製操作說明,並將「元件」設為 Blink>Storage>FileSystemGlitch 非常適合用於分享快速重現問題的資訊。

打算使用 API 嗎?

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

實用連結

特別銘謝

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