Electron:学习&应用

162 阅读11分钟

好记性不如烂笔头。

前言

大家好,我是黑翼。本文是自己对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:优化卸载代码&自动上传代码;

参考

Electron入门及原理浅析

前端必学的桌面开发:Electron+React开发桌面应用(1W多字超详细)