StockTracker 是一个本地优先的投资记录和 AI 投研工具,之前一直以纯 Web 服务的形式运行:用户需要装 Node.js、pnpm,然后在终端里 pnpm dev 启动。
对于开发者来说这没什么,但我想让更多人能用上它——不需要命令行,不需要 Docker,下载安装就能用。
Electron 是最直接的选择。问题是:怎么把一个已经成型的 Next.js 应用塞进 Electron,而不用大改代码?
一开始的想法
最朴素的做法是把 Next.js 的路由改成 Electron 的页面,把 API Routes 改成 IPC 通信,前端代码全部重构一遍。
但这意味着:
- 现有的
app/、lib/、components/目录全部要动 - Docker 部署可能会受影响
- 测试要全部重跑
- 后续维护两套代码
我不想这样。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 主进程做三件事:
- 启动 Next.js standalone server(子进程)
- 等服务就绪后,BrowserWindow 加载
http://127.0.0.1:{port} - 检查是否首次运行,决定要不要展示引导页
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 代码,只是启动器不同:
| Docker | Electron | |
|---|---|---|
| 代码 | 同一份 Next.js | 同一份 Next.js |
| 构建 | pnpm build → standalone | pnpm build → standalone → 打包进 Electron |
| 运行 | 容器内 node server.js | 子进程 node server.js |
| 数据目录 | Docker volume | 用户系统目录 |
| 访问方式 | 浏览器 localhost:3218 | 内嵌窗口 |
两者互不干扰。现有的 Docker 配置、开发流程、测试全部保持不变。
总结
这套方案的核心是零侵入:
Next.js 代码零改动 ├── Docker 部署不受影响 ├── 开发流程不受影响 ├── 测试不受影响 └── Electron 只是另一个"启动器"
关键设计决策:
- 子进程模式:Next.js server 作为子进程运行,Electron 不侵入 Next.js 内部
- 系统 Node.js:解决原生模块 ABI 兼容问题
- 用户数据目录:数据和应用分离,升级不丢数据
- 纯 HTML 引导页:不依赖 Next.js,可以在服务启动前展示
- 关闭 asar:避免原生模块加载问题
如果你有一个成型的 Next.js 应用,想把它变成桌面客户端,这套方案值得参考。核心思路就是:不要改应用,改启动器。