第13章实战篇系统插件的加载和取色插件的开发
## 前言
在 Rubick
插件系统中,插件主要分为两大类,一类是 UI 类插件,这类插件是通过 BrwoserView
进行加载,因为通过 BrowserView
加载,所以通常会有视图 UI 界面,插件销毁后,不会驻留在内存中。还有一类是系统类插件,这类插件是会常驻在 Rubick
运行时中的,可以是一段 JavaScript
代码片段,会伴随着 Rubick
特定运行状态钩子而启动运行。
在章节《实战篇:插件的安装、发布、卸载》中,我们介绍了插件安装的方法;在《实战篇:如何支持工具插件化》中,我们介绍了如何加载安装好的 UI 类插件。
接下来,我们将接着介绍如何加载安装好的系统类插件,然后再教大家编写一个简单的 UI 类插件和系统类插件。
系统类插件的目录结构说明
前面我们介绍了 UI
类插件的目录结构,接下来,我们再看一下最简单的系统类插件目录结构:
plugin
|-- index.js
└── package.json
1. package.json
和之前 UI 类插件字段是一样的,区别是需要额外提供一个 entry
字段来标记入口 js
,以及 pluginType
字段为 system
来标记是一个系统类插件:
{
"pluginType": "system",
"entry": "index.js"
}
2. index.js
系统插件的入口 js
文件,通过自定义一些系统插件钩子函数,来实现代码片段注入的能力:
// index.js
module.exports = () => {
return {
// 定义 beforeReady 钩子函数
beforeReady() {
// ...
}
// 定义 onReady 钩子函数
onReady(ctx) {
// ...
},
// 定义 onRunning 钩子函数
onRunning(ctx) {
// ...
},
// 定义 onQuit 钩子函数
onQuit() {
// ...
}
}
}
加载系统类插件
我们知道,Electron 运行时主要是通过 app 对象来完成对应用生命周期的监听和处理,我们想要实现系统插件,就是希望可以注入到这些生命周期中运行的代码段。因此,我们可以把 app
的启动类比成 Vue
的生命周期那样设置几个关键阶段:
- beforeReady:Electron App 启动前的准备工作,执行在 Electron 钩子函数
app.on('ready')
之前。这里会加载执行系统插件的onBeforeReady
钩子函数。 - onReady:Electron
app.on('ready')
函数执行期,会做一些更新检测、创建系统菜单、创建主窗口等操作。这里会加载执行系统插件的onReady
钩子函数。 - onRunning:这里会处理 Electron 的
app.on('second-instance')
钩子函数和app.on('activate')
钩子函数。这里会加载执行系统插件的onRuning
钩子函数。 - onQuit:这里会处理 Electron 的
app.on('window-all-closed')
钩子函数和app.on('will-quit')
钩子函数。这里会加载执行系统插件的onQuit
钩子函数。
所以,我们需要对入口 main/index.js
文件做一下改造:
import electron, { app, protocol } from 'electron';
class App {
constructor() {
// 注册协议
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } },
]);
// 处理多应用实例
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
// 注册系统插件
this.systemPlugins = registerSystemPlugin();
// 注册生命周期
this.beforeReady();
this.onReady();
this.onRunning();
this.onQuit();
}
}
beforeReady() {
// ...
// 触发 onBeforeReady
this.systemPlugins.triggerBeforeReadyHooks()
}
createWindow() {
this.windowCreator.init();
}
onReady() {
const readyFunction = async () => {
// ...
// 触发 onReady
this.systemPlugins.triggerReadyHooks();
};
if (!app.isReady()) {
app.on('ready', readyFunction);
} else {
readyFunction();
}
}
onRunning() {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// ...
if (win) {
if (win.isMinimized()) {
win.restore();
}
win.focus();
}
});
app.on('activate', () => {
// ...
});
// 触发 onRunning
this.systemPlugins.triggerOnRunningHooks();
}
onQuit() {
// ...
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('will-quit', () => {
// ...
// 触发 OnQuit
this.systemPlugins.triggerOnQuitHooks();
});
}
}
export default new App();
上面代码也很简单,我们可以详细介绍一下:
- 首先通过一个
App
类来定义了四个钩子函数,并通过constructor
来实现对钩子函数的注册调用。 - 在
constructor
中,通过registerSystemPlugin
函数完成对系统插件的注册,并赋值给了this.systemPlugins
。 - 监听
app
阶段性钩子函数,并通过this.systemPlugins.triggerXXXHooks
完成对系统插件钩子函数的触发调用。
接下来,我们以实现 triggerXXXHooks
来作为示例,来说明一个插件入口 js
程序中的 onReady
钩子是如何被执行的:
// registerSystemPlugin.js
import path from 'path';
import fs from 'fs';
// 插件通过 npm 安装下载的路径
import { PLUGIN_INSTALL_DIR } from '@/common/constans/main';
const registerSystemPlugin = () => {
// ...
// 通过 pluginType 从所有插件 totalPlugins 中过滤出系统插件
let systemPlugins = totalPlugins.filter(
(plugin) => plugin.pluginType === 'system'
);
// 处理插件的 entry 路径,由相对路径转为绝对路径
systemPlugins = systemPlugins
.map((plugin) => {
try {
const pluginPath = path.resolve(
PLUGIN_INSTALL_DIR,
'node_modules',
plugin.name
);
return {
...plugin,
indexPath: path.join(pluginPath, './', plugin.entry),
};
} catch (e) {
return false;
}
})
.filter(Boolean);
// 定义插件的所有钩子函数
const hooks = {
onReady: [],
};
// 收集所有系统插件的 onReady 钩子函数
systemPlugins.forEach((plugin) => {
if (fs.existsSync(plugin.indexPath)) {
const pluginModule = __non_webpack_require__(plugin.indexPath)();
hooks.onReady.push(pluginModule.onReady);
}
});
// 定义触发所有插件的 onReady 钩子
const triggerReadyHooks = (ctx) => {
hooks.onReady.forEach((hook: any) => {
try {
hook && hook(ctx);
} catch (e) {
console.log(e);
}
});
};
return {
triggerReadyHooks,
};
};
上述代码中,首先通过 pluginType = system
这个条件来过滤出所有的系统插件,因为插件中的 package.json
定义的 entry
入口文件是一个相对路径,所以我们将其处理成了一个绝对路径 indexPath
。接下来就是通过 __non_webpack_require__
函数动态引用入口 index.js
文件,并把其中注册的 onReady
钩子函数派发到 hooks
中。然后包装成一个 triggerReadyHooks
函数在特定时机触发。
__non_webpack_require__
是 Node.js 中的一个全局变量,它是为了避免 Webpack 对require
函数的处理而引入的。在 Webpack 打包的过程中,它会把所有的
require
函数替换成一些用于模块加载的特殊函数,这可能导致某些情况下无法直接使用原生的 Node.js 模块加载方式。为了绕过这种替换,可以使用__non_webpack_require__
来调用原生的require
函数。当你使用 Webpack 打包的代码中需要引入 Node.js 模块时,可以使用
__non_webpack_require__
,这样就可以确保使用原生的 Node.js 的模块加载机制而不受 Webpack 的影响。
插件的开发
前面我们已经分别介绍了关于 UI 类插件和系统类插件的最基础的目录结构。但是开发过程中,使用最基础的目录结构肯定是不行的,为了界面样式的统一和美观我们可能会使用一些组件库,为了提升开发效率,我们也可能会使用一些开发框架,比如 Vue
、React
。
因此,这里我们将以 Vue 3 + antdv
作为基础依赖,实现一个桌面取色器插件。
1. 初始化工程
我们使用 vueCli
来创建一个插件应用:
$ vue create rubick-plugin-colorpicker
得到的目录结构如下:
.
├── README.md
├── babel.config.js
├── jsconfig.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── src
│ ├── App.vue
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ └── HelloWorld.vue
│ └── main.js
├── vue.config.js
└── yarn.lock
vueCli
在执行 npm run build
命令构建后,会把 public
目录下的文件拷贝到 dist
目录,所以我们可以在 public
目录下新建一个 preload.js
和一个 package.json
,调整后的 public
目录结构如下:
public
├── favicon.ico
├── index.html
├── package.json
└── preload.js
然后我们给 package.json
文件加入以下内容:
{
"name": "rubick-plugin-colorpicker",
"pluginName": "取色器",
"version": "1.0.0",
"description": "取色器",
"main": "index.html",
"preload": "preload.js",
"logo": "https://pic1.zhimg.com/80/v2-5f1810a71af6eefcd77edbbf07ea1cc7_720w.png",
"pluginType": "ui",
"features": [
{
"code": "colorpicker",
"explain": "取色器",
"cmds": [
"colorpicker",
"qs",
"取色"
]
}
]
}
因为插件是以文件方式引用资源,所以需要调整一下 vue.confg.js
设置 publicPath
的路径:
// vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
// ...
publicPath: process.env.NODE_ENV === "production" ? "" : "/",
})
我们开始构建这个项目:
$ npm run build
构建完成后,看一下 dist
目录结构:
dist
├── css
│ └── app.2cf79ad6.css
├── favicon.ico
├── index.html
├── js
│ ├── app.42798930.js
│ ├── app.42798930.js.map
│ ├── chunk-vendors.4114fffc.js
│ └── chunk-vendors.4114fffc.js.map
├── package.json
└── preload.js
这个就是我们后面需要发布的 npm
插件包。
2. 调试插件
因为我们的插件是基于 npm 的,所以可以通过 npm link
的方式进行插件调试,在 dist
目录下,执行:
$ npm link
然后在 rubick
中通过路径的方式安装这个插件:
安装的本质就是 npm link rubick-plugin-colorpicker
。安装完成后,我们就可以通过搜索框输入关键词:colorpicker
、qs
、取色
来进行唤起插件。
但这样有一个问题就是每次无法热更新,每次修改代码都需要进行 npm run build
后才能显示结果,因此,需要在插件的 package.json
中添加一个 development
字段来标记开发环境的前端入口地址:
// public/package.json
{
"development": "http://localhost:8080",
}
这样,每次更新代码,也会热更新插件页面。
3. 功能完善
插件页面已经搭建好了,接下来要实现的就是屏幕取色的能力,这里我们可以直接使用 electron-color-picker 这个开源库,接下来,需要在 public
目录下安装这个依赖库:
$ npm install electron-color-picker
然后在 preload.js
中使用这个库:
const {
getColorHexRGB,
darwinGetScreenPermissionGranted,
darwinRequestScreenPermissionPopup
} = require('electron-color-picker');
const os = require("os");
const isDarwin = os.platform === 'darwin';
window.colorpicker = async () => {
try {
window.rubick.hideMainWindow();
// 如果是 macOS 需要检测屏幕录制权限
if (isDarwin) {
const permission = await darwinGetScreenPermissionGranted();
if (!permission) {
return darwinRequestScreenPermissionPopup();
}
}
// 调用 color picker 来取色
const result = await getColorHexRGB();
// 取色成功写入剪贴板,然后并展示系统通知
if (result) {
window.rubick.copyText(result);
window.rubick.showNotification(`${result}, 取色成功!已复制剪切板`);
}
} catch (e) {
console.log(e);
}
}
然后我们在前端页面中加一个取色按钮,点击后,触发取色功能:
<!-- App.vue -->
<template>
<button @click="window.colorpicker">取色</button>
</template>
点击按钮后,就会出现取色的功能:
接下来,如果你想发布你的插件,那么只需要在构建完成后,在 dist
目录下执行:
$ npm publish
这样就可以完成对插件的发布能力。
总结
本小节,我们详细介绍了关于系统插件的加载实现过程,以及基于 vueCli
完成了一个屏幕取色插件的开发。细心的同学可能留意到这是个 UI 类型的插件。后面,我们将通过 《实现超级面板插件》 的实战章节,讲解如何开发一个好用的系统插件。