网络埋伏纪事

用 Electron 创建跨平台桌面应用

网络埋伏纪事 · 2017-01-06翻译 · 1729阅读 原文链接

今年早些时候,Github 发布了其知名开源编辑器 Atom 的核心 Atom-Shell,并在这个特殊时刻,将其重新命名为 Electron

对于基于 Node.js 的桌面应用程序来说,Electron 与其它竞争对手有所不同。它通过将 Node.js(直到最近的版本一直是io.js)的威力与 Chromium 引擎 相结合,让我们兼备服务器端和客户端 JavaScript 的精华,从而给这个已经成熟的市场带来转折。

试想一下,有这么个世界,我们不仅可以借助不断增长的 NPM 模块仓库,还可以借助整个 Bower 仓库来实现所有客户端需求,从而可以创建出高性能的、数据驱动的、跨平台的桌面应用程序。

进入 Electron

Building Cross-platform Desktop Apps with Electron

在本教程中,我们将用 Electron、Angular.js 和 Loki.js,创建一个简单的密码钥匙串应用程序。

Loki.js 是一个轻量级的内存数据库,其语法对于 MongoDB 开发者来说是很熟悉的。

这个应用程序的完整源代码放在这里

本教程假设:

  • 读者的电脑上已经装好了 Node.js 和 Bower。
  • 读者熟悉 Node.js、Angular.js,以及类似 MongoDB 的查询语法。

收货

首先,我们需要得到 Electron 二进制文件,这样才可以在本地测试我们的应用。可以将它安装为全局,然后当作 CLI 来用,也可以安装在应用程序的本地路径中。我推荐安装为全局,这样就不用在开发每个应用时都要一次一次安装。

之后我们会学习如何使用 Gulp 将应用打包分发。这个过程包括复制 Electron 二进制文件,从而让手动在应用程序路径中安装太变得没有多大意义。

要安装 Electron CLI,可以在终端中键入如下命令:

$ npm install -g electron-prebuilt

要测试安装是否正确的话,就键入 electron -h,它会显示 Electron CLI 的版本。

本文编写时,Electron 的版本是 0.31.2

设置工程

假设有如下的基础目录结构:

my-app
|- cache/
|- dist/
|- src/
|-- app.js
| gulpfile.js

这里:

  • cache/ 用于在创建应用时下载 Electron 二进制文件。
  • dist/ 会包含生成的分发文件。
  • src/ 会包含我们的源代码。
  • src/app.js 将是应用程序的入口。

接着,我们在终端中导航到 src/ 文件夹,为应用程序创建 package.jsonbower.json 文件:

$ npm init
$ bower init

我们将在本教程后面安装所需的包。

理解 Electron 进程

Electron 中有两种类型的进程:

  • 主进程:主进程是应用程序的入口,是只要运行应用程序,就要被执行的文件。通常,这个文件声明应用程序的各种窗口,也可以根据需要用 Electron 的 IPC 模块来定义全局的事件监听器。
  • 渲染进程: 渲染进程是应用程序中指定窗口的控制器。每个窗口都会创建一个它自己的渲染进程。

为了代码清晰起见,每个渲染进程应该有一个单独的文件。为了为应用程序定义主进程,我们要打开 src/app.js,按如下这样,将启动应用程序的 app 模块,以及创建应用程序各种窗口的 browser-window 模块导入(二者都是 Electron 核心部分):

var app = require('app'),
    BrowserWindow = require('browser-window');

应用程序启动时,会触发一个 ready 事件,我们可以绑定到这个事件。在这里,可以初始化应用程序的主窗口:

var mainWindow = null;

app.on('ready', function() {
    mainWindow = new BrowserWindow({
        width: 1024,
        height: 768
    });

    mainWindow.loadUrl('file://' + __dirname + '/windows/main/main.html');
    mainWindow.openDevTools();
});

要点:

  • 通过创建一个新的 BrowserWindows 对象实例,创建了一个新窗口。
  • 这个窗口对象接受一个对象为参数,可以通过这个对象来定义各种设置,其中包括窗口的默认宽度和高度。
  • 窗口实例有一个 loadUrl() 方法,可以在当前窗口中加载一个实际 HTML 文件。这个 HTML 文件既可以是本地的,也可以是远程的。
  • 窗口实例有一个可选的 openDevTools() 方法,可以在当前窗口中打开一个 Chrome Dev Tools 的实例,用作调试。

接着,我们应该稍微组织一下我们的代码。我推荐在 src/ 文件夹中创建一个 windows/ 文件夹,在这个文件夹中,我们可以为每个窗口创建一个子文件夹,比如:

my-app
|- src/
|-- windows/
|--- main/
|---- main.controller.js
|---- main.html
|---- main.view.js

这里,main.controller.js 会包含应用程序的“服务器端”逻辑,而 main.view.js 会包含应用程序的“客户端”逻辑。

main.html 文件只是一个 HTML5 网页,所以我们可以像这样开始写它:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Password Keychain</title>
</head>
<body>
    <h1>Password Keychain</h1>
</body>
</html>

到了这里,我们的应用程序应该就可以运行了。要测试的话,只需要在终端中,在 src 文件夹下,键入如下命令:

$ electron .

可以通过在 package.json 文件中定义 start 脚本中,自动化这个过程。

创建密码钥匙串桌面应用

要创建密码钥匙串应用程序,我们需要:

  • 能添加、生成和保存密码;
  • 能方便地复制和删除密码。

生成和保存密码

一个简单的表单就足以插入新密码。为了演示 Electron 中多个窗口之间的通讯,下面我们开始在应用程序中添加第二个窗口,这个窗口会显示插入表单。因为我们会多次打开和关闭该窗口,所以应该将逻辑封装在一个方法中,这样在需要的时候只需调用该方法就可以了:

function createInsertWindow() {
    insertWindow = new BrowserWindow({
        width: 640,
        height: 480,
        show: false
    });

    insertWindow.loadUrl('file://' + __dirname + '/windows/insert/insert.html');

    insertWindow.on('closed',function() {
        insertWindow = null;
    });
}

要点:

  • 需要在 BrowserWindow 构造器的 options 对象中,把 show 属性设置为 false,防止应用程序启动时默认打开窗口。
  • 只要窗口触发一个 closed 事件,就需要销毁 BrowserWindows 实例。

打开和关闭插入 窗口

思路是当最终用户在主窗口中点击一个按钮时,能触发插入窗口。为此,我们需要从主窗口发送一个消息给主进程,通知主进程打开插入窗口。我们可以通过使用 Electron 的 IPC 模块实现此需求。IPC 模块实际上有两种变体:

  • 一个是对主进程的,允许应用订阅窗口发送的消息。
  • 一个是对渲染进程的,允许应用发送消息给主进程。

虽然 Electron 的通讯通道大部分是单向的,但是通过使用 remote 模块,是可以在渲染进程中访问主进程的 IPC 模块的。并且,主进程也可以通过使用 Event.sender.send() 方法,将消息从源事件发送回渲染进程。

要使用 IPC 模块,我们只需要在主进程的脚本中,像所有其它 NPM 模块一样 require 它就可以了:

var ipc = require('ipc');

然后用 on() 方法绑定到事件:

ipc.on('toggle-insert-view', function() {
    if(!insertWindow) {
        createInsertWindow();
    }
    return (!insertWindow.isClosed() && insertWindow.isVisible()) ? insertWindow.hide() : insertWindow.show();
});

要点:

  • 可以把事件命名为任何想要的名称,本例只是随意命名的。
  • 不要忘记检测 BrowserWindow 实例是否已经创建了,如果没有就实例化它。
  • BrowserWindow 实例有一些有用的方法:
    • isClosed():返回一个布尔值,指示窗口当前是否是 closed 状态。
    • isVisible():返回一个布尔值,指示窗口当前是否可见。
    • show() / hide():方便显示及隐藏窗口的方法。

现在,我们实际上是需要从渲染进程中触发该事件。我们将创建一个新脚本文件 main.view.js,然后把它像所有标准脚本一样添加到 HTML 页面中:

<script src="./main.view.js"></script>

通过 HTML 的 script 标记加载脚本文件,会将该文件加载到一个客户端上下文中。意思是说,比如,全局变量可以通过 window.<变量名> 方式访问。要在服务器端上下文中加载一个脚本,可以直接在 HTML 页面中使用 require() 方法:require('./main.controller.js');

即使脚本是在客户端上下文中加载的,我们依然可以以在主进程中同样的方式,访问渲染进程的 IPC 模块,然后像这样发送我们的事件:

var ipc = require('ipc');

angular
    .module('Utils', [])
    .directive('toggleInsertView', function() {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();
                ipc.send('toggle-insert-view');
            });
        };
    });

如果需要同步发送事件,还有一个 sendSync() 方法可用。

现在,要打开插入窗口,剩下来需要做的就是用对应的 Angular 指令在窗口上创建一个 HTML 按钮:

<div ng-controller="MainCtrl as vm">
    <button toggle-insert-view class="mdl-button">
        <i class="material-icons">add</i>
    </button>
</div>

并且将该指令添加为主窗口的 Angular 控制器的一个依赖:

angular
    .module('MainWindow', ['Utils'])
    .controller('MainCtrl', function() {
        var vm = this;
    });

生成密码

为简单起见,我们可以只用 NPM 的 uuid 模块来生成唯一的 ID 来作为本教程所用的密码。我们可以像所有其它 NPM 模块一样安装它,在 Utils 脚本中 require 它,然后创建一个会返回一个唯一 ID 的简单工厂:

var uuid = require('uuid');

angular
    .module('Utils', [])

    ...

    .factory('Generator', function() {
        return {
            create: function() {
                return uuid.v4();
            }
        };
    })

现在,剩下来要做的是在 insert 视图中创建一个按钮,并给它绑定一个指令。这个指令会监听按钮上的点击事件,然后调用 create() 方法:

<!-- in insert.html -->
<button generate-password class="mdl-button">generate</button>
// in Utils.js
angular
    .module('Utils', [])

    ...

    .directive('generatePassword', ['Generator', function(Generator) {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();
                if(!scope.vm.formData) scope.vm.formData = {};
                scope.vm.formData.password = Generator.create();
                scope.$apply();
            });
        };
    }])

保存密码

到了这里,我们想存储我们的密码。密码条目的数据结构很简单:

{
    "id": String
    "description": String,
    "username": String,
    "password": String
}

所以我们实际需要的是某种类型的内存数据库,这种内存数据库视需要可以把密码条目同步到文件来备份它。对于这种用途,Loki.js 貌似是很理想的候选方案。它刚好满足本应用程序的需求,并且在此之上提供动态视图功能,让我们可以做一些类似于 MongoDB 的Aggregation 模块所做的事情。

动态视图不会提供 MongoDB 的Aggregation 模块所具备的所有功能。更多信息,请参考 Lokijs 的文档

下面我们开始创建一个简单的 HTML 表单:

<div class="insert" ng-controller="InsertCtrl as vm">
    <form name="insertForm" no-validate>
        <fieldset ng-disabled="!vm.loaded">
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="text" id="description" ng-model="vm.formData.description" required />
                <label class="mdl-textfield__label" for="description">Description...</label>
            </div>
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="text" id="username" ng-model="vm.formData.username" />
                <label class="mdl-textfield__label" for="username">Username...</label>
            </div>
            <div class="mdl-textfield">
                <input class="mdl-textfield__input" type="password" id="password" ng-model="vm.formData.password" required />
                <label class="mdl-textfield__label" for="password">Password...</label>
            </div>
            <div class="">
                <button generate-password class="mdl-button">generate</button>
                <button toggle-insert-view class="mdl-button">cancel</button>
                <button save-password class="mdl-button" ng-disabled="insertForm.$invalid">save</button>
            </div>
        </fieldset>
    </form>
</div>

现在我们添加 JavaScript 逻辑,来处理表单内容的发送和保存:

var loki = require('lokijs'),
    path = require('path');

angular
    .module('Utils', [])

    ...

    .service('Storage', ['$q', function($q) {
        this.db = new loki(path.resolve(__dirname, '../..', 'app.db'));
        this.collection = null;
        this.loaded = false;

        this.init = function() {
            var d = $q.defer();

            this.reload()
                .then(function() {
                    this.collection = this.db.getCollection('keychain');
                    d.resolve(this);
                }.bind(this))
                .catch(function(e) {
                    // 创建集合
                    this.db.addCollection('keychain');
                    // 保存并创建文件
                    this.db.saveDatabase();

                    this.collection = this.db.getCollection('keychain');
                    d.resolve(this);
                }.bind(this));

                return d.promise;
        };

        this.addDoc = function(data) {
            var d = $q.defer();

            if(this.isLoaded() && this.getCollection()) {
                this.getCollection().insert(data);
                this.db.saveDatabase();

                d.resolve(this.getCollection());
            } else {
                d.reject(new Error('DB NOT READY'));
            }

            return d.promise;
        };
    })

    .directive('savePassword', ['Storage', function(Storage) {
        return function(scope, el) {
            el.bind('click', function(e) {
                e.preventDefault();

                if(scope.vm.formData) {
                    Storage
                        .addDoc(scope.vm.formData)
                        .then(function() {
                           // 重置表单,关闭插入窗口
                           scope.vm.formData = {};
                           ipc.send('toggle-insert-view');
                        });
                }
            });
        };
    }])

要点:

  • 首先需要初始化数据库。这个过程包括创建一个 Loki 对象的新实例,将数据库文件的路径提供为参数,看看被备份文件是否存在,如果需要就创建它(包括 keychain 集合),然后将该文件的内容加载到内存中。
  • 可以用 getCollection() 方法获取数据中指定的集合。
  • 集合对象有几个方法,其中包括 insert() 方法,该方法可以让我们将新文档添加到集合中。
  • 要把数据库内存持久化到文件,Loki 对象提供了一个 saveDatabase() 方法。
  • 一旦文档保存了,需要重置表单数据,发送一个 IPC 事件给主进程,让它关闭窗口。

现在,我们就有了一个简单的表单,让我们可以生成和保存密码。下面,我们回到主视图来列出这些条目。

列出密码

这里需要发生一些事情:

  • 需要能获取集合中的所有文档。
  • 只要保存了新密码,就要通知主视图,这样它就可以刷新视图。

可以通过调用 Loki 对象上的 getCollection() 方法来获取文档列表。这个方法会返回一个带有 data 属性的对象,该属性不过就是一个集合中所有文档的数组:

this.getCollection = function() {
    this.collection = this.db.getCollection('keychain');
    return this.collection;
};

this.getDocs = function() {
    return (this.getCollection()) ? this.getCollection().data : null;
};

然后我们就可以在初始化集合之后,在 Angular 控制器中调用 getDocs() 方法,获取存在数据库中的所有密码:

angular
    .module('MainView', ['Utils'])
    .controller('MainCtrl', ['Storage', function(Storage) {
        var vm = this;
        vm.keychain = null;

        Storage
            .init()
            .then(function(db) {
                vm.keychain = db.getDocs();
            });
    });

如下是一点 Angular 模板,在这里显示密码列表:

<tr ng-repeat="item in vm.keychain track by $index" class="item--{{$index}}">
    <td class="mdl-data-table__cell--non-numeric">{{item.description}}</td>
    <td>{{item.username || 'n/a'}}</td>
    <td>
        <span ng-repeat="n in [1,2,3,4,5,6]">•</span>
    </td>
    <td>
        <a href="#" copy-password="{{$index}}">copy</a>
        <a href="#" remove-password="{{item}}">remove</a>
    </td>
</tr>

在插入一个新密码之后,刷新密码列表,会是一个不错的功能。为此,我们可以用 Electron 的 IPC 模块。正如之前提到过的,主进程的 IPC 模块可以在渲染进程中通过用 remote 模块来调用,从而把它变成一个监听器进程。如下是一个如何在 main.view.js 中实现的示例:

var remote = require('remote'),
    remoteIpc = remote.require('ipc');

angular
    .module('MainView', ['Utils'])
    .controller('MainCtrl', ['Storage', function(Storage) {
        var vm = this;
        vm.keychain = null;

        Storage
            .init()
            .then(function(db) {
                vm.keychain = db.getDocs();

                remoteIpc.on('update-main-view', function() {
                    Storage
                        .reload()
                        .then(function() {
                            vm.keychain = db.getDocs();
                        });
                });
            });
    }]);

要点:

  • 需要使用 remote 模块,通过它自己的 require() 方法,来调用主进程中的远程 IPC 模块。
  • 然后可以通过 on() 方法,将渲染进程设置为事件监听器,并把回调函数绑定给这些事件。

然后只要保存新文档,insert 视图就会负责分发这个事件:

Storage
    .addDoc(scope.vm.formData)
    .then(function() {
        // 刷新主视图中的列表
        ipc.send('update-main-view');
        // 重置表单,并关闭插入窗口
        scope.vm.formData = {};
        ipc.send('toggle-insert-view');
    });

复制密码

以普通文本显示密码通常不是一个好主意。我们打算隐藏密码,并提供一个按钮,让最终用户可以直接复制指定条目的密码。

这里 Electron 又来拯救我们了,它提供一个 clipboard 模块,该模块有一些很方便的方法,不仅可以复制粘贴文本内容,还可以复制粘贴图像和 HTML 代码:

var clipboard = require('clipboard');

angular
    .module('Utils', [])

    ...

    .directive('copyPassword', [function() {
        return function(scope, el, attrs) {
            el.bind('click', function(e) {
                e.preventDefault();
                var text = (scope.vm.keychain[attrs.copyPassword]) ? scope.vm.keychain[attrs.copyPassword].password : '';
                // atom 的 clipboard 模块
                clipboard.clear();
                clipboard.writeText(text);
            });
        };
    }]);

因为生成的密码是简单的字符串,所以我们可以使用 writeText() 方法,将密码复制到系统剪贴板。然后,我们就可以更新主视图 HTML,在上面用 copy-password 指令添加一个 copy 按钮,提供密码数组的索引:

<a href="#" copy-password="{{$index}}">copy</a>

删除密码

最终用户可能还想能删除密码,以防密码过时。为此,我们只需要在 keychain 集合上调用 remove() 方法。我们需要把整个文档提供给 remove() 方法:

this.removeDoc = function(doc) {
    return function() {
        var d = $q.defer();

        if(this.isLoaded() && this.getCollection()) {
            // 从集合中删除文档,并持久化改变
            this.getCollection().remove(doc);
            this.db.saveDatabase();

            // 通知 insert 视图,数据库内容已经改变了
            ipc.send('reload-insert-view');

            d.resolve(true);
        } else {
            d.reject(new Error('DB NOT READY'));
        }

        return d.promise;
    }.bind(this);
};

Loki.js 文档声称我们可以通过 ID 号删除一个文档,但是它貌似并没有起作用。

创建桌面菜单

Electron 可以与我们的操作系统桌面环境无缝集成,给我们的应用提供了一种“原生”的用户体验外观和感觉。因此,Electron 提供了一个 Menu 模块,用于给应用程序创建复杂的桌面菜单结构。

menu 模块是个很大的主题,应该有自己单独的教程。我强烈推荐你阅读 Electron 的桌面环境集成教程,探索一下该模块的所有功能。

对于本教程所涉及的范围,我们将会看到如何创建自定义菜单,如何给菜单添加自定义命令,如何实现标准的 quit 命令。

为应用创建和指派一个自定义菜单

通常,Electron 菜单的 JavaScript 逻辑会放在应用的主脚本文件中,这里也是主进程定义的地方。不过,我们可以把它抽象到一个单独的文件中,然后通过 remote 模块访问 menu 模块:

var remote = require('remote'),
    Menu = remote.require('menu');

要定义一个简单的菜单,我们需要使用 buildFromTemplate() 方法:

var appMenu = Menu.buildFromTemplate([
    {
        label: 'Electron',
        submenu: [{
            label: 'Credits',
            click: function() {
                alert('Built with Electron & Loki.js.');
            }
        }]
    }
]);

数组中的第一个条目总是被用作为“默认的”菜单项。

label 属性的值对于默认菜单项并不重要。在开发模式中,它总是会显示为 Electron。在后面,我们会看到如何在构建阶段给默认菜单项指派一个自定义名称。

最后,我们需要用 setApplicationMenu() 方法,将这个自定义的菜单指派为应用的默认菜单:

Menu.setApplicationMenu(appMenu);

映射键盘快捷键

Electron 提供了“加速器”功能。加速器是一套预定义的映射到实际键盘组合的字符串,比如:Command+ACtrl+Shift+Z

Command 加速器不能用于 Windows 或 Linux。对于我们的密码钥匙串应用程序,应该添加一个 File 菜单项,提供两个命令:

  • Create Password:用 Cmd (或 Ctrl) + N 打开 insert 视图
  • Quit:用 Cmd (或 Ctrl) + Q 完全退出应用。
...
{
    label: 'File',
    submenu: [
        {
            label: 'Create Password',
            accelerator: 'CmdOrCtrl+N',
            click: function() {
                ipc.send('toggle-insert-view');
            }
        },
        {
            type: 'separator' // to create a visual separator
        },
        {
            label: 'Quit',
            accelerator: 'CmdOrCtrl+Q',
            selector: 'terminate:' // OS X only!!!
        }
    ]
}
...

要点:

  • 通过添加一个条目到数组,在条目中将 type 属性设置为 separator,可以添加一个可见的分隔符。
  • CmdOrCtrl 加速器兼容 Mac 和 PC 键盘
  • selector 属性只兼容 OSX!

样式化应用

可能你已经注意到,整个代码示例中引用的类名都是以 mdl- 开头。我专门为本教程选用过 Material Design Lite 作为 UI 框架,但是你也可以自由选用其它 UI 框架。

任何可以在 HTML5 中做的事情,都可以在 Electron 中做;只要记住,如果你用了太多的第三方库,那么,随着应用的二进制文件大小的增长,会导致性能问题出现。

为分发打包 Electron 应用

已经创建好了 Electron 应用,它看起来不错,你也用 Selenium 和 WebDriver 写了端对端测试,并且准备将应用发布到全世界!

但是你还想让它个性化点,给它一个自定义的名称,而不是默认的 “Electron”,还可能想为 Mac 和 PC 平台提供自定义的应用程序图标。

用 Gulp 构建

目前,有一个 Gulp 插件可以做我们想做的任何事情。只要在 Google 中键入 gulp electron,然后你会发现果真有一个gulp-electron 插件!

这个插件很容易使用,只要在本教程开头的文件夹结构还保持着。如果没有的话,可能就得费点周折。

这个插件可以像任何其它 Gulp 插件一样安装:

$ npm install gulp-electron --save-dev

然后像下面这样定义 Gulp 任务:

var gulp = require('gulp'),
    electron = require('gulp-electron'),
    info = require('./src/package.json');

gulp.task('electron', function() {
    gulp.src("")
    .pipe(electron({
        src: './src',
        packageJson: info,
        release: './dist',
        cache: './cache',
        version: 'v0.31.2',
        packaging: true,
        platforms: ['win32-ia32', 'darwin-x64'],
        platformResources: {
            darwin: {
                CFBundleDisplayName: info.name,
                CFBundleIdentifier: info.bundle,
                CFBundleName: info.name,
                CFBundleVersion: info.version
            },
            win: {
                "version-string": info.version,
                "file-version": info.version,
                "product-version": info.version
            }
        }
    }))
    .pipe(gulp.dest(""));
});

要点:

  • src/ 文件夹不能与 Gulpfile.js 文件所在的文件夹是同一个,也不能与分发文件夹是同一个。
  • 可以通过 platforms 数组定义想输出的平台。
  • 应该定义一个 cache 文件夹,Electron 二进制文件会被下载到这里,从而可以与应用一起打包。
  • 应用的 package.json 文件的内容必须通过 packageJson 属性传给 Gulp 任务。
  • 有一个可选的 packaging 属性,让我们还可以为生成的应用创建压缩文件。
  • 对于每个平台,可以定义的平台资源有所不同

添加应用图标

platformResources 属性之一是 icon 属性,可以用这个属性定义自定义的应用程序图标:

"icon": "keychain.ico"

OS X 需要图标的文件扩展名是 .icns。有几个免费在线工具可以让我们把 .png 文件转换为 .ico 以及 .icns 文件。

总结

在本文中,对于 Electron 能做什么,我们只是浅尝辄止。可以把像 Atom 或者 Slack 这种伟大的应用,当作是用这个工具可以做出来什么的灵感来源。

希望本文对你有帮助,请随意留下评论,并分享你使用 Electron 的经验!

相关文章