序言
从写代码开始,看到那些允许人家开发自己的插件嵌入到应用中的且自由度很高的软件就觉得很厉害,如VSCode,刚好一直都在研究electron-vite 制作了一个用来可以写在简历上的项目
初衷
1.开发这个小项目的初衷是为了研究electron-vite 研究多窗口代码 并且因为经常有一个问题就是项目文件夹太杂了 放在电脑的各个地方 或者随便命名了一个 找不到项目文件夹太麻烦 2. 要进到对应文件夹npm run 而且一个项目要启动一个后端代码+前端代码 且控制台太散乱了 3.做这个项目的时候 做完的时候 群友说 这个和utools有点像 我就看了一下utools 发现它是以插件的形式去加载 我就想到 那我的项目也能这样做吗
实现
说干就干 开始改造项目 添加插件管理页面

思路
- 定义目录plugins在不会被打包的目录resources下
- 定义插件json配置
- 启动时扫描插件目录获取所有配置文件
- 根据插件配置中的启用 判断是否是有效配置 去加载插件
- 正常插件:以约定好的方式 加载界面文件(注入默认的预加载脚本 封装代码 防止注册事件影响到主进程已注册的) 加载入口脚本文件到主进程中 注入electron api 以及窗口对象 并return一个unload 卸载事件的脚本
- 开发中的插件:选中正在开发的插件目录 配置有效性检测 检测配置中界面是端口开发还是本地文件开发 加载脚本注入参数 监听文件变动:界面端口运行不检测vue tsx文件 默认不检测static以及node_modules文件夹变动 每次文件变动卸载插件+加载插件 热更新保证效果
实现思路
已记载插件列表字典+加载插件函数+卸载插件函数 +开发插件对象(插件配置、插件路径、文件变动监听器(chokidar)) 注册事件 1.获取插件列表:用于加载插件 已加载的不处理 未加载的添加进插件列表里 2.插件设置:开启或关闭插件 3.开发插件开启:选择目录后开启开发插件模式 4.开发插件关闭:关闭开发插件模式 函数 加载插件函数:根据模式 创建对应的操作 是否要监听文件变动 以及窗口加载界面的方式是读取本地还是窗口 发起更新列表请求 卸载插件函数:调用注入脚本的upload函数并且移除窗口 发起更新列表请求
核心代码
javascript
let PLUGINS = {}
let DEV_PLUGINS = null
let DEV_PLUGINS_PATH = null
let DEV_PLUGINS_WATCH = null
let PLUGIN_SETTING = "setting.json"
// 卸载插件
const unLoadPlugin = (setting) => {
if (!PLUGINS[setting.name]) return
PLUGINS[setting.name].js && PLUGINS[setting.name].js.unload && PLUGINS[setting.name].js.unload()
killWin(PLUGINS[setting.name].win)
PLUGINS[setting.name] = null
DataListener.broadcast(api.UPDATE.TOOLS)
}
// 加载插件
const LoadPlugin = async (setting, dev = false) => {
if (PLUGINS[setting.name]) return
let type = setting.type
if (type == "window") {
let electron = setting.electron || { width: 400, height: 300, webPreferences: {} }
electron.webPreferences.preload = path.join(__dirname, '../../resources/js/preload.mjs')
let win = new BrowserWindow({ ...electron, parent: Application.window })
win.on("ready-to-show", () => {
electron.center && win.center()
})
win.on("closed", () => {
!dev && unLoadPlugin(setting)
})
win.setMenu(null)
if (dev) {
DEV_PLUGINS_WATCH && DEV_PLUGINS_WATCH.close()
let ignored = [path.join(DEV_PLUGINS_PATH, "node_modules"), path.join(DEV_PLUGINS_PATH, "static"), path.join(DEV_PLUGINS_PATH, "*.mjs")]
let dev = setting.dev || { local: true, port: null, devTools: true }
if (dev.local) {
DEV_PLUGINS_WATCH = chokidar.watch(DEV_PLUGINS_PATH, { ignored })
win.loadFile(path.join(DEV_PLUGINS_PATH, setting.main))
} else {
//TODO 特殊处理preload
ignored.push(path.join(DEV_PLUGINS_PATH, "/*.vue"))
ignored.push(path.join(DEV_PLUGINS_PATH, "/*.tsx"))
DEV_PLUGINS_WATCH = chokidar.watch(DEV_PLUGINS_PATH, { ignored })
win.loadURL(`http://127.0.0.1:${dev.port}`)
}
DEV_PLUGINS_WATCH.on("change", (et, fn) => {
unLoadPlugin(setting)
LoadPlugin(setting, true)
})
dev.devTools && win.webContents.openDevTools()
} else {
win.loadFile(path.join(PLUGINS_PATH, setting.name, setting.main))
}
electron.bounds && !win.setBounds(electron.bounds)
PLUGINS[setting.name] = {
win,
js: await LoadScript(setting, win)
}
}
DataListener.broadcast(api.UPDATE.TOOLS)
}
插件主进程入口函数
javascript
import moment from "moment"
export default async function (electron, window) {
let test = await LoadScript("./static/test.js")
let { ipcMain } = electron
console.log(moment);
console.log(test);
let events = {
"check-nvm": (event) => {
}
}
for (let kv in events) {
ipcMain.handle(kv, events[kv])
}
return {
unload() {
for (let kv in events) {
// console.log(1);
ipcMain.removeHandler(kv)
}
}
}
}

最核心的问题
开发模式下的时候遇到的问题 1.加载脚本的时候 出现了Import 缓存问题 2.使用import引用js脚本时 也会缓存 编写工具类的话就会有问题 3.加载node_modules的问题
踩坑点 缓存问题 头部引入 node_modules的问题
直接使用await import("入口文件")加载时 就算重新await import v8还是会自己缓存起来 electron-vite是已esm的形式加载的只能Import 没办法通过require的缓存去删除
通过动态读取脚本数据
javascript
let script = fs.readFileSync(path.join(setting.path, setting.script)).toString()
const nonce = Date.now() + Math.random().toString(36).slice(2);
const freshModule = await import("data:text/javascript,"+encodeURIComponent(injectedCode)+"#"+nonce);
return freshModule.default(this.EletronPack(setting.name), win);
踩坑了:这个方式引入以后 确实在加载卸载插件重载了代码 但是出现一个问题 由于是动态创建的脚本 导致没有上下文对象 没办法加载node_modules数据以及引用路径下的脚本无法读取
由于esm没有__dirname 导致我想通过路径去await import()脚本都做不到 import.meta.url更是指向之进程的根路径
那我们只能进行代码改造 我们能拿到原始脚本数据 那我们就可以注入一些脚本
在开头注入 一个__dirname 让脚本知道自己位置
javascript
const head = (path) => {
return `
var __dirname = "${encodeURIComponent(path)}";
__dirname=decodeURIComponent(__dirname);`
}
let injectedCode=`${head} ${script}`
然后我们在脚本里 引用一个同级的脚本 await import( path.join(__dirname,"xxx.js"))
什么?又报错了 一看 噢不支持盘符路径 那我就一个
javascript
import { pathToFileURL } from 'node:url';
await import(pathToFileURL(path.join(__dirname,"test.js")))
ok 加载完毕 改改代码保存一下试试 读出来了 那我再改改引用的脚本test.js 欸 怎么没有变化 又缓存了
解决方法:那我给这个脚本再注入一个方法LoadScript
javascript
const loadMethod = () => {
return `
const LoadScript=async (target_path)=> {
let script = fs.readFileSync(scriptPath).toString();
const nonce = Date.now() + Math.random().toString(36).slice(2);
const freshModule = await import("data:text/javascript,"+encodeURIComponent(injectedCode)+"#"+nonce);
return freshModule.default;
}
`
}
let injectedCode=`${head} ${loadMethod()} ${script}`
在引用的脚本里 引用一个脚本 并更改那个脚本 ok 没有问题
javascript
let test = await LoadScript("./static/test.js")
顶部import问题
当我开开心心准备编写逻辑的时候发现 导入fs path库 提示我重复导入库 那我就一个正则 把获取到的script的import先去除
javascript
script = script.replace(/import\s+fs\s+from\s+['"]node:fs['"]/g, "")
script = script.replace(/import\s+path\s+from\s+['"]node:path['"]/g, "")
script = script.replace(/import\s+path\s+from\s+['"]node:url['"]/g, "")
node_modules 无法正确引入
动态脚本有一个很大的问题 就是没有上下文 导致它不知道从哪里去引入node_modules 让开发变得很麻烦 那怎么办? 试了好多种方式 LoadScript是无法正确加载的 通过AI获取到解决方法 就是先创建临时文件让它有上下文 加载到内存后 删除临时文件
继续修改方法 并改进 会出现的问题 比如脚本引用的脚本还想引用脚本的问题 以及它们的node_modules引入
javascript
// 动态读取脚本
const LoadScript = async (setting, win) => {
// 动态读取插件下的脚本
let script = fs.readFileSync(path.join(setting.path, setting.script)).toString()
const head = (path) => {
return `
import fs from "node:fs"
import path from "node:path"
import url from "node:url";
var __dirname = "${encodeURIComponent(path)}";
__dirname=decodeURIComponent(__dirname);`
}
const loadMethod = () => {
return `const LoadScript=async (target_path)=>{
let scriptPath=path.join(__dirname, target_path);
let name=scriptPath.substring(scriptPath.lastIndexOf("\\\\")+1,scriptPath.lastIndexOf("."));
let script = fs.readFileSync(scriptPath).toString();
script = script.replace(/import\s+fs\s+from\s+['"]node:fs['"]/g, "")
script = script.replace(/import\s+path\s+from\s+['"]node:path['"]/g, "")
script = script.replace(/import\s+url\s+from\s+['"]node:url['"]/g, "")
// 去除路径文件名
scriptPath=scriptPath.substring(0,scriptPath.lastIndexOf("\\\\"));
const head = (path) => {
return "var __dirname ='"+encodeURIComponent(path)+"';"+"__dirname=decodeURIComponent(__dirname);"
}
const injectedCode=head(scriptPath)+script;
const tmpFile=path.join(scriptPath,name+Date.now()+".mjs");
try {
fs.writeFileSync(tmpFile, injectedCode, "utf-8");
const freshModule = await import(url.pathToFileURL(tmpFile).href);
let target=typeof(freshModule.default)=="function"?freshModule.default(LoadScript):freshModule.default
return target
} catch (err) {
console.log(err);
return null
}finally{
fs.rmSync(tmpFile);
}
}
`
}
script = script.replace(/import\s+fs\s+from\s+['"]node:fs['"]/g, "")
script = script.replace(/import\s+path\s+from\s+['"]node:path['"]/g, "")
script = script.replace(/import\s+path\s+from\s+['"]node:url['"]/g, "")
const injectedCode = `
${head(setting.path)}
${loadMethod()}
${script}
`;
const tmpFile = path.join(setting.path, `.temp-${Date.now()}.mjs`)
fs.writeFileSync(tmpFile, injectedCode, "utf-8");
try {
// 2. 用真实文件路径 import,Node 会自动解析 pluginDir/node_modules
const mod = await import(pathToFileURL(tmpFile).href);
return mod.default(this.EletronPack(setting.name), win)
} finally {
fs.rmSync(tmpFile);
}
}
解决了所有踩坑的地方 终于可以安心开发插件了
