【Electron + React 教學】#03 檔案系統操作:讀寫本地檔案

測驗:Electron + React 檔案系統操作

共 5 題,點選答案後會立即顯示結果

1. 在 Electron 應用中,為什麼檔案操作必須在主程序中執行?

  • A. 主程序的執行速度比渲染程序快
  • B. 渲染程序受限於安全性,無法直接存取檔案系統
  • C. 渲染程序不支援 JavaScript
  • D. Node.js 模組只能在瀏覽器中執行

2. dialog.showOpenDialog() 回傳的物件中,filePaths 是什麼類型?

  • A. 字串(單一檔案路徑)
  • B. 布林值(是否選中檔案)
  • C. 陣列(選中的檔案路徑清單)
  • D. 物件(包含檔案名稱與內容)

3. 使用 fs.readFileSync(filePath, 'utf-8') 時,指定 'utf-8' 編碼的作用是什麼?

  • A. 讓檔案可以被壓縮儲存
  • B. 讓回傳值是字串而非 Buffer
  • C. 讓檔案可以被加密
  • D. 讓檔案只能讀取不能寫入

4. 在 preload.js 中使用 contextBridge.exposeInMainWorld() 的目的是什麼?

  • A. 直接讓渲染程序存取 Node.js 模組
  • B. 關閉 Electron 的安全限制
  • C. 讓主程序可以直接操作 DOM
  • D. 安全地將 API 暴露給渲染程序使用

5. 在 React 元件中,當使用者修改文字但尚未儲存時,如何追蹤這個「已修改但未儲存」的狀態?

const [isDirty, setIsDirty] = useState(false); const handleContentChange = (e) => { setContent(e.target.value); setIsDirty(true); };
  • A. 使用 isDirty state,在內容變更時設為 true,儲存成功後設為 false
  • B. 直接比較 currentFile 是否為 null
  • C. 檢查 window.electronAPI 是否存在
  • D. 使用 localStorage 記錄修改時間

前言

桌面應用程式相較於網頁應用,最大的優勢之一就是能夠直接存取使用者的本地檔案系統。在 Electron 中,我們可以透過 Node.js 的 fs 模組搭配 Electron 的 dialog API,實作完整的檔案讀寫功能。

本篇將延續前一篇的 IPC 通訊概念,帶你實作一個簡易文字編輯器,讓 React 介面能夠開啟、編輯並儲存本地檔案。

學習目標

讀完本篇後,你將能夠:

  • 使用 Node.js fs 模組在主程序中操作檔案
  • 實作檔案選擇對話框(dialog.showOpenDialog)
  • 建立完整的檔案讀寫功能供 React 介面使用

桌面應用的檔案存取優勢

網頁應用受限於瀏覽器沙盒,無法直接讀寫使用者的檔案系統。即使使用 File API,也需要使用者主動選擇檔案,且無法直接儲存到原本的位置。

Electron 應用則不同:

┌─────────────────────────────────────────────┐
│           Electron 應用程式                   │
│  ┌─────────────────┐  ┌─────────────────┐   │
│  │   渲染程序       │  │   主程序         │   │
│  │   (React)       │  │   (Node.js)     │   │
│  │                 │  │                 │   │
│  │  無法直接存取    │  │  ✓ fs 模組      │   │
│  │  檔案系統       │◄─┼─► ✓ dialog API   │   │
│  │                 │  │  ✓ 完整權限      │   │
│  └─────────────────┘  └────────┬────────┘   │
└────────────────────────────────┼────────────┘
                                 │
                                 ▼
                    ┌─────────────────────────┐
                    │      本地檔案系統        │
                    │  /Users/xxx/Documents/  │
                    │  C:\Users\xxx\Desktop\  │
                    └─────────────────────────┘

重點是:檔案操作必須在主程序中執行,再透過 IPC 將結果傳給渲染程序。

Electron Dialog API

Electron 提供了原生的檔案對話框 API,讓使用者可以選擇要開啟或儲存的檔案。

開啟檔案對話框

// main.js(主程序)
const { dialog } = require('electron');

async function openFileDialog() {
  const result = await dialog.showOpenDialog({
    title: '選擇檔案',
    properties: ['openFile'],
    filters: [
      { name: '文字檔', extensions: ['txt', 'md'] },
      { name: '所有檔案', extensions: ['*'] }
    ]
  });

  return result;
}
Code language: JavaScript (javascript)

讓我們逐行解讀這段程式碼:

程式碼 說明
dialog.showOpenDialog() 顯示原生的「開啟檔案」對話框
title 對話框的標題文字
properties: ['openFile'] 設定為選擇檔案模式(也可以是 'openDirectory'
filters 檔案類型篩選器,限制可選擇的副檔名

回傳值結構:

{
  canceled: false,        // 使用者是否按了取消
  filePaths: ['/path/to/file.txt']  // 選中的檔案路徑陣列
}
Code language: CSS (css)

儲存檔案對話框

// main.js(主程序)
async function saveFileDialog() {
  const result = await dialog.showSaveDialog({
    title: '儲存檔案',
    defaultPath: 'untitled.txt',
    filters: [
      { name: '文字檔', extensions: ['txt'] },
      { name: 'Markdown', extensions: ['md'] }
    ]
  });

  return result;
}
Code language: JavaScript (javascript)
程式碼 說明
dialog.showSaveDialog() 顯示原生的「儲存檔案」對話框
defaultPath 預設的檔案名稱

回傳值結構:

{
  canceled: false,
  filePath: '/path/to/save/file.txt'  // 注意是單數 filePath
}
Code language: CSS (css)

使用 fs 模組讀寫檔案

Node.js 的 fs 模組提供了檔案系統操作功能。在 Electron 主程序中,我們可以直接使用它。

讀取檔案

// main.js(主程序)
const fs = require('fs');

function readFile(filePath) {
  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    return { success: true, content };
  } catch (error) {
    return { success: false, error: error.message };
  }
}
Code language: JavaScript (javascript)
程式碼 說明
fs.readFileSync() 同步讀取檔案內容
'utf-8' 指定編碼,讓回傳值是字串而非 Buffer

寫入檔案

// main.js(主程序)
function writeFile(filePath, content) {
  try {
    fs.writeFileSync(filePath, content, 'utf-8');
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
}
Code language: JavaScript (javascript)

透過 IPC 暴露檔案操作給 React

現在我們要把這些功能串連起來,讓 React 介面可以使用。

步驟 1:在主程序設定 IPC 處理器

// main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('fs');
const path = require('path');

// 開啟檔案
ipcMain.handle('file:open', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
    filters: [
      { name: '文字檔', extensions: ['txt', 'md'] },
      { name: '所有檔案', extensions: ['*'] }
    ]
  });

  if (result.canceled || result.filePaths.length === 0) {
    return { canceled: true };
  }

  const filePath = result.filePaths[0];
  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    return {
      canceled: false,
      filePath,
      fileName: path.basename(filePath),
      content
    };
  } catch (error) {
    return { canceled: false, error: error.message };
  }
});

// 儲存檔案
ipcMain.handle('file:save', async (event, { filePath, content }) => {
  try {
    fs.writeFileSync(filePath, content, 'utf-8');
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// 另存新檔
ipcMain.handle('file:saveAs', async (event, { content, defaultName }) => {
  const result = await dialog.showSaveDialog({
    defaultPath: defaultName || 'untitled.txt',
    filters: [
      { name: '文字檔', extensions: ['txt'] },
      { name: 'Markdown', extensions: ['md'] }
    ]
  });

  if (result.canceled) {
    return { canceled: true };
  }

  try {
    fs.writeFileSync(result.filePath, content, 'utf-8');
    return {
      canceled: false,
      success: true,
      filePath: result.filePath,
      fileName: path.basename(result.filePath)
    };
  } catch (error) {
    return { canceled: false, success: false, error: error.message };
  }
});
Code language: JavaScript (javascript)

步驟 2:在 preload 腳本中建立橋接

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  // 檔案操作
  openFile: () => ipcRenderer.invoke('file:open'),
  saveFile: (data) => ipcRenderer.invoke('file:save', data),
  saveFileAs: (data) => ipcRenderer.invoke('file:saveAs', data)
});
Code language: JavaScript (javascript)

這裡使用了第二篇學到的 contextBridge,將檔案操作 API 暴露給渲染程序。

步驟 3:在 React 中使用

// App.jsx
import { useState } from 'react';

function App() {
  const [content, setContent] = useState('');
  const [currentFile, setCurrentFile] = useState(null);
  const [fileName, setFileName] = useState('未命名');
  const [isDirty, setIsDirty] = useState(false);

  // 開啟檔案
  const handleOpen = async () => {
    const result = await window.electronAPI.openFile();

    if (!result.canceled && !result.error) {
      setContent(result.content);
      setCurrentFile(result.filePath);
      setFileName(result.fileName);
      setIsDirty(false);
    } else if (result.error) {
      alert(`開啟檔案失敗:${result.error}`);
    }
  };

  // 儲存檔案
  const handleSave = async () => {
    if (!currentFile) {
      // 如果還沒有檔案路徑,改用另存新檔
      handleSaveAs();
      return;
    }

    const result = await window.electronAPI.saveFile({
      filePath: currentFile,
      content
    });

    if (result.success) {
      setIsDirty(false);
    } else {
      alert(`儲存失敗:${result.error}`);
    }
  };

  // 另存新檔
  const handleSaveAs = async () => {
    const result = await window.electronAPI.saveFileAs({
      content,
      defaultName: fileName
    });

    if (!result.canceled && result.success) {
      setCurrentFile(result.filePath);
      setFileName(result.fileName);
      setIsDirty(false);
    } else if (result.error) {
      alert(`儲存失敗:${result.error}`);
    }
  };

  // 內容變更
  const handleContentChange = (e) => {
    setContent(e.target.value);
    setIsDirty(true);
  };

  return (
    <div className="editor">
      <div className="toolbar">
        <button onClick={handleOpen}>開啟</button>
        <button onClick={handleSave}>儲存</button>
        <button onClick={handleSaveAs}>另存新檔</button>
        <span className="filename">
          {fileName}{isDirty ? ' *' : ''}
        </span>
      </div>
      <textarea
        value={content}
        onChange={handleContentChange}
        placeholder="在這裡輸入內容..."
      />
    </div>
  );
}

export default App;
Code language: JavaScript (javascript)

完整範例:簡易文字編輯器

讓我們看看完整的檔案結構和各檔案的完整內容:

my-editor/
├── main.js          # 主程序
├── preload.js       # 預載腳本
├── src/
│   ├── App.jsx      # React 元件
│   └── App.css      # 樣式
└── package.json
Code language: PHP (php)

main.js 完整內容

const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false
    }
  });

  // 開發模式載入 Vite 開發伺服器
  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:5173');
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, 'dist/index.html'));
  }
}

// IPC 處理器:開啟檔案
ipcMain.handle('file:open', async () => {
  const result = await dialog.showOpenDialog(mainWindow, {
    properties: ['openFile'],
    filters: [
      { name: '文字檔', extensions: ['txt', 'md'] },
      { name: '所有檔案', extensions: ['*'] }
    ]
  });

  if (result.canceled || result.filePaths.length === 0) {
    return { canceled: true };
  }

  const filePath = result.filePaths[0];
  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    return {
      canceled: false,
      filePath,
      fileName: path.basename(filePath),
      content
    };
  } catch (error) {
    return { canceled: false, error: error.message };
  }
});

// IPC 處理器:儲存檔案
ipcMain.handle('file:save', async (event, { filePath, content }) => {
  try {
    fs.writeFileSync(filePath, content, 'utf-8');
    return { success: true };
  } catch (error) {
    return { success: false, error: error.message };
  }
});

// IPC 處理器:另存新檔
ipcMain.handle('file:saveAs', async (event, { content, defaultName }) => {
  const result = await dialog.showSaveDialog(mainWindow, {
    defaultPath: defaultName || 'untitled.txt',
    filters: [
      { name: '文字檔', extensions: ['txt'] },
      { name: 'Markdown', extensions: ['md'] }
    ]
  });

  if (result.canceled) {
    return { canceled: true };
  }

  try {
    fs.writeFileSync(result.filePath, content, 'utf-8');
    return {
      canceled: false,
      success: true,
      filePath: result.filePath,
      fileName: path.basename(result.filePath)
    };
  } catch (error) {
    return { canceled: false, success: false, error: error.message };
  }
});

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});
Code language: JavaScript (javascript)

preload.js 完整內容

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('file:open'),
  saveFile: (data) => ipcRenderer.invoke('file:save', data),
  saveFileAs: (data) => ipcRenderer.invoke('file:saveAs', data)
});
Code language: JavaScript (javascript)

App.css 樣式

.editor {
  display: flex;
  flex-direction: column;
  height: 100vh;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.toolbar {
  display: flex;
  gap: 8px;
  padding: 12px;
  background: #f5f5f5;
  border-bottom: 1px solid #ddd;
  align-items: center;
}

.toolbar button {
  padding: 8px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
}

.toolbar button:hover {
  background: #e9e9e9;
}

.filename {
  margin-left: auto;
  color: #666;
  font-size: 14px;
}

textarea {
  flex: 1;
  padding: 16px;
  border: none;
  resize: none;
  font-size: 14px;
  line-height: 1.6;
  font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
}

textarea:focus {
  outline: none;
}
Code language: CSS (css)

資料流向圖解

整個檔案操作的資料流向如下:

使用者點擊「開啟」按鈕
        │
        ▼
┌─────────────────────────────────────┐
│  React 元件 (App.jsx)               │
│  handleOpen() 被呼叫                │
│  await window.electronAPI.openFile()│
└─────────────────┬───────────────────┘
                  │ IPC invoke
                  ▼
┌─────────────────────────────────────┐
│  Preload (preload.js)               │
│  ipcRenderer.invoke('file:open')    │
└─────────────────┬───────────────────┘
                  │ 跨程序通訊
                  ▼
┌─────────────────────────────────────┐
│  主程序 (main.js)                   │
│  ipcMain.handle('file:open', ...)   │
│                                     │
│  1. dialog.showOpenDialog()         │
│     → 顯示原生檔案選擇對話框         │
│  2. fs.readFileSync()               │
│     → 讀取選中的檔案內容             │
│  3. return { filePath, content }    │
└─────────────────┬───────────────────┘
                  │ 回傳結果
                  ▼
┌─────────────────────────────────────┐
│  React 元件接收結果                  │
│  setContent(result.content)         │
│  setCurrentFile(result.filePath)    │
│  → 介面更新,顯示檔案內容            │
└─────────────────────────────────────┘
Code language: JavaScript (javascript)

常見問題

Q: 為什麼不能在渲染程序直接使用 fs?

A: 這是出於安全考量。渲染程序執行的是網頁內容,如果讓它直接存取檔案系統,惡意網頁就可能讀取或修改使用者的檔案。透過 IPC 和 preload 的設計,我們可以精確控制哪些檔案操作是被允許的。

Q: 同步和非同步的 fs 方法該選哪個?

A: 在 Electron 主程序中,通常建議使用同步方法(如 readFileSync),因為:

  1. 程式碼更簡潔
  2. 主程序不處理 UI,不會造成介面卡頓
  3. IPC 通訊本身就是非同步的,已經不會阻塞渲染程序

Q: 如何處理大型檔案?

A: 對於大型檔案,建議:

  1. 使用串流(Stream)而非一次讀取整個檔案
  2. 加入進度回報機制
  3. 考慮在背景程序中處理

重點整理

本篇學到的核心概念:

概念 說明
dialog.showOpenDialog() 顯示原生的「開啟檔案」對話框
dialog.showSaveDialog() 顯示原生的「儲存檔案」對話框
fs.readFileSync() 同步讀取檔案內容
fs.writeFileSync() 同步寫入檔案內容
檔案操作必須在主程序 渲染程序無法直接存取檔案系統

下一步

現在你已經學會了檔案系統操作,接下來的第四篇將介紹如何建立選單和快捷鍵,讓你的文字編輯器支援 Ctrl+O 開啟、Ctrl+S 儲存等常用快捷鍵。

進階測驗:Electron + React 檔案系統操作

測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。

1. 你正在開發一個簡易文字編輯器,需要實作「開啟檔案」功能。使用者點擊按鈕後,應該顯示原生的檔案選擇對話框,並讀取選中的檔案內容。下列哪個做法是正確的架構? 情境題

  • A. 在 React 元件中直接使用 fs.readFileSync() 讀取檔案
  • B. 在 preload.js 中直接操作 dialogfs
  • C. 在主程序設定 IPC handler,使用 dialogfs,透過 preload 暴露給 React 呼叫
  • D. 在 React 中使用瀏覽器的 File API 選擇檔案後,傳給主程序儲存

2. 你的文字編輯器需要實作「儲存」功能,但要處理兩種情況:已開啟的檔案直接覆蓋儲存,新建的檔案則要先讓使用者選擇儲存位置。以下哪個 React 處理邏輯是正確的? 情境題

const handleSave = async () => { // 該如何實作? };
  • A. 總是呼叫 saveFileAs,讓使用者每次都選擇儲存位置
  • B. 檢查 currentFile 是否存在:有則直接 saveFile,沒有則呼叫 saveFileAs
  • C. 檢查 isDirty 是否為 true,是則儲存,否則不做任何事
  • D. 直接呼叫 saveFile,如果路徑不存在會自動彈出對話框

3. 你想限制使用者只能選擇 .txt 和 .md 檔案。你需要在 dialog.showOpenDialog() 的設定中加入哪個選項? 情境題

  • A. extensions: ['txt', 'md']
  • B. fileTypes: [{ name: '文字檔', ext: ['txt', 'md'] }]
  • C. accept: '.txt,.md'
  • D. filters: [{ name: '文字檔', extensions: ['txt', 'md'] }]

4. 同事寫了以下 preload.js 程式碼,但 React 元件無法呼叫 window.electronAPI.openFile()。問題出在哪裡? 錯誤診斷

// preload.js const { ipcRenderer } = require(‘electron’); window.electronAPI = { openFile: () => ipcRenderer.invoke(‘file:open’), saveFile: (data) => ipcRenderer.invoke(‘file:save’, data) };
  • A. ipcRenderer.invoke 應該改成 ipcRenderer.send
  • B. 缺少 async/await 語法
  • C. 沒有使用 contextBridge.exposeInMainWorld(),直接操作 windowcontextIsolation: true 時無效
  • D. 主程序沒有正確匯入 ipcMain

5. 使用者回報:開啟檔案後內容顯示為亂碼。你檢查了主程序的讀檔程式碼如下。最可能的問題是什麼? 錯誤診斷

// main.js ipcMain.handle(‘file:open’, async () => { const result = await dialog.showOpenDialog({ properties: [‘openFile’] }); if (!result.canceled) { const content = fs.readFileSync(result.filePaths[0]); return { content }; } });
  • A. dialog.showOpenDialog() 缺少 filters 參數
  • B. fs.readFileSync() 沒有指定 'utf-8' 編碼,回傳的是 Buffer 而非字串
  • C. 應該使用 fs.readFile() 非同步版本
  • D. result.filePaths[0] 應該改成 result.filePath

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *