引言
用前端技术栈写桌面应用,绕不开两条路线:Electron 把 Chromium 与 Node.js 一并打包,生态成熟、能力完整,代价是几百 MB 的体积;Tauri 复用系统 WebView 并用 Rust 写后端,体积只有几 MB,代价是各平台 WebView 行为不一致。两者的共同点是——渲染层仍然是一个 Web 前端工程,而 Vite 正是这个前端工程最顺手的构建工具。
本文围绕「Vite 负责前端、壳层负责原生」这条主线展开:先做两套方案的取舍,再搭渲染进程与主进程的工程结构,接着解决开发期联调与生产构建这两处最容易翻车的地方,然后深入预加载脚本与 IPC 的类型安全、自动更新与签名打包、体积与启动优化、跨平台差异,最后给出调试手段与落地清单。
前置:
base与插件配置、生产产物优化。联调阶段的服务端能力见 Vite 开发服务器内部架构:中间件管线、模块图与按需编译。
目录
- 1. Electron 与 Tauri 的取舍
- 2. 工程结构:渲染进程、主进程与预加载脚本
- 3. 开发期联调:Vite 服务器与主进程
- 4. 生产构建:base 路径与 file 协议
- 5. 预加载脚本与 IPC 类型安全
- 6. 自动更新与签名打包
- 7. 体积与启动性能优化
- 8. 跨平台差异与注意事项
- 9. 调试、日志与崩溃排查
- 10. 常见陷阱与落地清单
1. Electron 与 Tauri 的取舍
1.1 两条路线的本质
Electron 的思路是「自带一个浏览器」:Chromium 负责渲染、Node.js 负责系统能力,渲染层与原生层跑在同一套 JS 运行时里,学习成本最低。Tauri 的思路是「借系统的浏览器」:渲染层跑在系统 WebView(macOS 的 WKWebView、Windows 的 WebView2、Linux 的 WebKitGTK)里,系统能力由 Rust 侧通过 IPC 暴露,前端只拿到一个受限的接口面。
1.2 关键维度对比
| 维度 | Electron | Tauri |
|---|---|---|
| 运行时体积 | 80~150MB 起 | 3~10MB 起 |
| 渲染引擎 | 自带 Chromium | 系统 WebView |
| 后端语言 | Node.js | Rust |
| 内存占用 | 较高 | 较低 |
| 生态成熟度 | 非常成熟 | 快速成长 |
| 渲染一致性 | 全平台一致 | 依赖系统 WebView |
1.3 选择建议
需要全平台像素级一致、依赖大量 Node 原生模块 → Electron
追求极小体积与低内存、愿意引入 Rust → Tauri
需要调用冷门系统 API → 先确认 Tauri 是否有官方或社区插件
对 Vite 而言,两者都只要求「渲染层是一个标准前端工程」:开发期连 Vite 服务器,生产期加载构建产物,区别只在壳层配置。
记忆:Electron 自带浏览器换一致性、Tauri 借系统 WebView 换体积——渲染层对 Vite 都是同一个前端工程,差别在壳层配置与原生能力通道。
2. 工程结构:渲染进程、主进程与预加载脚本
2.1 三个角色的职责
主进程 → 创建窗口、访问文件系统、菜单与托盘(Node/Rust 侧)
预加载脚本 → 在渲染前运行,用 contextBridge 暴露受控 API(桥接层)
渲染进程 → 就是你的 Vite 前端应用(浏览器环境)
三者的边界必须清晰:渲染进程不能直接 require 或访问 fs,一切系统能力都经预加载脚本中转。
2.2 推荐目录结构
my-app/
├─ src/ # 渲染进程(Vite 工程)
├─ electron/ # 壳层:main.ts 主进程、preload.ts 预加载
├─ index.html # Vite 入口 HTML
└─ vite.config.ts
2.3 让 Vite 同时构建壳层
主进程与预加载脚本同样需要编译(TS → JS),可以交给 Vite 的多入口构建,或用 vite-plugin-electron 统一编排:
// vite.config.ts
export default defineConfig({
plugins: [
electron([
{ entry: 'electron/main.ts' },
{ entry: 'electron/preload.ts', onstart: (a) => a.reload() },
]),
],
})
预加载脚本必须构建成 CJS 或 IIFE,因为它在 Node 上下文中被 require 加载,不能用 ESM 输出格式。
记忆:三进程职责要分清——渲染进程是纯前端、预加载脚本是受控桥、主进程才碰系统能力;预加载脚本构建格式必须是 CJS。
3. 开发期联调:Vite 服务器与主进程
3.1 开发期加载 dev server
开发时主进程不加载文件,而是加载 Vite 开发服务器的 URL,这样才能拿到 HMR:
// electron/main.ts
const DEV_URL = process.env.VITE_DEV_SERVER_URL
function createWindow() {
const win = new BrowserWindow({
webPreferences: { preload: `${__dirname}/preload.js` },
})
if (DEV_URL) win.loadURL(DEV_URL)
else win.loadFile(`${__dirname}/../dist/index.html`)
}
3.2 用环境变量传递地址
vite-plugin-electron 会自动把开发服务器地址注入 VITE_DEV_SERVER_URL;手写脚本时也可以在启动前探测端口,再拼出 http://localhost:${port}。
3.3 HMR 与主进程重启
渲染进程代码改动 → Vite HMR 直接热替换,无需重启
预加载脚本改动 → 需要 reload 窗口(onstart 里调 reload)
主进程代码改动 → 必须重启整个 Electron 进程
把主进程重启做成「改完自动重启」,能显著减少手动开关窗口的次数。
记忆:开发期主进程加载 dev server URL 才能拿到 HMR——渲染进程热替换、预加载脚本 reload、主进程必须重启,三档粒度要分清。
4. 生产构建:base 路径与 file 协议
4.1 base 必须是相对路径
生产环境页面通过 file:// 协议加载,绝对路径 /assets/x.js 会被解析成磁盘根目录,必然 404。必须把 base 设为相对路径:
export default defineConfig({
base: './',
})
4.2 路由模式的选择
Hash 模式(createWebHashHistory) → file:// 下开箱可用,推荐
History 模式(createWebHistory) → file:// 下刷新会 404,需自定义协议
使用 History 模式时,通常要注册自定义协议(如 app://)来托管静态资源,而不是直接 loadFile。
4.3 产物加载与资源核对
win.loadFile(path.join(__dirname, '../dist/index.html'))
# 期望看到 ./assets/index-xxxx.js 而非 /assets/index-xxxx.js
grep -o 'src="[^"]*"' dist/index.html | head
记忆:生产构建第一坑是路径——
base: './'必设、路由优先 Hash 模式,构建后务必 grep 一遍 HTML 确认资源引用是相对路径。
5. 预加载脚本与 IPC 类型安全
5.1 开启上下文隔离
安全底线是 contextIsolation: true 与 nodeIntegration: false:
new BrowserWindow({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
},
})
5.2 用 contextBridge 暴露受控 API
// electron/preload.ts
contextBridge.exposeInMainWorld('api', {
readConfig: () => ipcRenderer.invoke('config:read'),
})
// 主进程侧
ipcMain.handle('config:read', async () => readConfigFromDisk())
5.3 共享类型定义
把通道名与返回类型抽到一个共享文件,让渲染层与壳层共用:
// shared/ipc.ts
export interface AppApi {
readConfig(): Promise<AppConfig>
}
declare global { interface Window { api: AppApi } }
这样渲染层调用 window.api.readConfig() 就有完整类型提示,通道名写错会在编译期暴露。
记忆:IPC 的安全与类型要一起做——
contextIsolation必开、能力经contextBridge暴露、通道名与类型放共享文件,写错在编译期就报。
6. 自动更新与签名打包
6.1 自动更新
Electron 用 electron-updater,Tauri 用官方 updater 插件,思路一致:应用启动后拉取更新清单,比对版本号,下载新包,提示重启。
// Electron 侧
autoUpdater.autoDownload = false
autoUpdater.checkForUpdates()
autoUpdater.on('update-available', () => {
// 提示用户,确认后调用 autoUpdater.downloadUpdate()
})
6.2 代码签名
macOS → Apple Developer 证书 + 公证(notarization),否则 Gatekeeper 拦截
Windows → 代码签名证书,否则 SmartScreen 警告
Linux → 无强制签名,用 AppImage/deb/rpm 分发
签名证书必须放在 CI 的密钥里,绝不能提交到仓库。
6.3 打包工具
Electron → electron-builder / electron-forge
Tauri → tauri build(内置打包与签名流程)
记忆:自动更新的前提是签名——macOS 必须公证、Windows 必须签名,证书进 CI 密钥而非仓库,否则装不上也更新不了。
7. 体积与启动性能优化
7.1 体积来源拆解
| 来源 | Electron | Tauri |
|---|---|---|
| 运行时 | Chromium + Node | Rust 编译产物 |
| 前端产物 | Vite dist | Vite dist |
| 依赖 | node_modules 打进 asar | 少量 Rust crate |
前端产物在两者中都只占一小部分,优化前端对总体积影响有限,但仍直接影响启动速度。
7.2 启动优化
- 首屏不做重初始化,把非关键逻辑延迟到窗口显示后
- 主进程与渲染进程并行启动,别串行等待
- 前端首包做代码分割,先渲染骨架再加载功能
// 窗口内容就绪后再显示,避免白屏闪烁
win.once('ready-to-show', () => win.show())
7.3 产物瘦身
打包前确认前端产物已压缩、无 sourcemap 混入生产包,并在 Electron 侧开启 asar、排除 devDependencies。
记忆:桌面应用体积主要由运行时决定,但启动速度受前端首包影响——延迟非关键初始化、
ready-to-show再显示窗口、前端做代码分割。
8. 跨平台差异与注意事项
8.1 路径与文件系统
永远用 path.join / app.getPath,别手写斜杠拼接
用户数据写 app.getPath('userData'),别写死在安装目录
文件名大小写:Windows 不敏感、Linux 敏感,引用资源时保持一致
8.2 平台特有行为
| 差异点 | 说明 |
|---|---|
| 菜单栏 | macOS 在顶部全局,Windows/Linux 在窗口内 |
| 关闭行为 | macOS 关窗不退应用,Windows 关窗即退 |
| 系统托盘 | 三平台 API 与图标尺寸要求不同 |
| WebView | Tauri 在 Linux 上 WebKitGTK 版本差异大 |
8.3 构建矩阵
每个平台必须在对应系统上构建(或使用 CI 的对应 runner)
macOS 的公证、Windows 的签名都依赖各自平台的工具链
交叉编译 Electron 不现实,Tauri 跨编译也需额外配置
记忆:跨平台三件事——路径用 API 拼、关窗与菜单行为按平台分支、每个平台在对应系统上构建,别指望一套产物通吃。
9. 调试、日志与崩溃排查
9.1 渲染进程调试
渲染进程就是一个网页,直接用 webContents.openDevTools() 打开标准 DevTools,Network、Console、Performance 面板全都可用。
9.2 主进程调试
Electron → 用 --inspect 启动,Chrome 的 chrome://inspect 附加调试
Tauri → Rust 侧用 println!/log crate,前端侧仍是 DevTools
9.3 日志与崩溃
- 统一日志出口:主进程写文件,渲染进程经 IPC 汇总
- 捕获 uncaughtException 与 render-process-gone,写入崩溃日志
- 生产环境保留日志滚动,避免无限增长
app.on('render-process-gone', (_e, _wc, details) => logCrash(details.reason))
记忆:渲染进程用标准 DevTools、主进程用
--inspect附加——崩溃排查靠统一日志出口加render-process-gone捕获。
10. 常见陷阱与落地清单
10.1 高频陷阱表
| 现象 | 原因 | 处理 |
|---|---|---|
| 生产白屏 | base 为绝对路径 | 设 base: './' |
| 刷新后 404 | History 路由 + file 协议 | 改 Hash 模式 |
| IPC 调用报错 | 通道名不匹配 | 通道名集中定义 |
| 渲染层能读文件 | nodeIntegration 开启 | 关闭并走 preload |
| 更新装不上 | 未签名或未公证 | 配好证书与公证 |
| 预加载脚本失效 | 输出成了 ESM | 改 CJS 格式 |
| 体积异常大 | 开发依赖进了包 | 打包排除 devDependencies |
10.2 落地清单
□ 渲染层 base 为相对路径,路由优先 Hash 模式
□ contextIsolation 开启、nodeIntegration 关闭
□ 系统能力全部经 contextBridge 暴露并带类型
□ 预加载脚本构建格式为 CJS
□ 自动更新链路已接通并做了版本比对
□ macOS 公证、Windows 签名证书已进 CI 密钥
□ 窗口 ready-to-show 后再显示,首包做了代码分割
10.3 一句话总结
选型看体积与一致性(Electron vs Tauri)
联调看地址注入(dev server URL)
构建看路径协议(base 相对 + Hash 路由)
安全看桥接边界(preload + contextBridge)
发布看签名更新(公证 + updater)
记忆:Vite 桌面应用的闭环是「联调注地址、构建改相对、安全走桥接、发布做签名」——四步任一漏掉,都会以白屏、404 或装不上的形式在用户侧爆发。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。