好记性不如烂笔头。
前言
大家好,我是黑翼。本文是自己对electron 的学习总结。持续更新完善,不喜勿喷。
简介
Electron是一个开源的框架,它允许开发者使用Web技术(JavaScript, HTML, CSS)来构建跨平台的桌面应用。Electron是由GitHub开发并维护的,它的目标是让开发桌面应用的过程变得更简单。
Electron的核心是由Chromium(实验性chrome)和Node.js组成。这意味着你可以在Electron应用中使用最新的Web技术,同时也可以使用Node.js的API来访问底层的操作系统功能。
使用Electron开发的应用可以在Windows、Mac和Linux上运行,这使得Electron成为了开发跨平台桌面应用的理想选择之一。
为什么选择electron?
其实原因很简单,因为我们都是JSer。‘不要跟前端说什么Qt、nw.js,老夫一把梭,一套JS走天下。’
当然,electron也有其优势:
1、跨平台:在无需修改太多代码的情况下,Electron就可以让应用就可以在Windows、Mac和Linux上正常运行,大大减少了开发、维护的工作量。
2、使用Web技术:Electron允许你使用熟悉的Web技术(HTML、CSS和JavaScript)来开发桌面应用。对JSer而言,成本相对低。
3、访问底层API:由于Electron内置了Node.js,所以可以在Electron应用中直接访问文件系统、操作系统等底层功能。
4、生态成熟:Electron有着活跃的开发者社区,有大量的学习资源和第三方库。此外,由于Electron是由GitHub开发并维护的,所以更新、bug修复也非常及时。
架构
Electron通过将Chromium和Node.js合并到同⼀个运⾏时环境中,并将其打包为Mac, Windows和Linux系统下的应⽤来实现这⼀⽬的。
Electron本质就是提供了一个浏览器的壳子,用于运行我们的web应用,但是我们的代码具有更强大的功能。 JavaScript 可以访问文件系统,用户 shell 等。 这允许您构建更高质量的本机应用程序,但是内在的安全风险会随着授予您的代码的额外权力而增加。同时也内置了Nodejs环境,因此我们的页面也可以调用Node API。
更具体地:
1、Chromium :为Electron提供了强大的UI能力,可以不考虑传统浏览器兼容性的情况下,利用强大的Web生态来开发界面;
2、Node.js:让Electron有了底层的操作能力(比如文件读写、os读取),并可以使用大量开源的npm包来完成开发需求。
3、Native API:Native API让Electron有了跨平台和桌面端的原生能力(比如说窗口、托盘、消息通知这些能力)。
Electron就是通过这三者的巧妙组合,让我们开发跨平台应用变的十分高效。当然,这三者的组合,同时也带来了一些缺点:
1、应用比较大。因为不管项目的大小,Chromium、Node.js、Native API这些东西都必须打包,而这些依赖就导致了软件包会比较大。
2、性能不如原生。Electron 应用实际上是运行在一个 Chromium 浏览器实例中,这会带来一些额外的性能开销。
3、内存占用较大:卡、启动慢,新开一个进程,起步价就是一个NodeJS的内存开销;
进程与通信
在 Electron 中,进程主要分为两种:主进程(Main Process)和渲染进程(Renderer Process),具体来说:
1. 主进程:在 Electron 中,主进程是负责创建浏览器窗口的进程。主进程可以使用 Electron 提供的所有原生 API。一个 Electron 应用总是有且只有一个主进程(package.json中main字段的入口文件)。主进程通过创建 BrowserWindow 实例来创建渲染进程(网页),当一个 BrowserWindow 实例被销毁时,相应的渲染进程也会被终止。
2. 渲染进程:渲染进程负责运行用户界面的部分,即 web 页面。每个 Electron 应用可以有多个渲染进程,这与浏览器的多标签页非常相似。渲染进程是无法直接访问原生资源,但是渲染进程可以通过与主进程进行通信,让主进程对文件系统、操作系统等底层能力进行交互。
既然electron含有主进程、渲染进程,那么他们之间是如何通信的呢?
Electron使用的通信方式是IPC(Inner-Process Communication)。 Electron 中提供了 ipcMain、ipcRender 作为主进程以及渲染进程间通讯的桥梁,该方式属于 Electron 特有传输方式,不适用于其他前端开发场景。
具体来说,他们的职责分别是:
1. ipcMain:在主进程中使用,用来接收、发送渲染进程的信息。
2. ipcRenderer:在渲染进程中使用,用来接受、发送主进程的信息。
而渲染进程与主进程之间的通信,可以有以下几种方式(不限于):
1、send & on:这两个模块都提供了 send 和 on 两个方法;
2、invoke & handle:支持promise返回值。invoke为ipcRenderer的方法,handle为ipcMain的方法;
3、remote:可以让渲染进程直接调用主进程中的方法,但是存在性能问题,谨慎使用;
通常使用方法1或者方法2,而要使渲染进程使用这些方法,可以使用preload.js注入,具体代码可见预加载&上下文隔离。
生命周期
像Vue、React一样,electron应用的运行过程也有着自己的生命周期,在不同的生命周期中可以做对应的事情。
下面介绍一些常用的生命周期,electron的生命周期通过electron中的app实例监听,如app.on('ready', () => {})这种使用方式。
ready:当 Electron 初始化完成并且应用程序准备好创建浏览器窗口时,通常用于初始化应用程序的主要界面和一些基础设施。比如,在 ready 事件中创建主窗口和初始化托盘。
window-all-closed:所有应用程序窗口都被关闭时触发。在此事件中通常用于在应用程序完全退出之前保留某些功能。示例:在window中点击‘关闭’图片,只是让应用最小化托盘。
activate:在点击macOS图标或者任务栏图标(Windows)时运行。通常用于重新创建窗口或者恢复最小化的窗口。
will-quit:在应用程序退出前触发。
quit:应用程序即将退出时触发。
second-instance:在应用尝试启动第二个实例时触发。 当用户尝试启动第二个应用实例时,可以在 'second-instance' 事件的监听器中进行相关处理,例如激活主窗口的show、最大化等函数;
更多的生命周期函数请参考官方文档进行使用。
打包
基础配置
当项目开发完成后,还需要将项目打包成可执行文件,然后丢给QA进行测试。通常,如果对桌面端&electron这块不熟悉、没有经验的同学(我也是),在这里会遇见很多坑。
使用方式:electron-builder,这里不对其他的库进行额外介绍。
打包配置: package.json 中的 build 字段;
directories: {
output: "./release/release",
app: vueOutDir,
},
files: ["**"],
copyright: 'CopyRight @.. 2003',
// 打包后的软件名
productName: "**",
appId: "**.**.**",
// 加密
asar: true,
extraResources: "./resource/release",
// win要求256*256的icon/png,建议使用png
win: {
// target: ["zip", "nsis"],
// 64位系统默认兼容32位系统,所以只需要打包为32位即可
icon: "./public/logo.ico",
target: [
{
target: "zip",
arch: [
"ia32",
]
},
{
target: "nsis",
arch: [
"ia32",
]
},
]
},
// mac要求512*512的icon
mac: {
icon: "./public/logo.ico",
category: "public.app-category-productivity",
artifactName: "${productName}_${version}.${ext}", // 应用程序包名
target: ["dmg", "zip"],
},
nsis: {
// 是否一键安装,建议为 false,可以让用户点击下一步、下一步、下一步的形式安装程序,如果为true,当用户双击构建好的程序,自动安装程序并打开,即:一键安装(one-click installer)
oneClick: false,
// 是否开启安装时权限限制(此电脑或当前用户)
// perMachine: true,
// 允许修改安装目录,建议为 true,是否允许用户改变安装目录,默认是不允许
allowToChangeInstallationDirectory: true,
// 卸载时删除用户数据
deleteAppDataOnUninstall: true,
// 安装图标
// installerIcon: 'build/installerIcon_120.ico',
// 卸载图标
// uninstallerIcon: 'build/uninstallerIcon_120.ico',
// 安装时头部图标
// installerHeaderIcon: 'build/installerHeaderIcon_120.ico',
// 创建桌面图标
createDesktopShortcut: true,
// 创建开始菜单图标
// createStartMenuShortcut: true
include: 'installer.nsh'
}
},
加签
当应用可以正常打包生成exe后,当你安装未加签的应用时,会出现‘信任’问题。这个信任问题,有2种来源:1、第三方厂家,如QQ电脑管家、360安全卫士;2、公司内部的。很不巧,我做过的项目属于第2种,所以直接找公司相关部门进行加白,然后就行了。
这里之所以提这一点,是因为可能后续有项目会用到,知道有这个问题。
自动化上传
通常,在应用开发、测试、升级迭代等过程中,会产生大量的测试包、灰度包、线上包。为了管理好这些包,我们从工程化的角度出发,采用npm scripts实现自动化上传。这样就不用一直手动上传,减少重复性工作,提高效率。
const fs = require('fs');
const path = require('path');
const isProd = process.env.NODE_DEV === 'prod';
const bucket = isProd ? 'fe-prod' : 'fe-test';
const bucketDir = '/electron/';
const getConfig = () => {
if (isProd) {
return {
accessKey : 'prodKey',
accessVal : 'prodVal',
// ...其他配置
}
} else {
return {
accessKey : 'testKey',
accessVal : 'testVal',
// ...其他配置
}
}
}
const upload2S3 = async (filePath) => {
try {
const fileName = `${bucketDir}${path.basename(filePath)}`;
// s3:上传对应的第三方库
const client = new S3({
...getConfig(),
bucket,
})
const res = await client.multipartUpload(fileName, filePath, {
process(p) {
console.log(p)
}
})
if (res && res.code === 200) {
console.log('oh success!!')
}
} catch (error) {
throw 'error';
}
}
const bootstrap =async () => {
console.log('bigin.....')
const filePath = path.join(__dirname, '../release/release');
const fileList = fs.readdirSync(filePath, {
withFileTypes: true
})
const targets = [];
fileList.forEach((item) => {
// .....符合条件的
targets.push(path.join(filePath, item.name));
})
await Promise.all(targets.map(file => upload2S3(file)));
console.log('end.....');
}
bootstrap();
优化
预加载&上下文隔离
preload脚本包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 preload脚本虽然是运行在渲染器的环境中,但却定义于主进程的上下文,因此能访问 Node.js API ,这也就导致了preload拥有了更多的功能。
上下文隔离,则是考虑安全性问题,可确保preload 脚本和Electron的内部逻辑都在与您在webContent网页的上下文中运行。这对确保安全性很重要,因为它有助于防止网站访问electron内部或您的预加载脚本可以访问的强大API。上下文隔离 就是为了避免网页和预加载脚本的相互影响,限制网页的权限。
预加载&上下文隔离通常是一块使用的。 通常,可以通过 contextBridge 模块来实现交互
// main.ts
const { BrowserWindow } = require('electron')
//...
const win = new BrowserWindow({
webPreferences: {
preload: './preload.js'
}
})
// preload.ts
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld(
'electron',
{
os: () => getOsInfo(),
ipcRenderer: {
// 发送消息到主进程
sendMessage(channel, ...args) {
ipcRenderer.send(channel, ...args);
},
// 监听主进程发来的消息
on(channel, func) {
ipcRenderer.on(channel, func);
},
removeAllListeners(channel) {
ipcRenderer.removeAllListeners(channel);
},
},
}
)
// 当 DOMContentLoaded 事件触发时,根据应用版本修改title
window.addEventListener('DOMContentLoaded', () => {
const len = document.querySelector('body')?.childNodes.length;
document.title = `electron v${appVersion}`;
});
// 渲染进程:需要在electron应用首页展示本机、应用等信息;
// 通过window.electron可以直接获取信息,而不用再走IPC通信
window.electron.os()
避免同步操作
Electron 可以通过 NodeJS 进行 I/O 操作,但是一定要尽量避免同步 I/O。例如同步的文件操作、同步的进程间通信。频繁的同步操作,会阻塞页面的渲染和事件交互,有可能会成为性能瓶颈。
骨架屏/广告屏
最简单的方式,也是最有效的方式。
骨架屏:在资源未加载完毕之前,先展示页面的骨架。避免用户看到白茫茫的屏幕。
广告屏:在资源未加载完毕之前,先展示广告屏,并行加载页面资源,并可以在广告屏内推广、宣传。
Vue优化
渲染进程侧采用的框架是Vue,针对web页面的优化,有很多:
1、代码分割;
2、优化体积
3、........
面向业务
本部分主要记录自己在electron中做的一些业务需求(针对32&64位Window,不包括mac&Linux),方便后续总结。
打开网页
在electron中,我们需要从浏览器中打开URL链接。由于electron内嵌的是chrome实验版,如果不设置的话,是会在electron内打开的。而要实现从默认浏览器打开,可以这样设置:
mainWindow.webContents.setWindowOpenHandler((detail) => {
shell.openExternal(detail.url);
return { action: 'deny' };
});
快捷键
通常,在开发的时候,我们需要利用快捷键去进行一些操作,如打开控制台等。这种往往只出现在开发环境下,在生产环境下,我们需要屏蔽这种操作。
const isProd = process.env.NODE_ENV === 'production';
const registerDebugShortcut = () => {
globalShortcut.register('CommandOrControl+Shift+L', () => {
win.webContents.openDevTools()
})
}
// main.ts
app.whenReady().then(() => {
!isProd && registerDebugShortcut();
})
app.on('will-quit', () => {
// 注销所有快捷键
!isProd && globalShortcut.unregisterAll()
})
软件卸载
背景如下: 在web端项目中,某模块需要某软件配合使用才能使业务正常使用。但是,在用户电脑里,可能会存在软件不存在、软件版本不对等多种问题,因此,随之引出了这个需求。利用客户端来卸载、重装该软件。
方案:由于在windows电脑中,所有的软件都会被注册表进行信息记录,所以,采用读取注册表的方式进行软件信息获取。
根据个人经验,这里会提供2种方式。
方案1
本方案采用第三方库winreg进行处理(类似的还有regidit 、get-installed-apps),这里给出winreg的部分代码:
示例代码
import Winreg from "winreg";
const regPaths = [
{
hive: Winreg.HKCU,
key: "\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\",
},
{
hive: Winreg.HKLM,
key: "\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\",
},
{
hive: Winreg.HKLM,
key: "\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\",
}
];
const getRegApps = async (hive: string, key: string) => {
const regKeys = new Winreg({
hive,
key,
});
return new Promise((resolve) => {
regKeys.keys((err, keys) => {
console.log(err, keys);
if (err) {
resolve([]);
} else {
resolve(keys);
}
});
});
};
export const getRegAppInfoByField = async (data: Winreg.Registry, key: string) => {
return new Promise((resolve) => {
data.get(key, (err, item) => {
if (err) {
resolve(null);
}
const tmp = item ? item.value : "";
resolve(tmp);
});
});
};
export const checkAppInstalled = async (targetName: string, targetVersion: string) => {
if (process.platform === 'win32') {
for (let i = 0; i < regPaths.length; i++) {
const item = regPaths[i];
try {
const regAppsList = (await getRegApps(item.hive, item.key)) as any;
console.log(regAppsList);
if (regAppsList && regAppsList.length > 0) {
for (let j = 0; j < regAppsList.length; j++) {
const app = regAppsList[j];
const appRegInfo = new Winreg({
hive: item.hive,
key: app.key,
});
console.log(appRegInfo);
if (appRegInfo) {
const name = await getRegAppInfoByField(
appRegInfo,
"DisplayName"
) as string;
const version = await getRegAppInfoByField(
appRegInfo,
"DisplayVersion"
);
const installLocation = await getRegAppInfoByField(
appRegInfo,
"InstallLocation"
);
const uninstallString = await getRegAppInfoByField(
appRegInfo,
"UninstallString"
);
if (name.toLowerCase().includes(targetName) && version === targetVersion) {
return {
name,
version,
uninstallString,
installLocation
}
}
}
}
}
return false;
} catch (error) {
return false;
}
}
}
};
注意,采用方式1可能会遇见报错:Error: spawn system32\reg.exe,这个报错的原因有2种:
1、系统环境变量不存在注册表路径(比如 C:\Windows\System32);
2、项目读取注册表路径,比如绝对路径读成了相对路径;
对于报错1,你可以按住win+r,输入cmd后,通过echo %PATH%查看环境变量,然后通过SETX %PATH% XXXX(电脑的注册表路径),重启电脑后,再次启动项目。
对于报错2,由于网上资源偏少,找了很久资料并没有找到解决方案,因此采用了方案2。
方案2
相比于方案1,本方案采用原生reg命令进行注册表读取。这个方案的难点在于数据解析。因为很多人对这类buffer数据格式都不了解,网上资料也少。
示例代码
// 基础版
const command = `reg query "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"`;
const res = execSync(command);
const stdout = res.toString().split('\r\n');
console.log(stdout);
exec(`reg query "${stdout[1]}"`, (err, stdout) => {
if (err) {
console.error(err)
}
const lines = stdout.split('\n');
// 创建一个空对象来存储解析结果
const result = {};
// 遍历每一行
lines.forEach(line => {
// 使用正则表达式匹配行中的键和值
const match = line.match(/(\S+)\s+REG_\w+\s+(.+)/);
// 如果匹配成功,将键和值添加到结果对象中
if (match) {
const key = match[1];
const value = match[2];
result[key] = value;
}
});
console.log(result);
})
-------
// 优化版
export const parseAppRegInfoStr = (stdout: string) => {
const lines = stdout.split('\n');
// 创建一个空对象来存储解析结果
const result = {};
// 遍历每一行
lines.forEach(line => {
// 使用正则表达式匹配行中的键和值
const match = line.match(/(\S+)\s+REG_\w+\s+(.+)/);
// 如果匹配成功,将键和值添加到结果对象中
if (match) {
const key = match[1];
const value = match[2];
result[key] = value;
}
});
return result;
}
export const checkAppInstalled2 = async (targetName: string, targetVersion: string) => {
if (process.platform === 'win32') {
try {
const commandList = [
"HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
]
for (let i = 0; i < commandList.length; i++) {
const item = commandList[i];
const command = `reg query "${item}"`;
const stdout = execSync(command);
const res = stdout.toString().split('\r\n');
const validRes = res.filter(item => item && item.length > 0);
if (validRes.length > 0) {
for (const appKeyPath of validRes) {
try {
const appRegInfoStr = execSync(`reg query "${appKeyPath}"`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
const appRegInfoObj = parseAppRegInfoStr(appRegInfoStr) as any;
const { DisplayName: name, DisplayVersion: version, InstallLocation, UninstallString } = appRegInfoObj;
if (name.toLowerCase().includes(targetName) && version === targetVersion) {
console.log(name, version);
return {
name,
version,
InstallLocation,
UninstallString
}
}
} catch (error) {
continue;
}
}
return false;
}
}
} catch (error) {
console.log(error)
}
}
}
这里需要注意几点:
1、execSync需要指定utf-8,否则拿到的是buffer数据;
2、注册表的路径里可能存在中文,在reg query时可能会报错;
3、注册表信息可能存在信息不全,比如InstallLocation为空,或者不是全路径;
总结:只要拿到注册表里的软件信息(名称、版本、安装路径、卸载路径等),就可以进行后续的卸载以及安装。
总结
持续整理,未完待续;
更新记录
- 2023.11.05:进程&架构&生命周期;
- 2023.11.11:应用打包;
- 2023.11.19:快捷键&软件卸载;
- 2023.11.26:优化卸载代码&自动上传代码;