跨平台桌面应用开发(二)
原文:
zh.annas-archive.org/md5/FAEC8292A2BD4C155C2816C53DE9AEF2译者:飞龙
第二章:使用 NW.js 创建文件资源管理器-增强和交付
好了,我们有一个可以用于浏览文件系统并使用默认关联程序打开文件的文件资源管理器的工作版本。现在我们将扩展它以进行其他文件操作,比如删除和复制粘贴。这些选项将保留在动态构建的上下文菜单中。我们还将考虑 NW.js 在不同应用程序之间使用系统剪贴板传输数据的能力。我们将使应用程序响应命令行选项。我们还将提供对多种语言和区域设置的支持。我们将通过将其编译成本机代码来保护源代码。我们将考虑打包和分发。最后,我们将建立一个简单的发布服务器,并使文件资源管理器自动更新。
国际化和本地化
国际化,通常缩写为i18n,意味着一种特定的软件设计,能够适应目标本地市场的要求。换句话说,如果我们想将我们的应用程序分发到美国以外的市场,我们需要关注翻译、日期时间、数字、地址等的格式化。
按国家格式化日期
国际化是一个横切关注点。当您更改区域设置时,通常会影响多个模块。因此,我建议使用我们在处理DirService时已经检查过的观察者模式:
./js/Service/I18n.js
const EventEmitter = require( "events" );
class I18nService extends EventEmitter {
constructor(){
super();
this.locale = "en-US";
}
notify(){
this.emit( "update" );
}
}
exports.I18nService = I18nService;
正如您所看到的,我们可以通过为locale属性设置新值来更改locale属性。一旦我们调用notify方法,所有订阅的模块立即做出响应。
然而,locale是一个公共属性,因此我们无法控制其访问和变异。我们可以使用重载来修复它:
./js/Service/I18n.js
//...
constructor(){
super();
this._locale = "en-US";
}
get locale(){
return this._locale;
}
set locale( locale ){
// validate locale...
this._locale =
locale;
}
//...
现在,如果我们访问I18n实例的locale属性,它将通过 getter(get locale)传递。当设置它的值时,它将通过 setter(set locale)传递。因此,我们可以添加额外的功能,比如在属性访问和变异时进行验证和记录。
请记住,我们在 HTML 中有一个用于选择语言的组合框。为什么不给它一个视图呢?
./js/View/LangSelector.js:
class LangSelectorView {
constructor( boundingEl, i18n ){
boundingEl.addEventListener( "change",
this.onChanged.bind( this ), false );
this.i18n = i18n;
}
onChanged( e ){
const selectEl
= e.target;
this.i18n.locale = selectEl.value;
this.i18n.notify();
}
}
exports.LangSelectorView = LangSelectorView;
在上述代码中,我们监听组合框的更改事件。
当事件发生时,我们使用传入的I18n实例更改locale属性,并调用notify通知订阅者:
./js/app.js
const i18nService = new I18nService(),
{ LangSelectorView } = require( "./js/View/LangSelector" );
new LangSelectorView( document.querySelector( "[data-bind=langSelector]" ), i18nService );
好了,我们可以更改区域设置并触发事件。那么消费模块呢?
在FileList视图中,我们有formatTime静态方法,用于格式化传递的timeString以进行打印。我们可以使其根据当前选择的locale进行格式化:
./js/View/FileList.js:
constructor( boundingEl, dirService, i18nService ){
//...
this.i18n = i18nService;
//
Subscribe on i18nService updates
i18nService.on( "update", () => this.update( dirService.getFileList() )
);
}
static formatTime( timeString, locale ){
const date = new Date( Date.parse( timeString ) ),
options = {
year: "numeric", month: "numeric", day: "numeric",
hour:
"numeric", minute: "numeric", second: "numeric",
hour12: false
};
return
date.toLocaleString( locale, options );
}
update( collection ) {
//...
this.el.insertAdjacentHTML( "beforeend", `<li class="file-list__li" data-file="${fInfo.fileName}">
<span class="file-list__li__name">${fInfo.fileName}</span>
<span class="file-
list__li__size">${filesize(fInfo.stats.size)}</span>
<span class="file-list__li__time">
${FileListView.formatTime( fInfo.stats.mtime, this.i18n.locale )}</span>
</li>` );
//...
}
//...
在构造函数中,我们订阅I18n更新事件,并在区域设置更改时更新文件列表。formatTime静态方法将传递的字符串转换为Date对象,并使用Date.prototype.toLocaleString()方法根据给定的区域设置格式化日期时间。这个方法属于所谓的ECMAScript 国际化 API(norbertlindenberg.com/2012/12/ecmascript-internationalization-api/index.html)。这个 API 描述了内置对象--String、Date和Number--的方法,旨在格式化和比较本地化数据。然而,它真正做的是使用toLocaleString为英语(美国)区域设置(en-US)格式化Date实例,并返回日期,如下:
3/17/2017, 13:42:23
然而,如果我们将德国区域设置(de-DE)传递给该方法,我们会得到完全不同的结果:
17.3.2017, 13:42:23
为了付诸实践,我们给组合框设置了一个标识符。./index.html文件包含以下代码:
..
<select class="footer__select" data-bind="langSelector">
..
当然,我们必须创建一个I18n服务的实例,并将其传递给LangSelectorView和FileListView:
./js/app.js
// ...
const { I18nService } = require( "./js/Service/I18n" ),
{ LangSelectorView } = require(
"./js/View/LangSelector" ),
i18nService = new I18nService();
new LangSelectorView(
document.querySelector( "[data-bind=langSelector]" ), i18nService );
// ...
new FileListView(
document.querySelector( "[data-bind=fileList]" ), dirService, i18nService );
现在我们将启动应用程序。是的!当我们在组合框中更改语言时,文件修改日期会相应调整:
多语言支持
本地化日期和数字是一件好事,但为多种语言提供翻译将更加令人兴奋。我们的应用程序中有许多术语,即文件列表的列标题和窗口操作按钮上的工具提示(通过title属性)。我们需要的是一个字典。通常,它包含了映射到语言代码或区域设置的令牌翻译对的集合。因此,当您从翻译服务请求一个术语时,它可以与当前使用的语言/区域设置相匹配的翻译相关联。
在这里,我建议将字典作为一个静态模块,可以通过所需的函数加载:
./js/Data/dictionary.js
exports.dictionary = {
"en-US": {
NAME: "Name",
SIZE: "Size",
MODIFIED:
"Modified",
MINIMIZE_WIN: "Minimize window",
RESTORE_WIN: "Restore window",
MAXIMIZE_WIN:
"Maximize window",
CLOSE_WIN: "Close window"
},
"de-DE": {
NAME: "Dateiname",
SIZE: "Grösse",
MODIFIED: "Geändert am",
MINIMIZE_WIN: "Fenster minimieren",
RESTORE_WIN: "Fenster wiederherstellen",
MAXIMIZE_WIN: "Fenster maximieren",
CLOSE_WIN: "Fenster
schliessen"
}
};
因此,我们有两个翻译的术语。我们将字典作为依赖项注入到我们的I18n服务中:
./js/Service/I18n.js
//...
constructor( dictionary ){
super();
this.dictionary = dictionary;
this._locale = "en-US";
}
translate( token, defaultValue ) {
const dictionary =
this.dictionary[ this._locale ];
return dictionary[ token ] || defaultValue;
}
//...
我们还添加了一个新方法translate,它接受两个参数:token和default翻译。第一个参数可以是字典中的键之一,比如NAME。第二个参数是在字典中请求的 token 尚不存在时的默认值。因此,我们至少可以得到一个有意义的文本,至少是英文。
让我们看看如何使用这个新方法:
./js/View/FileList.js
//...
update( collection ) {
this.el.innerHTML = `<li class="file-list__li file-list__head">
<span class="file-list__li__name">${this.i18n.translate( "NAME", "Name" )}</span>
<span class="file-list__li__size">${this.i18n.translate( "SIZE", "Size" )}</span>
<span
class="file-list__li__time">${this.i18n.translate( "MODIFIED", "Modified" )}</span>
</li>`;
//...
我们用I18n实例的translate方法来更改FileList视图中的硬编码列标题,这意味着每次视图更新时,它都会接收到实际的翻译。我们也不要忘记TitleBarActions视图,那里有窗口操作按钮:
./js/View/TitleBarActions.js
constructor( boundingEl, i18nService ){
this.i18n = i18nService;
//...
// Subscribe on
i18nService updates
i18nService.on( "update", () => this.translate() );
}
translate(){
this.unmaximizeEl.title = this.i18n.translate( "RESTORE_WIN", "Restore window" );
this.maximizeEl.title =
this.i18n.translate( "MAXIMIZE_WIN", "Maximize window" );
this.minimizeEl.title = this.i18n.translate(
"MINIMIZE_WIN", "Minimize window" );
this.closeEl.title = this.i18n.translate( "CLOSE_WIN", "Close window" );
}
在这里,我们添加了translate方法,它会使用实际的翻译更新按钮标题属性。我们订阅i18n更新事件,以便在用户更改locale时调用该方法:
上下文菜单
好吧,通过我们的应用程序,我们已经可以浏览文件系统并打开文件,但是人们可能希望文件资源管理器有更多功能。我们可以添加一些与文件相关的操作,比如删除和复制/粘贴。通常,这些任务可以通过上下文菜单来完成,这给了我们一个很好的机会来研究如何在NW.js中实现。通过环境集成 API,我们可以创建系统菜单的实例(docs.nwjs.io/en/latest/References/Menu/)。然后,我们组合表示菜单项的对象,并将它们附加到菜单实例上(docs.nwjs.io/en/latest/References/MenuItem/)。这个menu可以在任意位置显示:
const menu = new nw.Menu(),
menutItem = new nw.MenuItem({
label: "Say hello",
click: () => console.log( "hello!" )
});
menu.append( menu );
menu.popup( 10, 10 );
然而,我们的任务更具体。我们必须在鼠标右键单击时在光标位置显示菜单,为了实现这一点,我们通过订阅contextmenu DOM 事件来实现:
document.addEventListener( "contextmenu", ( e ) => {
console.log( `Show menu in position ${e.x}, ${e.y}`
);
});
现在,每当我们在应用程序窗口内右键单击时,菜单就会显示出来。这并不完全是我们想要的,是吗?我们只需要在光标停留在特定区域时才显示菜单,例如当它悬停在文件名上时。这意味着我们必须测试目标元素是否符合我们的条件:
document.addEventListener( "contextmenu", ( e ) => {
const el = e.target;
if ( el instanceof
HTMLElement && el.parentNode.dataset.file ) {
console.log( `Show menu in position ${e.x}, ${e.y}` );
}
});
在这里,我们忽略事件,直到光标悬停在文件表行的任何单元格上,因为每一行都是由FileList视图生成的列表项,并且为数据文件属性提供了一个值。
这段话基本上解释了如何构建系统菜单以及如何将其附加到文件列表上。然而,在开始创建一个能够创建菜单的模块之前,我们需要一个处理文件操作的服务:
./js/Service/File.js
const fs = require( "fs" ),
path = require( "path" ),
// Copy file helper
cp = (
from, toDir, done ) => {
const basename = path.basename( from ),
to = path.join(
toDir, basename ),
write = fs.createWriteStream( to ) ;
fs.createReadStream( from
)
.pipe( write );
write
.on( "finish", done );
};
class FileService {
constructor( dirService ){
this.dir = dirService;
this.copiedFile = null;
}
remove( file ){
fs.unlinkSync( this.dir.getFile( file ) );
this.dir.notify();
}
paste(){
const file = this.copiedFile;
if (
fs.lstatSync( file ).isFile() ){
cp( file, this.dir.getDir(), () => this.dir.notify() );
}
}
copy( file ){
this.copiedFile = this.dir.getFile( file );
}
open( file
){
nw.Shell.openItem( this.dir.getFile( file ) );
}
showInFolder( file ){
nw.Shell.showItemInFolder( this.dir.getFile( file ) );
}
};
exports.FileService =
FileService;
这里发生了什么?FileService接收DirService的实例作为构造函数参数。它使用该实例通过名称获取文件的完整路径(this.dir.getFile(file))。它还利用实例的notify方法请求所有订阅DirService的视图更新。showInFolder方法调用nw.Shell的相应方法,在系统文件管理器中显示文件的父文件夹。正如你所料,remove方法删除文件。至于复制/粘贴,我们做了以下技巧。当用户点击复制时,我们将目标文件路径存储在copiedFile属性中。因此,当用户下次点击粘贴时,我们可以使用它将该文件复制到可能已更改的当前位置。open方法显然使用默认关联程序打开文件。这就是我们在FileList视图中直接做的。实际上,这个操作属于FileService。因此,我们调整视图以使用该服务:
./js/View/FileList.js
constructor( boundingEl, dirService, i18nService, fileService ){
this.file = fileService;
//...
}
bindUi(){
//...
this.file.open( el.dataset.file );
//...
}
现在,我们有一个模块来处理所选文件的上下文菜单。该模块将订阅contextmenuDOM 事件,并在用户右键单击文件时构建菜单。此菜单将包含在文件夹中显示项目、复制、粘贴和删除。复制和粘贴与其他项目分隔开,并且在我们存储了复制文件之前,粘贴将被禁用:
./js/View/ContextMenu.js
class ConextMenuView {
constructor( fileService, i18nService ){
this.file = fileService;
this.i18n = i18nService;
this.attach();
}
getItems( fileName ){
const file =
this.file,
isCopied = Boolean( file.copiedFile );
return [
{
label: this.i18n.translate( "SHOW_FILE_IN_FOLDER", "Show Item in the
Folder" ),
enabled: Boolean( fileName ),
click: () => file.showInFolder( fileName )
},
{
type: "separator"
},
{
label: this.i18n.translate( "COPY", "Copy" ),
enabled: Boolean(
fileName ),
click: () => file.copy( fileName )
},
{
label:
this.i18n.translate( "PASTE", "Paste" ),
enabled: isCopied,
click: () => file.paste()
},
{
type: "separator"
},
{
label:
this.i18n.translate( "DELETE", "Delete" ),
enabled: Boolean( fileName ),
click: () =>
file.remove( fileName )
}
];
}
render( fileName ){
const menu = new
nw.Menu();
this.getItems( fileName ).forEach(( item ) => menu.append( new
nw.MenuItem( item )));
return menu;
}
attach(){
document.addEventListener( "contextmenu", ( e ) => {
const el = e.target;
if ( !( el instanceof HTMLElement ) ) {
return;
}
if ( el.classList.contains( "file-list" ) ) {
e.preventDefault();
this.render()
.popup( e.x, e.y );
}
// If a child of an element matching [data-file]
if (
el.parentNode.dataset.file ) {
e.preventDefault();
this.render( el.parentNode.dataset.file )
.popup( e.x, e.y );
}
});
}
}
exports.ConextMenuView = ConextMenuView;
因此,在ConextMenuView构造函数中,我们接收FileService和I18nService的实例。在构造过程中,我们还调用attach方法,该方法订阅contextmenuDOM 事件,创建菜单,并在鼠标光标的位置显示它。除非光标悬停在文件上或停留在文件列表组件的空白区域中,否则事件将被忽略。当用户右键单击文件列表时,菜单仍然会出现,但除了粘贴(如果之前复制了文件)之外,所有项目都会被禁用。render方法创建菜单的实例,并使用getItems方法创建的nw.MenuItems填充它。该方法创建表示菜单项的数组。数组的元素是对象文字。label属性接受项目标题的翻译。enabled属性根据我们的情况定义项目的状态(我们是否持有复制的文件)。最后,click属性期望点击事件的处理程序。
现在我们需要在主模块中启用我们的新组件:
./js/app.js
const { FileService } = require( "./js/Service/File" ),
{ ConextMenuView } = require(
"./js/View/ConextMenu" ),
fileService = new FileService( dirService );
new FileListView(
document.querySelector( "[data-bind=fileList]" ), dirService, i18nService, fileService );
new ConextMenuView(
fileService, i18nService );
现在,让我们运行应用程序,在文件上右键单击,哇!我们有上下文菜单和新文件操作:
系统剪贴板
通常,复制/粘贴功能涉及系统剪贴板。NW.js提供了一个 API 来控制它(docs.nwjs.io/en/latest/References/Clipboard/)。不幸的是,它相当有限;我们无法在应用程序之间传输任意文件,这可能是您对文件管理器的期望。然而,对我们来说仍然有一些事情是可用的。
传输文本
为了检查使用剪贴板传输文本,我们修改了FileService的copy方法:
copy( file ){
this.copiedFile = this.dir.getFile( file );
const clipboard = nw.Clipboard.get();
clipboard.set( this.copiedFile, "text" );
}
它是做什么的?一旦我们获得文件的完整路径,我们创建一个nw.Clipboard的实例,并将文件路径保存为文本。因此,现在在文件资源管理器中复制文件后,我们可以切换到外部程序(例如文本编辑器)并从剪贴板中粘贴复制的路径:
传输图形
看起来不太方便,是吗?如果我们能复制/粘贴一个文件会更有趣。不幸的是,NW.js在文件交换方面并没有给我们太多选择。然而,我们可以在NW.js应用程序和外部程序之间传输 PNG 和 JPEG 图像:
./js/Service/File.js
//...
copyImage( file, type ){
const clip = nw.Clipboard.get(),
// load file content
as Base64
data = fs.readFileSync( file ).toString( "base64" ),
// image as HTML
html = `<img src="img/, "" ) )}">`;
// write both options
(raw image and HTML) to the clipboard
clip.set([
{ type, data: data, raw: true },
{ type:
"html", data: html }
]);
}
copy( file ){
this.copiedFile = this.dir.getFile(
file );
const ext = path.parse( this.copiedFile ).ext.substr( 1 );
switch ( ext ){
case
"jpg":
case "jpeg":
return this.copyImage( this.copiedFile, "jpeg" );
case "png":
return this.copyImage( this.copiedFile, "png" );
}
}
//...
我们用copyImage私有方法扩展了我们的FileService。它读取给定的文件,将其内容转换为 Base64,并将结果代码传递给剪贴板实例。此外,它创建了一个包含 Base64 编码图像的图像标签的 HTML,其中包含数据统一资源标识符(URI)。现在,在文件资源管理器中复制图像(PNG 或 JPEG)后,我们可以将其粘贴到外部程序中,例如图形编辑器或文本处理器。
接收文本和图形
我们已经学会了如何将文本和图形从我们的NW.js应用程序传递到外部程序,但是我们如何从外部接收数据呢?正如您可以猜到的那样,它可以通过nw.Clipboard的get方法访问。文本可以按如下方式检索:
const clip = nw.Clipboard.get();
console.log( clip.get( "text" ) );
当图形放在剪贴板上时,我们只能在 NW.js 中获取 Base64 编码的内容或 HTML。为了看到它的实际效果,我们向FileService添加了一些方法:
./js/Service/File.js
//...
hasImageInClipboard(){
const clip = nw.Clipboard.get();
return
clip.readAvailableTypes().indexOf( "png" ) !== -1;
}
pasteFromClipboard(){
const clip =
nw.Clipboard.get();
if ( this.hasImageInClipboard() ) {
const base64 = clip.get( "png", true ),
binary = Buffer.from( base64, "base64" ),
filename = Date.now() + "--img.png";
fs.writeFileSync( this.dir.getFile( filename ), binary );
this.dir.notify();
}
}
//...
hasImageInClipboard方法检查剪贴板是否保留任何图形。pasteFromClipboard方法将剪贴板中的图形内容作为 Base64 编码的 PNG 获取;它将内容转换为二进制代码,将其写入文件,并请求DirService订阅者更新它。
要使用这些方法,我们需要编辑ContextMenu视图:
./js/View/ContextMenu.js
getItems( fileName ){
const file = this.file,
isCopied = Boolean( file.copiedFile );
return [
//...
{
label: this.i18n.translate( "PASTE_FROM_CLIPBOARD", "Paste
image from clipboard" ),
enabled: file.hasImageInClipboard(),
click: () =>
file.pasteFromClipboard()
},
//...
];
}
我们向菜单添加一个新项目从剪贴板粘贴图像,仅当剪贴板中有一些图形时才启用。
系统托盘中的菜单
我们的应用程序可用的三个平台都有所谓的系统通知区域,也称为系统托盘。这是用户界面的一部分(在 Windows 的右下角和其他平台的右上角),即使在桌面上没有应用程序图标,也可以在其中找到应用程序图标。使用NW.js API(docs.nwjs.io/en/latest/References/Tray/),我们可以为我们的应用程序提供一个图标和托盘中的下拉菜单,但我们还没有任何图标。因此,我已经创建了带有文本Fe的icon.png图像,并将其保存在大小为 32x32px 的应用程序根目录中。它在 Linux,Windows 和 macOS 上都受支持。但是,在 Linux 中,我们可以使用更高的分辨率,因此我将 48x48px 版本放在了旁边。
我们的应用程序在托盘中将由TrayService表示:
./js/View/Tray.js
const appWindow = nw.Window.get();
class TrayView {
constructor( title ){
this.tray = null;
this.title = title;
this.removeOnExit();
this.render();
}
render(){
const icon = ( process.platform === "linux" ? "icon-48x48.png" : "icon-32x32.png" );
this.tray = new nw.Tray({
title: this.title,
icon,
iconsAreTemplates: false
});
const menu = new nw.Menu();
menu.append( new nw.MenuItem({
label: "Exit",
click: () => appWindow.close()
}));
this.tray.menu = menu;
}
removeOnExit(){
appWindow.on( "close", () => {
this.tray.remove();
appWindow.hide();
// Pretend to be closed already
appWindow.close( true );
});
// do not spawn Tray instances
on page reload
window.addEventListener( "beforeunload", () => this.tray.remove(), false );
}
}
exports.TrayView = TrayView;
它是做什么的?该类将托盘的标题作为构造函数参数,并在实例化期间调用removeOnExit和 render 方法。第一个订阅窗口的close事件,并确保在关闭应用程序时删除托盘。方法 render 创建nw.Tray实例。通过构造函数参数,我们传递了包含标题的配置对象,该标题是图标的相对路径。我们为 Linux 分配了icon-48x48.png图标,为其他平台分配了icon-32x32.png图标。默认情况下,macOS 尝试将图像调整为菜单主题,这需要图标由透明背景上的清晰颜色组成。如果您的图标不符合这些限制,您最好将其添加到配置对象属性iconsAreTemplates中,该属性设置为false。
在 Ubuntu 16.x 中启动我们的文件资源管理器时,由于白名单策略,它不会出现在系统托盘中。您可以通过在终端中运行sudo apt-get install libappindicator1来解决这个问题。
nw.Tray接受nw.Menu实例。因此,我们以与上下文菜单相同的方式填充菜单。现在我们只需在主模块中初始化Tray视图并运行应用程序:
./js/app.js
const { TrayView } = require( "./js/View/Tray" );
new TrayView( "File Explorer" );
如果现在运行应用程序,我们可以在系统托盘中看到应用程序图标和菜单:
是的,唯一的菜单项退出看起来有点孤单。
让我们扩展Tray视图:
./js/View/Tray.js
class TrayView {
constructor( title ){
this.tray = null;
this.title = title;
// subscribe to window events
appWindow.on("maximize", () => this.render( false ));
appWindow.on("minimize", () => this.render( false ));
appWindow.on("restore", () => this.render( true ));
this.removeOnExit();
this.render( true );
}
getItems( reset ){
return [
{
label: "Minimize",
enabled: reset,
click: () =>
appWindow.minimize()
},
{
label: "Maximize",
enabled: reset,
click: () => appWindow.maximize()
},
{
label: "Restore",
enabled:
!reset,
click: () => appWindow.restore()
},
{
type: "separator"
},
{
label: "Exit",
click: () => appWindow.close()
}
];
}
render( reset ){
if ( this.tray ) {
this.tray.remove();
}
const icon = ( process.platform === "darwin" ? "macicon.png" : "icon.png" );
this.tray =
new nw.Tray({
title: this.title,
icon,
iconsAreTemplates: true
});
const menu = new nw.Menu();
this.getItems( reset ).forEach(( item ) => menu.append( new nw.MenuItem(
item )));
this.tray.menu = menu;
}
removeOnExit(){
appWindow.on(
"close", () => {
this.tray.remove();
appWindow.hide(); // Pretend to be closed already
appWindow.close( true );
});
}
}
exports.TrayView = TrayView;
现在,render方法接收一个布尔值作为参数,定义应用程序窗口是否处于初始模式;该标志传递给新的getItems方法,该方法生成菜单项元数据数组。如果标志为 true,则所有菜单项都可用,除了还原。有意义的是在最小化或最大化后将窗口恢复到初始模式。显然,当标志为false时,Minimize和Maximize项将被禁用,但我们如何知道窗口的当前模式?在构造时,我们订阅窗口事件最小化、最大化和还原。当事件发生时,我们使用相应的标志调用render。由于我们现在可以从TitleBarActions和Tray视图中更改窗口模式,因此TitleBarActions的toggle方法不再是窗口模式的可靠来源。相反,我们更倾向于重构模块,依赖窗口事件,就像我们在Tray视图中所做的那样:
./js/View/TitleBarActions.js
const appWindow = nw.Window.get();
class TitleBarActionsView {
constructor(
boundingEl, i18nService ){
this.i18n = i18nService;
this.unmaximizeEl = boundingEl.querySelector(
"[data-bind=unmaximize]" );
this.maximizeEl = boundingEl.querySelector( "[data-bind=maximize]" );
this.minimizeEl = boundingEl.querySelector( "[data-bind=minimize]" );
this.closeEl = boundingEl.querySelector(
"[data-bind=close]" );
this.bindUi();
// Subscribe on i18nService updates
i18nService.on(
"update", () => this.translate() );
// subscribe to window events
appWindow.on("maximize", ()
=> this.toggleButtons( false ) );
appWindow.on("minimize", () => this.toggleButtons( false ) );
appWindow.on("restore", () => this.toggleButtons( true ) );
}
translate(){
this.unmaximizeEl.title = this.i18n.translate( "RESTORE_WIN", "Restore window" );
this.maximizeEl.title =
this.i18n.translate( "MAXIMIZE_WIN", "Maximize window" );
this.minimizeEl.title = this.i18n.translate(
"MINIMIZE_WIN", "Minimize window" );
this.closeEl.title = this.i18n.translate( "CLOSE_WIN", "Close window" );
}
bindUi(){
this.closeEl.addEventListener( "click", this.onClose.bind( this ), false );
this.minimizeEl.addEventListener( "click", this.onMinimize.bind( this ), false );
this.maximizeEl.addEventListener( "click", this.onMaximize.bind( this ), false );
this.unmaximizeEl.addEventListener( "click", this.onRestore.bind( this ), false );
}
toggleButtons( reset ){
this.maximizeEl.classList.toggle( "is-hidden", !reset );
this.unmaximizeEl.classList.toggle( "is-hidden", reset );
this.minimizeEl.classList.toggle( "is-hidden", !reset
);
}
onRestore( e ) {
e.preventDefault();
appWindow.restore();
}
onMaximize( e ) {
e.preventDefault();
appWindow.maximize();
}
onMinimize( e ) {
e.preventDefault();
appWindow.minimize();
}
onClose( e ) {
e.preventDefault();
appWindow.close();
}
}
exports.TitleBarActionsView =
TitleBarActionsView;
这次当我们运行应用程序时,我们可以在系统托盘应用程序菜单中找到窗口操作:
命令行选项
其他文件管理器通常接受命令行选项。例如,您可以在启动 Windows 资源管理器时指定一个文件夹。它还响应各种开关。比如,您可以给它开关/e,资源管理器将以展开模式打开文件夹。
NW.js将命令行选项显示为nw.App.argv中的字符串数组。因此,我们可以更改主模块中DirService初始化的代码:
./js/app.js
const dirService = new DirService( nw.App.argv[ 0 ] );
现在,我们可以直接从命令行中打开指定的文件夹:
npm start ~/Sandbox
在基于 UNIX 的系统中,波浪线表示用户主目录。在 Windows 中的等效表示如下:
npm start %USERPROFILE%Sandbox
我们还能做什么?仅作为展示,我建议实现--minimize和--maximize选项,分别在启动时切换应用程序窗口模式:./js/app.js
const argv = require( "minimist" )( nw.App.argv ),
dirService = new DirService( argv._[ 0 ] );
if ( argv.maximize ){
nw.Window.get().maximize();
}
if ( argv.minimize ){
nw.Window.get().minimize();
}
当我们可以使用外部模块 minimist(www.npmjs.com/package/minimist)时,手动解析nw.App.argv数组就没有意义了。它导出一个函数,该函数将所有不是选项或与选项相关联的参数收集到_(下划线)属性中。我们期望该类型的唯一参数是启动目录。它还在命令行上提供maximize和minimize属性时将它们设置为 true。
应该注意,NPM 不会将选项委托给运行脚本,因此我们应该直接调用NW.js可执行文件:
nw . ~/Sandbox/ --minimize
或
nw . ~/Sandbox/ --maximize
本机外观和感觉
现在,人们可以找到许多具有半透明背景或圆角的本机桌面应用程序。我们能否用NW.js实现这样的花哨外观?当然可以!首先,我们应该编辑我们的应用程序清单文件:
./package.json
...
"window": {
"frame": false,
"transparent": true,
...
},
...
通过将 frame 字段设置为false,我们指示NW.js不显示窗口框架,而是显示其内容。幸运的是,我们已经实现了自定义窗口控件,因为默认的窗口控件将不再可用。通过透明字段,我们去除了应用程序窗口的不透明度。要看它的实际效果,我们编辑 CSS 定义模块:
./assets/css/Base/definitions.css
:root {
--titlebar-bg-color: rgba(45, 45, 45, 0.7);
--titlebar-fg-color: #dcdcdc;
--dirlist-
bg-color: rgba(222, 222, 222, 0.9);
--dirlist-fg-color: #636363;
--filelist-bg-color: rgba(249, 249, 249,
0.9);
--filelist-fg-color: #333341;
--dirlist-w: 250px;
--titlebar-h: 40px;
--footer-h:
40px;
--footer-bg-color: rgba(222, 222, 222, 0.9);
--separator-color: #2d2d2d;
--border-radius:
1em;
}
通过 RGBA 颜色函数,我们将标题栏的不透明度设置为 70%,其他背景颜色设置为 90%。我们还引入了一个新变量--border-radius,我们将在titlebar和footer组件中使用它来使顶部和底部的角变圆:
./assets/css/Component/titlebar.css
.titlebar {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
./assets/css/Component/footer.css
.footer {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
现在我们可以启动应用程序并享受我们更新的花哨外观。
在 Linux 上,我们需要使用nw . --enable-transparent-visuals --disable-gpu命令行选项来触发透明度。
源代码保护
与原生应用程序不同,我们的源代码没有编译,因此对所有人都是开放的。如果你考虑商业用途,这可能不适合你。你至少可以混淆源代码,例如使用 Jscrambler(jscrambler.com/en/)。另一方面,我们可以将我们的源代码编译成本地代码,并用NW.js加载它,而不是 JavaScript。为此,我们需要将 JavaScript 与应用程序捆绑分离。让我们创建app文件夹,并将除了js之外的所有内容移动到那里。js文件夹将被移动到一个新创建的目录src中:
.
├── app
│
└── assets
│ └── css
│ ├── Base
│ └──
Component
└── src
└──
js
├── Data
├──
Service
└── View
我们的 JavaScript 模块现在已经超出了项目范围,当需要时我们无法访问它们。然而,这些仍然是 Node.js 模块(nodejs.org/api/modules.html),符合 CommonJS 模块定义标准。因此,我们可以使用捆绑工具将它们合并成一个单一文件,然后将其编译成本地代码。我建议使用 Webpack(webpack.github.io/),这似乎是目前最流行的捆绑工具。因此,我们将其放在根目录 webpack 配置文件中,内容如下:
webpack.config.js
const { join } = require( "path" ),
webpack = require( "webpack" );
module.exports = {
entry: join( __dirname, "src/js/app.js" ),
target: "node-webkit",
output: {
path: join(
__dirname, "/src/build" ),
filename: "bundle.js"
}
};
通过这样做,我们指示 Webpack 将所有必需的模块转译,从src/js/app.js开始,转译成一个单一的src/build/bundle.js文件。然而,与NW.js不同,Webpack 期望从托管文件(而不是项目根目录)相对于所需的依赖项;因此,我们必须从主模块的文件路径中删除js/:
./src/js/app.js
// require( "./js/View/LangSelector" ) becomes
require( "./View/LangSelector" )
为了转换 CommonJS 模块并将派生文件编译成本地代码,我们需要在清单的脚本字段中添加一些任务:
package.json
//...
"scripts": {
"build:js": "webpack",
"protect:js": "node_modules/nw/nwjs/nwjc
src/build/bundle.js app/app.bin",
"build": "npm run build:js && npm run protect:js",
//...
},
//...
在第一个任务中,我们让 webpack 将我们的 JavaScript 源代码构建成一个单一文件。第二个任务使用NW.js编译器对其进行编译。最后一个任务同时完成了这两个任务。
在 HTML 文件中,我们用以下代码替换调用主模块的代码:
app/index.html
<script>
nw.Window.get().evalNWBin( null, "./app.bin" );
</script>
现在我们可以运行应用程序,并观察实现的功能是否仍然符合我们的要求。
打包
好吧,我们已经完成了我们的应用程序,现在是时候考虑分发了。正如你所理解的,要求我们的用户安装Node.js并从命令行输入npm start并不友好。用户会期望一个可以像其他软件一样简单启动的软件包。因此,我们必须将我们的应用程序与NW.js捆绑在每个目标平台上。在这里,nwjs-builder派上了用场(github.com/evshiron/nwjs-builder)。
因此,我们安装了npm i -D nwjs-builder工具,并在清单中添加了一个任务:
./package.json
//...
"scripts": {
"package": "nwb nwbuild -v 0.21.3-sdk ./app -o ./dist -p linux64, win32,osx64",
//...
},
//...
在这里,我们一次指定了三个目标平台(-p linux64, win32,osx64),因此,在运行此任务(npm run package)后,我们在dist目录中得到特定于平台的子文件夹,其中包含以我们应用程序命名的其他可执行文件:
dist
├── file-explorer-linux-x64
│ └── file-explorer
├── file-explorer-osx-x64
│ └── file-explorer.app
└── file-explorer-win-x64
└── file-explorer.exe
Nwjs-builder接受各种选项。例如,我们可以要求它将软件包输出为 ZIP 存档:
nwb nwbuild -v 0.21.3-sdk ./app -o ./dist --output-format=ZIP
或者,我们可以在构建过程后运行包并使用给定的选项:
nwb nwbuild -v 0.21.3-sdk ./app -o ./dist -r -- --enable-transparent-visuals --disable-gpu
自动更新
在持续部署的时代,新版本发布得相当频繁。作为开发人员,我们必须确保用户可以透明地接收更新,而不必经过下载/安装的流程。对于传统的 Web 应用程序,这是理所当然的。用户访问页面,最新版本就会加载。对于桌面应用程序,我们需要传递更新。不幸的是,NW.js并没有提供任何内置设施来处理自动更新,但我们可以欺骗它;让我们看看如何做。
首先,我们需要一个简单的发布服务器。让我们给它一个文件夹(例如server)并在那里创建清单文件:
./server/package.json
{
"name": "release-server",
"version": "1.0.0",
"packages": {
"linux64": {
"url": "http://localhost:8080/releases/file-explorer-linux-
x64.zip",
"size": 98451101
}
},
"scripts": {
"start": "http-server ."
}
}
该文件包含一个packages自定义字段,描述可用的应用程序发布。这个简化的实现只接受每个平台的最新发布。发布版本必须在清单版本字段中设置。每个包对象的条目包含可下载的 URL 和包大小(以字节为单位)。
为了为release文件夹中的清单和包提供 HTTP 请求服务,我们将使用 HTTP 服务器(www.npmjs.com/package/http-server)。因此,我们安装该软件包并启动 HTTP 服务器:
npm i -S http-server
npm start
现在,我们将回到我们的客户端并修改应用程序清单文件:
./client/package.json
{
"name": "file-explorer",
manifestUrl": "http://127.0.0.1:8080/package.json",
"scripts": {
"package": "nwb nwbuild -v 0.21.3-sdk . -o ../server/releases --output-format=ZIP",
"postversion": "npm
run package"
},
//...
}
在这里,我们添加了一个自定义字段manifestUrl,其中包含指向服务器清单的 URL。启动服务器后,清单将在http://127.0.0.1:8080/package.json上可用。我们指示nwjs-builder使用 ZIP 打包应用程序包并将它们放在../server/release中。最终,我们设置了postversion钩子;因此,当提升软件包版本(例如npm version patch)时,NPM 将自动构建并发送一个发布包到服务器,每次都是如此。
从客户端,我们可以读取服务器清单并将其与应用程序进行比较。如果服务器有更新版本,我们会下载与我们平台匹配的发布包,并将其解压缩到临时目录。现在我们需要做的就是用下载的版本替换正在运行的应用程序版本。但是,该文件夹在应用程序运行时被锁定,因此我们关闭正在运行的应用程序并启动下载的应用程序(作为一个独立的进程)。它备份旧版本并将下载的软件包复制到初始位置。所有这些都可以很容易地使用nw-autoupdater(https://github.com/dsheiko/nw-autoupdater)完成,因此我们安装npm i -D nw-autoupdater软件包并创建一个新的服务来处理自动更新流程:
./client/js/Service/Autoupdate.js
const AutoUpdater = require( "nw-autoupdater" ),
updater = new AutoUpdater( nw.App.manifest );
async function start( el ){
try {
// Update copy is running to replace app with the update
if
( updater.isSwapRequest() ) {
el.innerHTML = `Swapping...`;
await updater.swap();
el.innerHTML = `Restarting...`;
await updater.restart();
return;
}
//
Download/unpack update if any available
const rManifest = await updater.readRemoteManifest();
const
needsUpdate = await updater.checkNewVersion( rManifest );
if ( !needsUpdate ) {
return;
}
if ( !confirm( "New release is available. Do you want to upgrade?" ) ) {
return;
}
// Subscribe for progress events
updater.on( "download", ( downloadSize, totalSize ) => {
const procent = Math.floor( downloadSize / totalSize * 100 );
el.innerHTML = `Downloading - ${procent}%`;
});
updater.on( "install", ( installFiles, totalFiles ) => {
const procent = Math.floor(
installFiles / totalFiles * 100 );
el.innerHTML = `Installing - ${procent}%`;
});
const updateFile = await updater.download( rManifest );
await updater.unpack( updateFile );
await updater.restartToSwap();
} catch ( e ) {
console.error( e );
}
}
exports.start = start;
在这里,我们应用了 ES2016 的 async/await 语法。通过在函数前加上async,我们声明它是异步的。之后,我们可以在任何 Promise(https://mzl.la/1jLTOHB)前使用 await 来接收其解析值。如果 Promise 被拒绝,异常将在 try/catch 语句中捕获。
这段代码到底是做什么的?正如我们商定的那样,它比较本地和远程清单版本。
如果发布服务器有更新版本,它会使用 JavaScript 的 confirm 函数通知用户。如果用户同意升级,它会下载最新版本并解压缩。在下载和解压缩过程中,更新程序对象会发出相应的消息;因此,我们可以订阅并表示进度。准备就绪后,服务将重新启动应用程序进行交换;因此,现在它用下载的版本替换了过时的版本并再次重新启动。在此过程中,服务通过在传入的 HTML 元素(el)中写入来向用户报告。按照设计,它期望元素代表标题栏中的路径容器。
因此,我们现在可以在主模块中启用服务:
./client/js/app.js
const { start } = require( "./js/Service/Autoupdate" ),
// start autoupdate
setTimeout(() => {
start( document.querySelector( "[data-bind=path]" ) );
}, 500 );
好了,我们如何测试它?我们跳转到客户端文件夹并构建一个分发包:
npm run package
据说它会落在服务器/releases。我们解压到任意位置,例如~/sandbox/:
unzip ../server/releases/file-explorer-linux-x64.zip -d ~/sandbox/
在这里,我们将找到可执行文件(对于 Linux,它将是file-explorer)并运行它。文件资源管理器将像往常一样工作,因为发布服务器没有更新版本,所以我们回到客户端文件夹并创建一个:
npm version patch
现在我们切换到服务器文件夹并编辑清单的版本以匹配刚生成的版本(1.0.1)。
然后,我们重新启动捆绑的应用程序(例如,~/sandbox/file-explorer)并观察提示:
点击“确定”后,我们可以在标题栏中看到下载和安装的进度:
然后,应用程序重新启动并报告交换。完成后,它再次重新启动,现在已更新。
总结
在本章的开始,我们的文件资源管理器只能浏览文件系统并打开文件。我们扩展了它以显示文件夹中的文件,并复制/粘贴和删除文件。我们利用了NW.js API 来为文件提供动态构建的上下文菜单。我们学会了在应用程序之间使用系统剪贴板交换文本和图像。我们使我们的文件资源管理器支持各种命令行选项。我们提供了国际化和本地化的支持,并通过在本机代码中进行编译来保护源代码。我们经历了打包过程并为分发做好了准备。最后,我们建立了一个发布服务器,并为文件资源管理器扩展了一个自动更新的服务。
第三章:使用 Electron 和 React 创建聊天系统-规划、设计和开发
在之前的章节中,我们使用了 NW.js。这是一个很棒的框架,但并不是市场上唯一的一个。它的对手 Electron 在功能集方面并不逊色于 NW.js,并且拥有更大的社区。为了做出最合适的选择,我认为必须尝试这两个框架。因此,我们下一个示例应用将是一个简单的聊天系统,我们将使用 Electron 来实现它。我们用纯 JavaScript 制作了文件浏览器。我们必须注意抽象的一致性,数据绑定,模板等。事实上,我们可以将这些任务委托给 JavaScript 框架。在撰写本文时,React、Vue 和 Angular 这三种解决方案处于短列表的前列,其中 React 似乎是最流行的。我认为它最适合我们下一个应用。因此,我们将深入了解 React 的基本知识。我们将为基于 React 的应用程序设置 Electron 和 webpack。这次我们不会手动编写所有的 CSS 样式,而是会使用 PhotonKit 标记组件。最后,我们将使用 React 组件构建聊天静态原型,并准备使其功能化。
应用蓝图
为了描述我们的应用需求,与之前一样,我们从用户故事开始:
-
作为用户,我可以向聊天室介绍自己
-
作为用户,我可以实时看到聊天参与者的列表
-
作为用户,我可以输入并提交消息
-
作为用户,我可以看到聊天参与者的消息随着它们的到来
如果将其放在线框上,第一个屏幕将是一个简单的用户名提示:
第二个屏幕包含一个带有参与者的侧边栏和一个包含对话线程和提交消息表单的主区域:
第二个屏幕与第一个屏幕共享标题和页脚,但主要部分包括参与者列表(左侧)和聊天窗格(右侧)。聊天窗格包括传入消息和提交表单。
Electron
我们已经熟悉了 NW.js。你可能知道,它有一个叫做 Electron 的替代品(electron.atom.io/)。总的来说,两者提供了可比较的功能集(bit.ly/28NW0iX)。另一方面,我们可以观察到 Electron 拥有一个更大、更活跃的社区(electron.atom.io/community/)。
Electron 也是一些知名开源项目的 GUI 框架,比如 Visual Studio Code(code.visualstudio.com/)和 Atom IDE(atom.io/)。
从开发者的角度来看,我们面临的第一个区别是,Electron 的入口点是 JavaScript,而不是 NW.js 中的 HTML。当我们启动一个 Electron 应用程序时,框架首先运行指定的脚本(主进程)。该脚本创建应用程序窗口。Electron 提供了分成模块的 API。其中一些只适用于主进程,一些适用于渲染进程(由主脚本发起的网页请求的任何脚本)。
让我们付诸实践。首先,我们将创建./package.json清单文件:
{
"name": "chat",
"version": "1.0.0",
"main": "./app/main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"devtron": "¹.4.0",
"electron": "¹.6.2",
"electron-debug": "¹.1.0"
}
}
总的来说,这个清单与我们在之前的章节中为 NW.js 创建的清单并没有太大的区别。然而,我们这里不需要window字段,main字段指向主进程脚本。
至于依赖关系,显然我们需要electron,此外,我们还将使用electron-debug包,它激活了热键F12和F5,分别用于 DevTools 和重新加载(github.com/sindresorhus/electron-debug)。我们还包括了 Electron 的 DevTools 扩展,称为 Devtron(electron.atom.io/devtron)。
现在,我们可以编辑主进程脚本:
./app/main.js
const { app, BrowserWindow } = require( "electron" ),
path = require( "path" ),
url = require( "url" );
let mainWindow;
在这里,我们从electron模块导入app和BrowserWindow。第一个允许我们订阅应用程序生命周期事件。通过第二个,我们创建和控制浏览器窗口。我们还获得了对 NPM 模块path和url的引用。第一个帮助创建与平台无关的路径,第二个帮助构建有效的 URL。在最后一行,我们声明了浏览器窗口实例的全局引用。接下来,我们将添加一个创建浏览器窗口的函数:
function createWindow() {
mainWindow = new BrowserWindow({
width: 1000, height: 600
});
mainWindow.loadURL( url.format({
pathname: path.join( __dirname, "index.html" ),
protocol: "file:",
slashes: true
}) );
mainWindow.on( "closed", () => {
mainWindow = null;
});
}
实际上,该函数只是创建一个窗口实例并在其中加载index.html。当窗口关闭时,对窗口实例的引用将被销毁。此外,我们订阅应用程序事件:
app.on( "ready", createWindow );
app.on( "window-all-closed", () => {
if ( process.platform !== "darwin" ) {
app.quit();
}
});
app.on( "activate", () => {
if ( mainWindow === null ) {
createWindow();
}
});
应用程序事件"ready"在 Electron 完成初始化时触发;然后我们创建浏览器窗口。
当所有窗口都关闭时,将触发window-all-closed事件。对于除 macOS 之外的任何平台,我们都会退出应用程序。OS X 应用程序通常会保持活动状态,直到用户明确退出。
activate事件只在 macOS 上触发。特别是,当我们单击应用程序的 Dock 或任务栏图标时会发生这种情况。如果此时没有窗口存在,我们将创建一个新窗口。
最后,我们调用electron-debug来激活调试热键:
require( "electron-debug" )();
如果现在启动 Electron,它将尝试加载我们首先要创建的index.html:
./app/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
</head>
<body>
<ul>
<li id="app"></li>
<li id="os"></li>
<li id="electronVer"></li>
</ul>
</body>
<script src="img/renderer.js"></script>
</html>
这里没有什么激动人的事情发生。我们只是声明了几个占位符并加载了一个渲染器进程脚本:
./app/renderer.js
const manifest = require( "../package.json" );
const platforms = {
win32: "Windows",
darwin: "macOS",
linux: "Linux"
};
function write( id, text ){
document.getElementById( id ).innerHTML = text;
}
write( "app", `${manifest.name} v.${manifest.version}` );
write( "os", `Platform: ${platforms[ process.platform ]}` );
write( "electronVer", `Electron v.${process.versions.electron}` );
在渲染器脚本中,我们将package.json读入manifest常量中。我们定义一个字典对象,将process.platform键映射到有意义的平台名称。我们添加一个辅助函数write,它将给定的文本分配给与给定 ID 匹配的元素。使用这个函数,我们填充 HTML 的占位符。
此时,我们预期有以下文件结构:
.
├── app
│ ├── index.html
│ ├── main.js
│ └── renderer.js
├── node_modules
└── package.json
现在,我们安装依赖项(npm i)并运行(npm start)示例。我们将看到以下窗口:
React
React 正在蓬勃发展。根据 2016 年 Stack Overflow 开发者调查,它是最流行的技术(stackoverflow.com/insights/survey/2016#technology)。有趣的是,React 甚至不是一个框架。它是一个用于构建用户界面的 JavaScript 库--非常干净、简洁和强大。该库实现了基于组件的架构。因此,我们创建组件(可重用、可组合和有状态的 UI 单元),然后像乐高积木一样使用它们来构建预期的 UI。React 将派生结构视为内存中的 DOM 表示(虚拟 DOM)。当我们将其绑定到真实的 DOM 时,React 会保持两者同步,这意味着每当其组件之一改变其状态时,React 会立即在 DOM 中反映视图的变化。
除此之外,我们可以在服务器端将虚拟 DOM 转换为 HTML 字符串(bit.ly/2oVsjVn),并通过 HTTP 响应发送它。客户端将自动绑定到已存在的 HTML。因此,我们加快页面加载速度,并允许搜索引擎抓取内容。
简而言之,组件是一个接受给定属性并返回一个元素的函数,其中元素是表示组件或 DOM 节点的普通对象。或者,可以使用扩展React.Component的类,其render方法产生元素:
要创建一个元素,可以使用 API。然而,如今,通常不直接使用,而是通过被称为JSX的语法糖。JSX 用一个看起来像 HTML 模板的新类型扩展了 JavaScript:
const name = "Jon", surname = "Snow";
const element = <header>
<h1>{name + " " + surname}</h1>
</header>;
基本上,我们直接在 JavaScript 中编写 HTML,而在 HTML 中编写 JavaScript。JSX 可以使用 Babel 编译器和预设 react(babeljs.io/docs/plugins/preset-react/)转换为普通的 JavaScript。
大多数现代 IDE 都支持 JSX 语法。
为了更好地理解,我们稍微调整了一下 React。一个基于函数的组件可能如下所示:
function Header( props ){
const { title } = props;
return (
<header>
<h1>{title}</h1>
</header>
);
}
因此,我们声明了一个 Header 组件,它生成一个表示标题的元素,标题由 title 属性填充。我们也可以使用类。因此,我们可以将组件相关的方法封装在类范围内:
import React from "react";
class Button extends React.Component {
onChange(){
alert( "Clicked!" );
}
render() {
const { text } = this.props;
return <button onChange={this.onChange.bind( this )} >{text}</button>;
}
}
该组件创建一个按钮,并为其提供了最简单的功能(当单击按钮时,我们会收到一个带有“Clicked!”文本的警报框)。
现在,我们可以将我们的组件附加到 DOM,如下所示:
import ReactDOM from "react-dom";
ReactDOM.render(<div>
<Header />
<Button text="Click me" />
</div>, document.querySelector( "#app" ) );
正如您所注意到的,组件意味着单向流动。您可以从父级向子级传递属性,但反之则不行。属性是不可变的。当我们需要从子级通信时,我们将状态提升:
import React from "react";
class Item extends React.Component {
render(){
const { onSelected, text } = this.props;
return <li onClick={onSelected( text )}>{text}</li>;
}
}
class List extends React.Component {
onItemSelected( name ){
// take care of ...
}
render(){
const names = [ "Gregor Clegane", "Dunsen", "Polliver" ];
return <nav>
<ul>{names.map(( name ) => {
return <Item name={name} onSelected={this.onItemSelected.bind( this )} />;
})}
</ul>
</nav>;
}
}
在 List 组件的 render 方法中,我们有一个名称数组。使用 map 数组原型方法,我们遍历名称列表。该方法会产生一个元素数组,JSX 会欣然接受。在声明 Item 时,我们传入当前的 name 和绑定到列表实例范围的 onItemSelected 处理程序。Item 组件呈现 <li> 并订阅传入的处理程序以处理单击事件。因此,子组件的事件由父组件处理。
Electron meets React
现在,我们对 Electron 和 React 都有了一些了解。那么如何将它们一起使用呢?为了更好地理解,我们将不从我们的真实应用程序开始,而是从一个简单的类似示例开始。它将包括一些组件和一个表单。该应用程序将在窗口标题中反映用户输入。我建议克隆我们上一个示例。我们可以重用清单和主进程脚本。但是我们必须对清单进行以下更改:
./package.json
{
"name": "chat",
"version": "1.0.0",
"main": "./app/main.js",
"scripts": {
"start": "electron .",
"dev": "webpack -d --watch",
"build": "webpack"
},
"dependencies": {
"prop-types": "¹⁵.5.7",
"react": "¹⁵.4.2",
"react-dom": "¹⁵.4.2"
},
"devDependencies": {
"babel-core": "⁶.22.1",
"babel-loader": "⁶.2.10",
"babel-plugin-transform-class-properties": "⁶.23.0",
"babel-preset-es2017": "⁶.22.0",
"babel-preset-react": "⁶.22.0",
"devtron": "¹.4.0",
"electron": "¹.6.2",
"electron-debug": "¹.1.0",
"webpack": "².2.1"
}
}
在前面的示例中,我们添加了 react 和 react-dom 模块。第一个是库的核心,第二个用作 React 和 DOM 之间的粘合剂。prop-types 模块为我们带来了类型检查能力(直到 React v.15.5,这是库的内置对象)。除了特定于 electron 的模块,我们还将 webpack 添加为开发依赖项。Webpack 是一个模块打包工具,它接受各种类型(源代码、图像、标记和 CSS)的资产,并生成客户端可以加载的包。我们将使用 webpack 来打包基于 React/JSX 的应用程序。
然而,webpack 本身不会转译 JSX;它使用 Babel 编译器(babel-core)。我们还包括 babel-loader 模块,它在 webpack 和 Babel 之间建立桥梁。babel-preset-react 模块是所谓的 Babel 预设(一组插件),它允许 Babel 处理 JSX。通过 babel-preset-es2017 预设,我们让 Babel 将符合 ES2017 的代码编译为 ES2016,这是 Electron 极大支持的。此外,我还包括了 babel-plugin-transform-class-properties Babel 插件,以解锁名为 ES Class Fields & Static Properties 的提案的功能(github.com/tc39/proposal-class-public-fields)。因此,我们将能够直接定义类属性,而无需构造函数的帮助,这在规范中尚未出现。
在脚本部分有两个额外的命令。build 命令用于为客户端打包 JavaScript。dev 命令将 webpack 设置为监视模式。因此,每当我们更改任何源代码时,它会自动打包应用程序。
在使用 webpack 之前,我们需要对其进行配置:
./webpack.config.js
const { join } = require( "path" ),
webpack = require( "webpack" );
module.exports = {
entry: join( __dirname, "app/renderer.jsx" ),
target: "electron-renderer",
output: {
path: join( __dirname, "app/build" ),
filename: "renderer.js"
},
module: {
rules: [
{
test: /.jsx?$/,
exclude: /node_modules/,
use: [{
loader: "babel-loader",
options: {
presets: [ "es2017", "react" ],
plugins: [ "transform-class-properties" ]
}
}]
}
]
}
};
我们将app/renderer.jsx设置为入口点。因此,webpack 将首先读取它并递归解析任何遇到的依赖关系。然后编译后的捆绑包可以在app/build/renderer.js中找到。到目前为止,我们已经为 webpack 设置了唯一的规则:每个遇到的.js或.jsx文件(除了node_modules目录)都会经过 Babel 处理,Babel 配置了es2017和react预设(以及transform-class-properties插件,确切地说)。因此,如果我们现在运行npm run build,webpack 将尝试将app/renderer.jsx编译成app/build/renderer.js,然后我们可以在 HTML 中调用它。
./app/index.html文件的代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
</head>
<body>
<app></app>
</body>
<script>
require( "./build/renderer.js" );
</script>
</html>
主渲染器脚本可能如下所示:
./app/renderer.jsx
import React from "react";
import ReactDOM from "react-dom";
import Header from "./Components/Header.jsx";
import Copycat from "./Components/Copycat.jsx";
ReactDOM.render((
<div>
<Header />
<Copycat>
<li>Child node</li>
<li>Child node</li>
</Copycat>
</div>
), document.querySelector( "app" ) );
在这里,我们导入了两个组件--Header和Copycat--并在一个复合组件中使用它们,然后将其绑定到 DOM 自定义元素<app>。
以下是我们用函数描述的第一个组件:
./app/Components/Header.jsx
import React from "react";
import PropTypes from "prop-types";
export default function Header( props ){
const { title } = props;
return (
<header>
<h3>{title}</h3>
</header>
);
}
Header.propTypes = {
title: PropTypes.string
};
上述代码中的函数接受一个属性--title(我们在父组件<Header />中传递了它),并将其呈现为标题。
请注意,我们使用PropTypes来验证title属性的值。如果我们设置title以外的其他值,将在 JavaScript 控制台中显示警告。
以下是用类呈现的第二个组件:
./app/Components/Copycat.jsx
import React from "react";
import { remote } from "electron";
export default class Copycat extends React.Component {
onChange( e ){
remote.getCurrentWindow().setTitle( e.target.value );
}
render() {
return (
<div>
<input placeholder="Start typing here" onChange={this.onChange.bind( this )} />
<ul>
{this.props.children}
</ul>
</div>
)
}
}
此组件呈现一个输入字段。在字段中输入的任何内容都会反映在窗口标题中。在这里,我设定了一个目标,展示一个新概念:子组件/节点。
你还记得我们在父组件中声明了带有子节点的Copycat吗?Copycat元素的代码如下:
<Copycat>
<li>Child node</li>
<li>Child node</li>
</Copycat>
现在,我们在this.props.children中接收这些列表项,并在<ul>中呈现它们。
除此之外,我们为输入元素订阅了一个this.onChange处理程序。当它改变时,我们从 electron 的远程函数中获取当前窗口实例(remote.getCurrentWindow()),并用输入内容替换其标题。
为了查看我们得到了什么,我们使用npm i安装依赖项,使用npm run build构建项目,并使用npm start启动应用程序:
启用 DevTools 扩展
我相信你在运行上一个示例时没有遇到问题。然而,当我们需要跟踪 React 应用程序中的问题时,可能会有些棘手,因为 DevTools 向我们展示的是真实 DOM 发生的事情;然而,我们也想了解虚拟 DOM 的情况。幸运的是,Facebook 提供了一个名为 React Developer Tools 的 DevTools 扩展(bit.ly/1dGLkxb)。
我们将使用 electron-devtools-installer(www.npmjs.com/package/electron-devtools-installer)来安装此扩展。该工具支持多个 DevTools 扩展,包括一些与 React 相关的:React Developer Tools(REACT_DEVELOPER_TOOLS),Redux DevTools Extension(REDUX_DEVTOOLS),React Perf(REACT_PERF)。我们现在只选择第一个。
首先我们安装包:
npm i -D electron-devtools-installer
然后我们在主进程脚本中添加以下行:
./app/main.js
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require( "electron-devtools-installer" );
我们从包中导入了installExtension函数和REACT_DEVELOPER_TOOLS常量,它代表 React Developer Tools。现在我们可以在应用程序准备就绪时调用该函数。在此事件上,我们已经调用了我们的createWindow函数。因此,我们可以扩展该函数,而不是再次订阅该事件:
function createWindow() {
installExtension(REACT_DEVELOPER_TOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log("An error occurred: ", err));
//..
现在,当我启动应用程序并打开DevTools(F12)时,我可以看到一个新的选项卡React,它将我带到相应的面板。现在,可以浏览 React 组件树,选择其节点,并检查相应的组件,编辑其 props 和 state:
静态原型
在这一点上,我们已经准备好开始使用聊天应用程序了。然而,如果我们先创建一个静态版本,然后再扩展它以实现预期的功能,那么理解起来会更容易。如今,开发人员通常不会从头开始编写 CSS,而是重用 HTML/CSS 框架(如 Bootstrap)的组件。有一个专门为 Electron 应用程序设计的框架——Photonkit(photonkit.com)。该框架为我们提供了诸如布局、窗格、侧边栏、列表、按钮、表单、表格和按钮等构建块。由这些块构建的 UI 看起来像 macOS 风格,自动适应 Electron 并响应其视口大小。理想情况下,我会选择使用 React 构建的现成的 PhotonKit 组件(react-photonkit.github.io),但我们将使用 HTML 来完成。我想向您展示如何在 PhotonKit 示例中引入任意第三方 CSS 框架。
首先,我们使用 NPM 安装它:
npm i -S photonkit
我们从包中真正需要的是dist子文件夹中的 CSS 和字体文件。从应用程序中访问包内容的唯一可靠方式是使用 require 函数(bit.ly/2oGu0Vn)。请求 JavaScript 或 JSON 文件很明显,但其他类型的文件呢,例如 CSS 呢?使用 webpack,我们理论上可以捆绑任何内容。我们只需要在 webpack 配置文件中指定相应的加载器:
./webpack.config.js
...
module.exports = {
{
...
module: {
rules: [
...
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
}
]
}
};
我们通过一个新规则扩展了 webpack 配置,该规则匹配任何扩展名为css的文件。Webpack 将使用style-loader和css-loader处理这些文件。第一个读取请求的文件,并通过注入样式块将其添加到 DOM 中。第二个将使用@import和url()请求的任何资源带到 DOM 中。
启用此规则后,我们可以直接在 JavaScript 模块中加载 Photon 样式:
import "photonkit/dist/css/photon.css";
然而,此 CSS 中使用的自定义字体仍然不可用。我们可以通过进一步扩展 webpack 配置来解决这个问题:
./webpack.config.js
module.exports = {
...
module: {
rules: [
...
{
test: /\.(eot|svg|ttf|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
use: [{
loader: "file-loader",
options: {
publicPath: "./build/"
}
}]
}
]
}
};
这个规则旨在处理字体文件,并利用file-loader,它从包中获取请求的文件,将其存储在本地,并返回新创建的本地路径。
因此,鉴于样式和字体由 webpack 处理,我们可以继续处理组件。我们将有两个组件代表窗口的标题和页脚。对于主要部分,当用户尚未提供任何名称时,我们将使用Welcome,之后使用ChatPane。第二个是Participants和Conversation组件的布局。我们还将有一个根组件App,它将所有其他组件与未来的聊天服务连接起来。实际上,这个组件不像一个展示性组件那样工作,而是作为一个容器(redux.js.org/docs/basics/UsageWithReact.html)。因此,我们将它与其他组件分开。
现在我们已经完成了架构,我们可以编写我们的启动脚本:
./app/renderer.jsx
import "photonkit/dist/css/photon.css";
import React from "react";
import ReactDOM from "react-dom";
import App from "./Containers/App.jsx";
ReactDOM.render((
<App />
), document.querySelector( "app" ) );
在这里,我们向 DOM 添加了 PhotonKit 库的 CSS(import "photonkit/dist/css/photon.css"),并将App容器绑定到<app>元素。接下来是以下容器:
./app/js/Containers/App.jsx
import React from "react";
import PropTypes from "prop-types";
import ChatPane from "../Components/ChatPane.jsx";
import Welcome from "../Components/Welcome.jsx";
import Header from "../Components/Header.jsx";
import Footer from "../Components/Footer.jsx";
export default class App extends React.Component {
render() {
const name = "Name";
return (
<div className="window">
<Header></Header>
<div className="window-content">
{ name ?
( <ChatPane
/> ) :
( <Welcome /> ) }
</div>
<Footer></Footer>
</div>
);
}
}
在这个阶段,我们只需使用 PhotonKit 应用程序布局样式(.window和.window-content)布置其他组件。正如我们商定的,根据本地常量name的值,我们在标题和页脚之间渲染ChatPane或Welcome。
顺便说一句,我们从 Photon 标记组件(photonkit.com/components/)构建的标题和页脚都称为bar。除了整洁的样式,它还可以使应用程序窗口在桌面上拖动:
./app/js/Components/Header.jsx
import React from "react";
export default class Header extends React.Component {
render() {
return (
<header className="toolbar toolbar-header">
<div className="toolbar-actions">
<button className="btn btn-default pull-right">
<span className="icon icon-cancel"></span>
</button>
</div>
</header>
)
}
}
从Header组件中的 Photon CSS 类(.toolbar和.toolbar-header)可以看出,我们在窗口顶部渲染了一个栏。该栏接受操作按钮(.toolbar-actions)。目前,唯一可用的按钮是关闭窗口的按钮。
在Footer组件中,我们在底部位置渲染了一个栏(.toolbar-footer):
./app/js/Components/Footer.jsx
import React from "react";
import * as manifest from "../../../package.json";
export default function Footer(){
return (
<footer className="toolbar toolbar-footer">
<h1 className="title">{manifest.name} v.{manifest.version}</h1>
</footer>
);
}
它包括了清单中的项目名称和版本。
对于欢迎屏幕,我们有一个简单的表单,其中包含输入字段(input.form-control)用于名称和一个提交按钮(button.btn-primary):
./app/js/Components/Welcome.jsx
import React from "react";
export default class Welcome extends React.Component {
render() {
return (
<div className="pane padded-more">
<form>
<div className="form-group">
<label>Tell me your name</label>
<input required className="form-control" placeholder="Name"
/>
</div>
<div className="form-actions">
<button className="btn btn-form btn-primary">OK</button>
</div>
</form>
</div>
)
}
}
ChatPane组件将Participants放在左侧,Conversation放在右侧。目前它所做的几乎就是这些:
./app/js/Components/ChatPane.jsx
import React from "react";
import Participants from "./Participants.jsx";
import Conversation from "./Conversation.jsx";
export default function ChatPane( props ){
return (
<div className="pane-group">
<Participants />
<Conversation />
</div>
);
}
在Participants组件中,我们使用了一个侧边栏类型的布局窗格(.pane.pane-sm.sidebar):
./app/js/Components/Participants.jsx
import React from "react";
export default class Participants extends React.Component {
render(){
return (
<div className="pane pane-sm sidebar">
<ul className="list-group">
<li className="list-group-item">
<div className="media-body">
<strong><span className="icon icon-user"></span> Name</strong>
<p>Joined 2 min ago</p>
</div>
</li>
</ul>
</div>
);
}
}
它有一个聊天参与者列表。我们为每个名字添加了由 Photon 提供的 Entype 图标。
最后一个组件--Conversation--在列表(.list-group)中渲染聊天消息和提交表单:
./app/js/Components/Conversation.jsx
import React from "react";
export default class Conversation extends React.Component {
render(){
return (
<div className="pane padded-more l-chat">
<ul className="list-group l-chat-conversation">
<li className="list-group-item">
<div className="media-body">
<time className="media-body__time">10.10.2010</time>
<strong>Name:</strong>
<p>Text...</p>
</div>
</li>
</ul>
<form className="l-chat-form">
<div className="form-group">
<textarea required placeholder="Say something..."
className="form-control"></textarea>
</div>
<div className="form-actions">
<button className="btn btn-form btn-primary">OK</button>
</div>
</form>
</div>
);
}
}
这是我们第一次需要一些自定义样式:
./app/assets/css/custom.css
.l-chat {
display: flex;
flex-flow: column nowrap;
align-items: stretch;
}
.l-chat-conversation {
flex: 1 1 auto;
overflow-y: auto;
}
.l-chat-form {
flex: 0 0 110px;
}
.media-body__time {
float: right;
}
在这里,我们让表单(.l-form)固定在底部。它有固定的高度(110px),并且所有向上的可用空间都被消息列表(.l-chat-conversation)占据。此外,我们将消息时间信息(.media-body__time)对齐到右侧,并将其从流中取出(float: right)。
这个 CSS 可以在 HTML 中加载:
./index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Chat</title>
<link href="./assets/css/custom.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<app></app>
</body>
<script>
require( "./build/renderer.js" );
</script>
</html>
我们确保所有依赖项都已安装(npm i),然后构建(npm run build)并启动应用程序(npm start)。完成后,我们可以看到以下预期的 UI:
总结
尽管我们还没有一个功能性的应用程序,只有一个静态原型,但我们已经走了很长的路。我们谈论了 Electron GUI 框架。我们将其与 NW.js 进行了比较,并了解了它的特点。我们制作了一个简化的 Electron 演示应用程序,包括一个主进程脚本,一个渲染器脚本和 HTML。我们对 React 基础知识进行了介绍。我们专注于组件和元素,JSX 和虚拟 DOM,props 和 state。我们配置了 webpack 将我们的 ES.Next 兼容的 JSX 编译成 Electron 可接受的 JavaScript。为了巩固我们的知识,我们制作了一个由 Electron 驱动的小型演示 React 应用程序。此外,我们还研究了如何在 Electron 中启用 DevTools 扩展(React Developer Tools)来跟踪和调试 React 应用程序。我们简要介绍了 PhotonKit 前端框架,并使用 PhotonKit 样式和标记创建了聊天应用程序的 React 组件。最后,我们将我们的组件捆绑在一起,并在 Electron 中渲染应用程序。