查看: 110|回复: 0

Windows下Electron桌面待办应用开发实践:从环境配置到IPC

[复制链接]
发表于 半小时前 | 显示全部楼层 |阅读模式
Electron 在 Windows 桌面开发中一直有稳定的使用场景:它把 Chromium 渲染引擎和 Node.js 运行时打包进应用,让开发者能用 HTML、CSS 和 JavaScript 写桌面软件,同时通过主进程调用文件系统、窗口、菜单、通知等系统能力。对于第一次接触 Electron 的开发者,容易陷入一个误区:一上来就引入 React、Vue 或 TypeScript,结果连主进程、渲染进程、预加载脚本之间的关系都没搞清楚。下面这篇实践,用一种更朴素的方式——原生三件套加 Electron 官方 API,在 Windows 上从零完成一个带本地持久化能力的桌面待办应用。

整个开发过程基于 Windows + PowerShell + VS Code,macOS 和 Linux 的代码基本相同,差别主要在于安装命令、打包格式和系统路径。

[section]开发环境准备[/section]

Electron 开发要求本机先有 Node.js 和 npm。打开 Node.js 官方下载页面,选择当前的 LTS 版本,不要选奇数版本或标注为 Current 的版本。安装完成后在 PowerShell 中执行以下命令验证:
  1. node -v
  2. npm -v
复制代码

能看到版本号即可。注意,Electron 应用运行时并不依赖系统安装的 Node.js,它只服务于开发和构建阶段,普通用户运行打包后的应用时不会用到这个环境。

Git 建议同时安装。它虽不是运行 Electron 的硬性要求,但安装依赖、使用模板和版本管理都会用到。安装后同样用 git --version 验证。

编辑器推荐 VS Code,自带的 JavaScript 和 TypeScript 语言特性已经足够支撑当前项目。ESLint 和 Prettier 后续需要代码检查或统一格式时再装,第一次入门不需要挂一堆 Electron 专用插件。

[section]项目初始化和 Electron 安装[/section]

在 PowerShell 中创建项目目录,路径尽量使用英文、数字和短横线,避免空格、中文或过深的层级。Windows 下路径太复杂时,npm 和打包工具给出的报错会很不直观。
  1. mkdir D:\electron-projects
  2. cd D:\electron-projects
  3. mkdir electron-todo
  4. cd electron-todo
  5. npm init -y
复制代码

npm init -y 会生成 package.json,这是项目的配置文件,记录入口文件、命令和依赖。Electron 只在开发和构建阶段需要,因此作为开发依赖安装:
  1. npm install --save-dev electron
复制代码

安装过程中除了下载 npm 包,还会下载对应平台的 Electron 二进制文件,第一次安装比普通 npm 包慢是正常现象。如果网络较慢,先确认 PowerShell 能否正常访问 npm registry。

安装完成后修改 package.json,将 main 字段指向 main.js,并添加 start 脚本:
  1. {
  2.   "name": "electron-todo",
  3.   "version": "1.0.0",
  4.   "description": "一个简单的 Electron 桌面待办应用",
  5.   "author": "你的名字或团队名称",
  6.   "license": "MIT",
  7.   "main": "main.js",
  8.   "scripts": {
  9.     "start": "electron ."
  10.   }
  11. }
复制代码

main 表示 Electron 启动后首先执行 main.js;npm start 实际运行的是 electron .,点号表示把当前目录作为 Electron 应用启动。

[section]先跑出一个最小窗口[/section]

在项目根目录创建 main.js 和 index.html。主进程代码负责创建窗口:
  1. const { app, BrowserWindow } = require('electron/main')
  2. const path = require('node:path')
  3. function createWindow() {
  4.   const mainWindow = new BrowserWindow({
  5.     width: 900,
  6.     height: 650,
  7.     webPreferences: {
  8.       preload: path.join(__dirname, 'preload.js'),
  9.       contextIsolation: true,
  10.       nodeIntegration: false,
  11.       sandbox: true,
  12.     },
  13.   })
  14.   mainWindow.loadFile('index.html')
  15. }
  16. app.whenReady().then(() => {
  17.   createWindow()
  18.   app.on('activate', () => {
  19.     if (BrowserWindow.getAllWindows().length === 0) {
  20.       createWindow()
  21.     }
  22.   })
  23. })
  24. app.on('window-all-closed', () => {
  25.   if (process.platform !== 'darwin') {
  26.     app.quit()
  27.   }
  28. })
复制代码

这段代码完成了四件事:引入 app 和 BrowserWindow;创建 900x650 的窗口;加载根目录的 index.html;在应用 ready 后创建窗口。Windows 和 Linux 下关闭所有窗口后进程退出,macOS 通常保留应用进程。contextIsolation、nodeIntegration 和 sandbox 这几个安全相关配置先按默认值保留,不要为了某个功能直接放开渲染进程的 Node 权限。

index.html 先做最简单的页面:
  1. <!doctype html>
  2. <html lang="zh-CN">
  3.   <head>
  4.     <meta charset="UTF-8" />
  5.     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6.     <title>桌面待办</title>
  7.   </head>
  8.   <body>
  9.     <h1>桌面待办</h1>
  10.     <p>Electron 窗口已经启动。</p>
  11.   </body>
  12. </html>
复制代码

在项目根目录执行 npm start,如果弹出桌面窗口,说明 Electron 环境已经跑通。关闭窗口后,PowerShell 中的应用进程也会结束。终端里出现的主进程日志不要当成页面内容,它们属于开发者输出。

[section]三进程模型与代码职责[/section]

一个完整的 Electron 待办项目,文件划分应该明确:main.js 负责主进程,preload.js 负责预加载脚本,index.html 和 renderer.js 属于渲染进程,style.css 负责样式。

主进程是整个应用的入口,只有一个实例,管理应用生命周期、创建窗口、调用系统能力。它可以使用 Node.js API 和 Electron 的窗口、菜单、对话框、文件路径等能力。

每个 BrowserWindow 对应一个渲染进程,本质就是页面本身。渲染进程应严格按照浏览器页面方式开发,不要在 renderer.js 中直接 require('fs'),也不要试图直接读取电脑文件。

预加载脚本在页面加载前执行,它可以接触部分 Electron 能力,再通过 contextBridge 向页面暴露明确、安全的接口。不要直接把完整的 ipcRenderer 暴露给页面,例如下面的写法是反模式:
  1. // 错误示例,不要使用
  2. contextBridge.exposeInMainWorld('electron', {
  3.   ipcRenderer,
  4. })
复制代码

正确做法是只暴露当前页面真正需要的函数。本文的待办应用会暴露 loadTodos、saveTodos 和 getAppVersion 三个函数。

[section]带本地持久化的待办应用[/section]

接下来把空窗口升级为能真正使用的应用,任务数据保存到 Electron 的 userData 目录,重启后依然存在。

先修改 main.js,引入 ipcMain 和文件系统模块,实现读写 todos.json 的逻辑:
  1. const { app, BrowserWindow, ipcMain } = require('electron/main')
  2. const fs = require('node:fs/promises')
  3. const path = require('node:path')
  4. function getTodoFilePath() {
  5.   return path.join(app.getPath('userData'), 'todos.json')
  6. }
  7. async function loadTodos() {
  8.   try {
  9.     const content = await fs.readFile(getTodoFilePath(), 'utf8')
  10.     const todos = JSON.parse(content)
  11.     return Array.isArray(todos) ? todos : []
  12.   } catch (error) {
  13.     if (error.code === 'ENOENT') {
  14.       return []
  15.     }
  16.     throw error
  17.   }
  18. }
  19. async function saveTodos(todos) {
  20.   if (!Array.isArray(todos)) {
  21.     throw new TypeError('待办数据必须是数组')
  22.   }
  23.   const filePath = getTodoFilePath()
  24.   await fs.mkdir(path.dirname(filePath), { recursive: true })
  25.   await fs.writeFile(filePath, JSON.stringify(todos, null, 2), 'utf8')
  26. }
复制代码

todos.json 不放在项目目录,而是存放在 app.getPath('userData') 返回的路径中。这是桌面应用保存用户数据的标准做法,开发环境和打包后的应用都能获得正确的路径,不会受安装目录只读权限影响。

在 app.whenReady 回调中注册 IPC 处理器:
  1. app.whenReady().then(() => {
  2.   ipcMain.handle('todos:load', () => loadTodos())
  3.   ipcMain.handle('todos:save', (_event, todos) => saveTodos(todos))
  4.   ipcMain.handle('app:get-version', () => app.getVersion())
  5.   createWindow()
  6.   app.on('activate', () => {
  7.     if (BrowserWindow.getAllWindows().length === 0) {
  8.       createWindow()
  9.     }
  10.   })
  11. })
复制代码

接着创建 preload.js,通过 contextBridge 封装 IPC 调用:
  1. const { contextBridge, ipcRenderer } = require('electron')
  2. contextBridge.exposeInMainWorld('electronAPI', {
  3.   loadTodos: () => ipcRenderer.invoke('todos:load'),
  4.   saveTodos: (todos) => ipcRenderer.invoke('todos:save', todos),
  5.   getAppVersion: () => ipcRenderer.invoke('app:get-version'),
  6. })
复制代码

页面渲染部分沿用普通 DOM 操作思路。index.html 包含表单、任务列表、统计信息等元素,renderer.js 负责处理新增、完成、删除任务,每次变更后调用 window.electronAPI.saveTodos(todos) 持久化数据。核心交互逻辑如下:
  1. const todoForm = document.querySelector('#todoForm')
  2. const todoInput = document.querySelector('#todoInput')
  3. const todoList = document.querySelector('#todoList')
  4. const todoCount = document.querySelector('#todoCount')
  5. const emptyText = document.querySelector('#emptyText')
  6. const statusText = document.querySelector('#statusText')
  7. const clearCompletedButton = document.querySelector('#clearCompleted')
  8. const appVersion = document.querySelector('#appVersion')
  9. let todos = []
  10. async function persistTodos() {
  11.   try {
  12.     await window.electronAPI.saveTodos(todos)
  13.     showStatus('已保存')
  14.   } catch (error) {
  15.     console.error(error)
  16.     showStatus('保存失败,请查看控制台')
  17.   }
  18. }
  19. function renderTodos() {
  20.   todoList.replaceChildren()
  21.   todos.forEach((todo) => {
  22.     const item = document.createElement('li')
  23.     item.className = 'todo-item'
  24.     const checkbox = document.createElement('input')
  25.     checkbox.type = 'checkbox'
  26.     checkbox.checked = todo.done
  27.     checkbox.addEventListener('change', async () => {
  28.       todo.done = checkbox.checked
  29.       renderTodos()
  30.       await persistTodos()
  31.     })
  32.     const title = document.createElement('span')
  33.     title.className = `todo-title${todo.done ? ' done' : ''}`
  34.     title.textContent = todo.title
  35.     const deleteButton = document.createElement('button')
  36.     deleteButton.className = 'delete-button'
  37.     deleteButton.type = 'button'
  38.     deleteButton.textContent = '删除'
  39.     deleteButton.addEventListener('click', async () => {
  40.       todos = todos.filter((currentTodo) => currentTodo.id !== todo.id)
  41.       renderTodos()
  42.       await persistTodos()
  43.     })
  44.     item.append(checkbox, title, deleteButton)
  45.     todoList.append(item)
  46.   })
  47.   const unfinishedCount = todos.filter((todo) => !todo.done).length
  48.   todoCount.textContent = `共 ${todos.length} 项,未完成 ${unfinishedCount} 项`
  49.   emptyText.hidden = todos.length > 0
  50. }
  51. todoForm.addEventListener('submit', async (event) => {
  52.   event.preventDefault()
  53.   const title = todoInput.value.trim()
  54.   if (!title) {
  55.     showStatus('请输入待办事项')
  56.     todoInput.focus()
  57.     return
  58.   }
  59.   todos.unshift({ id: Date.now().toString(), title, done: false })
  60.   todoInput.value = ''
  61.   renderTodos()
  62.   await persistTodos()
  63.   todoInput.focus()
  64. })
复制代码

页面加载时调用 window.electronAPI.loadTodos() 读取本地数据,再通过 getAppVersion() 显示版本号,这两个异步调用放在初始化函数中即可。

[section]关键实践总结[/section]

从这次工作流可以提炼出几条适用于 Windows 桌面开发的经验:

1. 安全默认值不要动。contextIsolation: true、nodeIntegration: false、sandbox: true 是 Electron 官方推荐的安全基线。渲染进程被设计为不可信页面,如果所有页面代码都能直接访问 Node.js,任何一处 XSS 都可能升级为系统命令执行。

2. 数据持久化路径使用 app.getPath('userData')。这个路径由系统统一管理,在 Windows 上类似 %APPDATA%\electron-todo,开发环境和打包后都能正确解析,且不会污染项目目录。

3. IPC 接口要收敛。主进程只注册应用真正需要的 handler,预加载脚本只暴露必要的函数,不把 ipcRenderer 整体暴露出去,这是隔离边界的基本要求。

4. 渲染进程按浏览器页面方式开发,不要设想它能直接 require 模块。保持这种心智模型,后续迁移到 Web 端或对接 Electron 更高级能力时才不会混乱。

5. 调试时区分进程。主进程的日志出现在启动它的终端中,渲染进程的日志可以在开发者工具中查看。通过 console.error 和状态提示结合的方式,能快速定位保存失败等问题。

这个例子虽然简单,却完整覆盖了 Electron 应用的核心链路:环境搭建、进程分工、安全配置、IPC 通信和本地数据持久化。掌握这些基础后,再按需引入 React、Vue 或 TypeScript,理解起来会顺畅得多。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-8-26 14:37 , Processed in 0.020108 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部