【Electron + React 教學】#02 IPC 通訊:主程序與渲染程序的橋樑

測驗:IPC 通訊:主程序與渲染程序的橋樑

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

1. 在 Electron 架構中,為什麼渲染程序預設無法直接存取 Node.js API?

  • A. 因為 Node.js API 效能太差,不適合在渲染程序中使用
  • B. 基於安全考量,避免惡意網頁腳本存取檔案系統等敏感資源
  • C. 因為渲染程序只能執行 CSS,不支援 JavaScript
  • D. 因為 Chromium 不支援 Node.js

2. 在 Electron 中,哪個模組用於在主程序中接收渲染程序的請求?

  • A. ipcMain
  • B. ipcRenderer
  • C. contextBridge
  • D. BrowserWindow

3. contextBridge.exposeInMainWorld() 的主要功能是什麼?

  • A. 直接在渲染程序中啟用 Node.js API
  • B. 建立新的 BrowserWindow 視窗
  • C. 安全地將指定的函式暴露給渲染程序,注入到 window 物件
  • D. 關閉主程序與渲染程序之間的通訊

4. 現代 Electron 應用推薦使用哪種 IPC 通訊模式?

  • A. send / on 模式
  • B. handle / invoke 模式
  • C. emit / listen 模式
  • D. request / response 模式

5. 在建立 BrowserWindow 時,為什麼建議將 nodeIntegration 設為 false

  • A. 因為這樣可以提升應用程式的執行速度
  • B. 因為 nodeIntegration 已被棄用,不再支援
  • C. 因為啟用後會導致 React 無法正常運作
  • D. 因為開啟後若載入外部內容,惡意腳本可能存取檔案系統

前言

在上一篇文章中,我們建立了 Electron + React 的開發環境,也初步認識了主程序(Main Process)與渲染程序(Renderer Process)的分工。但你可能會好奇:如果 React 介面需要存取檔案系統、開啟對話框,這些只有主程序才能做的事情,該怎麼辦?

答案就是 IPC(Inter-Process Communication,程序間通訊)。這是 Electron 應用中最核心的溝通機制。

為什麼需要 IPC?

程序隔離的設計

Electron 採用 Chromium 的多程序架構:

┌─────────────────────────────────────────────────┐
│                   主程序 (Main)                  │
│  - 可存取 Node.js APIfspathchild_process)│
│  - 管理視窗生命週期                              │
│  - 處理系統層級操作                              │
└─────────────────────────────────────────────────┘
          │                    ▲
          │ IPCIPC
          ▼                    │
┌─────────────────────────────────────────────────┐
│               渲染程序 (Renderer)                │
│  - 執行網頁內容(HTMLCSSJavaScript)         │
│  - React 應用在此執行                           │
│  - 預設無法存取 Node.js API                      │
└─────────────────────────────────────────────────┘
Code language: CSS (css)

這種隔離是刻意的設計,主要基於安全考量。如果讓渲染程序直接存取 Node.js API,惡意網頁腳本就可能讀寫你的檔案系統。

常見需求情境

在實際開發中,你會頻繁遇到這些需求:

  • 從 React 介面讀取本機檔案
  • 顯示系統原生對話框(開啟檔案、儲存檔案)
  • 操作剪貼簿內容
  • 發送系統通知

這些都需要透過 IPC 來完成。

IPC 通訊的核心模組

Electron 提供兩個主要模組處理 IPC:

模組 執行位置 用途
ipcMain 主程序 接收渲染程序的請求
ipcRenderer 渲染程序 發送請求給主程序

通訊模式:handle / invoke

現代 Electron 應用推薦使用 handle / invoke 模式,這是一種基於 Promise 的雙向通訊方式。

主程序端:ipcMain.handle()

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

// 註冊 IPC 處理器
ipcMain.handle('open-file-dialog', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openFile'],
    filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }]
  });
  return result.filePaths[0]; // 回傳選擇的檔案路徑
});
Code language: JavaScript (javascript)

這段程式碼做了什麼?

  1. ipcMain.handle('open-file-dialog', ...) — 註冊一個名為 'open-file-dialog' 的處理器
  2. 當收到請求時,呼叫系統原生的檔案對話框
  3. 回傳使用者選擇的檔案路徑

渲染程序端:ipcRenderer.invoke()

// 在渲染程序中呼叫
const filePath = await ipcRenderer.invoke('open-file-dialog');
console.log('使用者選擇的檔案:', filePath);
Code language: JavaScript (javascript)

invoke() 回傳 Promise,可以搭配 async/await 使用,寫起來就像呼叫一般的非同步函式。

Preload Script 的角色

等等,上面的程式碼有個問題:渲染程序預設無法使用 ipcRenderer

這就是 preload script 登場的時機。它是一個特殊的腳本,在渲染程序載入網頁之前執行,可以安全地橋接主程序與渲染程序。

建立 preload.js

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

// 透過 contextBridge 安全地暴露 API
contextBridge.exposeInMainWorld('electronAPI', {
  openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
  readFile: (path) => ipcRenderer.invoke('read-file', path),
  saveFile: (path, content) => ipcRenderer.invoke('save-file', path, content)
});
Code language: JavaScript (javascript)

關鍵概念:contextBridge.exposeInMainWorld()

這個 API 做了幾件重要的事:

  1. 建立安全的橋樑 — 只暴露你明確指定的函式,而非整個 ipcRenderer
  2. 注入到 window 物件 — 讓渲染程序可以透過 window.electronAPI 存取
  3. 隔離執行環境 — 網頁腳本無法直接修改或存取 preload 的內部狀態

在主程序中指定 preload

// main.js
const mainWindow = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    contextIsolation: true,  // 必須開啟(預設已開啟)
    nodeIntegration: false   // 建議關閉(預設已關閉)
  }
});
Code language: JavaScript (javascript)

實作範例:從 React 呼叫主程序功能

完整的程式碼結構

my-electron-app/
├── main.js          # 主程序
├── preload.js       # Preload 腳本
└── src/
    └── App.jsx      # React 元件
Code language: PHP (php)

主程序(main.js)

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

let mainWindow;

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

  mainWindow.loadURL('http://localhost:5173'); // Vite 開發伺服器
}

// IPC 處理器:開啟檔案對話框
ipcMain.handle('open-file-dialog', async () => {
  const result = await dialog.showOpenDialog(mainWindow, {
    properties: ['openFile'],
    filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }]
  });

  if (result.canceled) return null;
  return result.filePaths[0];
});

// IPC 處理器:讀取檔案內容
ipcMain.handle('read-file', async (event, filePath) => {
  const content = await fs.readFile(filePath, 'utf-8');
  return content;
});

app.whenReady().then(createWindow);
Code language: JavaScript (javascript)

Preload 腳本(preload.js)

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

contextBridge.exposeInMainWorld('electronAPI', {
  openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
});
Code language: JavaScript (javascript)

React 元件(App.jsx)

import { useState } from 'react';

function App() {
  const [content, setContent] = useState('');
  const [filePath, setFilePath] = useState('');

  const handleOpenFile = async () => {
    // 呼叫主程序的檔案對話框
    const path = await window.electronAPI.openFileDialog();

    if (path) {
      setFilePath(path);
      // 呼叫主程序讀取檔案
      const fileContent = await window.electronAPI.readFile(path);
      setContent(fileContent);
    }
  };

  return (
    <div>
      <button onClick={handleOpenFile}>開啟檔案</button>
      {filePath && <p>檔案路徑: {filePath}</p>}
      <pre>{content}</pre>
    </div>
  );
}

export default App;
Code language: JavaScript (javascript)

注意 window.electronAPI — 這就是 preload 腳本透過 contextBridge 暴露的物件。

TypeScript 型別提示

如果你使用 TypeScript,可以為 electronAPI 加上型別定義:

// src/types/electron.d.ts
export interface ElectronAPI {
  openFileDialog: () => Promise<string | null>;
  readFile: (filePath: string) => Promise<string>;
}

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}
Code language: PHP (php)

這樣在 React 元件中就能得到完整的型別提示與錯誤檢查。

常見問題與解決方案

Q1: window.electronAPI 是 undefined?

檢查以下幾點:

  1. preload.js 路徑是否正確?
  2. contextIsolation 是否設為 true
  3. 開發者工具的 Console 是否有錯誤訊息?

Q2: ipcMain.handle 沒有收到請求?

確認 channel 名稱完全一致:

// 主程序
ipcMain.handle('open-file-dialog', ...);

// preload
ipcRenderer.invoke('open-file-dialog');  // 名稱必須相同
Code language: JavaScript (javascript)

Q3: 為什麼不直接用 nodeIntegration: true?

開啟 nodeIntegration 會讓渲染程序直接存取 Node.js API,這是嚴重的安全風險。如果你的應用載入任何外部內容(甚至是 CDN 的 library),惡意腳本就可能存取你的檔案系統。

本篇重點整理

概念 說明
IPC 程序間通訊,是主程序與渲染程序溝通的管道
ipcMain.handle() 主程序註冊處理器,回應渲染程序的請求
ipcRenderer.invoke() 渲染程序發送請求,回傳 Promise
preload.js 在渲染程序載入網頁前執行的腳本
contextBridge 安全地將 API 暴露給渲染程序

下一篇預告

在下一篇文章中,我們將探討視窗管理與 BrowserWindow 設定,學習如何建立多視窗應用、設定視窗屬性,以及處理視窗事件。

進階測驗:IPC 通訊:主程序與渲染程序的橋樑

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

1. 你正在開發一個 Electron 應用,需要讓 React 介面能夠開啟系統的檔案選擇對話框。以下哪種做法最符合 Electron 的安全最佳實踐? 情境題

  • A. 在 BrowserWindow 設定中啟用 nodeIntegration: true,直接在 React 中使用 require('electron').dialog
  • B. 在 React 元件中直接 import ipcRenderer 並呼叫 invoke()
  • C. 透過 preload 腳本使用 contextBridge 暴露 API,再從 React 透過 window.electronAPI 呼叫
  • D. 在主程序中直接將 dialog 模組掛載到 global 物件

2. 小明在開發 Electron 應用時,發現 React 元件中 window.electronAPIundefined。以下是他的 BrowserWindow 設定:錯誤診斷

const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, ‘preload.js’), contextIsolation: false, nodeIntegration: false } });

最可能的原因是什麼?

  • A. preload.js 檔案路徑錯誤
  • B. contextIsolation 設為 false,導致 contextBridge.exposeInMainWorld() 無法正常運作
  • C. nodeIntegration 設為 false,需要改成 true
  • D. 視窗尺寸太小導致載入失敗

3. 你需要在 Electron 應用中實作「讀取檔案」功能。主程序已經註冊了 ipcMain.handle('read-file', ...),但從 React 呼叫時一直沒有收到回應。以下是 preload.js 的內容:錯誤診斷

const { contextBridge, ipcRenderer } = require(‘electron’); contextBridge.exposeInMainWorld(‘electronAPI’, { readFile: (path) => ipcRenderer.invoke(‘readFile’, path) });

問題最可能出在哪裡?

  • A. contextBridge 不支援傳遞參數
  • B. 應該使用 ipcRenderer.send() 而非 invoke()
  • C. channel 名稱不一致:主程序是 'read-file',preload 是 'readFile'
  • D. exposeInMainWorld 的第一個參數應該是 'electron'

4. 你正在為團隊建立 Electron + React 專案範本。團隊成員建議直接開啟 nodeIntegration 讓開發更方便。身為專案架構師,你應該如何回應?情境題

  • A. 同意,因為這樣可以大幅簡化程式碼結構
  • B. 同意,但只在開發環境開啟,生產環境關閉
  • C. 反對,因為這會讓應用程式執行變慢
  • D. 反對,因為這會帶來嚴重的安全風險,即使載入 CDN 的 library 也可能讓惡意腳本存取檔案系統

5. 你正在開發一個檔案管理功能,需要先讓使用者選擇檔案,然後讀取檔案內容。以下哪種實作方式最佳?情境題

// preload.js 中已暴露: // window.electronAPI.openFileDialog() -> 回傳選擇的檔案路徑 // window.electronAPI.readFile(path) -> 回傳檔案內容
  • A. 在 React 中使用 callback 嵌套:openFileDialog(path => readFile(path, content => ...))
  • B. 使用 async/await:先 await openFileDialog() 取得路徑,再 await readFile(path) 讀取內容
  • C. 合併成單一 IPC 呼叫,在主程序中同時處理選擇和讀取
  • D. 使用 Promise.all() 同時執行兩個操作

發佈留言

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