electron+vue3 实战记录
一、通过 vue-cli 创建 vue3 工程
vue create electron-vue3
创建后使用vue add electron-builder
为工程添加electron-builder
插件
cd electron-vue3
vue add electron-builder
然后项目基础搭建完成
二、electron 相关概念
-
进程: electron 程序运行由两个进程来控制主进程(main)和渲染进程(render)
- 主进程负责控制整个程序的生命周期、窗口管理等,每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。
- 渲染进程负责页面的显示,我们可以使用前端技术(html、css、javaScript 等)来编写渲染进程。
-
Preload 脚本
官方解释:预加载(preload)脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。预加载脚本可以在 BrowserWindow 构造方法中的 webPreferences 选项里被附加到主进程。
我的理解:由于 electron 的主进程可以直接使用 NodeJs 的 api,那么如果渲染进程直接和主进程通信来调用主进程的 NodeJs 相关的 api,就显得不那么安全。如果我们的程序对安全性的考虑和要求非常高,我们则就可以启用 electron 的上下文隔离选项,启用之后渲染进程(render)就不能直接和主进程通信,我们需要通过 prelaod 脚本来定义一些 api 或者说方法然后直接作为 window 对象的属性赋给 window 对象供渲染进程使用。
注意:-
如果开启了上下文隔离(
contextIsolation
),那么 preload 脚本里面访问的 window 对象并非渲染进程也就是浏览器的 window 对象,因此直接给 window 对象赋值是不不生效的:window.myApi = { do() {} };
这样声明后在渲染进程 window 对象里面访问 myApi 属性只会返回
undefined
,因此我们需要 electron 提供的模块contextBridge
来实现通信,其实顾名思义 contextBridge 直译就是上下文桥梁,还是比较好理解,使用方法:// 在上下文隔离启用的情况下使用预加载 const { contextBridge } = require("electron"); const myApi = { doAThing: () => {}, }; contextBridge.exposeInMainWorld("myAPI");
上面这段代码的意思就是在预加载脚本中使用
contextBridge
这个模块将myApi这个对象作为window的属性在预加载脚本执行的时候添加到window,然后渲染进程就可以通过访问window.myApi
来访问myApi里面的内容了。 -
在使用
contextBridge
暴露接口的时候不要直接暴露node相关的api,这样做是不安全的,而是应该封装后暴露。
-
三、进程间通信
进程间通信(IPC)是electron应用飞铲关键的技术部分,因为electron应用是由主进程和渲染进程来构成的,因此需要通过进程间通信来彼此联系起来。
-
上面提到的preload脚本就是进程间通信的重要组成部分,如果我们在electron应用中开启了上下文隔离,则需要通过prelaod脚本配置来暴露接口,然后渲染进程通过调用接口实现进程间通信通信。
-
除此之外,进程间可以通过
ipcRenderer
和ipcMain
来通过消息订阅发布的方式来进行通信:比如现有需求,需要在渲染进程中加载的时候获取设备所连接的安卓设备的信息的:实现:
<template> <div class="select-devices"> <h4 class="title">{{ $t("addDevices") }}</h4> <v-divider style="margin: 16px 0"></v-divider> <p>{{ deviceInfoStr }}</p> </div> </template> <script> import { GET_DEVICES_BY_ADB } from "@/util/actions"; import { onMounted, onBeforeUnmount, reactive, toRefs, computed } from "vue"; const { ipcRenderer } = window.require("electron"); export default { setup() { const data = reactive({ adbVersionInfo: "", vueInfo: "", deviceInfoStr: "", }); const getDeviceList = () => { // 渲染进程发送GET_DEVICES_BY_ADB消息,主进程监听到该消息后就可以操作了 ipcRenderer.send(GET_DEVICES_BY_ADB); }; const onGetDeviceResponse = (args, result) => { data.deviceInfoStr = result; console.log(result); window.re = result; }; const deviceList = computed(() => { // adb -s id shell getprop ro.product.model return []; }); onMounted(() => { // mounted的时候调用获取deviceList的方法 getDeviceList(); ipcRenderer.addListener(GET_DEVICES_BY_ADB, onGetDeviceResponse); }); onBeforeUnmount(() => { ipcRenderer.removeListener(GET_DEVICES_BY_ADB, onGetDeviceResponse); }); return { ...toRefs(data), getDeviceList, deviceList }; }, }; </script>
主进程:
... // 注册事件监听,监听到渲染进程发送的GET_DEVICES_BY_ADB消息后就调用this.getAdbVersion方法来 ipcMain.on(GET_DEVICES_BY_ADB, (event, args) => { this.getAdbVersion(); }); // 业务逻辑 getAdbVersion() { execAdbCmd("devices") .then((res) => { // 业务执行完毕后向渲染进程发送消息并且带上res这个结果一并发送给渲染进程 this.windowInstance.webContents.send(GET_DEVICES_BY_ADB, res); }) .catch((err) => { console.log(err); }); } ... ... // node的api调用系统的adb命令 export function execAdbCmd(cmd = "--version") { return new Promise((resolve, reject) => { const result = spawn(ADB_TOOL_PATH, cmd.split(" ")).stdout; result.on("data", (data) => { resolve(data.toString()); }); }).catch((err) => { reject(err); }); } ...
四、其他配置
-
icon
const icon = nativeImage.createFromPath(path.join(__static, "favicon.ico")); // 然后在new BrowserWindow的时候传入icon配置项即可
-
打开文件选择对话框
electron提供了dialog模块,有很多种用法,这里用打开问加你对话框选择文件举例
// 打开文件选择对话框 返回选择的文件的路径 openFileSelectDialog(event, args = {}) { const result = dialog.showOpenDialog({ properties: ["openFile"], ...args, }); result .then((res) => { if (res.canceled) { return; } const filePath = res.filePaths[0]; const fileInfo = { name: path.basename(filePath), filePath }; this.sendFileSelectResult(event, fileInfo); }) .catch((err) => { console.log("error"); }); } // 文件选择对话框选择文件之后触发回调 将选择的文件的路径返回给前端页面 sendFileSelectResult(event, args) { const webContents = event.sender; const win = BrowserWindow.fromWebContents(webContents); win.webContents.send(ON_FILE_SELECT, args); }
其他具体参数配置参考官网
-
应用最小化托管
const logo = path.join(__static, "favicon.ico"); function setTray() { tray = new Tray(logo); tray.setToolTip("这是我的应用"); const contextMenu = Menu.buildFromTemplate([ { label: "退出", click() { const quitApp = () => { BrowserWindow.getAllWindows().forEach((v) => v.destroy()); console.log("退出 "); app.quit(); }; quitApp(); // new BrowserWindow({ modal: true, parent: mainWindow }); }, }, ]); // tray.setToolTip("This is my application."); tray.setContextMenu(contextMenu); tray.on("click", () => { mainWindow.onTrayClick(); }); }
-
配置其他外部资源(打包配置)
需要注意的是在开发环境中__dirname指的是src目录,而生产打包后指向的是安装目录的resource目录下
//vue.config.js /*eslint-disable */ const { defineConfig } = require("@vue/cli-service"); module.exports = defineConfig({ transpileDependencies: true, pluginOptions: { vuetify: { // https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vuetify-loader }, electronBuilder: { nodeIntegration: true, // 打包参数配置 builderOptions: { productName: "BlueSphere Device Provisioner", // 应用名称 // copyright: "xxxx", // directories: { // output: "build_electron", // 输出文件夹 // }, extraResources: { from: "./resources", to: "" }, // 打包的静态文件 // 该配置的意义为将resource文件夹下的资源打包到根目录 win: { // icon: "./src/assets/img/logo.svg", //图标路径, target: [ { target: "nsis", // 利用nsis制作安装程序 arch: [ "x64", // 64位 // "ia32", ], }, ], }, nsis: { oneClick: false, // 一键安装 perMachine: true, // 是否开启安装时权限限制(此电脑或当前用户) // allowElevation: true, // 允许请求提升。 如果为false,则用户必须使用提升的权限重新启动安装程序。 allowToChangeInstallationDirectory: true, // 允许修改安装目录 // installerIcon: "./src/assets/img/logo.svg", // 安装图标 // uninstallerIcon: "./src/assets/img/logo.svg", //卸载图标 // installerHeaderIcon: "./src/assets/img/logo.svg", // 安装时头部图标 // createDesktopShortcut: true, // 创建桌面图标 // createStartMenuShortcut: true, // 创建开始菜单图标 }, }, }, }, });
五、相关Api
-
app
控制整个应用的事件生命周期,相关的api可以参考官网。
-
BrowserWindow
BrowserWindow是electron应用中创建窗口的构造器,可以直接new该构造器来创建窗口实例:
import { BrowserWindow } from "electron"; new BrowserWindow(options) // options是相关配置,常用的有: // width = 1360, 窗口默认宽度 // height = 768, 窗口默认高度 // minWidth = 1360, 窗口最小宽度 // minHeight = 768, 窗口最小高度(同理还是有最大配置) // frame = false, 是否展示默认的边框(最小化最大化关闭这个栏) // resizable = false, 是否支持用户自己调整大小 // nodeIntegration = true, node集成 // contextIsolation = false, 是否启用上下文隔离 // background = "#3f51b5" 窗口的背景色
BrowserWindow还有很多静态方法和实例方法,具体按照挂官方文档,文档有详细说明。
BrowserWindow实例的webContents属性是主进程给渲染进程发送消息的重要特性,
const win = new BrowserWindow(options) win.webContents.send(channel, payload) // 这里就是主进程给渲染进程的channel频道发送消息,消息体就是payload
-
ipcMain
从主进程到渲染进程的异步通信。常用方法:
ipcMian.on方法来监听渲染进程发布的消息:
ipcMain.on(OPEN_FILE_DIALOG, (event, args) => { this.openFileSelectDialog(event, args); }); ipcMain.on(SAVE_FILE, (event, args) => { console.log(SAVE_FILE, "args"); }); ipcMain.on(GET_DEVICES_BY_ADB, (event, args) => { this.getAdbVersion(); }); ipcMain.on(GET_EXISTED_APPS, (event) => { // this.windowInstance.webContents.send(GET_EXISTED_APPS, []) this.getExistedApps(); }); ipcMain.on(DOWNLOAD_APPS, (event, args) => { this.downloadApps(args); });
然后收到消息后触发对应的注册好的事件回调,比如上面的第一条,收到渲染进程发布的
OPEN_FILE_DIALOG
消息后就调用this.openFileSelectDialog方法,这样就完成过了一次才从渲染进程到主进程的通信。 -
ipcRenderer
用来控制渲染进程,常用方法:
发布消息和订阅消息:
const downloadApp = () => { data.progress = null; ipcRenderer.send(DOWNLOAD_APPS, templateApps); }; const getExistedApps = () => { ipcRenderer.send(GET_EXISTED_APPS); }; onMounted(() => { getExistedApps(); ipcRenderer.addListener(GET_EXISTED_APPS, onGetAppResponse); ipcRenderer.addListener(APP_DOWNLOAD_PROGRESS, onDownloadAppProgress); }); onUnmounted(() => { ipcRenderer.removeListener(GET_EXISTED_APPS, onGetAppResponse); ipcRenderer.removeListener(APP_DOWNLOAD_PROGRESS, onDownloadAppProgress); });
在vue中我们可以分别在组件挂载和卸载的时候去订阅消息和取消订阅消息(如果需要全局订阅可以在根组件(App.vue)或者状态管理store中去做消息订阅和发布),然后在对应的事件回调中去做出相关操作,比如再发送消息给主进程等等。
这样,一个完整的主进程和渲染进程之间的通信模型就构建完成了。