生活本质就是遇到问题解决问题的过程
前言
好长时间没有写过文章了,这段时间确实很忙,终于整完了,这里做个复盘,记录一下探索过程中遇到的问题。
背景:项目需求说:我要一个桌面客户端程序,最好能跨平台。
技术选型:Electron 因为使用 Web 技术构建应用、开源、跨平台的优秀特性,自然首当其冲。我有得选吗?没有,我没有。
知识储备:HTML、CSS、JS、VUE
开始
打开 Electron 官网,首先看到:
我们常用的开发工具vscode竟然是用Electron开发的,直呼Electron 强大。
然后下翻,了解下特性:
好像挺牛的样子。
通过官网首页,该知道的也知道了,接下来,我们打开文档的快速入门页面,参照教程一步步构建出自己的第一个 demo。
我们需要重点关注 流程模型 这一节,理解在每个 Electron 应用中都是由一个运行在 Node.js 环境中的单一的主进程来管理多个渲染进程(如果你创建多个窗口的话)以及辅助进程等。主进程与渲染进程之间通过ipc管道通信,具体的几种写法,百度一大堆。预加载脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码,我们通常的做法是通过预加载脚本向渲染进程传递数据,这些数据会被挂载在 window 对象下面。
好,知道这些就足够了,余下的就是在具体开发中反复查阅API的事情了。
本次共用 Electron 开发了两个应用,第一个应用比较简单,先来谈第一个。
第一个应用
考虑到该应用较为简单,总共下来也就三个页面。所以vue单文件组件、webpack打包、路由vue-router、axios等等这些我通通都不需要,即使是有electron-vue这现成的项目可以更方便的做二次开发,我也不会去用。我认为这么小的项目不应该用工程化的东西去增加复杂性,这就是我的思考。偶尔写写原生开发,感觉还是不错的。
我直接用显示与隐藏取代掉vue-router实现的路由功能,vue-router的内部实现原理无非也是根据url去加载的对应的组件。因为Electron的渲染进程是跑在Chromium中,所以请求部分我直接用fetch来发起。UI部分你还要用ElementUI框架?别懒,自己手写。
在这第一个应用中,发现的第一个坑是存在于渲染进程中的ipcRenderer对象的send方法不存在,导致渲染进程向主进程通信受阻。给到的解决办法是,在预加载脚本中传递数据:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
// 向渲染进程传递参数
contextBridge.exposeInMainWorld('ipc', {
"ipcRenderer": {
send: (channel, data) => {
ipcRenderer.send(channel, data)
},
on: (channel, callback) => {
ipcRenderer.on(channel, callback)
}
}
})
第二个坑是关于打包的问题,在 官方文档 - 快速入门 - 打包并分发您的应用程序一节,Electron Forge这个打包工具包并不好用,后来发现Electron-builder真香。
第一个应用就这些,没有太多的东西,重点来了,第二个应用,难度一下子上来了。
第二个应用
第二个应用,根据需求来看,判定以后会集成很多东西,功能比较复杂,所以一开始我就考虑electron-vue这样便捷的脚手架,但是技术是发展的,我发现现有的electron-vue脚手架只有vue2.x版本,我想用vue3 + ts + vite来做这个项目,推动技术革新。
我将之前根据从 0 开始手把手带你搭建一套规范的 Vue3.x 项目工程环境这篇文章搭建的vue3.x项目模板拿过来,集成electron进去。注意到此为止还只是个web项目,只是添加了electron依赖包,关于本地启动方式、打包等,还需要进一步去修改。
将 vue3.x 项目改造为 electron 工程化项目
这一部分是最为复杂的,涉及打包配置,本人还在学习NodeJS,所以对于打包也没有很深入的理解、探究,后续会学习打包这部分,工程化可是很重要的呦。
好在github上已经有现成的项目模板 electron-vue-vite,我关于打包部分的内容更多的是参考该作者的,致敬。当然,在发现这个仓库之前,我也进行了深入的思考,下面是我的一些思考,或者你可以认为是对electron-vue-vite这个项目的解读。
思考:electron 项目需要一个跑在主进程的如main.js入口文件来控制应用程序,还需要至少一个跑在渲染进程的如index.html文件来渲染应用界面,最后还可以根据需要提供一个预加载文件如preload.js在渲染器进程加载之前来传递一些数据。
现在我们vue3.x项目打包后生成在dist文件夹下的文件就是最终跑在渲染进程的文件,我们还缺main.js与preload.js。现在着手修改项目,首先修改src目录,我们修改项目目录为如下三部分:
将src目录下原先的内容移入render 目录下。render目录的样子类似如下:
修改vit.config.ts配置,将vite打包的根目录变更为src/render。
main文件夹与preload文件夹,考虑到下面可能也会划分很多细小的文件模块,那我们也可以将这两个文件夹分别使用rollup打包。rollup相比webpack更适合打包js库,所以这里使用rollup来打包更合适。
现在新建目录script用来编写rollup打包脚本,目录可能如下:
然后,我们去package.json文件去配置相关打包命令,大概长这个样子:
"build:render": "vite build",
"build:preload": "node -r ts-node/register script/build-preload --env=production",
"build:main": "node -r ts-node/register script/build-main --env=production",
"build": "rimraf dist && npm run build:render && npm run build:preload && npm run build:main"
关于npm scripts的使用,可以阅读npm scripts 使用指南这篇。
现在我们执行npm run build命令,打包后的目录如下:
已经满足最开始思考中所需要的三部分。
在最终集成electron-builder打包后,我还添加了如下命令,方便打包:
"win32": "npm run build && electron-builder --win --ia32",
"win64": "npm run build && electron-builder --win --x64",
"mac": "npm run build && electron-builder --mac",
"linux": "npm run build && electron-builder --linux"
注意&&符号表示继发执行。
就目前情况而言,我们构建好了打包相关的东西,但这也只是满足了在生产环境的需要。在本地开发环境下我们又该怎样让electron程序启动且方便的做到热重载?
思考:可以先将web项目启动起来,然后再打包mian、preload文件夹的内容并执行,所以开发环境的npm script命令会配置如下:
"dev": "concurrently -n=R,P,M -c=green,yellow,blue \"npm run dev:render\" \"npm run dev:preload\" \"npm run dev:main\"",
"dev:render": "vite",
"dev:preload": "node -r ts-node/register script/build-preload --env=development --watch",
"dev:main": "node -r ts-node/register script/build-main --env=development --watch"
注意这里的dev命令,会平行的执行dev:render、dev:preload、dev:main三个命令,这三个命令执行的先后顺序每次可能都不一样,所以在打包的那部分脚本(build-main.ts)中有个waitOn函数轮询监听vite的启动状态,目的即在vite启动后,也就是本地的web服务器起来后,才去执行rollup打包main文件夹下文件的操作,最后用child_process.spawn()执行打包后的main.js,这样就做到了在本地启动electron + vue3.x + vite + ts的应用程序。
项目已经很好的搭建起来了,能够较好的完成本地开发与生产环境的打包。遂着手开发项目,下面是我在开发中碰到的问题记录,或许大家也会碰到这方面的问题。
问题记录
1. RabbitMQ 的使用
打开RabbitMQ的官网JavaScript Get Started,关于生产者、消费者、交换机、路由、RPC这些的介绍都在文档里了,所以撸文档就好了。
参照官方教程,我在建立RabbitMQ连接的时候总是不成功。
第一个问题,我的项目RabbitMQ服务端是有SSL证书验证的,添加证书的写法在amqplib官网-SSL
第二个问题,即使我配置好了ssl证书,连接也会有问题,需要添加
checkServerIdentity: () => {
return null
}
完整的连接代码,Promise形式,附加接收消息与发送消息示例:
const amqp = require('amqplib')
const constains = require('constants')
const url = {
protocol: 'amqps',
hostname: 'xxx.xxx.xxx.xxx',
port: 'xxxx',
username: 'xxxxxxx',
password: 'xxxxxxxxxxxxxxxx'
}
const opts = {
cert: fs.readFileSync('clientcert.pem'),
key: fs.readFileSync('clientkey.pem'),
passphrase: 'MySecretPassword',
ca: [fs.readFileSync('cacert.pem')],
secureOptions: constains.SSL_OP_NO_TLSv1_1, // SSL 协议版本
checkServerIdentity: () => {
return null
}
}
function reconnect() {
console.log('到服务器的连接已断开')
return connectRabbitMq()
}
// 连接 RabbitMQ 示例
function connectRabbitMq() {
return amqp
.connect(url, opts)
.then(connect => {
connect.on('error', reconnect) // 错误重连
console.log('到服务器的连接正常')
return connect.createChannel()
})
.then(async (channel) => {
await channel.assertExchange('exchange', 'topic', {
durable: true, // 消息持久化
autoDelete: false
})
return channel
})
.catch(reconnect)
}
// 接收消息示例
function receiveMessage() {
connectRabbitMq()
.then(async (channel) => {
// 声明队列
await channel.assertQueue('receiveQueue', {
durable: true,
autoDelete: true
})
// 在处理并确认前一条消息之前,不要向工作人员发送新消息
await channel.prefetch(1)
// 绑定队列
await channel.bindQueue('receiveQueue', 'exchange', 'receiveRoutingKey')
// 消费消息
channel.consume(
'receiveQueue',
async (msg) => {
console.log('======= receive =======')
console.log(msg.content.toString())
await dealMsg(msg) // 处理消息
channel.ack(msg) // 应答
},
{
noAck: false
}
)
})
.catch(console.warn)
}
// 发送消息示例
function sendMessage(msg) {
connectMQ()
.then((channel) => {
channel.publish('exchange', 'sendRoutingKey', Buffer.from(JSON.stringify(msg)))
})
.catch(console.warn)
}
2. NodeJS 下载文件并显示下载进度
我首先使用了axios去下载文件,发现axios的下载进度只支持在浏览器环境下能获取到,Node环境无法获取到。这一点在官网Request Config可以看到:
于是axios不再做考虑,最终我使用了另外两个包来实现,示例如下:
const fs = require('fs')
const fetch = require('node-fetch')
const progressStream = require('progress-stream')
/**
* @param {*}
* fileURL: string 文件下载地址
* fileSavePath: string 文件保存地址
* callback 可选参数,默认空函数,可用于文件下载过程中的一些操作
*/
function downLoadFile(
fileURL,
fileSavePath,
callback = function () {}
) {
const fileStream = fs
.createWriteStream(fileSavePath)
.on('error', () => {
console.log('下载出错')
})
fetch(fileURL, {
method: 'GET',
headers: { 'Content-Type': 'application/octet-stream' }
})
.then(res => {
let fsize = res.headers.get('content-length')
//创建进度
let str = progressStream({
length: fsize,
time: 100 /* ms */
})
// 下载进度
str.on('progress', function (progressData) {
let progress = Math.round(progressData.percentage)
callback(process) // 拿到进度后可以做进一步处理
})
// 保存文件
res.body.pipe(str).pipe(fileStream)
})
.catch(console.warn)
}
关于断点续传,更多的可以参考NodeJS使用node-fetch下载文件并显示下载进度示例
3. electron-builder 打包,修改程序默认安装路径
这个功能需要通过编写nsis脚本来实现,首先修改package.json中的nsis配置项,添加include选项,如下:
"nsis": {
"oneClick": true,
"allowElevation": true,
"installerIcon": "dist/favicon.ico",
"uninstallerIcon": "dist/favicon.ico",
"installerHeaderIcon": "dist/favicon.ico",
"createDesktopShortcut": false,
"createStartMenuShortcut": true,
"shortcutName": "rabbit",
"deleteAppDataOnUninstall": true,
"include": "./installer.nsh"
}
关于nsh脚本,你可以去electron-builder官网看看,点这里。
installer.nsh脚本示例如下:
!macro preInit
SetRegView 64
WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\Program Files (x86)\rabbit"
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\Program Files (x86)\rabbit"
SetRegView 32
WriteRegExpandStr HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\Program Files (x86)\rabbit"
WriteRegExpandStr HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation "C:\Program Files (x86)\rabbit"
!macroend
4. rollup 打包时文件的复制
我项目的根目录下有个图标文件favicon.ico,在rollup打包的时候,我需要将它移动到dist目录下,这样才符合我上面nsis配置项中图标项的地址配置。
我还没有傻到通过fs内置模块来操作的地步,我先去查看了vue-cli项目中webpack是怎样做到的,我找到了copy-webpack-plugin这个包。于是我也去找rollup类似的包,于是找到了rollup-plugin-copy这个包,示例代码如下:
import copy from 'rollup-plugin-copy'
const RollupOptions = {
plugins: [
copy({
// 复制 favicon.ico 到指定目录
targets: [
{ src: 'favicon.ico', dest: 'dist' }
]
})
]
}
你可以在npm官网上看到更多的使用示例,点这里rollup-plugin-copy
5. 注册 windows 服务
首先给到结论:electron应用注册为windows 服务的做法不可取,建议放弃。
两点原因:
- 通过
electron-builder打包出来的exe可执行程序,根本就不满足服务的规范。
在知道这一点之前,我尝试了两种方式。
第一种是使用NSIS Simple Service Plugin 这个插件,我按照文档编写好nsh脚本后打包程序,然后运行程序,发现服务注册上了,但是启不来,查看windows 日志也无果。
第二种就是SC命令,长这样子:SC [Servername] command Servicename [Optionname= Optionvalues],我直接cmd执行命令注册,得到的结果与第一种情况相同。
网络上更多人说node-windows,我的可是要将整个程序注册为windows 服务,又不是一个NodeJS脚本,况且我的应用还要打包为exe可执行程序,所以要视情况,不能人云亦云。
windows 服务属于操作系统核心态,也就是说windows 服务工作在操作系统内核。在操作系统内核工作的程序是不会有图形化界面这些展示功能的。 最有说服力的情况就是,我用nssm工具(NSSM 是一款可将 Nodejs 项目注册为 Windows 系统服务的工具)将我的应用程序注册为windows服务后,查看进程发现程序在运行,但是应用程序的托盘图标无论如何都显示不出来。所以将electron应用注册为windows根本不可取。
我最终的做法就是项目拆分,将需要注册为windows 服务的部分作为NodeJS项目单独打包,这里的打包我用到的是pkg这个包,需要注意的是NodeJS项目里面如果你没有配置babel,请不要使用ESModule,否则打包会失败。另外的部分你可以作为图形化界面来展示,拆分的两部分之间如果需要通信,可以使用websocket,ws这个包还不错。
6. 其他程序的安装与卸载
我们打包的安装程序,在安装的过程中可能还需要顺带安装别的可执行程序,同样卸载时也应该将之一并卸载。
这里我给到一个自己的,在安装electron程序时同时安装msi程序的nsh脚本片段,以供参考:
!macro customInstall
SetRegView 64
ExecWait '"msiexec" /i "$INSTDIR\rabbit.msi" /passive'
SetRegView 32
ExecWait '"msiexec" /i "$INSTDIR\rabbit.msi" /passive'
!macroend
!macro customUnInstall
SetRegView 64
ExecWait '"msiexec" /qn /quiet /uninstall {6D4A6ED0-CF41-4615-A4B3-BDA018C3C1CD}'
SetRegView 32
ExecWait '"msiexec" /qn /quiet /uninstall {4FA44F21-DDD9-4A36-95D2-0792765F7D5C}'
!macroend
注意:rabbit.msi程序需要复制到安装解压后的根目录下。
上面卸载脚本的如{6D4A6ED0-CF41-4615-A4B3-BDA018C3C1CD}这串productcode在注册表中查找,路径如下:
7. Electron 使用 regedit 模块时的正确配置方法
在需要操作注册表时,我们会使用第三方模块regedit,在本地开发时没有问题,但是在打包后,你会发现涉及到使用regedit模块的地方,代码跑不过去,导致操作受限。
问题解决:
- 在
package.json的build部分,增加如下内容:
"extraResources": [
{
"from": "node_modules/regedit/vbs",
"to": "vbs",
"filter": [
"**/*"
]
}
]
- 在声明
regedit的地方修改如下:
import { app } from 'electron'
const path = require('path')
const regedit = require("regedit")
if (app.isPackaged)
regedit.setExternalVBSLocation(path.resolve(process.execPath, '../resources/vbs'))
事实上,regedit在npm网站上已经提醒过各位了,如下:
8. Electron 正确使用 @thiagoelg/node-printer 模块的方法
现象:
- 在本地新建文件夹,单独安装该模块并测试正常;
- 在 ELectron 项目中正常安装该模块并使用,报错:
Module did not self-register。
解决方法:
- 查看本地 Node 版本:
node -v - 查看 electron 内置的 Node 版本,两种方法:①在主进程中打印
process.versions,返回的对象中会包含版本信息;②点electron-releases - npm (npmjs.com)这里查看指定 electron 版本对应的 Node 版本信息。 - 将两个环境的 Node 版本对齐:你可以升降级任意一个环境的 Node 版本
- 安装 electron-rebuild 模块:
npm i electron-rebuild -D - package.json 文件添加 scripts 脚本:
"rebuild": "electron-rebuild -f -w @thiagoelg/node-printer" - 执行
npm run rebuild重新构建 node-printer 模块 - 重新运行项目
说明:
@thiagoelg/node-printer 模块比较特殊,内部调用了 C++ 模块,若不执行npm run rebuild重新构建该依赖包,则运行项目会报错:Module did not self-register。
这是因为编译底层 Node 库用的 Node 版本与 electron 内部运行的 Node 不是同一个版本,重新构建以解决此错误。
举例说明:本地开发 Node 版本为 v14.17.0,执行 npm run rebuild 重新构建 @thiagoelg/node-printer 模块,构建出来的是只可以在 Node v14.17.0 版本下正常执行的模块,此时就要求 electron 应用运行时,主进程所使用的 Node 版本也为 v14.17.0。经查证 electron v14.2.9 版本内置的 Node 版本为 v14.17.0,所以我要安装的 electron 版本为 v14.2.9
我的模板地址
欢迎 star !!!
最后
项目还在持续迭代中,以后遇到更多的问题,我都会在这里记录,欢迎大家一起探讨。
参考
从 0 开始手把手带你搭建一套规范的 Vue3.x 项目工程环境
RabbitMQ 官网 JavaScript Get Started