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

透過 File System Access API,網路應用程式可以直接讀取或儲存使用者裝置中的檔案和資料夾。

發布日期:2024 年 8 月 19 日

開發人員可透過 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() 會傳回 File 物件,其中包含 Blob。如要從 Blob 取得資料,請呼叫其中一個方法 (slice()stream()text()arrayBuffer())。

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

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

正在統整內容

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

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

將檔案寫入本機檔案系統

在文字編輯器中,儲存檔案的方法有兩種:「儲存」和「另存新檔」儲存 使用先前擷取的檔案控制代碼,將變更寫回原始檔案。但「另存新檔」會建立新檔案,因此需要新的檔案控制代碼。

建立新檔案

如要儲存檔案,請呼叫 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/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 System Access API 的 FileSystemHandle.kind 適用於檔案,"directory" 則適用於目錄。"file""file"

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 Worker

// (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 });

Polyfill

無法完全填補 File System Access API 方法。

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

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

安全性和權限

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

開啟檔案或儲存新檔案

檔案選擇器,用於開啟檔案以供讀取
檔案選擇器,用於開啟現有檔案以供讀取。

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

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

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

受限制的資料夾

為保護使用者和他們的資料,瀏覽器可能會限制使用者將檔案儲存至特定資料夾,例如 Windows 等核心作業系統資料夾,以及 macOS 程式庫資料夾。此時瀏覽器會顯示提示,要求使用者選擇其他資料夾。

修改現有檔案或目錄

未經使用者明確授權,網頁應用程式就無法修改磁碟上的檔案。

權限提示

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

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

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

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

透明度

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

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

權限保留

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

意見回饋

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

介紹 API 設計

API 是否有任何部分無法如預期運作?或者,是否有缺少的屬性或方法需要實作,才能實現您的想法?對安全模型有任何問題或意見嗎?

導入時發生問題嗎?

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

  • 前往 https://new.crbug.com 提出錯誤報告。請務必盡可能提供詳細資料、重現步驟,並將「Components」(元件) 設為 Blink>Storage>FileSystem

打算使用 API 嗎?

您打算在網站上使用 File System Access API 嗎?您的公開支持有助於我們優先處理功能,並向其他瀏覽器供應商說明支援這些功能的重要性。

實用連結

特別銘謝

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