今天教你烤一份香喷喷的Electron

20,585 阅读19分钟

开场一把胡椒粉

为什么要学 electron?作为一个 web 前端工程师,一定不会主动去学习这个东西的,真的没必要,做 web 不香吗!嗯,当然,前面的论述是基于你在你的团队中有威望、独立自主(也就是团队大佬级别),否则,乖乖就范吧!像我,两年前,刚入职,web 都没有玩得明白,就被我导师安排去一个人做一个 electron 项目,极其无助和迷茫(有生之年如果我导师看到这篇文章可以微微忏悔一下,好吗!)。

上主菜

什么是 Electron?

Electron 是使用 JavaScript,HTML 和 CSS 构建跨平台的桌面应用程序。本文是基于 windows 系统讲述的。

Electron 为什么能跨平台?

如下图所示,Electron 由 chromium、nodejs、native api 构成。其中,nodejs 是一个基于 Chrome V8 引擎的 JavaScript 运行时,提供了不同系统平台的支持,chromium 是谷歌公司开源的浏览器引擎,同样的也提供了不同系统平台的支持。Electron 开发团队通过继承不同系统的 chromium 和 nodejs,提供一些桌面应用依赖的系统级别 api,实现了 Electron 的跨平台。

Electron 香不香

互联网时代,效率至上。随着互联网的发展,特别是前端技术的发展,前端开发效率、质量等方面取得了非常非常大的进步,同时前端拥有了世界上最大的最好的包管理工具 NPM,为前端开发工程师提供了数不胜数的开箱即用的优质的开发资源,这一切都是其他开发语言所不拥有的。

传统桌面应用开发的弊端

传统桌面客户端开发基本上是使用 C++和 C#,像 C++的 MFC 和 QT、C#的 winform 都是开发桌面 UI 客户端的利器。虽说是利器,但也会有弊端。众所周知,C++和 C#进入的门槛都很高,没个三五年都不能融会贯通,而且这些语言不单单能开发客户端,还能研究实现更加底层的东西如驱动,专注于 UI 开发的人才就更加少了。不像前端,门槛低,成效快。同时,传统的客户端技术开发 UI 界面的周期是很长的,像一个简单的 loading 动画,前端只需要一个 CSS3 动画就能实现,而使用 C++技术开发就要非常久了(除非使用 gif)。所以效率低的事务必将被效率高的事务所取代。

真香定律

Electron 开发继承了 web 前端开发几乎全部的优点:高效、完善的生态、优美的界面...只要你对 Javascript 能无障碍使用,就可以在短时间内上手 Electron。Electron 还可以渲染线上资源,界面资源存于服务器可以实现高效便捷的更新,这对于一些 web 转客户端的需求是非常不错的选择。毫不夸张的讲:Electron 是一个真正面向互联网时代的框架,让客户端开发像 web 开发一样快速地迭代开发、快速地部署更新,香甜可口!

上烤架

安装

    npm install --save-dev electron --arch=ia32|x64|arm64|armv7l

因为安装 Electron 需要下载整个 electron 的资源压缩文件(70MB 左右),第一次安装速度会很慢,建议使用淘宝 npm 仓库镜像:--register=https://registry.npm.taobao.org

arch: ia32 | x64 | arm64 | armv7l

  • ia32: X86 体系结构的 32 位版本,32 位内存,兼容 32 位系统,一般 windows 版本是使用这个能匹配 32 位和 64 位系统
  • x64:64 位微处理器架构及其相应指令集,也是 Intel x86 架构的延伸产品
  • arm64:64 位 ARM 处理器架构,多出现于 Linux 系统
  • armv7l:32 位 ARM 处理器架构,多出现于 Linux 系统

很明显如果项目是多人开发的话,这个--arch 在另外一个人的设备上就丢失了,除非又重新 install 一次 electron,这里可以将这个参数保存在 package.json 中。

    "config": {
        "arch": "ia32"
    }

精湛的烧烤技术

窗口创建

配置入口、配置启动脚本

    //package.json
    {
        "main": "index.js",
        "scripts": {
            "start": "electron ."
        }
    }

Electron 运行时将 package.json 作为其入口文件。下面创建一个窗口:

    const { BrowserWindow } = require('electron');
    let win = new BrowserWindow({ width: 800, height: 600 });

    win.loadURL('https://google.com');

具体的参数含义可以去官方文档查询,这里就不做过多的罗列了!

BrowserWindow 和 BrowserView 有啥区别

BrowserView 被用来让 BrowserWindow 嵌入更多的 web 内容。这里涉及到一个概念 webview。

  • webview 是网页上的元素
  • webwiew 的作用是用来嵌入外来不受信任资源,实现外来资源与本地资源的隔离,保证嵌入资源的安全性
  • 与 iframe 的作用是相似的,但是其与 iframe 的本质区别其是运行 webview 的是一个单独的进程(也就是子窗口)

BrowserView 就是 webview 的替代工具,能让 BrowserWindow 在主进程中动态引入 webview。

loadURL 和 loadFile 的区别

这两者最大的区别是 loadURL 可以加载线上的资源地址而 loadFile 不能,两者都能加载本地的文件地址。

windows7 下出现窗口设置透明属性 transparent 不生效,窗口出现默认背景色(黑色)

windows 系统负责实现窗口透明状态的进程是 dwm.exe 进程,在 win7 非 aero 主体下,该进程处于不工作或停止状态,无法实现窗口透明。规避方式:systemPreferences.isAeroGlassEnabled()

调用窗口实例的程序报错:Object has been destroyed

Electron 在创建窗口的时候会返回窗口的实例对象,让开发在任何时候都可以去操作已经创建的窗口。然而窗口会因为各种原因被销毁关闭,如下表:

正常关闭 非正常关闭
通过窗口 api
任务栏图标右键“关闭窗口”按钮
任务管理器进程列表中关闭对应窗口进程
窗口被一些安全软件关闭拦截
窗口自身的崩溃

无论是正常或者非正常的关闭,如果程序在没有去同步销毁窗口对应的实例对象的情况再次调用窗口的功能属性或功能方法,程序就会抛出 Object has been destroyed 异常,嗯,这时候测试小姐姐就会送你一个严重属性的 bug!防止这个异常的出现有两种方式:

1、马后炮式:在调用窗口属性的时候先判断窗口是否被销毁

    //Electron BrowserWindow 实例对象提供有isDestroyed的方法查询窗口是否被销毁
    if(!window.isDestroyed()){
        window.hide();
    }
    //或
    try {
        window.hide();
    } catch(error => {})

除非你的应用只有一个窗口,用这种方式就没什么问题,但如果你的程序有很多窗口,这种方式就很笨重了,完全没有编程的愉悦感,甚至容易漏写。

2、预防式:监听窗口被销毁,同步将窗口实例对象销毁。BrowserWindow 窗口实例中有监听窗口关闭的事件回调,通过该回调可以进行窗口实例的销毁。下面根据 BrowserWindow 提供的方式实现的代码:

   window.on('closed', () => {
       delete window;
   })

这种代码实现方式看起来就很别扭。

更好的实现方式是依据工厂模式和中介者模式设计一个窗口类,实现窗口的创建、控制窗口实例的访问、窗口实例的同步销毁等功能。

    //WindowController
    class WindowController {
        constructor() {
            this.windowInstances = {}
        }

        //创建窗口,从配置中获取窗口配置
        createWindow(wname) {
            let window = new BrowserWindow(config[wname]);

            //窗口实例保存
            this.windowInstances[wname] = window;

            //监听窗口被销毁
            window.on('closed', ((_wname) => {
               return () => {
                    delete this.windowInstances[_wname];
               }
            })(wname));

            //监听窗口内容崩溃
            window.webContents.on('crashed', ((_wname) => {
                return ()=> {
                    this.windowInstances[_wname].close();
                    this.createWindow(_wname)
                }
            })(wname))
        }

        //获取窗口实例
        getWindow(wname) {
            return this.windowInstances[_wname];
        }
    }
窗口没有完全置顶

创建窗口的时候可以通过设置窗口属性 alwaysOnTop:true 或通过调用窗口实例防范 setAlwaysOnTop 实现窗口的置顶。这种置顶方式实质上是设置窗口的 topMost 属性(详情),一般来说,这个置顶属性已经够用了,但是如果你想让自己的窗口置于所有窗口之上,那这个属性是完全不能支持的。

如 Windows10 系统中的任务管理器中设置的置于顶层,其他 topMost 窗口就不可能跑到它的上面。要实现完全置顶,要引入一个很多 C++大佬都不知道的属性:用户界面特权隔离。这个属性对于窗口的最大作用就是当窗口设置 topMost 时,窗口置于最顶层,在 windows10 下甚至比系统锁屏界面还置顶。但是在 win7 下就不是那回事了(几乎没用),win7 下只能循环置顶,而且效果不好。至于用户界面特权隔离请看:UIAccess

窗口如何置底

Electron 没有提供有相应的窗口置底的方法或 api,但是像一些桌面的常驻窗口实现就需要窗口置底。怎么去实现? 依靠 Electron 自身的窗口配置是无法完全实现的,只能做个简单的样子。 窗口置底有两个现象,一个是不可激活,所以不能调用窗口的 show 方法,应该使用 showInactive;

另外一个是窗口不能获取焦点,如果窗口能获取焦点将会跑到其他的窗口之上。

   focusable: false

有了这两个属性,窗口创建之后就会慢慢沉到底部(因为窗口创建之后不会自动跑到底部,只会在其他窗口激活或获取焦点之后位置替换)。那如何完全置顶?

需要借助第三方势力!系统的桌面也是一个窗口,它就是一个完全置底的窗口,只要把 Electron 中的窗口挂在桌面窗口之下成为它的子窗口,并且设置上面所述的两个属性方法,就实现了完全置底。Electron 中有一个设置父窗口的方法

    /**
    * parent BrowserWindow | null
    * 设置 parent 为当前窗口的父窗口. 为null时表示将当前窗口转为顶级窗口
    /
    win.setParentWindow(parent)

然而桌面窗口不属于 BrowserWindow 的实例。所以需要借助 C++编写的动态链接库实现该功能。此处@C++ 大佬。

如何设置窗口的任务栏图标

一般来说,窗口创建出来之后在任务栏会有窗口对应的窗口图标,该图标在不设置的情况下是默认提取当前窗口所在可执行程序的图标,也就是对应 exe 的图标。

当然我们可以自定义窗口图标

    win.setIcon(iconPath)

但是,仅仅这样设置会有问题

任务栏图标右键菜单上应用名显示不正确以及启用固定到任务栏功能时存留在任务栏的图标不正确(点击这个图标启动的窗口也不知正确)

这时需要使用窗口 api setAppDetails

    win.setAppDetails(options)
    · options Object
        · appId String (可选) - 窗口的 App User Model ID. 该项必须设置, 否则其他选项将没有效果.
        · appIconPath String (可选) -窗口的 Relaunch Icon.
        · appIconIndex Integer (optional) - Index of the icon in appIconPath. Ignored when appIconPath is not set.    Default is 0.
        · relaunchCommand String (可选) - 窗口的 重新启动命令.
        · relaunchDisplayName String (可选) - 窗口的重新启动显示名称.

其中:

  • appId 作为应用的标识,编写格式为“公司名.产品名.版本号”,appId 作为应用的唯一标识,最好不做变动;
  • relaunchDisplayName 是指窗口任务栏图标右键菜单显示的名称;
  • relaunchCommand 是指窗口任务栏图标右键菜单中固定到到任务栏功能启用后点击图标执行的地址,可带参数;

例子:

     let exePath = app.getPath('exe');
     window.setAppDetails({
        appId: 'Juejin.qianduanshaokaotan.v20190118001',
        appIconPath: iconPath,
        appIconIndex: 0,
        relaunchDisplayName: '前端烧烤摊',
        relaunchCommand: '"' + exePath + '"', //加参数的意义是C盘program files文件夹有空格风险,导致路由错误
    });

当程序调用 setAppDetails 之后,会在 C:\Users\xx\AppData\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned\ImplicitAppShortcuts 地址下生成相应的包含快捷方式的文件夹,文件夹名是随 appId 变化而变的。

这个快捷方式是指向可执行程序地址,如桌面快捷方式一样,当可执行程序丢失或卸载时,相对应的跨界方式就会失效,需要手动删除,所以 setAppDetails 生成的快捷方式最好在程序卸载的时候删除,防止带来不好的体验!

如何实现无边框窗口的拖拽移动

有边框窗口对比无边框窗口多了一个工具栏和标题栏,并且标题栏让窗口拥有鼠标拖拽移动的能力。但是无边框窗口没有标题栏,所以其无法拖拽移动,需要代码实现。

第一种方式 ****将要模拟标题栏区域的 Dom 节点样式设置-webkit-app-region:drag,设置该属性之后,该节点内部节点(包好本身)将不响应点击事件,如果想要内部节点响应点击事件,则在内部节点样式设置-webkit-app-region: no-drag。

    <div style="-webkit-app-region: drag;">
        <span>标题栏</span>
        <button style=": no-drag;">按钮</button>
    </div>

但是这种方式实现不了像 360 安全卫士在桌面上便捷功能球,无法做到可拖拽和可点击快速切换。

第二种方式 使用 html 的原生事件实现。这里推荐使用的是 mousedomn + mousemove + mouseup 组合,HTML 拖放(Drag and Drop)当然也是可以,但是相对比来说没那么好控制!

index.html

   <div id="header">我是标题栏</div>

    <script>
        let headerDom = document.getElementById('header');
        let isMoving = false,
            isClick = false,
            startX = 0,
            startY = 0;

        headerDom.onmousedown = (e) => {
            isMoving = false;
            isClick = true;
            startX = e.clientX;
            startY = e.clientY;
        };

        document.onmousemove = (e) => {
            let distanceX = e.clientX - startX;
            let distanceY = e.clientY - startY;

            if (isClick) {
                //窗口移动
                //发送窗口位置到主进程进行处理
                ipcRenderer.send('windowMove', {distanceX, distanceY});
                isMoving = true;
            }
        };

        headerDom.onmouseup = () => {
            isClick = false;
            if (!isMoving) {
                //单纯点击
                //处理点击事件
            } else {
                isMoving = false;
            }
        };
    </script>

ipcMain.js

   ipcMain.on('windowMove', (event, position) => {
       let wx = Math.ceil(position.distanceX + window.getPosition()[0]);
       let wy = Math.ceil(position.distanceY + window.getPosition()[1]);

       if (wx > 0 && wy > 0) {
           window.setPosition(wx, wy);
       }
   })

玩转 chromium 命令行开关

Electron 中的 chromium 内核支撑了窗口的创建和内容的渲染,而 chromium 内核对网页的渲染运行有很多默认设置,比如处于网页内容调用摄像头需要用户授权、音视频自动播放。

Electron 官网上只提供了一些命令行,然而 chromium 提供了非常多的命令(看这里)。

那么怎么知道自己什么时候需要去找这些命令行参数来辅助开发呢?

只要是窗口页面的功能不符合浏览器的设置或标准,都可以尝试去找相对应的 chromium 命令开关来解决实际问题。


    //触摸屏上会出现点击范围扩大的问题,这个可以解决
    app.commandLine.appendSwitch('disable-touch-adjustment', true);
    //chrome 66以上版本屏蔽自动播放限制设置,音视频随心放
    app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
    //触摸屏上禁用双指缩放
    app.commandLine.appendSwitch('disable-pinch', true);
    //关闭网络代理
    app.commandLine.appendSwitch('no-proxy-server', true);
    //关闭浏览器的证书拦截
    app.commandLine.appendSwitch('ignore-certificate-errors', true);

玩转 node-ffi

由于 Electron 是一个专注于 UI 展示的框架,所以对于一些更加底层的功能就无法支持了。这时就要使用到 node-ffi。

node-ffi 是一个 nodejs 调用动态链接库的第三方 npm 模块。

那么啥是动态链接库呢?动态链接库是一种可执行代码的二进制文件,可以被操作系统载入内存执行,像 Windows 系统中的.dll 文件、Linux 系统中的.so 文件。

像上面说过的实现窗口置底的终极方式是让 C++来实现,nodejs 与 C++功能交互就可以通过动态链接库这种形式去实现。当然,最重要的媒介就是 node-ffi。

安装 node-ffi

    $ npm install ffi

因为 node-ffi 的源码是一些 C 语言的文件,安装时需要 node-gyp 进行同步编译,所以安装 node-ffi 之前需要先正确安装 node-fyp。

node-gyp 安装教程看这里

安装 electron-rebuild

    npm install --save-dev electron-rebuild

安装 electron-rebuild 的原因是通过 node-gyp 编译的 node-ffi 模块有可能和项目中使用的 Electron 中的 nodejs 在版本、arch 上存在差异,需要 electron-rebuild 对 node-ffi 进行重新编译方能使用。

安装 ref、ref-array、ref-struct

ref、ref-array、ref-struct 这三个模块都是对 C++一些参数类型的包装,可以在 ffi 定义 dll 的导出函数是充当参数类型使用。

借助 node-ffi 调用动态链接库

具体参数定义可以看官方 github

简单调用

#use-ffi.js
ffi.Library('D://code//myProgram/demo.dll', {
    MyFunction: [ref.types.int, []]
})

函数参数为回调函数

记住回调函数是一种指针类型,所以初始化导出函数参数的时候需要填入 “pointer” 类型

    //定义导出函数
    let dllFunc = ffi.Library('D://code//myProgram/demo.dll', {
        MyFunction: [ref.types.int, ['pointer']]
    });

    //使用ffi.Callback初始化回调函数
    const newCallback = (func) => {
        /**
        * ffi.Callback的第一参数是回调函数的返回类型,第二个是回调函数中的参数类型,第三个是回调函数
        */
        return ffi.Callback(ref.types.void, [], func)
    }

    // 保存回调函数,防止内存回收
    let callbackStore = [];
    let callBack = newCallback(() => { console.log('函数回调')});
    callbackStore.push(callBack);

    process.on('exit', () => {
        delete callbackStore
    });

    //调用导出函数
    dllFunc.MyFunction(callBack);

指针作为参数传入

   //定义导出函数,ref.refType将基础类型转成指针类型
   let dllFunc = ffi.Library('D://code//myProgram/demo.dll', {
       MyFunction: [ref.types.int, [ref.refType(ref.types.int)]]
   });

   //调用导出函数,使用Buffer.alloc开辟内存
   let myParams = new Buffer.alloc(100);
   dllFunc.MyFunction(myParams);

结构体作为参数传入

    //ref-struct初始化结构体
    const myStruct = Struct({
        id: ref.types.int
    });

    //定义导出函数,ref.refType将结构体类型转成指针结构体类型
     let dllFunc = ffi.Library('D://code//myProgram/demo.dll', {
        MyFunction: [ref.types.int, [ref.refType(myStruct)]]
    });

    //调用导出函数,通过.ref()获取结构体内存首地址,方便dll内函数复写
    dll.MyFunction(myStruct.ref());

数组作为参数传入

    //ref-array定义数组
    const MyArray = RefArray(ref.types.int);

    //定义导出函数,不需要传入数组定义,只需要传入数组成员定义
     let dllFunc = ffi.Library('D://code//myProgram/demo.dll', {
        MyFunction: [ref.types.int, [ref.refType(ref.types.int)]]
    });

    //初始化数组
    let myArray = new MyArray(10)
    for(let i = 0; i < 10; i++) {
        myArray[i] = 0;
    }

    //调用导出函数,传入第一个成员内存地址
    dllFunc.MyFunction(myArray[0].ref());

小技巧:获取 Electron 窗口句柄并转成整数

   //deref方法获取指针对应的值
   let windowHandle = window.getNativeWindowHandle();
   let _handle = Buffer.from(windowHandle);
   _handle.type = ref.types.int;
   dllFunc.MyFunction(handle.deref())

玩转 Electron 打包

这里所说的 Electron 打包分为两部分,一部分是代码打包,另一部分是程序打包。

代码打包的意义在于:

  • 减少安装程序体积,进一步来说是为了减少程序在被下载、自动更新时占用的网络带宽。
  • 提高源代码安全。因为 Electron 程序中的源代码是没有任何加密措施的,源代码很容易被提取,所以需要对源代码进行打包压缩混淆,降低代码的可读性,提高代码安全性。

代码打包

这里使用 webpack 对代码打包。

webpack 对 Electron 项目提供了两种代码打包方式:target: 'electron-renderer' | 'electron-main',分别为渲染进程打包和主进程打包,都会自动忽略引入的 nodejs 模块和 electron 模块。

其中主进程代码打包没那么简单,像主进程中会使用的 nodejs 的原生模块如上面所说的 node-ffi、ref 模块,需要对这些模块被编译之后产生的.node 文件进行特殊处理。这里使用 node-loader 对.node 文件进行处理。

    //webpack.config.js
    module.exports = {
       entry: './main.js',
        mode: 'production',
        target: 'electron-main',
        //防止打包之后__dirname改变
        node: {
            __dirname: false,
        },
        output: {
            path: path.resolve(__dirname, 'dist'),
            filename: 'main.js',
        },
        module: {
            rules: [
                {
                    test: /\.node$/,
                    use: [
                        {
                            loader: 'node-loader',
                            options: {
                                name: '/[path][name].[ext]',
                            },
                        },
                    ],
                },
            ],
        },
    }

执行打包之后没有报错,看似成功了,但是运行打包代码后发现报错了,咋回事?

同时发现,dist 文件夹中没有导出.node 文件。

查找原因发现 ffi 中引入 ffi_bindings.node 不是常规的方式,bindings 模块中通过便利地址获取.node 文件。

    //node_modules/ffi/lib/bindings.js
    module.exports = require('bindings')('ffi_bindings.node')

    //node_modules/bindings/bindings.js
    ...
     try: [
          // node-gyp's linked version in the "build" dir
          [ 'module_root', 'build', 'bindings' ]
          // node-waf and gyp_addon (a.k.a node-gyp)
        , [ 'module_root', 'build', 'Debug', 'bindings' ]
        , [ 'module_root', 'build', 'Release', 'bindings' ]
          // Debug files, for development (legacy behavior, remove for node v0.9)
        , [ 'module_root', 'out', 'Debug', 'bindings' ]
        , [ 'module_root', 'Debug', 'bindings' ]
          // Release files, but manually compiled (legacy behavior, remove for node v0.9)
        , [ 'module_root', 'out', 'Release', 'bindings' ]
        , [ 'module_root', 'Release', 'bindings' ]
          // Legacy from node-waf, node <= 0.4.x
        , [ 'module_root', 'build', 'default', 'bindings' ]
          // Production "Release" buildtype binary (meh...)
        , [ 'module_root', 'compiled', 'version', 'platform', 'arch', 'bindings' ]
        ]

因为 node-loader 模块只会去处理 require/import 形式的文件,像 ffi 这种引入文件的形式就无法处理了,所以就报错了。

解决方式是重写 bindings 模块,webpack 中有一个配置 resolve.alias 设置引用模块的别名,能够更改引用模块的应用地址。

    //webpack.config.js
    ...
    resolve: {
        alias: {
            bindings: path.resolve(__dirname, 'replaceBindings.js')
        }
    }

    //replaceBindings.js
    modules.exports = function(filename){
        if(filename === "ffi_bindings.node") {
            return require('ffi/build/Release/ffi_bindings.node')
        }
    }

重新打包,程序运行无异常,问题得以解决!

程序打包

Electron 程序打包是指将代码进一步打包成可分发、下载、安装、快速启动运行的程序。

比较常用的 Electron 程序打包模块是 electron-packager 和 electron-builder。这里推荐使用 electron-builder,因为对比 electron-packager,electron-builder 功能更完善,文档也跟清晰一些。

electron-builder 打包流程:

使用 electron-builder api 形式进行打包实例:

const electronBuilder = require('electron-builder');
const child_process = require('child_process');
const path = require('path');

//获取git提交数作为版本号
child_process.execSync('rm -rf dist');
const version = child_process.execSync('git rev-list HEAD | wc -l').toString().trim();

const baseVersion = require('./package.json').version;
const buildVersion = baseVersion + '.' + version;
const semverVersion = baseVersion + '-' + version;
electronBuilder.argv = 'x64';

const targetDist = path.resolve(__dirname, 'dist');

const baseOptions = {
    //应用唯一标识
    appId: 'shaokaotan.demo.demo',
    buildVersion: buildVersion,
    //更替根目录下package.json中对应信息
    extraMetadata: {
        //应用产品版本号,用于
        productVerion: buildVersion,
        author: {
            name: 'Shaokaotan',
            email: 'www.Shaokaotan.com',
            url: 'www.Shaokaotan.com',
        },
        version: semverVersion,
    },
    productName: 'MyProgram',
    copyright: 'Copyright (C) 2020. Shaokaotan Electronics. All Rights Reserved.',
    directories: {
        buildResources: path.resolve(__dirname, '.'),
        output: targetDist,
    },
    nsis: {
        oneClick: false,
        perMachine: true,
        allowElevation: false,
        allowToChangeInstallationDirectory: true,
        installerIcon: path.resolve(__dirname, './icons/favicon.ico'),
        uninstallerIcon: path.resolve(__dirname, './icons/favicon.ico'),
        installerHeaderIcon: path.resolve(__dirname, './icons/favicon.ico'),
        createDesktopShortcut: true,
        createStartMenuShortcut: true,
        shortcutName: 'MyProgram',
        artifactName: 'MyProgram' + buildVersion + '.${ext}',
        uninstallDisplayName: 'MyProgram',
        include: path.resolve(__dirname, 'install.nsh'),
    },
    win: {
        icon: path.resolve(__dirname, './icons/favicon.ico'),
        target: {
            target: 'nsis',
            arch: 'x64',
        },
        //需要打包的文件列表
        files: ['!build.js'],
        extraResources: [path.resolve(__dirname, 'icons/favicon.ico')],
        publish: {
            provider: 'generic',
            channel: 'winLatest_' + buildVersion,
            url: 'www.Shaokaotan.com',
        },
        //复制文件
        extraFiles: [
            {
                from: path.resolve(__dirname, 'icons/favicon.ico'),
                to: './resources/icons/favicon.ico',
            },
        ],
    },
};

electronBuilder.createTargets(['--win'], null, 'x64');
process.env.BUILD_NUMBER = version;
electronBuilder.build({
    config: baseOptions,
});

以上部分信息在实际应用中的对应

buildVersion和semverVersion对比

  • buildVersion 是 windows 系统中正规的版本格式:*.*.*.*
  • semverVersion 则是语义化版本号:*.*.*[-*],多用于互联网应用,像 js 模块的 package.json 的 version 字段就是语义化版本

所以可以看到配置中 extraMetadata 的 version 字段使用的是 semverVersion 而不是 buildVersion。

应用安装之后在 windows 系统控制面板的程序与功能中也需要正确的显示(除非你们测试不 care)。

electron-builder 的配置中没有提供这些信息的配置项,经过一番追寻,这些信息来自于系统的注册表,所以需要在注册表的指定位置保存这些信息。注册标储存这些信息的地址是

    计算机\HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall

如何设置?这里涉及到 NSIS,上面配置中 nsis.include 可以加入自定义 nsis 文件:

    !include "FileFunc.nsh"

    #获取版本号
    !getdllversion "${BUILD_RESOURCES_DIR}\dist\win-unpacked\MyProgram.exe" expv_

    !macro customInstall
        #写入icon地址
        WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}"  "DisplayIcon" "$INSTDIR\resources\icons\favicon.ico"
        #写入支持链接地址
        WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}"  "URLInfoAbout" "www.Shaokaotan.com"
        #写入版本信息
        WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_APP_KEY}"  "DisplayVersion" "${expv_1}.${expv_2}.${expv_3}.${expv_4}"
    !macroend

    !macro customUnInstall

    !macroend

玩转自动更新

请看electron-builder Auto Update 教程

嗯!看完之后还是不怎么懂。请看分解:

先画个粗糙的流程图:

要完成这个流程,自己手写的工作量看似不大,但是需要兼顾很多边界问题,所以electron-builder提供了一个独立的模块electron-updater来完成这流程。

安装electron-updater

npm install --save electron-updater

客户端调用自动更新

代码呈上

const log = require('electron-log');
const { autoUpdater } = require('electron-updater');
const { updateWindow } = require('./window');
const { app,ipcMain } = require('electron');

//设置日志打印
autoUpdater.logger = log;
autoUpdater.logger.transports.file.level = 'info';

//是否自动下载更新,设置为false时将通过api触发更新下载
autoUpdater.autoDownload = false;
//是否允许版本降级,也就是服务器版本低于本地版本时,依旧以服务器版本为主
autoUpdater.allowDowngrade = true;

//设置服务器版本最新版本查询接口配置
autoUpdater.setFeedURL({
    provider: 'generic',
    url: 'https://www.shaokaotan.com/autoUpdate/',
    channel: 'myProgram',
});

//保存是否需要安装更新的版本状态,因为需要提供用户在下载完成更新之后立即更新和稍后更新的操作
let NEED_INSTALL = false;

//调用api检查是否用更新
const checkUpdate = () => {
    autoUpdater.checkForUpdatesAndNotify().then((UpdateCheckResult) => {
        log.info(UpdateCheckResult, autoUpdater.currentVersion.version);
        //判断版本不一致,强制更新
        if (UpdateCheckResult && UpdateCheckResult.updateInfo.version !== autoUpdater.currentVersion.version) {
            //调起更新窗口
            updateWindow();
        }
    });
};

//api触发更新下载
const startDownload = (callback, downloadSuccessCallback) => {
    //监听下载进度并推送到更新窗口
    autoUpdater.on('download-progress', (data) => {
        callback && callback instanceof Function && callback(null, data);
    });
    //监听下载错误并推送到更新窗口
    autoUpdater.on('error', (err) => {
        callback && callback instanceof Function && callback(err);
    });
    //监听下载完成并推送到更新窗口
    autoUpdater.on('update-downloaded', () => {
        NEED_INSTALL = true;
        downloadSuccessCallback && downloadSuccessCallback instanceof Function && downloadSuccessCallback();
    });
    //下载更新
    autoUpdater.downloadUpdate();
};

//监听窗口发送来的进程消息,开始下载更新
ipcMain.on('startDownload', (event) => {
    startDownload(
            (err, progressInfo) => {
                if (err) {
                    //回推下载错误消息
                    event.sender.send('update-error');
                } else {
                    //回推下载进度消息
                    event.sender.send('update-progress', progressInfo.percent);
                }
            },
            () => {
                //回推下载完成消息
                event.sender.send('update-downed');
            }
        ); 
});

//监听用户点击更新窗口立即更新按钮进程消息
ipcMain('quitAndInstall', () => {
    NEED_INSTALL = false;
    autoUpdater.quitAndInstall(true, true);
})

//用户点击稍后安装后程序退出时执行立即安装更新
app.on('will-quit', () => {
    if (NEED_INSTALL) {
        autoUpdater.quitAndInstall(true, true);
    }
});

要使上面流程能够正确的执行下去,还需要提供一个正确的服务返回一个正确的更新配置:

代码中设置服务地址

autoUpdater.setFeedURL({
    provider: 'generic', //自定义服务器的选项,其他选项都不是自定义的
    url: 'https://www.shaokaotan.com/autoUpdate/',
    channel: 'myProgram',
});

这样子设置electron-updater将会请求https://www.shaokaotan.com/autoUpdate/myProgram.yml的网络服务地址,这个地址需要返回一个更新配置信息文件,这个文件就是electron-builder打包出来的 [channel].yml,这个channel在electron-builder打包配置中的win.publish中配置。比如我的打包配置出来的yml文件是这样:

//winLatest_1.0.0.111.yml
version: 1.0.0-111
files:
  - url: MyProgram1.0.0.111.exe
    sha512: nRDCB1qk7GZ8DxUpH4wgHQ2zjr2n3kSrHhyJSXeOgx6akfYGEBp9/8qz8OLoos5q9+wKNR1LH0oelj70isN2tA==
    size: 44897270
path: MyProgram1.0.0.111.exe
sha512: nRDCB1qk7GZ8DxUpH4wgHQ2zjr2n3kSrHhyJSXeOgx6akfYGEBp9/8qz8OLoos5q9+wKNR1LH0oelj70isN2tA==
releaseDate: '2020-07-27T07:28:08.168Z'

其中需要注意以下三点

  • electron-updater会校验配置中version的格式,需要符合为semver version规范(以后打包说过),但是一般windows应用是4位版本号,所以采用像*.*.*-*这种形式能通过检验
  • electron-updater会校验配置中的sha512值与更新下载下来的文件的sha512值,不匹配会抛出更新错误,所以不能够随意修改安装程序(只要文件md5值不变即可)
  • 配置中的path值即是electron-updater执行下载更新的目标地址,是新版本程序安装包的文件地址(cdn盛行的时代,这个地址应该是安装包存放在cdn服务器上的文件地址)

当然,不可能直接将electron-builder打包出来的yml文件直接返回给electron-updater使用,上面的安装包地址很明显需要重新配置的,你可以在生成一个版本之后手动去创建一个符合规范的yml文件,也可以像我这样做:

//https://www.shaokaotan.com/autoUpdate/myProgram.yml 
//该地址是一个正规的restful接口,不是文件地址
router.get('/autoUpdate/myProgram.yml', (req, res)=> {
    //将electron-builder打包出来的yml文件上传到服务器保存成json配置,期间可以修改其中的值
    let ymlConfig = config.updaterInfo;
    //将响应头设置成文件附件
    res.attachment('myProgram.yml');
    //返回json形式的信息,json和yaml格式可以相互转换的
    res.json(hugoAppConfig.updateVersionInfo);
});

自动更新就没啥问题了!

本文总结

本文只是把本人踩过的坑重点总结了一番,其实 electron 还有很多地方可以让我们去研究,如插件化、工程化、性能优化等等。后面前端烧烤摊也会继续分享 electron 相关的探索,敬请期待!

最后

关注「漫步大前端」, 第一时间获取优质文章。

本文使用 mdnice 排版