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 中执行以下命令验证:
能看到版本号即可。注意,Electron 应用运行时并不依赖系统安装的 Node.js,它只服务于开发和构建阶段,普通用户运行打包后的应用时不会用到这个环境。
Git 建议同时安装。它虽不是运行 Electron 的硬性要求,但安装依赖、使用模板和版本管理都会用到。安装后同样用 git --version 验证。
编辑器推荐 VS Code,自带的 JavaScript 和 TypeScript 语言特性已经足够支撑当前项目。ESLint 和 Prettier 后续需要代码检查或统一格式时再装,第一次入门不需要挂一堆 Electron 专用插件。
[section]项目初始化和 Electron 安装[/section]
在 PowerShell 中创建项目目录,路径尽量使用英文、数字和短横线,避免空格、中文或过深的层级。Windows 下路径太复杂时,npm 和打包工具给出的报错会很不直观。
- mkdir D:\electron-projects
- cd D:\electron-projects
- mkdir electron-todo
- cd electron-todo
- npm init -y
复制代码
npm init -y 会生成 package.json,这是项目的配置文件,记录入口文件、命令和依赖。Electron 只在开发和构建阶段需要,因此作为开发依赖安装:
- npm install --save-dev electron
复制代码
安装过程中除了下载 npm 包,还会下载对应平台的 Electron 二进制文件,第一次安装比普通 npm 包慢是正常现象。如果网络较慢,先确认 PowerShell 能否正常访问 npm registry。
安装完成后修改 package.json,将 main 字段指向 main.js,并添加 start 脚本:
- {
- "name": "electron-todo",
- "version": "1.0.0",
- "description": "一个简单的 Electron 桌面待办应用",
- "author": "你的名字或团队名称",
- "license": "MIT",
- "main": "main.js",
- "scripts": {
- "start": "electron ."
- }
- }
复制代码
main 表示 Electron 启动后首先执行 main.js;npm start 实际运行的是 electron .,点号表示把当前目录作为 Electron 应用启动。
[section]先跑出一个最小窗口[/section]
在项目根目录创建 main.js 和 index.html。主进程代码负责创建窗口:
- const { app, BrowserWindow } = require('electron/main')
- const path = require('node:path')
- function createWindow() {
- const mainWindow = new BrowserWindow({
- width: 900,
- height: 650,
- webPreferences: {
- preload: path.join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false,
- sandbox: true,
- },
- })
- mainWindow.loadFile('index.html')
- }
- app.whenReady().then(() => {
- createWindow()
- app.on('activate', () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow()
- }
- })
- })
- app.on('window-all-closed', () => {
- if (process.platform !== 'darwin') {
- app.quit()
- }
- })
复制代码
这段代码完成了四件事:引入 app 和 BrowserWindow;创建 900x650 的窗口;加载根目录的 index.html;在应用 ready 后创建窗口。Windows 和 Linux 下关闭所有窗口后进程退出,macOS 通常保留应用进程。contextIsolation、nodeIntegration 和 sandbox 这几个安全相关配置先按默认值保留,不要为了某个功能直接放开渲染进程的 Node 权限。
index.html 先做最简单的页面:
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>桌面待办</title>
- </head>
- <body>
- <h1>桌面待办</h1>
- <p>Electron 窗口已经启动。</p>
- </body>
- </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 暴露给页面,例如下面的写法是反模式:
- // 错误示例,不要使用
- contextBridge.exposeInMainWorld('electron', {
- ipcRenderer,
- })
复制代码
正确做法是只暴露当前页面真正需要的函数。本文的待办应用会暴露 loadTodos、saveTodos 和 getAppVersion 三个函数。
[section]带本地持久化的待办应用[/section]
接下来把空窗口升级为能真正使用的应用,任务数据保存到 Electron 的 userData 目录,重启后依然存在。
先修改 main.js,引入 ipcMain 和文件系统模块,实现读写 todos.json 的逻辑:
- const { app, BrowserWindow, ipcMain } = require('electron/main')
- const fs = require('node:fs/promises')
- const path = require('node:path')
- function getTodoFilePath() {
- return path.join(app.getPath('userData'), 'todos.json')
- }
- async function loadTodos() {
- try {
- const content = await fs.readFile(getTodoFilePath(), 'utf8')
- const todos = JSON.parse(content)
- return Array.isArray(todos) ? todos : []
- } catch (error) {
- if (error.code === 'ENOENT') {
- return []
- }
- throw error
- }
- }
- async function saveTodos(todos) {
- if (!Array.isArray(todos)) {
- throw new TypeError('待办数据必须是数组')
- }
- const filePath = getTodoFilePath()
- await fs.mkdir(path.dirname(filePath), { recursive: true })
- await fs.writeFile(filePath, JSON.stringify(todos, null, 2), 'utf8')
- }
复制代码
todos.json 不放在项目目录,而是存放在 app.getPath('userData') 返回的路径中。这是桌面应用保存用户数据的标准做法,开发环境和打包后的应用都能获得正确的路径,不会受安装目录只读权限影响。
在 app.whenReady 回调中注册 IPC 处理器:
- app.whenReady().then(() => {
- ipcMain.handle('todos:load', () => loadTodos())
- ipcMain.handle('todos:save', (_event, todos) => saveTodos(todos))
- ipcMain.handle('app:get-version', () => app.getVersion())
- createWindow()
- app.on('activate', () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow()
- }
- })
- })
复制代码
接着创建 preload.js,通过 contextBridge 封装 IPC 调用:
- const { contextBridge, ipcRenderer } = require('electron')
- contextBridge.exposeInMainWorld('electronAPI', {
- loadTodos: () => ipcRenderer.invoke('todos:load'),
- saveTodos: (todos) => ipcRenderer.invoke('todos:save', todos),
- getAppVersion: () => ipcRenderer.invoke('app:get-version'),
- })
复制代码
页面渲染部分沿用普通 DOM 操作思路。index.html 包含表单、任务列表、统计信息等元素,renderer.js 负责处理新增、完成、删除任务,每次变更后调用 window.electronAPI.saveTodos(todos) 持久化数据。核心交互逻辑如下:
- const todoForm = document.querySelector('#todoForm')
- const todoInput = document.querySelector('#todoInput')
- const todoList = document.querySelector('#todoList')
- const todoCount = document.querySelector('#todoCount')
- const emptyText = document.querySelector('#emptyText')
- const statusText = document.querySelector('#statusText')
- const clearCompletedButton = document.querySelector('#clearCompleted')
- const appVersion = document.querySelector('#appVersion')
- let todos = []
- async function persistTodos() {
- try {
- await window.electronAPI.saveTodos(todos)
- showStatus('已保存')
- } catch (error) {
- console.error(error)
- showStatus('保存失败,请查看控制台')
- }
- }
- function renderTodos() {
- todoList.replaceChildren()
- todos.forEach((todo) => {
- const item = document.createElement('li')
- item.className = 'todo-item'
- const checkbox = document.createElement('input')
- checkbox.type = 'checkbox'
- checkbox.checked = todo.done
- checkbox.addEventListener('change', async () => {
- todo.done = checkbox.checked
- renderTodos()
- await persistTodos()
- })
- const title = document.createElement('span')
- title.className = `todo-title${todo.done ? ' done' : ''}`
- title.textContent = todo.title
- const deleteButton = document.createElement('button')
- deleteButton.className = 'delete-button'
- deleteButton.type = 'button'
- deleteButton.textContent = '删除'
- deleteButton.addEventListener('click', async () => {
- todos = todos.filter((currentTodo) => currentTodo.id !== todo.id)
- renderTodos()
- await persistTodos()
- })
- item.append(checkbox, title, deleteButton)
- todoList.append(item)
- })
- const unfinishedCount = todos.filter((todo) => !todo.done).length
- todoCount.textContent = `共 ${todos.length} 项,未完成 ${unfinishedCount} 项`
- emptyText.hidden = todos.length > 0
- }
- todoForm.addEventListener('submit', async (event) => {
- event.preventDefault()
- const title = todoInput.value.trim()
- if (!title) {
- showStatus('请输入待办事项')
- todoInput.focus()
- return
- }
- todos.unshift({ id: Date.now().toString(), title, done: false })
- todoInput.value = ''
- renderTodos()
- await persistTodos()
- todoInput.focus()
- })
复制代码
页面加载时调用 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,理解起来会顺畅得多。 |