跳至主要內容
Skip to content

在瀏覽器直接讀寫本機檔案:File System Access API

傳統網頁要處理檔案,通常依賴 <input type="file"> 讓使用者選擇檔案,讀取後上傳或處理,處理完再透過 <a> 標籤下載。這種流程是「唯讀且一次性」的——你無法直接將修改寫回原本的檔案。

File System Access API 改變了這一切。它賦予網頁應用 (Web App) 直接讀寫使用者本機檔案系統的能力(需使用者明確授權),實現了如同 VS Code for Web 或 Photoshop Web 版的流暢體驗。

WARNING

相容性警告: 此 API 目前主要支援 Desktop Chromium 瀏覽器(Chrome, Edge, Opera)。 Safari 與 Firefox 尚未支援(Firefox 僅部分支援 Origin Private File System)。


一、 核心概念:FileHandle

API 的核心是 Handle(控制代碼),代表了一個檔案或資料夾的參照:

  1. FileSystemFileHandle: 代表一個檔案。
  2. FileSystemDirectoryHandle: 代表一個資料夾。

透過這些 Handle,我們可以重複讀取或寫入,而不需使用者每次都重新選擇檔案。


二、 封裝 useFileSystem Composable

我們將封裝一個 useFileSystem,提供開啟、儲存與另存新檔的功能,並管理目前的 FileHandle。

2.1 介面定義

typescript
export interface UseFileSystemReturn {
  isSupported: boolean;
  fileHandle: Ref<FileSystemFileHandle | null>;
  fileData: Ref<string | null>;
  openFile: (options?: OpenFilePickerOptions) => Promise<void>;
  saveFile: (content: string) => Promise<void>;
  saveAs: (content: string, options?: SaveFilePickerOptions) => Promise<void>;
}

2.2 完整實作

typescript
// composables/useFileSystem.ts
import { ref } from 'vue';

export function useFileSystem() {
  const isSupported = typeof window !== 'undefined' && 'showOpenFilePicker' in window;
  
  const fileHandle = ref<FileSystemFileHandle | null>(null);
  const fileData = ref<string | null>(null);

  /**
   * 開啟檔案
   */
  const openFile = async (options: OpenFilePickerOptions = {}) => {
    if (!isSupported) return;

    try {
      // 1. 顯示開啟檔案對話框 (回傳陣列,因為可選多檔)
      const handles = await window.showOpenFilePicker(options);
      const handle = handles[0]; // 我們只取第一個
      
      // 2. 獲取 File 物件並讀取內容
      const file = await handle.getFile();
      const text = await file.text();

      // 3. 更新狀態
      fileHandle.value = handle;
      fileData.value = text;
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        console.error('開啟檔案失敗:', err);
      }
    }
  };

  /**
   * 儲存檔案 (寫回原檔)
   */
  const saveFile = async (content: string) => {
    if (!isSupported) return;

    try {
      // 如果沒有 handle,則轉為「另存新檔」
      if (!fileHandle.value) {
        return await saveAs(content);
      }

      // 1. 建立可寫入的 stream
      const writable = await fileHandle.value.createWritable();
      
      // 2. 寫入內容
      await writable.write(content);
      
      // 3. 關閉 stream (重要!這時才會真正寫入檔案)
      await writable.close();
      
      fileData.value = content;
    } catch (err: any) {
      console.error('儲存檔案失敗:', err);
    }
  };

  /**
   * 另存新檔
   */
  const saveAs = async (content: string, options: SaveFilePickerOptions = {}) => {
    if (!isSupported) return;

    try {
      // 1. 顯示儲存對話框,取得新的 handle
      const newHandle = await window.showSaveFilePicker(options);
      
      // 2. 寫入內容
      const writable = await newHandle.createWritable();
      await writable.write(content);
      await writable.close();

      // 3. 更新目前的 handle 與內容
      fileHandle.value = newHandle;
      fileData.value = content;
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        console.error('另存新檔失敗:', err);
      }
    }
  };

  return {
    isSupported,
    fileHandle,
    fileData,
    openFile,
    saveFile,
    saveAs
  };
}

三、 實戰範例:純網頁文字編輯器

這是一個具備「開啟」、「儲存」功能的簡易記事本。

vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useFileSystem } from '@/composables/useFileSystem';

const { isSupported, fileHandle, fileData, openFile, saveFile, saveAs } = useFileSystem();

const content = ref('');

// 當 fileData 改變(例如開啟了新檔案),同步到編輯器內容
watch(fileData, (newVal) => {
  if (newVal) content.value = newVal;
});

const handleOpen = () => {
  openFile({
    types: [{
      description: 'Text Files',
      accept: { 'text/plain': ['.txt', '.md', '.json'] },
    }],
  });
};

const handleSave = () => saveFile(content.value);
const handleSaveAs = () => saveAs(content.value);
</script>

<template>
  <div v-if="!isSupported" class="error">
    您的瀏覽器不支援 File System Access API (請使用 Chrome/Edge)。
  </div>
  
  <div v-else class="editor-container">
    <div class="toolbar">
      <button @click="handleOpen">📂 開啟舊檔</button>
      <button @click="handleSave" :disabled="!fileHandle">💾 儲存</button>
      <button @click="handleSaveAs">📑 另存新檔</button>
      
      <span v-if="fileHandle" class="file-name">
        目前編輯: {{ fileHandle.name }}
      </span>
    </div>

    <textarea v-model="content" class="editor" />
  </div>
</template>

<style scoped>
.toolbar {
  padding: 10px;
  background: #f0f0f0;
  border-bottom: 1px solid #ccc;
  display: flex;
  gap: 10px;
  align-items: center;
}
.editor {
  width: 100%;
  height: 400px;
  padding: 15px;
  font-family: monospace;
  font-size: 14px;
  border: none;
  resize: vertical;
}
.file-name {
  margin-left: auto;
  font-size: 0.9em;
  color: #666;
}
.error {
  color: red;
  padding: 20px;
  text-align: center;
  background: #fff0f0;
}
</style>

四、 權限管理細節

4.1 權限提示

當你第一次呼叫 openFilesaveFile 時,瀏覽器會詢問使用者是否允許。

  • 讀取權限:在 showOpenFilePicker 選取檔案時自動授權。
  • 寫入權限:在 createWritable 時,若尚未授權寫入,瀏覽器會再次彈窗確認。

4.2 持久化權限 (Persisting Permissions)

目前的 File Handle 在頁面重新整理後就會失效(權限遺失)。若要持久化(例如:重新開啟網頁時能記住上次開啟的檔案),可以將 Handle 儲存在 IndexedDB 中。

不過,即使從 IndexedDB 取回 Handle,下次使用時瀏覽器仍會要求使用者再次點擊「驗證權限」,以確保安全性。


總結

File System Access API 大幅縮小了 Web App 與 Native App 之間的差距。它非常適合生產力工具(編輯器、繪圖軟體)使用。雖然目前支援度有限,但在 Electron 或 PWA 的場景中,它已經是不可或缺的重磅功能。


延伸閱讀