Electron:轻松实现跨平台桌面应用程序

已发表: 2022-03-11

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

与基于 Node.js 的桌面应用程序类别中的其他竞争对手不同,Electron 通过将 Node.js(直到最近发布的 io.js)的强大功能与 Chromium 引擎相结合,为这个已经成熟的市场带来了自己的变化。我们最好的服务器和客户端 JavaScript。

想象一个世界,我们可以构建高性能的、数据驱动的、跨平台的桌面应用程序,不仅由不断增长的 NPM 模块存储库提供支持,而且由整个 Bower 注册表提供支持,以满足我们所有的客户端需求。

输入电子。

使用 Electron 构建跨平台桌面应用程序

使用 Electron 构建跨平台桌面应用程序
鸣叫

在本教程中,我们将使用 Electron、Angular.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 的 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(); });

关键点:

  • 我们通过创建BrowserWindow对象的新实例来创建新窗口。
  • 它将一个对象作为单个参数,允许我们定义各种设置,其中包括窗口的默认宽度高度
  • 窗口实例有一个loadUrl()方法,允许我们在当前窗口中加载实际 HTML 文件的内容。 HTML 文件可以是本地的或远程的。
  • 窗口实例有一个可选的openDevTools()方法,允许我们在当前窗口中打开 Chrome 开发工具的实例以进行调试。

接下来,我们应该稍微组织一下我们的代码。 我建议在我们的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.son 文件的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 ,以防止应用程序启动时默认打开窗口。
  • 每当窗口触发关闭事件时,我们都需要销毁 BrowserWindow 实例。

打开和关闭“插入”窗口

这个想法是能够在最终用户单击“主”窗口中的按钮时触发“插入”窗口。 为此,我们需要从主窗口向主进程发送一条消息,指示它打开插入窗口。 我们可以使用 Electron 的 IPC 模块来实现这一点。 IPC 模块实际上有两种变体:

  • 一个用于主进程,允许应用订阅从窗口发送的消息。
  • 一个用于渲染器进程,允许应用程序向主进程发送消息。

尽管 Electron 的通信通道大多是单向的,但可以通过使用远程模块访问渲染器进程中的主进程的 IPC 模块。 此外,主进程可以使用 Event.sender.send() 方法将消息发送回事件起源的渲染器进程。

要使用 IPC 模块,我们只需像主进程脚本中的任何其他 NPM 模块一样需要它:

 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.<varname>获得。 要在服务器端上下文中加载脚本,我们可以直接在 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”脚本中使用它,然后创建一个简单的工厂来返回一个唯一的 ID:

 var uuid = require('uuid'); angular .module('Utils', []) ... .factory('Generator', function() { return { create: function() { return uuid.v4(); } }; })

现在,我们剩下要做的就是在插入视图中创建一个按钮,并为其附加一个指令,该指令将监听按钮上的点击事件并调用 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 的聚合模块的事情。

动态视图不提供 MongodDB 的聚合模块提供的所有功能。 请参阅文档以获取更多信息。

让我们从创建一个简单的 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" 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" 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" 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) { // create collection this.db.addCollection('keychain'); // save and create file 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() { // reset form & close insert window scope.vm.formData = {}; ipc.send('toggle-insert-view'); }); } }); }; }])

关键点:

  • 我们首先需要初始化数据库。 此过程包括创建 Loki 对象的新实例,提供数据库文件的路径作为参数,查找该备份文件是否存在,在需要时创建它(包括“钥匙串”集合),然后加载这个文件在内存中。
  • 我们可以使用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]">&bull;</span> </td> <td> <a href="#" copy-password="{{$index}}">copy</a> <a href="#" remove-password="{{item}}">remove</a> </td> </tr> 

一个不错的附加功能是在插入新密码后刷新密码列表。 为此,我们可以使用 Electron 的 IPC 模块。 如前所述,可以在渲染器进程中调用主进程的 IPC 模块,通过使用远程模块将其变为侦听器进程。 这是一个关于如何在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(); }); }); }); }]);

关键点:

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

每当保存新文档时,插入视图将负责调度此事件:

 Storage .addDoc(scope.vm.formData) .then(function() { // refresh list in main view ipc.send('update-main-view'); // reset form & close insert window scope.vm.formData = {}; ipc.send('toggle-insert-view'); });

复制密码

以纯文本形式显示密码通常不是一个好主意。 相反,我们将隐藏并提供一个方便的按钮,允许最终用户直接复制特定条目的密码。

在这里,Electron 再次拯救了我们,它为我们提供了一个剪贴板模块,该模块不仅可以复制和粘贴文本内容,还可以复制和粘贴图像和 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's clipboard module clipboard.clear(); clipboard.writeText(text); }); }; }]);

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

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

删除密码

我们的最终用户可能还希望能够删除密码,以防它们过时。 为此,我们需要做的就是在钥匙串集合上调用remove()方法。 我们需要将整个文档提供给“remove()”方法,如下所示:

 this.removeDoc = function(doc) { return function() { var d = $q.defer(); if(this.isLoaded() && this.getCollection()) { // remove the doc from the collection & persist changes this.getCollection().remove(doc); this.db.saveDatabase(); // inform the insert view that the db content has changed 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 模块,专门为我们的应用程序创建复杂的桌面菜单结构。

菜单模块是一个庞大的主题,几乎值得拥有自己的教程。 我强烈建议您阅读 Electron 的桌面环境集成教程,以了解该模块的所有功能。

在本教程的范围内,我们将了解如何创建自定义菜单、向其中添加自定义命令以及实现标准退出命令。

为我们的应用创建和分配自定义菜单

通常,Electron 菜单的 JavaScript 逻辑属于我们应用程序的主脚本文件,其中定义了我们的主进程。 但是,我们可以将其抽象为一个单独的文件,并通过远程模块访问 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菜单项,提供两个命令:

  • 创建密码:使用Cmd(或 Ctrl)+ N打开插入视图
  • 退出:使用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 应用程序,它看起来很棒,你用 Selenium 和 WebDriver 编写了你的​​ e2e 测试,你准备将它分发给全世界!

但是您仍然想对其进行个性化设置,给它一个自定义名称而不是默认的“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属性,允许我们创建生成的应用程序的 zip 存档。
  • 对于每个平台,可以定义一组不同的“平台资源”。

添加应用程序图标

platformResources属性之一是icon属性,它允许我们为我们的应用程序定义一个自定义图标:

 "icon": "keychain.ico"

OS X 需要带有.icns文件扩展名的图标。 有多种在线工具允许我们免费将.png文件转换为.ico.icns

结论

在本文中,我们只触及了 Electron 实际可以做的事情的皮毛。 将 Atom 或 Slack 等出色的应用程序视为您可以使用此工具的灵感来源。

我希望您发现本教程对您有用,请随时留下您的评论并分享您使用 Electron 的经验!