File System Access API 可讓網頁應用程式直接讀取或儲存使用者裝置上的檔案和資料夾變更,
什麼是 File System Access API?
File System Access API (舊稱 Native File System API) 可讓開發人員建構功能強大的網頁應用程式,與使用者本機裝置上的檔案互動,例如 IDE、相片和影片編輯器、文字編輯器等。使用者授予網頁應用程式存取權後,這個 API 即可讓使用者直接讀取或儲存變更至使用者裝置上的檔案和資料夾。除了讀取及寫入檔案之外,File System Access API 還可讓您開啟目錄並列舉其內容。
如果您先前曾讀取及寫入檔案,我接下來與您分享的大部分資訊應該不陌生。仍建議您閱讀這份報告,因為並非所有系統都是如此。
Windows、macOS、ChromeOS 和 Linux 的大多數 Chromium 瀏覽器目前都支援 File System Access API。值得注意的例外是 Brave,目前只能在旗標後方使用。在 Chromium 109 中,Android 支援 API 的來源私人檔案系統部分。目前沒有挑選器方法的方案,但您可以透過為 crbug.com/1011535 加上星號以追蹤潛在進度。
使用 File System Access API
為了展現 File System Access API 的強大性和實用性,我編寫了單一檔案文字編輯器。您可以開啟文字檔案、編輯文字檔案、將變更儲存至磁碟,或是啟動新檔案並將變更儲存至磁碟。這只是為了讓您瞭解這些概念而已,但實在太厲害了。
瀏覽器支援
立即試用
請參閱文字編輯器示範中的 File System Access API 實際應用情形。
讀取本機檔案系統中的檔案
我想處理的第一個用途是要求使用者選擇檔案,然後開啟檔案並從磁碟讀取該檔案。
要求使用者挑選要讀取的檔案
File System Access API 的進入點為 window.showOpenFilePicker()
。呼叫此方法時,系統會顯示檔案選擇器對話方塊,並提示使用者選取檔案。選取檔案後,API 會傳回檔案控制代碼的陣列。選用的 options
參數可讓您影響檔案選擇器的行為,例如允許使用者選取多個檔案、目錄或不同類型的檔案類型。如未指定任何選項,檔案選擇器可讓使用者選取單一檔案。這十分適用於文字編輯器。
呼叫 showOpenFilePicker()
和其他許多功能強大的 API 一樣,必須在安全環境中呼叫,而且必須在使用者手勢內呼叫。
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;
});
將檔案寫入本機檔案系統
在文字編輯器中,有兩種方式可以儲存檔案:「Save」和「Save As」。儲存功能只會使用先前擷取的檔案控制代碼將變更寫回原始檔案。不過,「另存新檔」會建立新檔案,因此需要新的檔案控制代碼。
建立新檔案
如要儲存檔案,請呼叫 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()
方法會採用字串,這是文字編輯器所需的字串。但也可以使用 BufferSource 或 Blob。舉例來說,您可以直接透過管道直接傳輸串流:
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'],
},
}],
});
預設 start 目錄也是如此。如要建構文字編輯器,建議您在預設的 documents
資料夾中開啟「儲存檔案」或「檔案開啟」對話方塊;如果是圖片編輯器,則可以從預設的 pictures
資料夾開始。您可以將 startIn
屬性傳遞至 showSaveFilePicker
、showDirectoryPicker()
或 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
});
指定不同檔案選擇器的用途
應用程式有時會針對不同用途提供不同的挑選器。舉例來說,RTF 格式編輯器可讓使用者開啟文字檔案,但也可以匯入圖片。根據預設,每個檔案選擇器都會在最後記住的位置開啟。您可以為每種挑選器儲存 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' }
傳遞至方法。
const butDir = document.getElementById('butDirectory');
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()
。
const butDir = document.getElementById('butDirectory');
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()
方法分別建立或存取檔案和資料夾。透過傳入包含 create
索引鍵以及 true
或 false
布林值的新 options
物件,您可以判定是否應在不存在的新檔案或資料夾時建立。
// 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 });
直接刪除檔案或資料夾
如果您可以存取檔案或目錄控制代碼,請在 FileSystemFileHandle
或 FileSystemDirectoryHandle
上呼叫 remove()
來移除檔案或目錄控制代碼。
// Delete a file.
await fileHandle.remove();
// Delete a directory.
await directoryHandle.remove();
重新命名及移動檔案和資料夾
透過在 FileSystemHandle
介面上呼叫 move()
,即可將檔案和資料夾重新命名或移至新位置。FileSystemHandle
具有子介面 FileSystemFileHandle
和 FileSystemDirectoryHandle
。move()
方法使用一或兩個參數。第一個欄位可以是採用新名稱的字串,或是指向目的地資料夾的 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
物件;如果拖曳的項目是目錄,則會傳回 FileSystemDirectoryHandle
物件的保證。下方列出的實際運作方式。請注意,拖曳介面的 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 });
存取經過最佳化處理,讓來源私人檔案系統找出最佳效能的檔案
來源私人檔案系統提供選用權限,提供對效能高度最佳化調整的特殊檔案類型,例如提供檔案內容的專屬寫入權限。在 Chromium 102 以上版本中,還可以在來源私人檔案系統中使用另一種方法簡化檔案存取:createSyncAccessHandle()
(適用於同步讀取和寫入作業)。它會在 FileSystemFileHandle
上公開,但只會在網路工作站上公開。
// (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 方法無法完全簡化。
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 資料庫資料夾等。在這種情況下,瀏覽器會顯示提示,並要求使用者選擇其他資料夾。
修改現有檔案或目錄
網頁應用程式必須取得使用者的明確授權,才能修改磁碟上的檔案。
權限提示
如果使用者想儲存先前授予檔案讀取權限的檔案變更,瀏覽器會顯示權限提示,要求網站將變更寫入磁碟。只有使用者手勢 (例如點選「儲存」按鈕) 才能觸發權限要求。
或者,可以編輯多個檔案 (例如 IDE) 的網頁應用程式,也可以要求在開啟時儲存變更。
如果使用者選擇「Cancel」,但未授予寫入權限,網頁應用程式將無法將變更儲存至本機檔案。應用程式應為使用者提供其他儲存資料的方法,例如提供「下載檔案」、將資料儲存到雲端的方法。
資訊公開
當使用者授予網頁應用程式儲存本機檔案的權限後,瀏覽器會在網址列中顯示圖示。按一下圖示即可開啟彈出式視窗,顯示使用者授予的檔案清單。使用者可以選擇輕鬆撤銷存取權。
權限持續性
網頁應用程式可以繼續儲存檔案變更,但不會提示您,直到其來源的所有分頁關閉為止。分頁關閉後,網站就會失去所有存取權。當使用者下次使用網頁應用程式時,系統會重新提示使用者,要求他們存取檔案。
意見回饋:
我們想瞭解您對 File System Access API 的使用感想。
告訴我們 API 設計
該 API 有什麼功能不如預期嗎?或者您需要實作提案的方法或屬性嗎?對於安全性模型有任何疑問或意見嗎?
- 在 WICG 檔案系統存取 GitHub 存放區提交規格問題,或是將您的想法新增至現有問題中。
實作時遇到問題嗎?
您在執行 Chrome 時發現錯誤了嗎?或者實作與規格不同?
- 前往 https://new.crbug.com 回報錯誤。請務必盡可能提供詳細資料、重現簡易操作說明,並將「元件」設為
Blink>Storage>FileSystem
。Glitch 適合用來快速分享簡單快速的提案,
打算使用 API 嗎?
打算在自家網站上使用 File System Access API 嗎?我們會透過您的公開支援,決定各項功能的優先順序,以及向其他瀏覽器廠商瞭解這項功能有多重要。
- 請在 WICG Discourse 討論串上分享計畫使用方式。
- 請使用主題標記
#FileSystemAccess
將 Tweet 訊息傳送至 @ChromiumDev,並告訴我們您的使用地點和方式。
實用連結
- 公開說明
- 檔案系統存取規格與檔案規格
- 追蹤錯誤
- ChromeStatus.com 項目
- TypeScript 定義
- File System Access API - Chromium 安全性模型
- 閃爍元件:
Blink>Storage>FileSystem
特別銘謝
File System Access API 規格是由 Marijn Kruisselbrink 編寫。