測驗:Electron + React 檔案系統操作
共 5 題,點選答案後會立即顯示結果
1. 在 Electron 應用中,為什麼檔案操作必須在主程序中執行?
2. dialog.showOpenDialog() 回傳的物件中,filePaths 是什麼類型?
3. 使用 fs.readFileSync(filePath, 'utf-8') 時,指定 'utf-8' 編碼的作用是什麼?
4. 在 preload.js 中使用 contextBridge.exposeInMainWorld() 的目的是什麼?
5. 在 React 元件中,當使用者修改文字但尚未儲存時,如何追蹤這個「已修改但未儲存」的狀態?
前言
桌面應用程式相較於網頁應用,最大的優勢之一就是能夠直接存取使用者的本地檔案系統。在 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),因為:
- 程式碼更簡潔
- 主程序不處理 UI,不會造成介面卡頓
- IPC 通訊本身就是非同步的,已經不會阻塞渲染程序
Q: 如何處理大型檔案?
A: 對於大型檔案,建議:
- 使用串流(Stream)而非一次讀取整個檔案
- 加入進度回報機制
- 考慮在背景程序中處理
重點整理
本篇學到的核心概念:
| 概念 | 說明 |
|---|---|
dialog.showOpenDialog() |
顯示原生的「開啟檔案」對話框 |
dialog.showSaveDialog() |
顯示原生的「儲存檔案」對話框 |
fs.readFileSync() |
同步讀取檔案內容 |
fs.writeFileSync() |
同步寫入檔案內容 |
| 檔案操作必須在主程序 | 渲染程序無法直接存取檔案系統 |
下一步
現在你已經學會了檔案系統操作,接下來的第四篇將介紹如何建立選單和快捷鍵,讓你的文字編輯器支援 Ctrl+O 開啟、Ctrl+S 儲存等常用快捷鍵。
進階測驗:Electron + React 檔案系統操作
共 5 題,包含情境題與錯誤診斷題。