Skip to content
Go back

给 Next.js 套一层 Electron 壳:零改动打包成桌面应用

StockTracker 是一个本地优先的投资记录和 AI 投研工具,之前一直以纯 Web 服务的形式运行:用户需要装 Node.js、pnpm,然后在终端里 pnpm dev 启动。

对于开发者来说这没什么,但我想让更多人能用上它——不需要命令行,不需要 Docker,下载安装就能用。

Electron 是最直接的选择。问题是:怎么把一个已经成型的 Next.js 应用塞进 Electron,而不用大改代码?

一开始的想法

最朴素的做法是把 Next.js 的路由改成 Electron 的页面,把 API Routes 改成 IPC 通信,前端代码全部重构一遍。

但这意味着:

我不想这样。Next.js 代码本身是好的,问题只是”怎么启动它”。

后来想到一个思路:Electron 只做壳,Next.js 以 standalone 模式作为子进程运行,BrowserWindow 通过 localhost 加载。

这样 Next.js 代码完全不用改,Docker 部署、开发流程、测试全部不受影响。Electron 只是另一个”启动器”。

整体架构

graph TB
    subgraph Electron["StockTracker.app"]
        Main["Electron Main Process"]
        Window["BrowserWindow"]
        Server["Next.js Server<br/>子进程"]
    end

    Main -->|"启动"| Server
    Server -->|"localhost:3218"| Window

    subgraph UserData["用户数据目录"]
        SQLite["finance.sqlite"]
        EnvLocal[".env.local"]
    end

    Server -->|"读写"| UserData

应用启动时,Electron 主进程做三件事:

  1. 启动 Next.js standalone server(子进程)
  2. 等服务就绪后,BrowserWindow 加载 http://127.0.0.1:{port}
  3. 检查是否首次运行,决定要不要展示引导页

Next.js Standalone 模式

Next.js 默认构建后需要完整的 node_modules 才能运行。对于 Electron 打包来说,这意味着要把几百 MB 的依赖一起塞进安装包,不现实。

standalone 模式解决了这个问题。它会把服务端需要的代码和依赖打包成一个独立的目录,可以用 node server.js 直接运行,不需要完整的 node_modules

开启方式

// next.config.mjsconst nextConfig = {  output: 'standalone',};

就这么简单。pnpm build 之后,.next/standalone/ 目录就是独立产物。

输出结构

.next/standalone/├── server.js              # 入口文件├── node_modules/          # 精简后的依赖(只有服务端需要的)├── package.json└── ...

对比一下:

默认构建standalone 构建
node_modules完整(几百 MB)精简(几十 MB)
能否独立运行不能,需要完整依赖能,node server.js 即可
静态资源自动包含需要手动复制 .next/static
public 目录自动包含需要手动复制

原理

Next.js standalone 的核心是 自动 tree-shaking 服务端依赖

普通 Next.js 应用的 node_modules 里包含大量只在构建时用的依赖(如 Babel、Webpack 插件),以及前端依赖(如 React 的客户端版本)。standalone 模式会分析服务端代码的 import 链,只保留运行时真正需要的包。

服务端代码  ├── import next/server  ├── import better-sqlite3  └── import zod      └── 只保留这些依赖的运行时代码

但 standalone 不会自动处理静态资源。需要手动复制:

# 静态资源cp -r .next/static .next/standalone/.next/static# public 目录(如果有)cp -r public .next/standalone/public

本地测试

打包 Electron 之前,可以先本地测试 standalone 是否正常:

pnpm buildnode .next/standalone/server.js

访问 http://localhost:3000,确认功能正常后再进行 Electron 打包。

与 Electron 的结合

standalone 产物就是 Electron 需要启动的目标:

function getServerScript(): string {  const isPackaged = app.isPackaged  const appPath = isPackaged ? process.resourcesPath : process.cwd()  if (isPackaged) {    // 打包后:server 文件在 resources/app/server/    return path.join(appPath, 'app', 'server', 'server.js')  }  // 开发时:使用 standalone 目录  return path.join(appPath, '.next', 'standalone', 'server.js')}

electron-builder 配置里把 standalone 产物打包进去:

files:  - electron/**/*  - ".next/standalone/**/*"  - ".next/static/**/*"

这样安装包里只有精简后的依赖,体积可控。

Server Manager:管理子进程

核心是一个 ServerManager,负责启动、监控和停止 Next.js 子进程。

启动流程:

start()  -> 找到 standalone/server.js 路径  -> 查找可用端口(默认 3218,被占用则向后找)  -> 读取用户数据目录下的 .env.local  -> 用 spawn 启动子进程:node server.js  -> 轮询 HTTP 健康检查,等待服务就绪  -> 返回端口号

停止流程:

stop()  -> 发送 SIGTERM 信号  -> 等待 3 秒,如果还没退出则 SIGKILL  -> 清理子进程引用

主进程退出时调用 stop:

app.on('before-quit', async () => {  if (serverManager) {    await serverManager.stop()  }})

几个细节值得注意。

端口自动查找

默认端口 3218,如果被占用就向后找:

// 从 startPort 开始,逐个尝试for (let offset = 0; offset < 20; offset++) {  const port = startPort + offset  // 尝试监听该端口,成功则说明可用  if (await isPortAvailable(port)) return port}

健康检查

子进程启动后,轮询 HTTP 接口确认服务就绪:

// 每 500ms 检查一次,30 秒超时while (Date.now() < deadline) {  try {    const res = await http.get(`http://127.0.0.1:${port}`)    if (res.statusCode < 500) {      resolve()  // 服务就绪      return    }  } catch {    // 服务还没启动,继续等  }  await sleep(500)}reject(new Error('Server not ready within 30s'))

服务就绪后再加载 BrowserWindow,避免白屏。

踩坑:better-sqlite3 与 Electron 的兼容性

核心问题:Electron 42 的 nativeRebuilder 在 CI 环境下有 bug,导致 better-sqlite3 原生模块编译失败。

Error: The module was compiled against a different Node.js versionusing NODE_MODULE version 127.This version of Node.js requires NODE_MODULE version 115.

这个错误说的是 ABI(Application Binary Interface)版本不匹配。原生模块(C++ 编译的 .node 文件)需要和特定版本的 Node.js 运行时“对齐”才能加载。

折腾了好几轮:

1. Electron 42 + nativeRebuilder: true → Windows CI 失败2. 改成 nativeRebuilder: legacy → 还是失败3. 升级 better-sqlite3 到 12.10.0 → 还是失败4. 删除 nativeRebuilder + 降级 Electron 到 41.7.1 → 成功

最终配置

# electron-builder.ymlasar: trueasarUnpack:  - "**/*.node"  - "**/node_modules/.pnpm/better-sqlite3*/**"  - ".next/standalone/**"# 不配置 nativeRebuilder
// package.json{  "devDependencies": {    "electron": "^41.7.1"  // 目前和 better-sqlite3 兼容的最高版本  }}

为什么用系统 Node.js 运行子进程

即使解决了 ABI 兼容问题,还有一个问题:Electron 内置的 Node.js 是精简版,缺少一些系统库。原生模块(如 better-sqlite3)依赖这些系统库才能正常运行。

所以最终方案是:子进程用系统 Node.js 运行,原生模块正常加载;Electron 主进程用自己的 Node.js,两者互不干扰。

function getNodeBinary(): string {  // 优先找系统 Node.js  try {    const nodePath = execSync('which node').trim()    if (nodePath && !nodePath.includes('electron')) return nodePath  } catch {}  // 常见路径兜底  const candidates = ['/usr/local/bin/node', '/opt/homebrew/bin/node', '/usr/bin/node']  for (const p of candidates) {    if (fs.existsSync(p)) return p  }  // 最后才用 Electron 内置的  return process.execPath}

用户数据目录

桌面应用的数据应该存在用户目录下,而不是应用包内。Electron 提供了 app.getPath('userData'),自动适配平台:

平台路径
macOS~/Library/Application Support/StockTracker/
Windows%APPDATA%/StockTracker/

存放内容:

userData/  ├── finance.sqlite      # 数据库  ├── .env.local           # AI 配置  └── .onboarding-done     # 引导完成标记

环境变量注入到子进程,Next.js 代码通过 process.env.FINANCE_SQLITE_PATH 读取,无需硬编码。

首次引导页

用户第一次打开应用时,可能还没配置 AI 服务。需要一个引导页。

一开始想用 Next.js 渲染引导页,但问题是:引导页要在服务启动前展示,而 Next.js 服务还没启动完。

最终决定用纯 HTML 实现引导页,不依赖 Next.js:

showOnboarding()  -> 创建 modal 子窗口(560x500,不可调整大小)  -> 加载 onboarding/index.html(纯 HTML + CSS + JS)  -> 监听 IPC 事件:     - save-config:写入 .env.local     - finish-onboarding:关闭窗口,标记完成

判断是否需要引导:

function needsOnboarding(): boolean {  // 之前完成过引导  if (fs.existsSync(getOnboardingDonePath())) return false  // 已经有配置文件  if (fs.existsSync(getConfigPath())) return false  return true}

配置写入用户数据目录:

function writeEnvLocal(config) {  const lines = [    `AI_PROVIDER=${config.provider}`,    config.baseUrl ? `AI_BASE_URL=${config.baseUrl}` : '',    `AI_API_KEY=${config.apiKey}`,    config.model ? `AI_MODEL=${config.model}` : '',  ].filter(Boolean)  fs.writeFileSync(getConfigPath(), lines.join('\n') + '\n')}

自动更新

使用 electron-updater 集成 GitHub Releases:

setupAutoUpdater()  -> autoDownload = false(手动确认)  -> autoInstallOnAppQuit = true(退出时自动安装)    监听事件:    update-available -> 弹窗询问是否下载    update-downloaded -> 弹窗询问是否重启    error -> 记录日志    启动 10 秒后检查更新(不阻塞启动)

配置文件里指定 GitHub 仓库:

# electron-builder.ymlpublish:  provider: github  owner: byte92  repo: stocktracker

打 tag 推送到 GitHub,electron-updater 会自动检测并提示用户更新。

打包配置

electron-builder.yml 的关键配置:

appId: com.stocktracker.appproductName: StockTrackerdirectories:  output: dist-electronfiles:  - electron/**/*  - "server/**/*"  - app/icon.svg  - skills/**/*asar: falsemac:  category: public.app-category.finance  icon: build/icon.icns  target:    - target: dmg      arch:        - x64        - arm64win:  icon: build/icon.ico  target:    - target: nsis      arch:        - x64

asar: false 是一个重要决策。asar 打包会把所有文件压成一个归档,但原生模块在 asar 内可能无法正常加载。关闭 asar 可以避免这类问题,代价是安装包体积稍大。

构建脚本:

{  "scripts": {    "electron:build": "pnpm build && tsc -p electron/tsconfig.json && electron-rebuild -m .next/standalone && electron-builder",    "electron:build:mac": "pnpm build && tsc -p electron/tsconfig.json && electron-rebuild -m .next/standalone && electron-builder --mac",    "electron:build:win": "pnpm build && tsc -p electron/tsconfig.json && electron-rebuild -m .next/standalone && electron-builder --win"  }}

流程是:先构建 Next.js standalone,再编译 TypeScript 主进程代码,然后为系统 Node.js 重新编译原生模块,最后打包为 .dmg / .exe。

启动失败处理

Next.js server 启动失败时,不能让应用白屏。用 data:text/html 加载错误页面,不依赖任何外部资源:

try {  const { port } = await serverManager.start()  await mainWindow.loadURL(`http://127.0.0.1:${port}`)} catch (error) {  // 加载内联 HTML 错误页  await mainWindow.loadURL(`data:text/html,...`)}

外部链接处理

Electron 应用内点击链接,默认会在窗口内跳转。但外部链接应该用系统浏览器打开:

// 拦截新窗口打开请求,改为系统浏览器win.webContents.setWindowOpenHandler(({ url }) => {  shell.openExternal(url)  return { action: 'deny' }})

与 Docker 部署的关系

两种部署方式共享同一份 Next.js 代码,只是启动器不同:

DockerElectron
代码同一份 Next.js同一份 Next.js
构建pnpm build → standalonepnpm build → standalone → 打包进 Electron
运行容器内 node server.js子进程 node server.js
数据目录Docker volume用户系统目录
访问方式浏览器 localhost:3218内嵌窗口

两者互不干扰。现有的 Docker 配置、开发流程、测试全部保持不变。

总结

这套方案的核心是零侵入

Next.js 代码零改动  ├── Docker 部署不受影响  ├── 开发流程不受影响  ├── 测试不受影响  └── Electron 只是另一个"启动器"

关键设计决策:

  1. 子进程模式:Next.js server 作为子进程运行,Electron 不侵入 Next.js 内部
  2. 系统 Node.js:解决原生模块 ABI 兼容问题
  3. 用户数据目录:数据和应用分离,升级不丢数据
  4. 纯 HTML 引导页:不依赖 Next.js,可以在服务启动前展示
  5. 关闭 asar:避免原生模块加载问题

如果你有一个成型的 Next.js 应用,想把它变成桌面客户端,这套方案值得参考。核心思路就是:不要改应用,改启动器。


Share this post on: