Next.js + Electron 打包实战指南

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 应用由两部分组成:

  1. 主进程:Node.js 环境,负责窗口管理、系统交互
  2. 渲染进程:浏览器环境,负责 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 时:

  1. 浏览器发起 HTTP 请求 GET /dashboard
  2. 服务器需要返回 dashboard/index.htmldashboard.html
  3. 如果服务器返回 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 存储数据,但在桌面应用中,这种方式有以下问题:

  1. 数据存储在 Electron 的缓存目录,可能被系统清理
  2. 无法实现跨窗口数据共享
  3. 不适合大量数据存储

解决方案:使用 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。优化方法:

  1. 使用 ASAR 压缩资源文件
  2. 排除不必要的依赖
  3. 使用 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 桌面应用,核心要点:

  1. 静态导出 :Next.js 必须配置 output: 'export'
  2. 资源加载 :推荐使用内置 HTTP 服务器,避免 file:// 协议问题
  3. 路由处理:服务器需要正确处理 SPA 路由请求
  4. 原生模块 :需要使用 @electron/rebuild 重新编译
  5. 数据持久化:推荐使用 SQLite 替代 localStorage
  6. 安全配置:遵循 Electron 安全最佳实践

希望这篇指南能帮助你顺利完成 Electron 打包!


参考资料