electron 桌面端业务中的小结(坑)(二)

原创
2021/04/13 13:35
阅读数 752

背景

接着上一篇文章

在开发 electron 桌面端的时候,会遇到各种各样的坑,稍微总结下。

  • 杀毒软件破坏检查
  • 防止debug调试
  • 客户端崩溃报告
  • 提升客户端启动速度
  • 性能监测分析
  • 延迟加载模块
  • 滚动条样式统一
  • browserWindow 错误监听
  • browserWindow a 标签,打开默认浏览器
  • electron-中无法使用-jquery、requirejs、meteor、angularjs。
  • electron-bridge
  • 代理设置
  • 系统版本比较(mac)
  • 多窗口管理
  • 类似vscode无缝升级安装

杀毒软件破坏检查

对于内部的一些核心的文件,可以通过白名单机制,来查看文件是否存在,如果不存在则报告软件被破坏,直接退出。

const getBinaryFileCheckList = ()=>{
    const dir = [];
    // 比如 network-interface 这个包是必须的。
    const network = require.resolve("network-interface/package"),

    dir.push(network);
    
    return dir;
}

const binaryFileCheckList = getBinaryFileCheckList();


<!--检查--> 
for (let e = 0; e < binaryFileCheckList.length; e++) {
    const n = binaryFileCheckList[e];

    if (!fs.existsSync(n)) {
        dialog.showErrorBox("启动失败", "应用文件损坏,可能是杀毒软件导致,请重新下载安装");
        // 直接 exit
        electronApp.exit(1);
        break
    }
}

防止debug调试

需要检查 argv 的参数上是否存在 chrome 调试的关键词 例如inspect 或者debugging 等。

const runWithDebug = process.argv.find(e => e.includes("--inspect") || e.includes("--inspect-brk") || e.includes("--remote-debugging-port"));

if(runWithDebug){
    // 直接退出。
    electronApp.quit()
}

客户端崩溃报告

可以借助第三方插件来辅助客户端崩溃报告

@sentry/electron

中文文档

https://www.yuque.com/lizhiyao/dxydance/sentry-javascript-readme-cn

提升客户端启动速度

提升客户端的启动速度有好几个方面去着手。

使用 V8 缓存数据

electorn 使用 V8 引擎运行 js,V8 运行 js 时,需要先进行解析和编译,再执行代码。其中,解析和编译过程消耗时间多,经常导致性能瓶颈。而 V8 缓存功能,可以将编译后的字节码缓存起来,省去下一次解析、编译的时间。

使用v8-compile-cache缓存编译插件的代码

v8-compile-cache 的使用非常简单,在需要缓存的代码中,添加一行代码即可:

require('v8-compile-cache')

v8-compile-cache 默认缓存到临时文件夹 <os.tmpdir()>/v8-compile-cache-<V8_VERSION> 下,电脑重启后,该文件会被清除掉。

如果希望缓存永久化,可以通过环境变量 process.env.V8_COMPILE_CACHE_CACHE_DIR 来指定缓存文件夹,避免电脑重启后删除。另外,如果希望项目的不同版本对应的缓存不同,可以在文件夹名中加入代码版本号(或其他唯一标识),以此保证缓存和项目版本完全对应。当然,这也意味着项目的多个版本有多份缓存。为了不占用过多磁盘空间,在程序退出时,我们需要删除其他版本的缓存。

性能监测分析

主进程,可以用 v8-inspect-profiler 进行性能监测。生成的 .cpuprofile 文件,可以用 devtools 上的 Javascript Profiler 进行分析。如果用 fork 等方法启动了子进程,也可以用相同的方法监测,只需要设置不同的监测端口。

v8-inspect-profiler

设置启动命令,添加参数 --inspect=${port},设置主进程的 v8 调试端口。

// package.json
{
    "name": "test",
    "version": "1.0.0",
    "main": "main.js",
    "devDependencies": {
        "electron": "9.2.1"
    },
    "scripts": {
        "start": "electron . --inspect=5222"
    },
    "dependencies": {
        "v8-inspect-profiler": "^0.0.20"
    }
}

延迟加载模块

项目的一些依赖模块,是在特定功能触发时才需要使用。所以,没有必要在应用启动时立刻加载,可以在方法调用时再加载。

优化前

// 导入模块
const xxx = require('xxx');

export function share() {
    ...
    // 执行依赖的方法
    xxx()
}

优化后

export function share() {
    // 导入模块
    const xxx = require('xxx');

    ...
    // 执行依赖的方法
    xxx()
}

滚动条样式统一

对于windowmacOs系统里面,默认的滚动轴的宽度是不一样的,先获取滚动轴宽度

function getScrollbarWidth() {
    const div = document.createElement('div');
    div.style.visibility = 'hidden';
    div.style.width = '100px';
    document.body.appendChild(div);
    const offsetWidth = div.offsetWidth;
    div.style.overflow = 'scroll';
    const childDiv = document.createElement('div');
    childDiv.style.width = '100%';
    div.appendChild(childDiv);
    const childOffsetWidth = childDiv.offsetWidth;
    div.parentNode.removeChild(div);
    return offsetWidth - childOffsetWidth;
}

然后根据 getScrollbarWidth 做不同设置。

例如:

<!-- 滚动条上的滚动滑块 -->
::-webkit-scrollbar-thumb{
     background-color: rgba(180, 180, 180, 0.2);
     border-radius: 8px;
}

::-webkit-scrollbar-thumb:hover{
    background-color: rgba(180, 180, 180, 0.5);
}

<!-- 滚动条轨道 -->
::-webkit-scrollbar-track {
    border-radius: 8px;
}

<!-- 整个滚动条 -->
::-webkit-scrollbar{
    width: 8px;
    height: 8px;
}

document.onreadystatechange=(()=>{
 if("interactive" === document.readyState){
    // 处理逻辑
 }
})

browserWindow 错误监听

主要是监听 unhandledrejectionerror 事件

error

window.addEventListener('error',(error)=>{

    const message = {
        message: error.message,
        source: error.source,
        lineno: error.lineno,
        colno: error.colno,
        stack: error.error && error.error.stack,
        href: window.location.href
    };
    
    // 通过 ipcRender 发送到 主进程 进行日志记录。
    ipcRenderer.send("weblog", n)
},false)

unhandledrejection

window.addEventListener('unhandledrejection',(error)=>{
    if(!error.reason){
        return;   
    }
    
    const message = {
           message: error.reason.message,
            stack: error.reason.stack,
            href: window.location.href
    }
    
    <!-- 通过 ipcRender 发送到 主进程 进行日志记录。-->
    ipcRenderer.send("weblog", n)

},false)

browserWindow a 标签,打开默认浏览器

业务上面,一般会在 browserWindow 页面上面,会存在a标签,这个时候,如果在electron容器里面,就需要做拦截,并通过默认浏览器打开。

document.addEventListener('click',(event)=>{
    const target = event.target;
    if(target.nodeName === 'A'){
        if(event.defaultPrevented){
            return;
        }
        if(location.hostname){
            event.preventDefault();
        }
        
        if(target.href){
            shell.openExternal(target.href);
        }
        
    }
},false);

暴露一个全局的 打开浏览器的方法

window.openExternalLink = ((r)=>{

    shell.openExternal(r)
});

electron-中无法使用-jquery、requirejs、meteor、angularjs。

因为 Electron 在运行环境中引入了 Node.js,所以在 DOM 中有一些额外的变量,比如 module、exports 和 require。 这导致 了许多库不能正常运行,因为它们也需要将同名的变量加入运行环境中。

两种方式,

  1. 一种就是通过配置 webPreferences.nodeIntegrationfalse ,通过禁用node.js
  2. 通过在electron-bridge.js 里面 最头部 delete window.require;,delete window.exports;,delete window.module; 方式
// 在主进程中.
const { BrowserWindow } = require('electron')
const win = new BrowserWindow(format@@
  webPreferences: {
    nodeIntegration: false
  }
})
win.show()

<head>
<script>
window.nodeRequire = require;
delete window.require;
delete window.exports;
delete window.module;
</script>
<script type="text/javascript" src="jquery.js"></script>
</head>

electron-bridge

通过bridge 向 browserWindow 注入 electron 额外的 api

const {ipcRenderer: ipcRenderer, shell: shell, remote: remote, clipboard: clipboard} = require("electron"),

<!-- process 里面参数--> 
const processStaticValues = _.pick(process, ["arch", "argv", "argv0", "execArgv", "execPath", "helperExecPath", "platform", "type", "version", "versions"]);


module.exports = (() => ({
    ipcRenderer: ipcRenderer, // ipc renderer
    shell: shell, // shell
    remote: remote, //
    clipboard: clipboard,
    process: {
        ...processStaticValues,
        hang: () => {
            process.hang()
        },
        crash: () => {
            process.crash()
        },
        cwd: () => {
            process.cwd()
        }
    }
}));

代理设置

对于代理设置,一般有两种模式:

  • PAC
  • HTTP

PAC

直接输入地址 Protocol://IP:Port

HTTP

对于HTTP 模式下,有

  • HTTP
  • SOCKS4
  • SOCKS5

输入 Protocol://IP:Port

系统版本比较(mac)

推荐使用semver 来。

多窗口管理

推荐electron-windows,支持动态创建窗口。

地址

类似vscode无缝升级安装

大体思路:先挂载 dmg, 找到挂载目录,在 mac 下是 /Volumes 目录下; 删除 /Applications 下的 app, 将 /Volumes 下的 app 拷贝到 /Applications 目录下; 再卸载 dmg; 重启应用即可,该方法可实现类似无缝更新的效果。

主要借助于 hdiutil 实现的、

主要分为六个步骤:

  • which hdiutil
  • hdiutil eject [/Volumes/appDisplayName latestVersion]
  • hdiutil attach [latest Dmg Path]
  • mv [local App Path] [temp dir]
  • cp -R [latest app path] [local app path]
  • hdiutil eject [/Volumes/appDisplayName latestVersion]

which hdiutil

查看 hdiutil 可执行文件 是否存在。

hdiutil eject [/Volumes/appDisplayName latestVersion]

卸载 [/Volumes/appDisplayName latestVersion] 下面的文件 。

hdiutil attach [latest Dmg Path]

安装 dmg 文件

mv [local App Path] [temp dir]

将 旧的本地app目录 移动到 tempDir 目录中。

cp -R [latest app path] [local app path]

将 latest app path 文件下的所有文件,都复制到原本的app 目录下面。

hdiutil eject [/Volumes/appDisplayName latestVersion]

再次 卸载 [/Volumes/appDisplayName latestVersion] 下面的文件

每一步下来,如果都成功了,则成功了。

实例代码。

const path = require("path");
const os = require('os');
const {waitUntil, spawnAsync} = require('../../utils');
const {existsSync} = require('original-fs');

const getMacOSAppPath = () => {
    const sep = path.sep;
    const execPathList = process.execPath.split(sep);
    const index = execPathList.findIndex(t => 'Applications' === t);
    return execPathList.slice(0, index + 2).join(sep);
};

module.exports = (async (app) => {
    const {appDisplayName} = app.config;

    const {latestVersion, latestDmgPath} = app.updateInfo;
    //
    const macOsAppPath = getMacOSAppPath();
    // temp dir
    const tempDir = path.join(os.tmpdir(), String((new Date).getTime()));

    const appDisplayNameVolumesDir = path.join('/Volumes', `${appDisplayName} ${latestVersion}`);
    //
    const latestAppPath = path.join(appDisplayNameVolumesDir, `${appDisplayName}.app`);

    // step 1 which hdiutil
    // /usr/bin/hdiutil
    try {
        const hdiutilResult = await spawnAsync('which', ['hdiutil']);

        if (!hdiutilResult.includes('/bin/hdiutil')) {
            throw new Error('hdiutil not found');
        }
    } catch (e) {
        app.logger.warn(e);
        return {
            success: false,
            type: 'dmg-install-failed'
        }
    }

    // step 2 hdiutil eject appDisplayNameVolumesDir
    try {
        await spawnAsync("hdiutil", ["eject", appDisplayNameVolumesDir])
    } catch (e) {
        e.customMessage = '[InstallMacOSDmgError] step2 volume exists';
        app.logger.warn(e);
    } finally {
        const result = await waitUntil(() => !existsSync(latestAppPath), {
            ms: 300,
            retryTime: 5
        });
        if (!result) {
            app.logger.warn('[InstallMacOSDmgError] step2 volume exists');
            return {
                success: false
            }
        }
    }

    //step 3 hdiutil attach latestDmgPath
    try {
        await spawnAsync('hdiutil', ['attach', latestDmgPath])
    } catch (e) {
        e.customMessage = '[InstallMacOSDmgError] step3 hdiutil attach error';
        app.logger.warn(e);
    } finally {
        const result = await waitUntil(() => !existsSync(latestAppPath), {
            ms: 300,
            retryTime: 5
        });

        if (!result) {
            app.logger.warn('[InstallMacOSDmgError] step3 hdiutil attach fail');
            return {
                success: false
            }
        }
    }

    // step 4 mv
    try {
        await spawnAsync('mv', [macOsAppPath, tempDir]);
    } catch (e) {
        e.customMessage = '[InstallMacOSDmgError] step4 mv to tmp path error';
        app.logger.warn(e);
    } finally {
        const result = await waitUntil(() => !existsSync(tempDir), {
            ms: 300,
            retryTime: 5
        });

        if (!result) {
            app.logger.warn('[InstallMacOSDmgError] step4 cp to tmp path fail');
            return {
                success: false,
                type: "dmg-install-failed"
            }
        }
    }

    // step 5
    try {
        await spawnAsync('cp', ['-R', latestAppPath, macOsAppPath])
    } catch (e) {
        e.customMessage = '[InstallMacOSDmgError] step5 cp to app error';
        app.logger.warn(e);
    } finally {
        const result = await waitUntil(() => !existsSync(macOsAppPath), {
            ms: 300,
            retryTime: 5
        });
        if (!result) {
            app.logger.warn('[InstallMacOSDmgError] step5 cp to app fail');
            await spawnAsync('mv', [tempDir, macOsAppPath]);
            return {
                success: false,
                type: "dmg-install-failed"
            }
        }
    }

    // step 6
    try {
        await spawnAsync('hdiutil', ['eject', appDisplayNameVolumesDir])
    } catch (e) {
        e.customMessage = '[InstallMacOSDmgError] step6 hdiutil eject fail';
        app.logger.warn(e);
    }

    return {
        success: true
    }

});

项目地址

为此,我将业务系统里面用到的各个客户端需要的功能,剥离了出来,新建了一个 template 方便新业务系统的开发。

编码不易,欢迎star

https://github.com/bosscheng/electron-app-template

github

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部