起因
我的服务器到期了,服务器上有几个服务,人家问这几个网站怎么不好使了,奈何服务器续费太贵租不起了…
但是服务还是要提供的,所以我在想如何把 node 的项目变成桌面端应用,于是有了这个笔记
效果展示
页面没啥变化,就是把 node 打包成 exe 应用了

技术选型
我是前端出身,这里偏向使用 js 去开发桌面端,想要短期解决客户的需求嘛,这里有几个技术选择:
大致浏览了一下,这里选择使用 Electron,纯粹是官方说明第一眼看的比较顺,而且看论坛里关于它的讨论还挺多的,遇到问题估计能查到。
简单了解 Electron
类似浏览器套壳子,大概的组成如下图所示:

准备步骤:安装 Electron
首先把前端的整个项目复制一份,我们在复制的这份处理,然后打开 package.json,精简一下包内容(我是把 koa 都删掉了)

接着安装脚手架,(这里我是直接安装的 @electron-forge/cli, 方便以后打包,当然你也可以按照官方的直接安装 electron)官方的安装文档点我跳转!
yarn add --dev @electron-forge/clinpx electron-forge import 安装 yarn add --dev @electron-forge/cli后如下图,devDependencies 字段添加了新的内容

npx electron-forge import后 package.json 里的样子如下:

运行完 npx electron-forge import 之后
package.json 会自动修改,添加了一些脚本和依赖自动生成了forge.config.js 文件.gitignore 文件会帮我们添加一行 out/ 之后就可以通过 yarn start 启动了!
开始:通过 Electron 打开页面!
我们打开 package.json,把 main 字段设置成 main.js(只是跟官网设置成一样,这里也可以不改,不过也要注意一下:electron 的主线程就是根据这个 main 字段声明的.js,所以我这里改一下~)
这里的迁徙过程其实有两个选项:
新建:新建一个main.js,当作 Electron 的主线程改造:在原有的 node 启动文件 index.js 里修改 先看一下怎么改的吧,工作量不大,大家根据自己的项目去思考怎么迁徙比较方便,这里为了逻辑清晰,我选择新建一个 main.js
(对于我的项目,之前 node 的接口之类的代码都不适用了…)
// main.jsconst { app, BrowserWindow } = require('electron');const path = require('node:path');// explain: To handle the most common commands, such as managing desktop shortcuts, just add the following to the top of your main.js and you're good to go:// [electron-squirrel-startup](https://github.com/mongodb-js/electron-squirrel-startup)if (require('electron-squirrel-startup')) app.quit();const createWindow = () => { // 声明应用的初始化窗口尺寸 const mainWindow = new BrowserWindow({ width: 1280, height: 960, }); // 声明应用的主页面 mainWindow.loadFile(path.join(__dirname, '/static/index.html')); // mainWindow.webContents.openDevTools(); // 打开开发者工具};// 应用准备完成之后创建窗口app.whenReady().then(() => { createWindow();}); 这段代码有了之后就可以直接运行起来看看了~我们 yarn start 构建一下,构建成功后会自动弹出窗口

至此总算有点意思了对吧,我们接下来处理一下数据。
数据传递
Electron 里的数据传递不像前后端那样,前端带着数据请求后端接口,而是渲染线程向主线程数据传递,我们可以在官方中的流程模型介绍中深入理解这两个线程的具体作用,这里是进程间通信的实战。·
参考官方文档:
流程模型进程间通信我们先写个小 demo 来体验一下数据的传递
提示:之后的开发可能会经常用到控制台,所以我们在主进程中取消注释这句话 mainWindow.webContents.openDevTools(); // 打开开发者工具,方便我们调试
这里先新建 preload.js,注册事件,至于为什么新建这个文件,请查看进程间通信的官方文档(概括一下是为了安全)
然后我直接复制一下官方给的例子吧:
// preload.jsconst { contextBridge, ipcRenderer } = require('electron/renderer');contextBridge.exposeInMainWorld('electronAPI', { setTitle: (title) => ipcRenderer.send('set-title', title),}); 接着,在 main.js 中将 preload.js 注册到页面
webPreferences: { preload: path.join(__dirname, 'preload.js'),}, 
之后,构建一下,在页面中打印

可以看到,preload.js 里注册的 electronAPI 会挂载到前端的 window 变量上,我们通过注册的这些方法传递值
前端(渲染进程)===> 主进程
下面这张图是数据的传递过程

可以发发现,终端是乱码哎,我们可以这样做,在 package.json 的脚本的 start 字段 里添加 chcp 65001 &&
"start": "chcp 65001 && electron-forge start", 演示效果如下:

ok,终端也好了~
主线程和 preload.js 的代码如下,前端看场景可以自己写一下。
// main.jsconst { app, BrowserWindow, ipcMain } = require('electron');const path = require('node:path');// explain: To handle the most common commands, such as managing desktop shortcuts// just add the following to the top of your main.js and you're good to go:// [electron-squirrel-startup](https://github.com/mongodb-js/electron-squirrel-startup)if (require('electron-squirrel-startup')) app.quit();const createWindow = () => { // 声明应用的初始化窗口尺寸 const mainWindow = new BrowserWindow({ width: 1280, height: 960, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }); // 声明应用的主页面 mainWindow.loadFile(path.join(__dirname, '/static/index.html')); mainWindow.webContents.openDevTools(); // 打开开发者工具};function handleUploadFile(event, arg) { console.log('主线程 ipcMain.on 监听的 event:>>', event); console.log('主线程 ipcMain.on 监听的 arg:>>', arg);}// 应用准备完成之后创建窗口app.whenReady().then(() => { createWindow(); ipcMain.on('file:upload', handleUploadFile);}); // preload.jsconst { contextBridge, ipcRenderer } = require('electron/renderer');contextBridge.exposeInMainWorld('electronAPI', { uploadFile: (e) => ipcRenderer.send('file:upload', e),}); // 前端 render.jswindow.electronAPI.uploadFile({ message: '渲染进程 window.electronAPI 传递的值', data: '我是发送的数据',}); 我们接着研究主线程如何传递数据给前端。
主进程 ===> 前端(渲染进程)
想说的都在图片里了!

部分代码如下:
// main.jsmainWindow.webContents.send('word-data', { code: 1, message: '我是主线程发送给前端的数据',}); // preload.jsconst { contextBridge, ipcRenderer } = require('electron/renderer');contextBridge.exposeInMainWorld('electronAPI', { uploadFile: (e) => ipcRenderer.send('file:upload', e), onReceiveData: (callback) => ipcRenderer.on('word-data', (_event, value) => callback(value)),}); // 前端beforeMount() { window.electronAPI.onReceiveData((value) => { console.log('前端 onReceiveData 的 value 值:>>', value); });}, 数据传递的总结
先整理一下这个 preload.js 的作用

然后就是一组通信的整理
| 功能 | 代码 |
|---|---|
| 前端:发送 | window.electronAPI.AAA(data) |
| 后端:接收 | window.electronAPI.BBB((value) => {}) |
| 后端:发送 | mainWindow.webContents.send('CCC', data) |
| 前端:接收 | ipcMain.on('DDD', fn) |
具体函数名看下图:

以上,算是比较清晰的整理一便,几乎可以做一些小玩具了,还有就是打包的问题,当然坑也是千奇百怪,遇到就自行搜索引擎吧,下面列一下我遇到的问题。
Q & A
项目中遇到的坑:开发环境和生产环境的 __dirname 值不一样
我的项目里有个输出文件的问题,输出文件的路径用到了__dirname
开发的时候都还好,打包之后功能就不对了,后来打印发现 __dirname 不同,因为打包后的源代码在 app.asar 里

于是我参考了这篇文章:关于electron的开发应用路径和生产路径的问题
asar 是什么
参考这篇博文:详解 Electron 中的 asar 文件
终端中文不显示,或者显示乱码
运行的时候添加 chcp 65001,可以在 package.json 中写好脚本

具体参考这篇博文: 解决vscode终端乱码问题【疑难杂症,使用chcp命令修改活动代码页无效的解决方法】
安装:安装 Electron 坎坷
核心的问题还是国内防火墙给墙住了,所以可以切换你包管理器的地址,更换成淘宝源的,我当时也按了一上午,很烦闷
这里推荐使用 yarn 安装,或者用 pnpm 也可以
官网的安装指南
打包:遇到 Making for target: squirrel - On platform: win32 - For arch: x64错误
这是打包的倒数第二个环节,这里出现问题我觉得影响不大,因为当前路径下应该有 out 的打包文件夹了,不过看着闹心的话,要具体看看控制器报的什么错误,那谷歌翻译一下那个依赖包出的错误。
打包:打包失败问题推断
这里推荐官网的 demo 打包测试一下,跟你自己的项目做个对照,而且 electron 的打包有好几个选择的,官网上推荐用 Electron Forge,其实还有 Electron Packager 等等,可以参考一下这篇文章了解一下:详解 Electron 打包
这是 Electron Forge 官网,其中有小型的 demo,你把它克隆下来然后启动并打包一下,如果可以打包成功的话,参考一下自己的项目里缺了什么。
可能缺少的选项有
package.json 的打包配置,具体看官网这篇文档https://www.electronforge.io/import-existing-project#configuring-package.json(如果你是用cli 构建的项目,它会另外写一个forge.config.js 文件用于配置打包项,然而我的是已存在的项目,所以直接写在 package.json 里比较方便)
cmd 管理员权限:最好用 管理员终端去打包,有些权限比较高,普通的终端可能没有权限一些依赖无法正确安装:推荐更换包管理器的下载源,具体可以看这篇文章:更换 npm 依赖下载源 打包报错:An unhandled rejection has occurred inside Forge:
Error: EPERM: operation not permitted, symlink 'E:\Project_Front\electron-example\node_modules\.pnpm\electron-squirrel-startup@1.0.0\node_modules\electron-squirrel-startup'-> 'C:\Users\Wang\AppData\Local\Temp\electron-packager\tmp-UVFKGa\resources\app\node_modules\electron-squirrel-startup' 这里可能出现的愿意就是依赖没找到,首先检查一下 package.json 中的依赖都对不对,然后把 node_module 文件夹删掉,用 yarn 重新安装一下。
打包图标报错
参考这个博客:electron-forge打包如何自定义应用图标和安装动画
图标尺寸一定要大一点,不然生成的安装包会默认显示 electron 的图标,或者尝试清理 windows 的图标缓存,具体操作看下面
windows清理图标
将下面的文字放到笔记本里,然后保存,更改文件类型为 bat 并双击运行。
rem 关闭 Windows 外壳程序 Explorertaskkill /f /im explorer.exerem 清理系统图标缓存数据库attrib -h -s -r "%userprofile%\AppData\Local\IconCache.db"del /f "%userprofile%\AppData\Local\IconCache.db"attrib /s /d -h -s -r "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\*"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_32.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_96.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_102.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_256.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_1024.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_idx.db"del /f "%userprofile%\AppData\Local\Microsoft\Windows\Explorer\thumbcache_sr.db"rem 清理系统托盘记忆的图标echo y reg delete "HKEY_CLASSES_ROOT\Local Settings\Software\Microsoft\Windows\CurrentVersion\TrayNotify" /v IconStreamsecho y reg delete "HKEY_CLASSES_ROOT\Local Settings\Software\Microsoft\Windows\CurrentVersion\TrayNotify" /v PastIconsStreamrem 重启 Windows 外壳程序 Explorerstart explorer