# 用 Electron 创建跨平台桌面应用 > 本文转载自:[众成翻译](http://www.zcfy.cc) > 译者:[网络埋伏纪事](http://www.zcfy.cc/@bigshaw) > 链接:[http://www.zcfy.cc/article/2247](http://www.zcfy.cc/article/2247) > 原文:[https://www.toptal.com/javascript/electron-cross-platform-desktop-apps-easy](https://www.toptal.com/javascript/electron-cross-platform-desktop-apps-easy) 今年早些时候,Github 发布了其知名开源编辑器 [Atom](https://atom.io/) 的核心 Atom-Shell,并在这个特殊时刻,将其重新命名为 **Electron**。 对于基于 Node.js 的桌面应用程序来说,Electron 与其它竞争对手有所不同。它通过将 Node.js(直到最近的版本一直是[io.js](https://iojs.org/))的威力与 [Chromium 引擎](http://www.chromium.org/Home) 相结合,让我们兼备服务器端和客户端 JavaScript 的精华,从而给这个已经成熟的市场带来转折。 试想一下,有这么个世界,我们不仅可以借助不断增长的 NPM 模块仓库,还可以借助整个 Bower 仓库来实现所有客户端需求,从而可以创建出高性能的、数据驱动的、跨平台的桌面应用程序。 进入 [Electron](http://electron.atom.io/)。 ![Building Cross-platform Desktop Apps with Electron](http://p0.qhimg.com/t0177df2b273bcc9946.png) 在本教程中,我们将用 Electron、Angular.js 和 Loki.js,创建一个简单的密码钥匙串应用程序。 > [Loki.js](http://lokijs.org/#/) 是一个轻量级的内存数据库,其语法对于 [MongoDB 开发者](https://www.toptal.com/mongodb)来说是很熟悉的。 > 这个应用程序的完整源代码放在[这里](https://github.com/stephanepericat/toptal-electron-loki-demo)。 本教程假设: * 读者的电脑上已经装好了 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.json` 和 `bower.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` 对象实例,创建了一个新窗口。 * 这个窗口对象接受一个对象为参数,可以通过这个对象来定义[各种设置](http://electron.atom.io/docs/v0.31.0/api/browser-window/#class-browserwindow),其中包括窗口的默认宽度和高度。 * 窗口实例有一个 `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 网页,所以我们可以像这样开始写它: ``` Password Keychain

Password Keychain

``` 到了这里,我们的应用程序应该就可以运行了。要测试的话,只需要在终端中,在 `src` 文件夹下,键入如下命令: ``` $ electron . ``` > 可以通过在 package.json 文件中定义 `start` 脚本中,自动化这个过程。 ![](http://p0.qhimg.com/t01012deec3ffdbab31.png) ## 创建密码钥匙串桌面应用 要创建密码钥匙串应用程序,我们需要: - 能添加、生成和保存密码; - 能方便地复制和删除密码。 ### 生成和保存密码 一个简单的表单就足以插入新密码。为了演示 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 模块实际上有两种变体: * 一个是对[主进程](http://electron.atom.io/docs/api/ipc-main/)的,允许应用订阅窗口发送的消息。 * 一个是对[渲染进程](http://electron.atom.io/docs/api/ipc-renderer/)的,允许应用发送消息给主进程。 > 虽然 Electron 的通讯通道大部分是单向的,但是通过使用 [remote](http://electron.atom.io/docs/v0.31.0/api/remote/) 模块,是可以在渲染进程中访问主进程的 IPC 模块的。并且,主进程也可以通过使用 [Event.sender.send()](http://electron.atom.io/docs/api/ipc-main/) 方法,将消息从源事件发送回渲染进程。 要使用 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 页面中: ``` ` ``` > 通过 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 按钮: ```
``` 并且将该指令添加为主窗口的 Angular 控制器的一个依赖: ``` angular .module('MainWindow', ['Utils']) .controller('MainCtrl', function() { var vm = this; }); ``` ![](http://p0.qhimg.com/t01169032b013f131b1.png) #### 生成密码 为简单起见,我们可以只用 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 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 的文档](http://lokijs.org/#/docs#views)。 下面我们开始创建一个简单的 HTML 表单: ```
``` 现在我们添加 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 模板,在这里显示密码列表: ``` {{item.description}} {{item.username || 'n/a'}} copy remove ``` ![](http://p0.qhimg.com/t01ed70316acf87d2e6.png) 在插入一个新密码之后,刷新密码列表,会是一个不错的功能。为此,我们可以用 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](http://electron.atom.io/docs/v0.31.0/api/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 按钮,提供密码数组的索引: ``` copy ``` ### 删除密码 最终用户可能还想能删除密码,以防密码过时。为此,我们只需要在 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](http://electron.atom.io/docs/v0.31.0/api/menu/) 模块,用于给应用程序创建复杂的桌面菜单结构。 menu 模块是个很大的主题,应该有自己单独的教程。我强烈推荐你阅读 [Electron 的桌面环境集成教程](http://electron.atom.io/docs/v0.31.0/tutorial/desktop-environment-integration/),探索一下该模块的所有功能。 对于本教程所涉及的范围,我们将会看到如何创建自定义菜单,如何给菜单添加自定义命令,如何实现标准的 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 提供了“[加速器](https://github.com/atom/electron/blob/master/docs/api/accelerator.md)”功能。加速器是一套预定义的映射到实际键盘组合的字符串,比如:`Command+A` 或 `Ctrl+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](http://www.getmdl.io/) 作为 UI 框架,但是你也可以自由选用其它 UI 框架。 任何可以在 HTML5 中做的事情,都可以在 Electron 中做;只要记住,如果你用了太多的第三方库,那么,随着应用的二进制文件大小的增长,会导致性能问题出现。 ## 为分发打包 Electron 应用 已经创建好了 Electron 应用,它看起来不错,你也用 [Selenium 和 WebDriver](http://electron.atom.io/docs/v0.31.0/tutorial/using-selenium-and-webdriver/) 写了端对端测试,并且准备将应用发布到全世界! 但是你还想让它个性化点,给它一个自定义的名称,而不是默认的 “Electron”,还可能想为 Mac 和 PC 平台提供自定义的应用程序图标。 ### 用 Gulp 构建 目前,有一个 [Gulp](http://gulpjs.com/) 插件可以做我们想做的任何事情。只要在 Google 中键入 `gulp electron`,然后你会发现果真有一个[gulp-electron](https://github.com/mainyaa/gulp-electron) 插件! 这个插件很[容易使用](https://www.toptal.com/nodejs/an-introduction-to-automation-with-gulp),只要在本教程开头的文件夹结构还保持着。如果没有的话,可能就得费点周折。 这个插件可以像任何其它 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` 属性,让我们还可以为生成的应用创建压缩文件。 * 对于每个平台,[可以定义的平台资源有所不同](https://github.com/mainyaa/gulp-electron#options)。 #### 添加应用图标 `platformResources` 属性之一是 `icon` 属性,可以用这个属性定义自定义的应用程序图标: ``` "icon": "keychain.ico" ``` > OS X 需要图标的文件扩展名是 `.icns`。有几个免费在线工具可以让我们把 `.png` 文件转换为 `.ico` 以及 `.icns` 文件。 ## 总结 在本文中,对于 Electron 能做什么,我们只是浅尝辄止。可以把像 Atom 或者 [Slack](https://slack.com/apps) 这种伟大的应用,当作是用这个工具可以做出来什么的灵感来源。 希望本文对你有帮助,请随意留下评论,并分享你使用 Electron 的经验!