【Electron + React 教學】#04 系統整合:選單、托盤與通知

測驗:Electron + React 教學 #04 系統整合

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

1. 在 Electron 中建立應用程式選單時,Menu.buildFromTemplate(template)template 參數是什麼結構?

  • A. 一個包含選單項目字串的陣列
  • B. 一個物件陣列,每個物件代表一個頂層選單,包含 label 和 submenu 屬性
  • C. 一個 JSON 設定檔的路徑
  • D. 一個 HTML 字串,描述選單的 DOM 結構

2. 在建立系統托盤(Tray)時,為什麼通常將 tray 變數宣告在全域作用域?

  • A. 因為 Electron 要求托盤變數必須是全域的
  • B. 為了方便在其他模組中存取托盤物件
  • C. 避免被 JavaScript 垃圾回收機制回收,導致托盤圖示消失
  • D. 全域變數可以讓托盤圖示顯示得更快

3. 選單項目中的 role 屬性有什麼作用?

{ label: ‘離開’, role: ‘quit’ }
  • A. 定義選單項目的視覺樣式
  • B. 使用 Electron 內建功能,如離開、複製、貼上等
  • C. 設定選單項目的存取權限
  • D. 指定選單項目所屬的使用者角色

4. 從渲染程序發送系統通知,文章推薦使用哪種方式?

  • A. 使用 Web API:new window.Notification('標題', { body: '內文' })
  • B. 直接 require Electron 的 Notification 模組
  • C. 使用 alert() 函式
  • D. 透過 WebSocket 傳送通知請求

5. 在 macOS 上建立應用程式選單時,有什麼特別需要注意的地方?

  • A. macOS 不支援自訂選單,只能使用系統預設
  • B. macOS 的選單必須使用英文
  • C. macOS 需要額外安裝選單外掛
  • D. macOS 的第一個選單項目必須是應用程式名稱

前言

當你用 AI 輔助閱讀 Electron 專案時,經常會看到 MenuTrayNotification 這些 API。這些是讓桌面應用程式「像個真正的桌面應用程式」的關鍵功能——應用程式選單、系統托盤圖示、原生通知。本篇將教你如何讀懂這些系統整合的程式碼。

學習目標

讀完本篇後,你將能夠:

  • 讀懂自訂應用程式選單(Menu)的程式碼
  • 理解系統托盤(Tray)的實作方式
  • 看懂系統原生通知(Notification)的使用方法

Menu API:建立應用程式選單

最小範例

// main.js - 主程序
const { app, Menu } = require('electron')

const template = [
  {
    label: '檔案',
    submenu: [
      { label: '新增', accelerator: 'CmdOrCtrl+N' },
      { label: '開啟', accelerator: 'CmdOrCtrl+O' },
      { type: 'separator' },
      { label: '離開', role: 'quit' }
    ]
  }
]

app.whenReady().then(() => {
  const menu = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menu)
})
Code language: JavaScript (javascript)

讀懂這段程式碼

當你在專案中看到類似程式碼時,注意這幾個重點:

1. template 陣列結構

const template = [
  {
    label: '選單名稱',      // 顯示在選單列的文字
    submenu: [ ... ]        // 下拉選單項目
  }
]
Code language: JavaScript (javascript)

這是選單的「藍圖」,每個物件代表一個頂層選單。

2. 選單項目屬性

{
  label: '新增',                    // 顯示文字
  accelerator: 'CmdOrCtrl+N',       // 快捷鍵
  click: () => { /* 處理函式 */ },  // 點擊事件
  type: 'separator',                // 分隔線
  role: 'quit'                      // 內建角色
}
Code language: JavaScript (javascript)

3. role 內建角色

看到 role 時,表示使用 Electron 內建功能:

{ role: 'quit' }      // 離開應用程式
{ role: 'copy' }      // 複製
{ role: 'paste' }     // 貼上
{ role: 'undo' }      // 復原
{ role: 'toggleDevTools' }  // 開發者工具
Code language: JavaScript (javascript)

右鍵選單(Context Menu)

// main.js
const { Menu } = require('electron')

const contextMenu = Menu.buildFromTemplate([
  { label: '複製', role: 'copy' },
  { label: '貼上', role: 'paste' },
  { type: 'separator' },
  { label: '全選', role: 'selectAll' }
])

// 在視窗中使用
mainWindow.webContents.on('context-menu', () => {
  contextMenu.popup()
})
Code language: PHP (php)

讀到這段時,關鍵是 popup() 方法——它會在滑鼠位置顯示選單。

Tray API:系統托盤圖示

系統托盤就是 Windows 右下角或 macOS 右上角的小圖示區域。

最小範例

// main.js
const { app, Tray, Menu } = require('electron')
const path = require('path')

let tray = null  // 必須保存參考,避免被垃圾回收

app.whenReady().then(() => {
  tray = new Tray(path.join(__dirname, 'icon.png'))

  const contextMenu = Menu.buildFromTemplate([
    { label: '顯示視窗', click: () => mainWindow.show() },
    { label: '離開', role: 'quit' }
  ])

  tray.setToolTip('我的應用程式')
  tray.setContextMenu(contextMenu)
})
Code language: JavaScript (javascript)

讀懂這段程式碼

1. 為什麼要用全域變數?

let tray = null  // 寫在最外層
Code language: JavaScript (javascript)

這是常見模式。如果 tray 是區域變數,會被 JavaScript 垃圾回收機制回收,托盤圖示就會消失。

2. 圖示路徑

new Tray(path.join(__dirname, 'icon.png'))
Code language: JavaScript (javascript)
  • 圖示大小建議:16×16 或 22×22 像素
  • macOS 使用「Template Image」效果更好(檔名加 Template,如 iconTemplate.png

3. 托盤事件

tray.on('click', () => {
  mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
})

tray.on('double-click', () => {
  mainWindow.show()
})
Code language: PHP (php)

實用模式:最小化到托盤

// 視窗關閉時隱藏而非退出
mainWindow.on('close', (event) => {
  if (!app.isQuitting) {
    event.preventDefault()
    mainWindow.hide()
  }
})

// 真正要退出時設定標記
app.on('before-quit', () => {
  app.isQuitting = true
})
Code language: PHP (php)

讀到這段程式碼時,理解流程:

  1. 點擊視窗的 X 按鈕 → 觸發 close 事件
  2. event.preventDefault() 阻止預設關閉行為
  3. mainWindow.hide() 只是隱藏視窗
  4. 從托盤選單選「離開」→ 設定 app.isQuitting = true
  5. 再次觸發關閉時,不會被阻止

Notification API:系統原生通知

最小範例

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

function showNotification() {
  const notification = new Notification({
    title: '下載完成',
    body: '檔案已儲存到下載資料夾'
  })

  notification.show()
}
Code language: JavaScript (javascript)

讀懂這段程式碼

1. 基本選項

new Notification({
  title: '標題',           // 通知標題
  body: '內文',            // 通知內容
  icon: '/path/icon.png',  // 圖示(可選)
  silent: false,           // 是否靜音
  urgency: 'normal'        // 緊急程度(Linux)
})
Code language: JavaScript (javascript)

2. 通知事件

notification.on('click', () => {
  // 使用者點擊通知
  mainWindow.show()
})

notification.on('close', () => {
  // 通知被關閉
})
Code language: PHP (php)

從渲染程序發送通知

有兩種方式:

方式一:使用 Web API(推薦)

// renderer.js - 渲染程序
new window.Notification('標題', {
  body: '內文'
})
Code language: JavaScript (javascript)

這是標準 Web API,不需要 IPC。

方式二:透過 IPC

// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
  showNotification: (title, body) =>
    ipcRenderer.invoke('show-notification', title, body)
})

// main.js
ipcMain.handle('show-notification', (event, title, body) => {
  new Notification({ title, body }).show()
})
Code language: JavaScript (javascript)

跨平台差異處理

選單的平台差異

macOS 的選單結構與 Windows/Linux 不同:

const isMac = process.platform === 'darwin'

const template = [
  // macOS 第一個選單是應用程式名稱
  ...(isMac ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'quit' }
    ]
  }] : []),
  {
    label: '檔案',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit' }
    ]
  }
]
Code language: JavaScript (javascript)

讀懂這段的關鍵:

  • process.platform === 'darwin' 判斷是否為 macOS
  • 使用展開運算子 ... 條件性加入選單項目

托盤的平台差異

// macOS 需要使用 Template Image
const iconPath = process.platform === 'darwin'
  ? path.join(__dirname, 'iconTemplate.png')
  : path.join(__dirname, 'icon.png')

tray = new Tray(iconPath)

// Windows 支援氣球通知
if (process.platform === 'win32') {
  tray.displayBalloon({
    title: '應用程式已啟動',
    content: '點擊托盤圖示開啟主視窗'
  })
}
Code language: JavaScript (javascript)

通知的平台差異

// 檢查通知是否支援
if (Notification.isSupported()) {
  new Notification({ title, body }).show()
}

// Windows 需要設定 AppUserModelId(打包時設定)
if (process.platform === 'win32') {
  app.setAppUserModelId('com.mycompany.myapp')
}
Code language: JavaScript (javascript)

實作範例:帶托盤的常駐應用程式

這是一個整合所有功能的完整範例:

// main.js
const { app, BrowserWindow, Menu, Tray, Notification } = require('electron')
const path = require('path')

let mainWindow = null
let tray = null

// 建立應用程式選單
function createMenu() {
  const template = [
    {
      label: '檔案',
      submenu: [
        {
          label: '新增通知',
          accelerator: 'CmdOrCtrl+N',
          click: () => showNotification('測試', '這是一則測試通知')
        },
        { type: 'separator' },
        { label: '離開', role: 'quit' }
      ]
    },
    {
      label: '視窗',
      submenu: [
        { label: '最小化到托盤', click: () => mainWindow.hide() },
        { label: '重新載入', role: 'reload' }
      ]
    }
  ]

  const menu = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menu)
}

// 建立系統托盤
function createTray() {
  const iconPath = path.join(__dirname, 'icon.png')
  tray = new Tray(iconPath)

  const contextMenu = Menu.buildFromTemplate([
    { label: '顯示視窗', click: () => mainWindow.show() },
    { label: '隱藏視窗', click: () => mainWindow.hide() },
    { type: 'separator' },
    { label: '發送通知', click: () => showNotification('來自托盤', '點擊了托盤選單') },
    { type: 'separator' },
    { label: '離開', click: () => {
      app.isQuitting = true
      app.quit()
    }}
  ])

  tray.setToolTip('我的常駐應用程式')
  tray.setContextMenu(contextMenu)

  // 點擊托盤圖示切換視窗顯示
  tray.on('click', () => {
    mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
  })
}

// 發送通知
function showNotification(title, body) {
  if (Notification.isSupported()) {
    const notification = new Notification({ title, body })
    notification.on('click', () => mainWindow.show())
    notification.show()
  }
}

// 建立主視窗
function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  mainWindow.loadFile('index.html')

  // 關閉視窗時隱藏到托盤
  mainWindow.on('close', (event) => {
    if (!app.isQuitting) {
      event.preventDefault()
      mainWindow.hide()

      // 首次隱藏時提示使用者
      showNotification('應用程式仍在執行', '點擊托盤圖示可重新開啟')
    }
  })
}

app.whenReady().then(() => {
  createWindow()
  createMenu()
  createTray()
})

app.on('before-quit', () => {
  app.isQuitting = true
})
Code language: JavaScript (javascript)

閱讀這個範例的重點

  1. 模組化函式createMenu()createTray()showNotification() 各司其職
  2. 狀態管理app.isQuitting 控制是隱藏還是真正關閉
  3. 使用者體驗:首次最小化時發送通知告知使用者
  4. 事件串接:托盤點擊、通知點擊都能讓視窗重新出現

常見模式速查

需求 關鍵程式碼
建立選單 Menu.buildFromTemplate(template)
設定應用程式選單 Menu.setApplicationMenu(menu)
顯示右鍵選單 menu.popup()
建立托盤 new Tray(iconPath)
托盤選單 tray.setContextMenu(menu)
發送通知 new Notification({ title, body }).show()
判斷平台 process.platform === 'darwin'
防止關閉 event.preventDefault()

除錯技巧

  1. 托盤圖示不顯示:確認 tray 變數是全域的,不會被垃圾回收
  2. 選單沒反應:檢查是否有 click 處理函式
  3. 通知不出現:用 Notification.isSupported() 確認支援度
  4. macOS 選單怪怪的:第一個選單項目必須是應用程式名稱

小結

本篇介紹了 Electron 三個重要的系統整合 API:

  • Menu:建立應用程式選單與右鍵選單,注意 template 結構和 role 內建角色
  • Tray:系統托盤圖示,記得保存全域參考避免被回收
  • Notification:系統原生通知,可從主程序或渲染程序發送

這些功能讓你的 Electron 應用程式更融入作業系統的使用體驗。下一篇我們將學習如何將應用程式打包發布。

延伸閱讀

進階測驗:Electron + React 教學 #04 系統整合

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

1. 你正在開發一個常駐型應用程式,希望使用者點擊視窗的 X 按鈕時,應用程式只是隱藏到系統托盤而非真正關閉。應該如何實作? 情境題

  • A. 在 BrowserWindow 建立時設定 closable: false
  • B. 移除視窗的關閉按鈕
  • C. 監聽 close 事件,使用 event.preventDefault() 阻止預設行為,然後呼叫 mainWindow.hide()
  • D. 在 app.on('window-all-closed') 中不呼叫 app.quit()

2. 你的 Electron 應用程式在 macOS 上執行時,選單看起來很奇怪,「檔案」選單的項目跑到了應用程式名稱的位置。以下是你的選單程式碼,問題出在哪裡? 錯誤診斷

const template = [ { label: ‘檔案’, submenu: [ { label: ‘開啟’, accelerator: ‘CmdOrCtrl+O’ }, { label: ‘離開’, role: ‘quit’ } ] } ]
  • A. macOS 不支援自訂選單
  • B. macOS 的第一個選單必須是應用程式名稱,需要條件性加入應用程式選單
  • C. 應該使用 role: 'fileMenu' 而非自訂 submenu
  • D. 缺少 Menu.setApplicationMenu() 呼叫

3. 你建立了一個系統托盤,但應用程式啟動後托盤圖示立刻消失了。以下是你的程式碼,問題出在哪裡? 錯誤診斷

app.whenReady().then(() => { const tray = new Tray(path.join(__dirname, ‘icon.png’)) tray.setToolTip(‘我的應用程式’) createWindow() })
  • A. setToolTip() 必須在 createWindow() 之後呼叫
  • B. 圖示路徑錯誤,找不到 icon.png
  • C. 需要設定 tray.setContextMenu() 才能顯示托盤
  • D. tray 是區域變數,函式執行完畢後被垃圾回收,應改為全域變數

4. 你需要在應用程式選單中新增一個「複製」功能。以下哪種寫法最合適? 情境題

  • A. { label: '複製', click: () => document.execCommand('copy') }
  • B. { label: '複製', role: 'copy' }
  • C. { label: '複製', accelerator: 'CmdOrCtrl+C' }
  • D. { label: '複製', click: () => clipboard.writeText(selection) }

5. 你的應用程式需要讓使用者真正關閉應用程式(而非只是隱藏到托盤)。在實作「最小化到托盤」功能後,你需要加入一個「真正離開」的機制。應該如何實作? 情境題

// 目前的 close 事件處理 mainWindow.on(‘close’, (event) => { event.preventDefault() mainWindow.hide() })
  • A. 使用 mainWindow.destroy() 取代 mainWindow.hide()
  • B. 移除 event.preventDefault()
  • C. 設定 app.isQuitting 標記,在 close 事件中檢查此標記來決定是隱藏還是真正關閉
  • D. 使用 process.exit(0) 強制結束程序

發佈留言

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