第8章 8.2 桌面端:Electron + Vue3 —— 用前端技术造一个桌面软件

(承接上一章)

上一章我们用 uniapp 把 H5 应用变成了手机 App,感觉如何?是不是发现「跨平台」这件事比想象中可控多了?

但手机屏幕就那么大,有时候你需要的不是一个「能装进口袋的 App」,而是一个「电脑桌面上的专业工具」—— 比如做一个数据采集小助手、一个团队协作白板、或者一个本地笔记软件。

这时候,Electron 就是你的答案。

学完这一章,你能:
- 理解 Electron 是怎么「用网页技术造桌面软件」的
- 用 electron-vite 快速搭起一个 Electron + Vue3 项目
- 实现主进程和渲染进程的通信(IPC)
- 独立做出一个能打包成 .exe 的小工具


🎯 开场 3 分钟:为什么要学这个?

现实场景

假设你是个运营小哥,每天要重复做一件事:打开 Excel,复制一堆数据,粘贴到网页后台,再点一堆按钮。机械、重复、容易点错。

你想:「要是有个软件能自动帮我干这活就好了。」
\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n

然后你去找程序员:「帮我做个桌面工具吧。」

程序员说:「行,Windows 上用 C# 写一个?Mac 上用 Swift 写一个?两个月。」

你:「……」

Electron 来了。 它告诉你:用你已经会的前端技术(HTML + CSS + JavaScript + Vue),就能造一个同时跑在 Windows、Mac、Linux 上的桌面软件。

痛点问题

  1. 「我会前端,但不会桌面开发」 —— Electron 让你的网页代码直接变成桌面应用
  2. 「想做个本地工具,但不想学新语言」 —— 用 Vue3 写界面,用 Node.js 写系统功能,完美

解决方案

Electron = Chromium 浏览器 + Node.js + 系统 API

你可以理解为:它把你的网页套了一层「浏览器外壳」,但这层外壳能访问你电脑的文件夹、剪贴板、系统通知等。


🧱 基础 25 分钟:核心概念

概念 1:主进程 vs 渲染进程(生活类比)

是什么: Electron 有两种「进程」,可以理解为一个公司里的两种角色。

生活类比:

想象你在一家餐厅:
- 主进程 = 前台经理 —— 不直接服务顾客,但管着所有重要资源:厨房、仓库、预约系统
- 渲染进程 = 服务员 —— 直接和顾客(你)打交道,端盘子、点菜、收银

Electron 里:
- 主进程(Main Process):一个应用只有一个,能访问文件系统、系统托盘等底层资源
- 渲染进程(Renderer Process):每个窗口都是一个渲染进程,就像每个服务员,只管自己那片区域

为什么用: 渲染进程跑的是你的 Vue 应用界面,但有些事情它干不了(比如读写本地文件),必须让主进程帮忙。

怎么用:

// main.js —— 主进程入口
const { app, BrowserWindow } = require('electron')

// 创建窗口的函数
function createWindow() {
// BrowserWindow 就是创建一个渲染进程(窗口)
const win = new BrowserWindow({
width: 800,
height: 600,
title: '我的第一个桌面应用'
})

// 加载一个网页(这里是你 Vue 应用的地址)
win.loadURL('http://localhost:5173')
}

// 当 Electron 启动完成后,执行创建窗口
app.whenReady().then(createWindow)

解释: app.whenReady() 像是「等餐厅开门」,开门后才开始接待客人(创建窗口)。

概念 2:electron-vite(工具类比)

是什么: 一个帮你快速搭建 Electron + Vue3 项目的脚手架工具。

生活类比:

你要开一家奶茶店,electron-vite 就像是「开店套餐」—— 包含店面装修、设备采购、原料供应商清单,你不用从零画图纸。

不用 electron-vite = 从买砖头开始自己盖房子
用 electron-vite = 拎包入住

为什么用: 手动配置 Electron + Vue3 很烦,要配一堆路径、脚本、调试命令。electron-vite 帮你一键搞定。

怎么用:

# 创建项目(一行命令)
npm create @electron-vite/electron-vite@latest my-electron-app

# 进入项目
cd my-electron-app

# 安装依赖
npm install

# 启动开发模式(同时启动主进程和渲染进程)
npm run dev

解释: 执行完这 4 步,一个带 Vue3 界面的 Electron 应用就跑起来了。

概念 3:IPC 通信(快递类比)

是什么: 主进程和渲染进程不在一个「世界」里,它们不能直接说话,必须通过 IPC(进程间通信)。

生活类比:

前台经理(主进程)和服务员(渲染进程)不能直接喊话,因为他们在不同地方工作。

他们靠对讲机沟通:
- 服务员按下按钮说:「经理,3号桌要加一份宫保鸡丁」
- 经理回复:「收到,已经让厨房做了」

这个「对讲机」就是 IPC。

为什么用: 渲染进程的 Vue 应用想读取本地文件?得通过对讲机喊主进程:「帮我读一下 D:/data.csv」。

怎么用:

// ====== 主进程这边(main.js)======
const { ipcMain } = require('electron')

// 监听渲染进程发来的消息
ipcMain.handle('read-file', async (event, filePath) => {
const fs = require('fs').promises
const content = await fs.readFile(filePath, 'utf-8')
return content  // 把文件内容返回给渲染进程
})
<!-- ====== 渲染进程这边(Vue 组件)====== -->
<script setup>
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'

const fileContent = ref('')

// 调用主进程的方法,像调用普通函数一样
async function loadFile() {
const content = await invoke('read-file', { 
filePath: 'D:/test.txt' 
})
fileContent.value = content
}
</script>

<template>
<button @click="loadFile">读取文件</button>
<p>{{ fileContent }}</p>
</template>

解释: invoke('read-file', {...}) 就是「按对讲机按钮」,告诉主进程「帮我读文件」。

概念 4:打包与分发(快递包装类比)

是什么: 开发时跑的是源码,打包就是把应用变成一个独立的 .exe 文件。

生活类比:

你在淘宝买了件家具,卖家发货是「散件」(板子、螺丝、说明书)。
你自己组装好,放在家里用 —— 这就是「开发模式」。

但你想送给朋友,总不能让他也买散件自己组装吧?
所以你先把家具打包好(上门安装服务),朋友直接用 —— 这就是「打包分发」。

为什么用: 用户电脑上没有 Node.js,也没有你的源码,只有你打包好的 .exe,双击就能用。

怎么用:

# 安装打包工具
npm install electron-builder

# 打包(生成 .exe)
npm run build

打包完成后,在 dist 文件夹里会生成一个 .exe 文件,双击就能跑。



🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):一个显示「Hello 桌面」的窗口

目标: 理解 Electron 项目的最小结构,跑起来看到窗口。

完整代码:

目录结构:

my-electron-app/
├── electron/
│   └── main.js          # 主进程入口
├── src/                 # Vue 源代码
│   ├── main.js
│   ├── App.vue
│   └── assets/
├── package.json
└── vite.config.js
// electron/main.js
const { app, BrowserWindow } = require('electron')
const path from 'path'

function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600
})

// 开发时加载 Vue 开发服务器
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
} else {
// 生产时加载打包好的文件
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
}

app.whenReady().then(createWindow)
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
const message = ref('Hello 桌面!')
</script>

<template>
<div class="container">
<h1>{{ message }}</h1>
<p>我的第一个 Electron + Vue3 应用</p>
</div>
</template>

<style>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
font-family: sans-serif;
}
</style>

预期输出: 运行 npm run dev,弹出一个 800x600 的窗口,显示「Hello 桌面!」

解释: 主进程创建窗口,渲染进程(Vue)负责显示内容。


项目 2(15 分钟):读取本地 CSV 文件并显示

目标: 用 IPC 让渲染进程(Vue)能读取主进程(Node.js)的文件。

场景: 你有一个 students.csv 文件,想在桌面软件里显示它的内容。

CSV 文件内容(students.csv):

姓名,语文,数学,英语
小明,85,92,88
小红,90,85,95
小李,78,80,82

完整代码:

// electron/main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')

function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 600,
webPreferences: {
  nodeIntegration: false,  // 安全考虑,不让渲染进程直接用 Node
  contextIsolation: true   // 开启上下文隔离,让 IPC 更安全
}
})

if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
}

// 处理「读取 CSV」的请求
ipcMain.handle('read-csv', async (event, filePath) => {
try {
const content = await fs.promises.readFile(filePath, 'utf-8')
// 解析 CSV
const lines = content.trim().split('\n')
const headers = lines[0].split(',')
const data = lines.slice(1).map(line => {
  const values = line.split(',')
  const row = {}
  headers.forEach((header, i) => {
    row[header] = values[i]
  })
  return row
})
return { success: true, data }
} catch (error) {
return { success: false, error: error.message }
}
})

app.whenReady().then(createWindow)
<!-- src/App.vue -->
<script setup>
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'

const students = ref([])
const filePath = ref('C:/Users/你的用户名/students.csv')
const error = ref('')

async function loadCSV() {
error.value = ''
const result = await invoke('read-csv', { filePath: filePath.value })

if (result.success) {
students.value = result.data
} else {
error.value = result.error
}
}
</script>

<template>
<div class="container">
<h1>学生成绩表</h1>

<div class="input-row">
  <input v-model="filePath" placeholder="输入 CSV 文件路径" />
  <button @click="loadCSV">读取</button>
</div>

<p v-if="error" class="error">{{ error }}</p>

<table v-if="students.length">
  <thead>
    <tr>
      <th v-for="header in ['姓名', '语文', '数学', '英语']" :key="header">
        {{ header }}
      </th>
    </tr>
  </thead>
  <tbody>
    <tr v-for="(student, index) in students" :key="index">
      <td>{{ student['姓名'] }}</td>
      <td>{{ student['语文'] }}</td>
      <td>{{ student['数学'] }}</td>
      <td>{{ student['英语'] }}</td>
    </tr>
  </tbody>
</table>
</div>
</template>

<style>
.container { padding: 20px; font-family: sans-serif; }
.input-row { display: flex; gap: 10px; margin-bottom: 20px; }
input { flex: 1; padding: 8px; font-size: 14px; }
button { padding: 8px 16px; background: #42b983; color: white; border: none; cursor: pointer; }
.error { color: red; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: center; }
th { background: #f5f5f5; }
</style>

预期输出: 点击「读取」后,表格显示:

姓名 语文 数学 英语
小明 85 92 88
小红 90 85 95
小李 78 80 82

解释: Vue 组件通过 invoke 发送请求给主进程,主进程用 Node.js 的 fs 模块读取文件,解析后返回数据。


项目 3(15 分钟):桌面便签小工具

目标: 综合运用学到的知识,做一个有点真实用途的本地便签工具。能添加、删除、持久化保存便签。

完整代码:

// electron/main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')

let mainWindow

function createWindow() {
mainWindow = new BrowserWindow({
width: 400,
height: 500,
webPreferences: {
  nodeIntegration: false,
  contextIsolation: true
}
})

if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173')
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
}

// 获取便签存储路径
function getNotesPath() {
return path.join(app.getPath('userData'), 'notes.json')
}

// 读取便签
ipcMain.handle('load-notes', async () => {
const notesPath = getNotesPath()
try {
if (fs.existsSync(notesPath)) {
  const content = await fs.promises.readFile(notesPath, 'utf-8')
  return JSON.parse(content)
}
return []
} catch {
return []
}
})

// 保存便签
ipcMain.handle('save-notes', async (event, notes) => {
const notesPath = getNotesPath()
await fs.promises.writeFile(notesPath, JSON.stringify(notes, null, 2))
return true
})

app.whenReady().then(createWindow)
<!-- src/App.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'

const notes = ref([])
const newNote = ref('')

// 页面加载时读取便签
onMounted(async () => {
notes.value = await invoke('load-notes')
})

// 添加便签
async function addNote() {
if (!newNote.value.trim()) return

notes.value.push({
id: Date.now(),
text: newNote.value.trim(),
time: new Date().toLocaleString()
})

await invoke('save-notes', { notes: notes.value })
newNote.value = ''
}

// 删除便签
async function deleteNote(id) {
notes.value = notes.value.filter(note => note.id !== id)
await invoke('save-notes', { notes: notes.value })
}
</script>

<template>
<div class="container">
<h1>📝 桌面便签</h1>

<div class="input-area">
  <input 
    v-model="newNote" 
    placeholder="写点啥..."
    @keyup.enter="addNote"
  />
  <button @click="addNote">添加</button>
</div>

<div class="notes-list">
  <div v-for="note in notes" :key="note.id" class="note-card">
    <p class="note-text">{{ note.text }}</p>
    <p class="note-time">{{ note.time }}</p>
    <button class="delete-btn" @click="deleteNote(note.id)">删除</button>
  </div>

  <p v-if="notes.length === 0" class="empty">
    还没有便签,写一个吧 ✍️
  </p>
</div>
</div>
</template>

<style>
.container {
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #fafafa;
height: 100vh;
box-sizing: border-box;
}
h1 { text-align: center; color: #333; }
.input-area {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
padding: 10px 20px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover { background: #3aa876; }
.notes-list { display: flex; flex-direction: column; gap: 10px; }
.note-card {
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.note-text { margin: 0 0 8px 0; color: #333; }
.note-time { margin: 0 0 10px 0; font-size: 12px; color: #999; }
.delete-btn {
padding: 4px 10px;
font-size: 12px;
background: #ff6b6b;
}
.delete-btn:hover { background: #ee5a5a; }
.empty { text-align: center; color: #999; margin-top: 40px; }
</style>

预期输出: 运行后显示一个便签应用,可以添加、删除便签,关闭再打开后便签还在(持久化保存到 notes.json)。

解释: 便签数据存在 userData 目录下,关闭应用后数据不丢失。



💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:主进程报错导致整个应用崩溃 ❌ → ✅

错误示例:

// 主进程中读取不存在的文件
ipcMain.handle('read-file', async () => {
const content = fs.readFileSync('不存在的路径.txt')  // 崩溃!
return content
})

正确示例:

// 用 try-catch 包装,并且用异步方法
ipcMain.handle('read-file', async () => {
try {
const content = await fs.promises.readFile('不存在的路径.txt', 'utf-8')
return { success: true, data: content }
} catch (error) {
return { success: false, error: error.message }  // 返回错误而不是崩溃
}
})

原因: 主进程崩溃 = 整个应用崩溃,必须 catch 住所有可能的错误。


坑 2:路径问题(相对路径 vs 绝对路径) ❌ → ✅

错误示例:

// 渲染进程
const content = await fs.readFile('./data.csv')  // 找不到!相对的是 Electron 执行目录

正确示例:

// 主进程中处理路径,用 app.getPath('userData') 获取可靠路径
const filePath = path.join(app.getPath('userData'), 'data.csv')

原因: 渲染进程的当前目录不一定是你的项目目录,要用绝对路径。


坑 3:contextIsolation 和 invoke 的关系 ❌ → ✅

错误示例:

// 主进程
webPreferences: {
nodeIntegration: true,  // ❌ 不安全
contextIsolation: false  // ❌ 和 invoke 不兼容
}

正确示例:

webPreferences: {
nodeIntegration: false,   // ✅ 安全
contextIsolation: true,   // ✅ 开启隔离
preload: path.join(__dirname, 'preload.js')  // ✅ 预加载脚本
}

原因: invoke 依赖 contextIsolation,如果关了它,invoke 就不好使了。


坑 4:打包后路径找不到 ❌ → ✅

错误示例:

// 开发时能用,打包后失效
win.loadURL('http://localhost:5173')

正确示例:

if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}

原因: 打包后没有开发服务器,必须加载本地文件。


性能小贴士:批量操作用事件而非多次调用

// ❌ 慢:每次删除都触发一次 IPC
for (const id of idsToDelete) {
await invoke('delete-note', { id })
}

// ✅ 快:一次 IPC 搞定所有删除
await invoke('delete-notes-batch', { ids: idsToDelete })

调试技巧:主进程日志

// 在主进程里加日志
const fs = require('fs')

ipcMain.handle('read-file', async (event, filePath) => {
console.log('[主进程] 收到读取请求:', filePath)
const content = await fs.promises.readFile(filePath, 'utf-8')
console.log('[主进程] 读取成功,长度:', content.length)
return content
})

开发时在终端看到主进程的输出,打包后可以写到日志文件。


✏️ 练习题 + 作业题

练习题(5 道,10 分钟内完成)

练习 1(2 分钟):改窗口标题
- 输入:在项目 1 的基础上
- 预期输出:把窗口标题改成「我的桌面工具」
- 提示:找 new BrowserWindow 那行,看看 title 属性

练习 2(2 分钟):添加一个判断
- 输入:在项目 2 的 loadCSV 函数里,加一个判断,文件路径为空时提示「请输入路径」
- 预期输出:不填路径就点按钮时,界面显示提示
- 提示:用 if (!filePath.value) 判断

练习 3(3 分钟):处理新数据
- 输入:有一个 grades.csv,内容是 科目,分数,多行
- 预期输出:在表格里显示出来
- 提示:复用项目 2 的代码,改一下表头映射

练习 4(3 分钟):串联读取和显示
- 输入:项目 2 读取 CSV,项目 3 显示便签列表 —— 把 CSV 里每行转成便签
- 预期输出:读取 CSV 后,表格下方显示「已导入 X 条便签」
- 提示:遍历 CSV 数据,调用 addNote 函数

练习 5(挑战,5 分钟):分析报错
- 输入:运行时报错 Cannot read property 'split' of undefined
- 预期输出:说出错误原因并修复
- 提示:检查 loadCSV 函数里哪行用了 .split(),往前看是不是读取失败了


作业题(30 分钟 - 2 小时)

作业:做一个「本地 Markdown 笔记工具」

需求描述:
做一个能读写本地 Markdown 文件的桌面笔记软件

功能点:
1. 左侧是文件列表(读取本地一个文件夹下的所有 .md 文件)
2. 右侧是编辑区(支持 Markdown 语法高亮)
3. 点击文件名加载内容,编辑后 Ctrl+S 保存
4. 新建按钮创建新笔记

加分项:
1. 添加搜索功能,能搜索文件名和内容
2. 添加「导出为 PDF」功能

验收标准:
- 能跑起来(npm run dev)
- 能读取本地文件夹的 .md 文件
- 编辑后保存,关闭再打开还在
- 代码有适当注释

提交方式: 评论区贴代码或 GitHub 链接


📚 总结 + 资源

一句话总结本文学到的 3 个核心点

  1. Electron = 网页 + Node.js = 桌面应用 —— 主进程管系统资源,渲染进程管界面
  2. IPC 是桥梁 —— 渲染进程通过 invoke 让主进程帮忙读写文件
  3. electron-vite 让你快速起步 —— 一行命令,Vue3 + Electron 项目跑起来

推荐延伸学习资源

  1. Electron 官方文档 —— 最权威,有中文版
  2. electron-vite 官方指南 —— 这个工具的专门文档
  3. 《 Electron + Vue3实战》人民邮电出版社 —— 适合想深入做桌面应用的

互动钩子:

你有没有想过用 Electron 做什么工具?是想做一个「自动签到脚本」,还是一个「本地日记本」?评论区说说你的想法,老粉优先回复!


下章剧透:

学会了桌面端开发,你手里已经有了「网页 + 本地能力」这两把刷子。但如果想在同一个页面里,塞进去好几个不同技术栈的前端应用( Vue + React + Angular 同时跑)呢?下一章我们来聊聊「微前端」这个听起来很厉害的概念 —— 用 qiankun 让多个独立应用无缝拼在一起。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。