🖥️ 从 Web 项目迁移到 Electron 的路径与方案
把一个 HTML/Vue 前端变成桌面应用,核心不是「加个壳」,而是把运行环境从浏览器换成 Node + Chromium,同时把浏览器里不能用的东西逐个换掉。
🎯 先判断迁移成本
| 现有形态 | 迁移难度 | 说明 |
|---|---|---|
| 纯静态 HTML + CSS + JS | ⭐ 很低 | 直接套壳,<script> 里不能有 require,改法最少 |
| Vue/React 单页(已有构建) | ⭐⭐ 低 | 把 dist/ 当静态资源加载即可,渲染层几乎不用改 |
依赖 window.open / alert / localStorage | ⭐⭐ 低 | 这些在 Electron 渲染进程里都可用 |
依赖 DOM API 操作浏览器(history.pushState 打不开新窗口等) | ⭐⭐⭐ 中 | 需要改用 BrowserWindow / webContents 替代 |
依赖 fetch 跨域请求 | ⭐⭐⭐ 中 | 要么配 CORS,要么走主进程代理 |
| 大量使用 Node 库(fs / path / crypto) | ⭐⭐⭐ 中 | 必须挪到主进程,通过 IPC 调用 |
| 需要访问本地文件系统 | ⭐⭐⭐⭐ 高 | 引入文件读写、权限、路径解析整套逻辑 |
| 已有后端服务依赖 | ⭐ 低 | 沿用 HTTP/RPC 即可,不用改 |
判断标准一句话:这个项目有多少逻辑依赖「浏览器沙箱」这个前提?依赖越多,迁移越难。
📦 方案选型
方案 A:Electron + Vite(推荐,最主流)
vue-vite 项目
├── vite.config.ts
├── src/ # 渲染进程代码,原样保留
├── electron/
│ ├── main.ts # 主进程:窗口、菜单、托盘
│ ├── preload.ts # 预加载:安全暴露 API
│ └── ipc.ts # IPC 通道定义
└── package.json
优点:生态成熟、electron-vite 插件开箱即用、HMR 体验好
缺点:主/渲染进程边界要自己划
构建产物:vite build 出渲染层静态资源 → electron-builder 打包成安装包
方案 B:Electron 纯手写(无框架)
零构建工具,HTML 直接进 loadFile。适合纯静态小项目,或想完全控制构建产物。
方案 C:Tauri(Rust 后端,非 Electron)
同样的 Web 技术 + 极小安装包(几 MB vs Electron 的 100MB+)。
但:IPC 要写 Rust,前端调 Rust 的学习成本高。除非对体积有硬需求,否则迁移路径不推荐——从 Web 项目过来,Tauri 的增量成本比 Electron 高。
给从 Vue/HTML 过来的人的建议:先走 Electron,路径最短、资料最多。等真的被安装包体积卡住了再考虑 Tauri。
🛠️ 迁移步骤(以 Vue3 + Vite 为例)
1. 初始化 Electron 骨架
npm install -D electron electron-vite electron-builder2. 配置 package.json 的构建脚本
{
"main": "dist-electron/main.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"dist": "npm run build && electron-builder"
},
"build": {
"appId": "com.example.myapp",
"productName": "MyApp",
"files": ["dist/**/*", "dist-electron/**/*"],
"mac": { "target": ["dmg"], "category": "public.app-category.developer-tools" },
"win": { "target": ["nsis"] },
"linux": { "target": ["AppImage"] }
}
}3. 写主进程(窗口 + 生命周期)
// electron/main.ts
import { app, BrowserWindow } from 'electron'
import path from 'node:path'
const createWindow = () => {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // 必须 true
nodeIntegration: false, // 必须 false
},
})
// 开发环境加载 dev server,生产加载打包后的静态文件
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '../dist/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()
})4. 写 preload(安全桥梁)
这是整个迁移最关键的设计决策。 不要在渲染进程里直接开 nodeIntegration,而是用 preload 精确暴露需要的能力:
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
// 文件操作
readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('fs:write', path, content),
// 系统
getVersion: () => ipcRenderer.invoke('app:version'),
openExternal: (url: string) => ipcRenderer.invoke('shell:open', url),
// 事件订阅
onUpdate: (cb: (info: any) => void) =>
ipcRenderer.on('app:update', (_e, info) => cb(info)),
})5. 写主进程 IPC 处理
// electron/ipc.ts
import { ipcMain, app, shell } from 'electron'
import fs from 'node:fs/promises'
export function registerIpc() {
ipcMain.handle('fs:read', async (_e, path: string) => {
return await fs.readFile(path, 'utf-8')
})
ipcMain.handle('fs:write', async (_e, path: string, content: string) => {
await fs.writeFile(path, content, 'utf-8')
return true
})
ipcMain.handle('app:version', () => app.getVersion())
// 安全:只允许 http/https,避免打开任意协议
ipcMain.handle('shell:open', async (_e, url: string) => {
if (!/^https?:\/\//.test(url)) throw new Error('只允许 http/https')
await shell.openExternal(url)
})
}6. 渲染进程里替换浏览器 API
// 原来(纯 Web)
window.open('https://example.com')
localStorage.setItem('key', JSON.stringify(data))
fetch('https://api.example.com/data')
// 迁移后
window.api.openExternal('https://example.com') // 走 IPC 到主进程
// localStorage 可用,不用改(但 Electron 的 localStorage 和浏览器的不共享)
await window.api.readFile('/path/to/file') // 文件操作走 IPC⚠️ 迁移中最容易踩的坑
1. file:// 协议下的跨域限制
用 loadFile 加载本地 HTML 时,fetch('/api/xxx') 会被当成本地文件请求,全部失败。
解决:
- 开发环境用
loadURL('http://localhost:5173'),保留 dev server 代理 - 生产环境要么把 API 地址改成绝对 URL(
https://api.example.com),要么配webSecurity: false(不推荐)
2. 路由 history 模式在 file:// 下 404
Vue Router 的 createWebHistory() 在 file:// 下刷新页面会 404(因为 /some/route 被当成文件路径找)。
解决:
// main.ts —— 注册协议拦截,把所有路径都指回 index.html
protocol.handle('app', (request) => {
return net.fetch('file://' + path.join(__dirname, '../dist/index.html'))
})
// 然后 loadURL('app://local/index.html')或改用 createWebHashMode()(#/route),最省事但 URL 难看。
3. 静态资源路径
file:// 下绝对路径 /assets/xxx.js 会指向磁盘根目录。Vite 配置里要改:
// vite.config.ts
export default defineConfig({
base: './', // 关键:从 /assets/ 改成 ./assets/
})4. 跨域请求被 CORS 拦
渲染进程的 fetch 仍然受浏览器 CORS 限制。绕法:
- 走主进程发请求(推荐,绕过 CORS)
- 或者主进程起个本地代理服务
5. localStorage 数据不迁移
Electron 有独立的 userData 目录,浏览器里的 localStorage 不会自动带过来。如果用户从浏览器版迁移过来,需要自己做数据导入导出。
6. 安全配置别偷懒
webPreferences: {
contextIsolation: true, // 隔离渲染进程和 Node 环境
nodeIntegration: false, // 禁止渲染进程直接用 Node API
sandbox: true, // 沙箱(默认开启)
}三个都别关。关掉 contextIsolation 等于把 Node 的全部能力暴露给页面,页面里任何 XSS 都能读你的文件系统。
7. macOS 打包签名与公证
不打签名(signing)和公证(notarization)的 macOS 应用,用户第一次打开会看到「无法验证开发者」警告,需要右键打开或 xattr -cr 绕过。要正常分发必须配 Apple Developer 证书。
📋 迁移检查清单
- 确认现有项目用了几处浏览器特有 API(
window.open、CORS、路由刷新) - 选 Electron(或评估 Tauri)
- 搭好 main / preload / renderer 三段结构
-
contextIsolation: true、nodeIntegration: false写死 - 文件/网络/系统操作全部走 IPC,不在渲染进程直接碰 Node
- Vite
base改成'./' - 路由模式处理(hash 或自定义协议)
- 写 preload 的 TS 类型声明(
window.api) - 配 electron-builder 的各平台 target
- macOS 签名 + 公证
- 自动更新(electron-updater 或自建)
- 代码签名失败时的用户引导提示
🔗 常用命令
# 开发(带热更新)
npm run dev
# 只构建
npm run build
# 打包成安装包
npm run dist
# 指定平台
npm run dist -- --mac
npm run dist -- --win
# 清除构建缓存
rm -rf dist dist-electron🔗 相关
最后更新:2026-10-05