Next.js + Electron 打包实战指南
从 Web 应用到桌面应用的完整旅程,记录踩坑与解决方案
前言
将一个现有的 Next.js Web 应用打包成 Windows 桌面应用(exe),听起来是个简单的任务,但实际上涉及到 Web 技术与桌面应用技术的深度融合。本文将详细记录整个过程,包括遇到的各类问题及其解决方案。
一、技术背景
1.1 项目概况
- 前端框架:Next.js 16.0.10(使用 Turbopack)
- UI 组件:React 19 + shadcn/ui + Tailwind CSS
- 状态管理:React Context + localStorage
- 目标:打包成独立的 Windows 桌面应用
1.2 为什么选择 Electron?
Electron 是目前最成熟的跨平台桌面应用框架,优势包括:
- 可以使用现有的 Web 技术栈(HTML/CSS/JavaScript)
- 跨平台支持(Windows、macOS、Linux)
- 丰富的生态系统和社区支持
- 许多知名应用都在使用(VS Code、Slack、Discord 等)
二、打包流程概览
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Next.js 构建 │ ──▶ │ Electron 集成 │ ──▶ │ 打包成 exe │
│ (静态导出) │ │ (主进程+ preload)│ │ (electron-packager) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
2.1 第一步:Next.js 静态导出
Next.js 默认是服务端渲染框架,要打包成桌面应用需要先导出为静态文件。
配置修改 (next.config.mjs):
javascript
const nextConfig = {
output: 'export', // 启用静态导出
images: {
unoptimized: true, // 禁用图片优化(静态导出不支持)
},
trailingSlash: true, // 添加尾部斜杠,确保路由正确
}
构建命令:
bash
npm run build
# 输出到 out/ 目录
2.2 第二步:创建 Electron 主进程
Electron 应用由两部分组成:
- 主进程:Node.js 环境,负责窗口管理、系统交互
- 渲染进程:浏览器环境,负责 UI 渲染
主进程核心代码 (electron/main.ts):
typescript
import { app, BrowserWindow } from 'electron'
import path from 'path'
function createWindow() {
const mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, // 安全:禁用 Node 集成
contextIsolation: true, // 安全:启用上下文隔离
},
})
// 加载 Next.js 构建的静态文件
mainWindow.loadFile(path.join(__dirname, '../out/index.html'))
}
app.whenReady().then(createWindow)
2.3 第三步:打包成 exe
使用 electron-packager 进行打包:
bash
npx electron-packager . --platform=win32 --arch=x64 --out=build-output
三、踩坑记录与解决方案
3.1 问题一:资源路径错误
现象:页面加载后只显示空白或加载动画,控制台报错找不到 JS/CSS 文件。
原因分析:
Next.js 构建后的 HTML 文件中,资源路径是绝对路径:
html
<script src="/_next/static/chunks/xxx.js"></script>
在 Electron 中使用 file:// 协议加载时,这个路径会被解析为:
file:///C:/_next/static/chunks/xxx.js ❌ 错误!
而不是相对于应用目录的正确路径。
解决方案 A:修改 HTML 路径(不推荐)
用脚本批量替换绝对路径为相对路径:
javascript
// post-build.js
const fs = require('fs')
const path = require('path')
function replacePaths(filePath) {
let content = fs.readFileSync(filePath, 'utf-8')
content = content.replace(/"\/_next/g, '"./_next')
fs.writeFileSync(filePath, content)
}
问题:这种方法不够优雅,且每次构建都需要手动处理。
解决方案 B:使用 HTTP 服务器(推荐)
在 Electron 主进程中启动一个本地 HTTP 服务器:
typescript
import http from 'http'
import fs from 'fs'
import path from 'path'
import url from 'url'
function createServer(): Promise<number> {
const outDir = path.join(__dirname, '../out')
const server = http.createServer((req, res) => {
let filePath = url.parse(req.url || '/').pathname || '/'
// 处理 SPA 路由:所有 HTML 请求都返回对应页面
const ext = path.extname(filePath)
if (ext === '' || ext === '.html') {
// 尝试多种路径格式
const possiblePaths = [
filePath + '.html',
filePath + '/index.html',
'/index.html' // 最终 fallback
]
for (const p of possiblePaths) {
const fullPath = path.join(outDir, p)
if (fs.existsSync(fullPath)) {
filePath = p
break
}
}
}
// 读取并返回文件
const fullPath = path.join(outDir, filePath)
fs.readFile(fullPath, (err, data) => {
if (err) {
res.writeHead(404)
res.end('Not found')
return
}
// 设置正确的 Content-Type
const contentType = getContentType(path.extname(filePath))
res.writeHead(200, { 'Content-Type': contentType })
res.end(data)
})
})
return new Promise(resolve => {
server.listen(0, () => {
resolve(server.address().port)
})
})
}
优势:
- 完全兼容 Next.js 的资源路径格式
- 自动处理 SPA 路由
- 无需修改构建产物
3.2 问题二:路由跳转失败
现象 :登录成功后跳转到 /dashboard,页面显示 404。
原因分析:
Next.js 静态导出后,路由是通过客户端 JavaScript 处理的。当用户访问 /dashboard 时:
- 浏览器发起 HTTP 请求
GET /dashboard - 服务器需要返回
dashboard/index.html或dashboard.html - 如果服务器返回 404,页面就会失败
解决方案:
HTTP 服务器需要正确处理所有路由请求:
typescript
// 路由映射逻辑
if (ext === '' || ext === '.html') {
const possiblePaths = [
filePath + '.html', // /dashboard → /dashboard.html
filePath + '/index.html', // /dashboard → /dashboard/index.html
'/index.html' // fallback 到首页(SPA 模式)
]
for (const p of possiblePaths) {
if (fs.existsSync(path.join(outDir, p))) {
filePath = p
break
}
}
}
3.3 问题三:SQLite 原生模块编译
现象:打包后的应用无法使用 SQLite 数据库,报错找不到模块。
原因分析:
sqlite3 是一个 Node.js 原生模块(Native Module),它包含 C/C++ 编写的二进制代码。这些代码需要针对特定的 Node.js 版本和操作系统进行编译。
Electron 使用自己的 Node.js 版本(与系统安装的 Node.js 版本可能不同),因此需要重新编译原生模块。
解决方案:
bash
# 1. 安装 Electron 重新编译工具
npm install @electron/rebuild --save-dev
# 2. 重新编译 sqlite3
npx @electron/rebuild -f -w sqlite3
# 3. 确保 sqlite3 在 dependencies 中(不是 devDependencies)
# 这样打包时才会包含该模块
配置 package.json:
json
{
"dependencies": {
"sqlite3": "^5.1.7" // 必须在 dependencies 中
},
"devDependencies": {
"@electron/rebuild": "^3.6.0",
"electron": "^42.4.1"
}
}
3.4 问题四:数据库初始化时序
现象:应用启动后页面一直显示加载动画,无法进入登录界面。
原因分析:
数据库初始化是异步操作,如果窗口创建后才初始化数据库,前端页面可能在数据库就绪之前就调用了 getData(),导致返回 null。
错误做法:
typescript
app.whenReady().then(() => {
createWindow() // 先创建窗口
setTimeout(async () => {
await initDatabase() // 延迟初始化数据库 ❌
}, 1000)
})
正确做法:
typescript
app.whenReady().then(async () => {
await initDatabase() // 先初始化数据库 ✅
createWindow() // 再创建窗口
})
3.5 问题五:打包时文件被占用
现象 :打包失败,报错 EPERM: operation not permitted。
原因分析:
之前运行的应用进程仍在占用打包输出目录中的文件。
解决方案:
powershell
# 先停止所有相关进程
Get-Process | Where-Object { $_.ProcessName -like '*airport*' } | Stop-Process -Force
# 等待进程完全退出
Start-Sleep -Seconds 5
# 删除旧的打包目录
Remove-Item -Recurse -Force build-output
# 再执行打包
npx electron-packager . --platform=win32 --arch=x64 --out=build-output
3.6 问题六:状态持久化问题
现象:应用关闭后数据丢失,或者数据存储位置不正确。
原因分析:
Web 应用通常使用 localStorage 存储数据,但在桌面应用中,这种方式有以下问题:
- 数据存储在 Electron 的缓存目录,可能被系统清理
- 无法实现跨窗口数据共享
- 不适合大量数据存储
解决方案:使用 SQLite 数据库
typescript
// electron/database.ts
import sqlite3 from 'sqlite3'
let db: sqlite3.Database
export async function initDatabase(dbPath: string) {
db = new sqlite3.Database(dbPath)
// 创建表
db.run(`
CREATE TABLE IF NOT EXISTS store (
key TEXT PRIMARY KEY,
value TEXT
)
`)
}
export async function getData() {
return new Promise((resolve) => {
db.all('SELECT key, value FROM store', (err, rows) => {
if (err) resolve(null)
const data = {}
for (const row of rows) {
data[row.key] = JSON.parse(row.value)
}
resolve(data)
})
})
}
数据存储位置:
typescript
// 使用 Electron 的 userData 目录(系统推荐的应用数据目录)
const userDataPath = app.getPath('userData')
const dbPath = path.join(userDataPath, 'airport-ems', 'data.db')
// Windows: C:\Users\<用户名>\AppData\Roaming\<应用名>\data.db
// macOS: ~/Library/Application Support/<应用名>/data.db
// Linux: ~/.config/<应用名>/data.db
四、最佳实践总结
4.1 安全配置
typescript
// webPreferences 安全配置
webPreferences: {
nodeIntegration: false, // 禁用 Node.js 集成
contextIsolation: true, // 启用上下文隔离
webSecurity: true, // 启用 Web 安全策略
sandbox: true, // 启用沙箱模式
}
4.2 预加载脚本(Preload)
预加载脚本是在渲染进程加载前执行的脚本,用于安全地暴露主进程功能:
typescript
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron'
// 通过 contextBridge 安全暴露 API
contextBridge.exposeInMainWorld('electronAPI', {
getData: () => ipcRenderer.invoke('get-data'),
saveData: (data) => ipcRenderer.invoke('save-data', data),
})
4.3 IPC 通信
主进程与渲染进程通过 IPC(Inter-Process Communication)通信:
typescript
// 主进程
ipcMain.handle('get-data', async () => {
return await getData()
})
ipcMain.handle('save-data', async (_event, data) => {
return await saveData(data)
})
// 渲染进程(通过 preload 暴露的 API)
const data = await window.electronAPI.getData()
await window.electronAPI.saveData(newData)
4.4 开发与生产环境区分
typescript
function createWindow() {
const isDev = !app.isPackaged // 判断是否为开发环境
const indexPath = isDev
? 'http://localhost:3000' // 开发环境:连接 Next.js 开发服务器
: `http://localhost:${port}` // 生产环境:使用内置 HTTP 服务器
mainWindow.loadURL(indexPath)
}
4.5 打包工具选择
| 工具 | 特点 | 适用场景 |
|---|---|---|
| electron-packager | 简单快速,只打包 | 快速测试、简单分发 |
| electron-builder | 功能丰富,支持安装程序、自动更新 | 正式发布、商业应用 |
electron-builder 配置示例:
json
{
"build": {
"appId": "com.example.airport-ems",
"productName": "设备管理系统",
"win": {
"target": ["nsis", "portable"],
"icon": "assets/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
}
}
五、完整项目结构
airport-equipment-management/
├── app/ # Next.js 页面
├── components/ # React 组件
├── lib/ # 业务逻辑
│ ├── store.ts # 状态管理
│ └── store-context.tsx # Context Provider
├── electron/ # Electron 相关代码
│ ├── main.ts # 主进程
│ ├── preload.ts # 预加载脚本
│ └── database.ts # 数据库模块
├── out/ # Next.js 构建产物
├── package.json
├── tsconfig.json
├── tsconfig.electron.json # Electron TypeScript 配置
└── next.config.mjs
六、打包命令汇总
bash
# 1. 构建 Next.js 静态文件
npm run build
# 2. 编译 Electron TypeScript
npx tsc --project tsconfig.electron.json
# 3. 重新编译原生模块(如果使用 SQLite)
npx @electron/rebuild -f -w sqlite3
# 4. 打包成 exe
npx electron-packager . --platform=win32 --arch=x64 --out=build-output
# 或使用 electron-builder
npx electron-builder --win
七、常见问题 FAQ
Q1:打包后的应用体积很大?
Electron 会打包整个 Chromium 浏览器,基础体积约 100-150MB。优化方法:
- 使用 ASAR 压缩资源文件
- 排除不必要的依赖
- 使用 electron-builder 的压缩选项
Q2:如何实现自动更新?
使用 electron-updater:
typescript
import { autoUpdater } from 'electron-updater'
autoUpdater.checkForUpdatesAndNotify()
Q3:如何添加应用图标?
准备 .ico 格式图标文件,在打包时指定:
bash
npx electron-packager . --icon=assets/icon.ico
Q4:如何调试打包后的应用?
在主进程中打开 DevTools:
typescript
mainWindow.webContents.openDevTools()
八、总结
将 Next.js 应用打包成 Electron 桌面应用,核心要点:
- 静态导出 :Next.js 必须配置
output: 'export' - 资源加载 :推荐使用内置 HTTP 服务器,避免
file://协议问题 - 路由处理:服务器需要正确处理 SPA 路由请求
- 原生模块 :需要使用
@electron/rebuild重新编译 - 数据持久化:推荐使用 SQLite 替代 localStorage
- 安全配置:遵循 Electron 安全最佳实践
希望这篇指南能帮助你顺利完成 Electron 打包!
参考资料: