Electorn + Vue3 + Vite + TS 实战探索

生活本质就是遇到问题解决问题的过程

前言

好长时间没有写过文章了,这段时间确实很忙,终于整完了,这里做个复盘,记录一下探索过程中遇到的问题。

背景:项目需求说:我要一个桌面客户端程序,最好能跨平台。

技术选型:Electorn 因为使用 Web 技术构建应用、开源、跨平台的优秀特性,自然首当其冲。我有得选吗?没有,我没有。

知识储备:HTML、CSS、JS、VUE

开始

打开 Electorn 官网,首先看到:

image.png

我们常用的开发工具vscode竟然是用Electorn开发的,直呼Electorn 强大

然后下翻,了解下特性: image.png

好像挺牛的样子。

通过官网首页,该知道的也知道了,接下来,我们打开文档的快速入门页面,参照教程一步步构建出自己的第一个 demo

我们需要重点关注 流程模型 这一节,理解在每个 Electron 应用中都是由一个运行在 Node.js 环境中的单一的主进程来管理多个渲染进程(如果你创建多个窗口的话)以及辅助进程等。主进程与渲染进程之间通过ipc管道通信,具体的几种写法,百度一大堆。预加载脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码,我们通常的做法是通过预加载脚本向渲染进程传递数据,这些数据会被挂载在 window 对象下面。

好,知道这些就足够了,余下的就是在具体开发中反复查阅API的事情了。

本次共用 Electorn 开发了两个应用,第一个应用比较简单,先来谈第一个。

第一个应用

考虑到该应用较为简单,总共下来也就三个页面。所以vue单文件组件、webpack打包、路由vue-routeraxios等等这些我通通都不需要,即使是有electorn-vue这现成的项目可以更方便的做二次开发,我也不会去用。我认为这么小的项目不应该用工程化的东西去增加复杂性,这就是我的思考。偶尔写写原生开发,感觉还是不错的。

我直接用显示与隐藏取代掉vue-router实现的路由功能,vue-router的内部实现原理无非也是根据url去加载的对应的组件。因为Electorn的渲染进程是跑在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这个打包工具包并不好用,后来发现Electorn-builder真香。

第一个应用就这些,没有太多的东西,重点来了,第二个应用,难度一下子上来了。

第二个应用

第二个应用,根据需求来看,判定以后会集成很多东西,功能比较复杂,所以一开始我就考虑electorn-vue这样便捷的脚手架,但是技术是发展的,我发现现有的electorn-vue脚手架只有vue2.x版本,我想用vue3 + ts + vite来做这个项目,推动技术革新。

我将之前根据从 0 开始手把手带你搭建一套规范的 Vue3.x 项目工程环境这篇文章搭建的vue3.x项目模板拿过来,集成electorn进去。注意到此为止还只是个web项目,只是添加了electorn依赖包,关于本地启动方式、打包等,还需要进一步去修改。

将 vue3.x 项目改造为 electorn 工程化项目

这一部分是最为复杂的,涉及打包配置,本人还在学习NodeJS,所以对于打包也没有很深入的理解、探究,后续会学习打包这部分,工程化可是很重要的呦。

好在github上已经有现成的项目模板 electron-vue-vite,我关于打包部分的内容更多的是参考该作者的,致敬。当然,在发现这个仓库之前,我也进行了深入的思考,下面是我的一些思考,或者你可以认为是对electron-vue-vite这个项目的解读。

思考:electorn 项目需要一个跑在主进程的如main.js入口文件来控制应用程序,还需要至少一个跑在渲染进程的如index.html文件来渲染应用界面,最后还可以根据需要提供一个预加载文件如preload.js在渲染器进程加载之前来传递一些数据。

现在我们vue3.x项目打包后生成在dist文件夹下的文件就是最终跑在渲染进程的文件,我们还缺main.jspreload.js。现在着手修改项目,首先修改src目录,我们修改项目目录为如下三部分:

image.png

src目录下原先的内容移入render 目录下。render目录的样子类似如下:

image.png

修改vit.config.ts配置,将vite打包的根目录变更为src/render

main文件夹与preload文件夹,考虑到下面可能也会划分很多细小的文件模块,那我们也可以将这两个文件夹分别使用rollup打包。rollup相比webpack更适合打包js库,所以这里使用rollup来打包更合适。

现在新建目录script用来编写rollup打包脚本,目录可能如下:

image.png

然后,我们去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命令,打包后的目录如下:

image.png

已经满足最开始思考中所需要的三部分。

在最终集成electorn-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"
复制代码

注意&&符号表示继发执行。

就目前情况而言,我们构建好了打包相关的东西,但这也只是满足了在生产环境的需要。在本地开发环境下我们又该怎样让electorn程序启动且方便的做到热重载?

思考:可以先将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,这样就做到了在本地启动electorn + 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可以看到:

image.png

于是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. electorn-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脚本,你可以去electorn-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 服务

image.png

首先给到结论:electorn应用注册为windows 服务的做法不可取,建议放弃。

两点原因:

  1. 通过electorn-builder打包出来的exe可执行程序,根本就不满足服务的规范。

在知道这一点之前,我尝试了两种方式。

第一种是使用NSIS Simple Service Plugin 这个插件,我按照文档编写好nsh脚本后打包程序,然后运行程序,发现服务注册上了,但是启不来,查看windows 日志也无果。

第二种就是SC命令,长这样子:SC [Servername] command Servicename [Optionname= Optionvalues],我直接cmd执行命令注册,得到的结果与第一种情况相同。

网络上更多人说node-windows,我的可是要将整个程序注册为windows 服务,又不是一个NodeJS脚本,况且我的应用还要打包为exe可执行程序,所以要视情况,不能人云亦云。

  1. windows 服务 属于操作系统核心态,也就是说windows 服务工作在操作系统内核。在操作系统内核工作的程序是不会有图形化界面这些展示功能的。

最有说服力的情况就是,我用nssm工具(NSSM 是一款可将 Nodejs 项目注册为 Windows 系统服务的工具)将我的应用程序注册为windows服务后,查看进程发现程序在运行,但是应用程序的托盘图标无论如何都显示不出来。所以将electorn应用注册为windows根本不可取。

我最终的做法就是项目拆分,将需要注册为windows 服务的部分作为NodeJS项目单独打包,这里的打包我用到的是pkg这个包,需要注意的是NodeJS项目里面如果你没有配置babel,请不要使用ESModule,否则打包会失败。另外的部分你可以作为图形化界面来展示,拆分的两部分之间如果需要通信,可以使用websocketws这个包还不错。

最后

项目还在持续迭代中,以后遇到更多的问题,我都会在这里记录,欢迎大家一起探讨。

参考

Electorn 官网

从 0 开始手把手带你搭建一套规范的 Vue3.x 项目工程环境

electron-vue-vite

npm scripts 使用指南

RabbitMQ 官网 JavaScript Get Started

amqplib官网-SSL

axios 官网 Request Config

NodeJS使用node-fetch下载文件并显示下载进度示例

electorn-builder 官网

rollup-plugin-copy

NSIS Simple Service Plugin

nssm

pkg

ws

猜你喜欢

转载自juejin.im/post/7019244597213675533