跨平台桌面应用开发-全-

跨平台桌面应用开发(全)

原文:zh.annas-archive.org/md5/FAEC8292A2BD4C155C2816C53DE9AEF2

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

HTML5 桌面应用程序开发正在蓬勃发展,如果考虑到 JavaScript 现在是网络上最流行的编程语言,这一点也就不足为奇了。HTML5 的特性与 Node.js 和运行时 API 的结合非常丰富,更不用说 GitHub 上提供的无数 Node.js 模块了。此外,HTML5 桌面应用程序可以在不修改代码的情况下分发到不同的平台(Windows、macOS 和 Linux)。

本书的目标是帮助读者发现令人兴奋的机会,解锁 Node.js 驱动的运行时(NW.js 和 Electron)给 JavaScript 开发人员,并且惊讶地发现在这个领域追赶编程细节是多么容易。

本书需要什么

要构建和运行本书中的示例,您需要使用 Linux 或 macOS;您还需要 npm/Node.js。在撰写本文时,作者使用以下软件测试了示例:

  • npm v.5.2.0

  • 节点 v.8.1.1

  • Ubuntu 16.04 LTS、Windows 10 和 macOS Sierra 10.12

本书适合谁

本书适用于任何对使用 HTML5 创建桌面应用程序感兴趣的开发人员。前两章需要基本的网络技能(HTML、CSS 和 JavaScript)和 Node.js 的基础知识。本书的这一部分包括了 npm 的速成课程,本书将使用它来构建和运行示例,前提是您有使用命令行的经验(Linux、macOS 或 Windows)。接下来的四章需要对 React 有一定的经验。最后两章,有基本的 TypeScript 知识会有所帮助。

约定

在本书中,您会发现一些区分不同信息类型的文本样式。以下是一些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"嗯,我们可以更改语言环境并触发事件。那么如何使用模块呢?

FileList 视图中,我们有一个 formatTime 静态方法,用于格式化传入的 timeString 以便打印。我们可以根据当前选择的 locale 进行格式化。

代码块设置如下:

{ 
 "name": "file-explorer", 
 "version": "1.0.0", 
 "description": "", 
 "main": "main.js", 
 "scripts": { 
 "test": "echo "Error: no test specified" && exit 1" 
 }, 
 "keywords": [], 
 "author": "", 
 "license": "ISC" 
} 

任何命令行输入或输出都以以下方式编写:

sudo npm install nw --global

新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:"显示项目"菜单包含"文件夹"、"复制"、"粘贴"和"删除"。

警告或重要说明会以这样的方式出现在框中。

提示和技巧会以这样的方式出现。

第一章:使用 NW.js 创建文件资源管理器-规划、设计和开发

如今,当谈到 HTML5 桌面应用程序开发时,人们通常指的是NW.jsElectron。第一个学习曲线较短,对于初学者来说是更好的选择。我们的第一个应用程序将是一个文件资源管理器。这种软件传统上被认为是经典的桌面应用程序。我相信你会发现用 HTML、CSS 和 JavaScript 构建一个文件资源管理器是令人兴奋的。本章不需要掌握 JavaScript 框架的技能,因为我们不会使用任何框架。你只需要基本的 HTML、CSS 和纯 JavaScript 知识(包括 Node.js)。

那么,我们要做什么?我们将规划和勾画项目。我们将设置开发环境并创建静态原型,并在 NW.js 中运行它。我们将实现基本功能,使其准备好在第二章中进行增强,使用 NW.js 创建文件资源管理器增强和交付

应用程序蓝图

通过文件资源管理器,我指的是一个小程序,允许浏览文件系统并对文件执行基本操作,这可以用以下用户故事来表达:

  • 作为用户,我可以查看当前目录的内容

  • 作为用户,我可以浏览文件系统

  • 作为用户,我可以用默认关联的程序打开文件

  • 作为用户,我可以删除文件

  • 作为用户,我可以复制文件到剪贴板,然后在新位置粘贴

  • 作为用户,我可以用系统文件管理器打开包含文件的文件夹

  • 作为用户,我可以关闭应用程序窗口

  • 作为用户,我可以最小化应用程序窗口

  • 作为用户,我可以最大化和恢复应用程序窗口

  • 作为用户,我可以更改应用程序语言

以视觉形式来理解会更容易,不是吗?线框图在这里很有用。线框图是应用程序的骨架框架,描述了应用程序内容的排列,包括 UI 元素和导航系统。线框图没有真正的图形、排版甚至颜色。它以图表的方式展示了应用程序的功能。你知道,用铅笔在纸上画是可能的,但不是创建线框图的最佳方式;我们需要的是原型工具。今天市场上有很多解决方案。在这里,我使用了一个令人印象深刻但价格实惠的工具,叫做WireframeSketcherwireframesketcher.com/)。它允许你勾画 Web、桌面和移动应用程序(正是我们需要的)。它还有丰富的样机库,包括样式、小部件、图标和模板,使得原型设计快速简单。此外,线框图以草图风格呈现得很好:

在线框图上我们看到的通常被称为圣杯布局。在我们的情况下,标题栏充当窗口标题栏。在那里,我们保留了窗口操作的控件,如关闭、最大化和最小化。除此之外,在标题栏中,我们显示当前目录的路径。在侧边栏中,我们有文件系统导航。主要部分包含一个表示当前目录文件的表格。它有列--名称、大小和修改日期。右键单击文件会打开一个包含可用文件操作的上下文菜单。页脚包括应用程序标题和语言选择器组合框。

建立一个 NW.js 项目

NW.js 是一个用于构建 HTML、CSS 和 JavaScript 应用程序的开源框架。你也可以把它看作是一个无头浏览器(基于 Chromium www.chromium.org/),它包括 Node.js 运行时,并提供桌面环境集成 API。实际上,这个框架非常容易上手。我们只需要一个起始页 HTML 文件和项目清单文件(package.json)。

为了看到它的运行情况,我们将在任意位置创建一个名为file-explorer的项目文件夹。文件夹位置的选择取决于您,但我个人更喜欢在 Linux/macOS 上保留 Web 项目在/<username>/Sites,在 Windows 上保留在%USERPROFILE%Sites

当我们进入目录时,我们为 JavaScript 和 CSS 源文件创建占位符文件夹(jsassets/css):

我们还放置了一个起始页 HTML(index.html),其中只包含几行:

./index.html
<!DOCTYPE html>
<html>
 <body>
  <h1>File Explorer</h1>
 </body>
</html>

正如您所猜测的,当将此文件输入浏览器时,我们将只看到这个文本--文件浏览器。

现在,我们需要 Node.js 清单文件(package.json)。嵌入在框架中的 Node.js 将使用它来解析依赖包名称,当使用require函数或从 npm 脚本调用时。此外,NW.js 还从中获取项目配置数据。

为什么不使用 npm 工具创建清单文件并填充它的依赖项?

Node 包管理器

如今,Node 包管理器npm)是 Web 开发人员工具中最受欢迎的工具之一。它是一个与相应的在线软件包存储库连接的命令行实用程序,能够进行软件包安装、版本管理和依赖管理。因此,当我们需要一个软件包(库、框架和模块),我们将检查它是否在 npm 存储库中可用,并运行 npm 将其引入我们的项目。它不仅下载软件包,还智能地解决其依赖关系。此外,npm 作为自动化工具非常方便。我们可以设置各种命令行任务,通过名称引用任何本地安装的软件包。npm 工具将在已安装的软件包中找到可执行软件包并运行它。

npm 工具与 Node.js 一起分发。因此,您可以在 Node.js 下载页面(nodejs.org/en/download)上找到 Windows 或 macOS 的安装程序。它也作为 APT 软件包提供,因此您可以使用apt-get工具在 Linux 上安装它:

sudo apt-get install npm

如果您已经安装了 npm,请确保它是最新的:

sudo npm install npm@latest -g

正如我已经说过的,我们可以使用 npm 安装包--例如 NW.js。如果我们想要全局安装,我们将运行以下命令:

sudo npm install nw --global

或者,我们可以运行以下命令:

sudo npm i nw -g

这将在{prefix}/lib/node_modules/中下载 NW.js 的最新版本,并将可执行文件放在{prefix}/bin中。它将二进制文件添加到PATH环境变量中,因此可以在 shell 中的任何位置调用nw

{prefix}为了找出{prefix}是什么,可以运行:

npm config get prefix。在 Linux/macOS 上,它将是/usr/local。在 Windows 上是%APPDATA%npm

这样,我们将在系统中拥有一个 NW.js 的单一实例,但是如果一个应用程序需要特定版本的 NW.js 怎么办?幸运的是,通过 npm,我们也可以在本地安装一个包,因此,依赖于解决我们应用程序的特定版本。此外,我们可以在package.json文件中管理本地依赖项。使用一个命令,npm 可以一次安装/更新所有在那里列出的依赖项。

让我们看看它在我们的项目上是如何工作的。我们转到项目根目录(file-explorer文件夹)并运行以下命令:

npm init -y 

它会生成一个包含以下内容的package.json文件:

{ 
  "name": "file-explorer", 
  "version": "1.0.0", 
  "description": "", 
  "main": "main.js", 
  "scripts": { 
   "test": "echo "Error: no test specified" && exit 1" 
  }, 
  "keywords": [], 
  "author": "", 
  "license": "ISC" 
} 

name字段中,我们设置我们的应用程序名称。请注意,NW.js 将使用提供的值来命名项目持久数据的系统相关路径中的目录(nw.App.dataPath)。因此,它应该是一个唯一的、小写的字母数字组合,但可能包括一些特殊符号,如._-

字段版本期望应用程序版本作为字符串,符合语义化版本标准(semver.org/)。这归结为三个用点分隔的数字组成的复合产品版本。第一个数字(MAJOR)在我们进行不兼容的 API 更改时递增,第二个数字(MINOR)在引入新功能时增加,最后一个数字(PATCH)标识错误修复。

main字段中,我们让 NW.js 知道在哪里找到我们的起始页 HTML。我们必须编辑清单以更改其值为index.html

./package.json

{ 
  ... 
  "main": "index.html", 
  ... 
} 

scripts字段接受一个键值对象,其中包含项目的自动化脚本。默认情况下,它有一个用于测试的占位符。现在,运行以下命令:

npm run test

Shell 响应错误消息,表示没有指定测试,因为我们还没有测试。但是,我们需要一个脚本来启动应用程序。因此,我们再次编辑package.json并在scripts字段中添加以下行:

package.json

{ 
  ... 
  "scripts": { 
    "start": "nw .", 
    "test": "echo "Error: no test specified" && exit 1" 
  }, 

  ... 
} 

现在,我们可以输入npm run startnpm start来在项目根目录上运行 NW.js,但是我们还没有安装框架。我们正要引入它。

清单字段-例如描述/关键字和作者-帮助其他人发现应用程序作为一个包。license字段告诉人们他们可以如何使用该包。您可以在docs.npmjs.com/files/package.json找到有关这些字段和其他可能选项的更多信息。

在告诉 npm 安装框架之前,我们注意到标准版本的 NW.js 不包括 DevTools,而我们在开发中肯定会需要。因此,我们寻找一个特定版本,即所谓的 SDK 版本。要找出 NW.JS 包(nw)可用的包版本,我们运行以下命令:

npm view nw dist-tags

或者,我们可以运行以下命令:

npm v nw dist-tags

这将收到以下输出:

{
  latest: '0.20.3',
  alphasdk: '0.13.0-alpha4sdk',
  alpha5sdk: '0.13.0-alpha5sdk',
  alpha6sdk: '0.13.0-alpha6sdk',
  alpha7sdk: '0.13.0-alpha7sdk',
  sdk: '0.20.3-sdk' 
}

从这个负载中,我们可以假设在撰写时的最新版本是0.20.3,并且它伴随着0.20.3-sdk。因此,我们可以按照以下方式安装框架:

npm install nw@0.20.3-sdk --save-dev

或者,我们可以按照以下方式安装它:

npm i nw@0.20.3-sdk -D

实际上,由于我们知道该包有一个名为sdk的分发标签,我们也可以按照以下方式进行:

npm i nw@sdk -D

运行任何这些命令后,我们可以在node_modules中找到一个新的子目录。在那里,npm 会安装本地依赖项。

您是否注意到我们应用了--save-dev (-D)选项?这样,我们要求 npm 将包保存在我们的开发依赖列表中。请注意package.json已更改:

{ 
  "name": "file-explorer", 
  "version": "1.0.0", 
  "description": "", 
  "main": "index.html", 
  "scripts": { 
    "start": "nw .", 
    "test": "echo "Error: no test specified" && exit 1" 
  }, 
  "keywords": [], 
  "author": "", 
  "license": "ISC", 
  "devDependencies": { 
 "nw": "⁰.20.3-sdk" 
 } 
} 

我们将该包安装为开发依赖,因为这个 SDK 版本仅用于开发。在第二章中,使用 NW.js 创建文件资源管理器-增强和交付,我们将研究分发和打包技术。因此,您将看到我们如何将应用程序与特定于平台的 NW.js 生产构建捆绑在一起。

由于我们在清单文件中反映了我们的依赖关系,我们可以通过运行以下命令随时更新此包以及任何进一步的包:

npm update

如果我们丢失了node_modules(例如,在从远程 GIT 存储库克隆项目时,给定依赖文件夹通常在忽略列表中),我们可以通过以下命令安装所有依赖项:

npm i

您是否注意到?在package.json中,我们将nw包分配为所谓的插入范围⁰.20.3-sdk的版本。这意味着在安装/更新过程中,npm 将接受具有补丁和次要更新的新版本,但不接受主要版本。

以下是一些有用的 npm 命令:

npm i pkg-name:安装包的最新可用版本

npm i pkg-name@version:安装包的具体版本

npm i pkg-name -S:将包安装为依赖项并保存在package.json

npm i pkg-name -D:将包安装为开发依赖项并保存在package.json

npm i: 安装package.json中列出的所有依赖项(包括开发依赖项)

npm i --production: 安装依赖项,但不包括开发依赖项

npm list: 显示所有已安装的依赖项

npm uninstall nw --save: 卸载一个包并从中删除

npm un nw -S: 更简洁的语法

package.json

在这一点上,我们有了框架实例和package.json指向index.html。因此,我们可以运行到目前为止在清单文件中定义的唯一脚本:

 npm start 

首先,在 Ubuntu 上在 NW.JS 上运行它:

然后,在 Windows 上在 NW.JS 上运行它:

最后,我们在 macOS 上运行它:

NW.js 创建了一个窗口并在其中呈现了index.html。它采用了默认的窗口参数。如果我们想要自定义它们,我们需要编辑package.json

首先,我们将添加接受以下属性的对象的window字段:

  • window.icon: 这指定了窗口图标的相对路径。

  • window.show: 这指示应用程序启动时窗口是否可见。例如,您可以在清单中将其设置为 false,然后使用 JavaScript 以编程方式更改它(nw.Window.get().show( true ))。

  • window.frame: 当设置为false时,这将使窗口无框架。

  • window.width / window.height: 这将以像素为单位设置窗口的默认大小。

  • window.min_width / window.min_height: 这将设置窗口的最小可接受大小。

  • window.position: 这指定窗口应放置的位置。该值可以是nullcentermouse

  • window.resizable: 当设置为true时,此属性使窗口可以调整大小。

我们还将使用chromium-args字段来指定要传递给 chromium 的命令行参数。在这里,我们将其设置为--mixed-context,以将 NW.js 切换到相应的模式。因此,我们可以直接从 Node.js 模块访问浏览器和 NW.js API。NW.js 引入了 Node.js 上下文,除了浏览器上下文之外,并将它们分开。在使用 NWJS 元数据扩展后,清单如下所示:

./package.json

{ 
  ... 
  "chromium-args": "--mixed-context", 
  "window": { 
    "show": true, 
    "frame": true, 
    "width": 1000, 
    "height": 600, 
    "min_width": 800, 
    "min_height": 400, 
    "position": "center", 
    "resizable": true 
  } 
} 

这些只是为我们简单应用程序设置的一些首选项。所有可用选项都可以在github.com/nwjs/nw.js/wiki/manifest-format找到。

一个 HTML 原型

我们已经到了可以开始为我们的应用程序创建模板的地步。使用 HTML 和 CSS,我们将实现预期的外观和感觉。稍后,我们将把 JavaScript 模块绑定到操作元素上。

我们首先用以下代码替换index.html的内容:

./index.html

<!DOCTYPE html> 
<html> 
  <head> 
   <title>File Explorer</title> 
   <meta charset="UTF-8"> 
   <meta name="viewport" content="width=device-width, initial-scale=1.0"> 
   <link href="./assets/css/app.css" rel="stylesheet" type="text/css"> 
  </head> 
  <body class="l-app"> 
   <header class="l-app__titlebar titlebar"> 
   </header> 
   <div class="l-app__main l-main"> 
   <aside class="l-main__dir-list dir-list"> 
   </aside> 
   <main class="l-main__file-list file-list"> 
   </main> 
   </div> 
   <footer class="l-app_footer footer"> 
   </footer> 
  </body> 
</html> 

在这里,我们只是用语义上有意义的 HTML 标签定义了页面布局。正如您所看到的,我们引用了./assets/css/app.css,我们将要创建它。

可维护的 CSS

在我们开始样式化之前,我想简要谈一下 CSS 中可维护性的重要性。尽管 CSS 是一种声明性语言,但它需要的细心程度并不比一般代码少。当浏览公共存储库(如 GitHub)时,您仍然可以找到许多项目,其中所有样式都放在一个充满代码异味的单个文件中,并且类命名没有一致性。

嗯,这一开始可能不是什么大问题,但 CSS 和其他代码一样,往往会增长。最终,您将以数千行腐烂的代码结束,通常是由不同的人编写的。

然后,你必须修复 UI 元素的外观,但你意识到跨级影响这个元素的现有 CSS 声明有数十个。你改变一个,样式就会不可预测地在其他元素上破坏。因此,你可能会决定添加自己的规则来覆盖现有的样式。之后,你可能会发现一些现有规则具有更高的特异性,你将不得不通过级联使用蛮力;每次都会变得更糟。

为了避免这种可维护性问题,我们必须将整个应用程序 UI 分解为组件,并设计 CSS 代码,使它们可重用、可移植和无冲突;以下启发法可能会有所帮助:

  • 将整个 CSS 代码分成代表组件、布局和状态的模块

  • 始终使用类来进行样式设置(而不是 ID 或属性)

  • 避免有资格的选择器(带有navullih2等标签的选择器)

  • 避免位置依赖(长选择器,如.foo.bar.bazarticle

  • 保持选择器简短

  • 不要反应性地使用!important

有不同的方法论可以帮助改善 CSS 的可维护性。可能最流行的方法是 元素 修饰符BEM)。它引入了一个令人惊讶的简单但强大的概念(en.bem.info/methodology/key-concepts/)。它描述了一种鼓励可读性和可移植性的类名模式。我相信最好的解释方法是通过一个例子来说明。假设我们有一个代表博客文章的组件:

<article class="post"> 
   <h2 class="post__title">Star Wars: The Last Jedi's red font is a 
    cause for concern/h2> 
   <time datetime="2017-01-23 06:00" class="post__time">Jan 23, 2017</time> 
</article> 

在 BEM 术语中,这个标记代表一个块,我们可以用类名post来定义。这个块有两个元素-post__titlepost_time。元素是块的组成部分;你不能在父块上下文之外使用它们。

现在想象一下,我们必须突出显示列表中的一篇文章。因此,我们向块的类添加了post--sponsored修改器:

<article class="post post--sponsored"> 
.... 
</article> 

起初,包含双破折号和下划线的类名可能会让你头晕,但过一段时间你会习惯的。BEM 命名约定通过显示缩进,极大地帮助开发人员。因此,当阅读自己或别人的代码时,你可以通过类名快速地弄清楚一个类的目的。

除了 BEM 命名约定之外,我们还将从实用 CSS 样式指南(github.com/dsheiko/pcss)中借鉴一些想法。我们将给代表全局状态的类添加以is-has-为前缀的名称(例如,is-hiddenhas-error);我们将用l-为前缀来表示与布局相关的类(例如,l-app)。最后,我们将把所有 CSS 文件合并到两个文件夹(ComponentBase)中。

定义基本规则

首先,我们将创建一个Base目录,并将重置样式放在其中:

./assets/css/Base/base.css

html { 
  -webkit-font-smoothing: antialiased; 
} 

* { 
  box-sizing: border-box; 
} 

nav > ul { 
  list-style: none; 
  padding: 0; 
  margin: 0; 
} 

body { 
  min-height: 100vh; 
  margin: 0; 
  font-family: Arial; 
} 

.is-hidden { 
  display: none !important; 
} 

对于 HTML 范围,我们将启用字体平滑,以获得更好的字体渲染。

然后,我们将设置每个元素(*)的盒子尺寸为border-box。默认的 CSS 盒模型是content-box,其中宽度和高度设置为一个元素不包括填充和边框。但是,如果我们设置,比如说,侧边栏宽度为250px,我希望它能覆盖这个长度。使用border-box,盒子的大小始终是我们设置的大小,而不受填充或边框的影响,但如果你问我,border-box模式感觉更自然。

我们将重置用于导航(nav > ul)的缩进和标记-无序列表。我们使 body 元素跨越整个视口的高度(min-height: 100vh),移除默认边距,并定义字体系列。

我们还将引入一个全局状态is-hidden,可以应用于任何元素,将其从页面流中移除。顺便说一句,这是对!important的积极和因此可允许的使用的一个很好的例子。通过添加一个is-hidden类(使用 JavaScript),我们声明我们希望隐藏该元素,没有例外。因此,我们永远不会遇到特异性问题。

定义布局

这就够作为基本样式了;现在,我们将开始布局。首先,我们将安排标题栏、主要部分和页脚:

为了实现这种设计,我们应该优先使用 Flexbox。如果您对这种布局模式不熟悉,我建议阅读文章《理解 Flexbox:你需要知道的一切》(bit.ly/2m3zmc1)。它可能是最清晰和易于理解的解释 Flexbox 是什么,有哪些选项可用,以及如何有效使用它们的方式。

因此,我们可以这样定义应用程序布局:

./assets/css/Component/l-app.css

.l-app { 
  display: flex; 
  flex-flow: column nowrap; 
  align-items: stretch; 
} 

.l-app__titlebar { 
  flex: 0 0 40px; 
} 

.l-app__main { 
  flex: 1 1 auto; 
} 

.l-app__footer { 
  flex: 0 0 40px; 
} 

我们使.l-app成为一个伸缩容器,沿着交叉轴垂直地排列内部项目(flex-flow: column nowrap)。此外,我们要求伸缩项目填充容器的整个高度(align-items: stretch)。我们将标题栏和页脚设置为固定高度(flex: 0 0 40px)。但是,主要部分可能会根据视口大小而收缩和增长(flex: 1 1 auto)。

由于我们有了应用程序布局,让我们为主要部分定义内部布局:

我们需要做的是使dir-listfile-list项目水平排列:

./assets/css/Component/l-main.css

.l-main { 
  display: flex; 
  flex-flow: row nowrap; 
  align-items: stretch; 
} 

.l-main__dir-list { 
  flex: 0 0 250px; 
} 

.l-main__file-list { 
  flex: 1 1 auto; 
} 

在上述代码中,我们使用flex-flow: row nowrap将伸缩项目沿着主轴水平排列。l-main__dir-list项目具有固定宽度,其宽度取决于视口。

实际上,在给组件添加一些颜色之前,很难看到我们工作的任何结果:

./assets/css/Component/titlebar.css

.titlebar { 
  background-color: #2d2d2d; 
  color: #dcdcdc; 
  padding: 0.8em 0.6em; 
} 

我们还给footer组件上色:

./assets/css/Component/footer.css

.footer { 
  border-top: 1px solid #2d2d2d; 
  background-color: #dedede; 
  padding: 0.4em 0.6em; 
} 

file-list组件:

./assets/css/Component/file-list.css

.file-list { 
  background-color: #f9f9f9; 
  color:  #333341; 
} 

最后是dir-list组件:

./assets/css/Component/dir-list.css

.dir-list { 
  background-color: #dedede; 
  color: #ffffff; 
  border-right: 1px solid #2d2d2d; 
} 

现在,我们只需要在索引文件中包含所有模块:

./assets/css/app.css

@import url("./Base/base.css"); 
@import url("./Component/l-app.css"); 
@import url("./Component/titlebar.css"); 
@import url("./Component/footer.css"); 
@import url("./Component/dir-list.css"); 
@import url("./Component/file-list.css"); 

应用程序启动后,我们使用以下命令启动应用程序:

npm start

它启动应用程序并显示布局:

对于字体大小和相关参数,如填充,我们使用相对单位(em)。这意味着我们将这些值相对于父字体大小设置:

.component { font-size: 10px; } .component__part { font-size: 1.6em; /* 计算后的字体大小为 10*1.6=16px */ }

这个技巧可以让我们有效地扩展组件。例如,当使用响应式 Web 设计RWD)方法时,我们可能需要按比例减小较小视口宽度的字体大小和间距。使用 ems 时,我们只需为目标组件更改字体大小,从而使从属规则的值适应。

定义 CSS 变量

NW.js 的发布频率相当高,基本上会随着每个新版本的 Chromium 更新。这意味着我们可以安全地使用最新的 CSS 功能。我最感兴趣的是自定义属性www.w3.org/TR/css-variables),它们以前被称为 CSS 变量。

实际上,变量是 CSS 预处理器存在的主要原因之一。使用 NW.js,我们可以在 CSS 中本地设置变量,如下所示:

--color-text: #8da3c5; 
--color-primary: #189ac4; 

之后,我们可以在文档范围内的所有模块中使用变量而不是实际值:

.post__title { 
  color: var(--color-primary); 
} 
.post__content { 
  color: var(--color-text); 
} 

因此,如果我们现在决定更改其中一个定义的颜色,我们只需要做一次,任何依赖于该变量的规则都会接收到新值。让我们为我们的应用程序采用这项技术。

首先,我们需要为模块创建定义:

./assets/css/Base/defenitions.css

:root { 
  --titlebar-bg-color: #2d2d2d; 
  --titlebar-fg-color: #dcdcdc; 
  --dirlist-bg-color: #dedede; 
  --dirlist-fg-color: #636363; 
  --filelist-bg-color: #f9f9f9; 
  --filelist-fg-color: #333341; 
  --dirlist-w: 250px; 
  --titlebar-h: 40px; 
  --footer-h: 40px; 
  --footer-bg-color: #dedede; 
  --separator-color: #2d2d2d; 
} 

在这里,我们在根范围内定义了代表颜色和固定尺寸的变量。这个新文件被包含在 CSS 索引文件中:

./assets/css/app.css

@import url("./Base/defenitions.css"); 
... 

然后,我们需要修改我们的组件。首先,我们要处理顶层应用程序布局:

./assets/css/Component/l-app.css

.l-app { 
  display: flex; 
  flex-flow: column nowrap; 
  align-items: stretch; 
} 

.l-app__titlebar { 
  flex: 0 0 var(--titlebar-h); 
} 

.l-app__main { 
  flex: 1 1 auto; 
} 

.l-app_footer { 
  flex: 0 0 var(--footer-h); 
} 

然后,我们布局由目录和文件列表组成的主要部分的两列:

./assets/css/Component/l-main.css

.l-main { 
  display: flex; 
  flex-flow: row nowrap; 
  align-items: stretch; 
} 

.l-main__dir-list { 
  flex: 0 0 var(--dirlist-w); 
} 

.l-main__file-list { 
  flex: 1 1 auto; 
} 

我们样式化页眉:

./assets/css/Component/titlebar.css

.titlebar { 
  background-color: var(--titlebar-bg-color); 
  color: var(--titlebar-fg-color); 
  padding: 0.8em 0.6em; 
} 

和页脚:

./assets/css/Component/footer.css

.footer { 
  border-top: 1px solid var(--separator-color); 
  background-color: var(--footer-bg-color); 
  padding: 0.4em 0.6em; 
} 

我们还需要为主要部分的子组件设置颜色。因此,样式化文件列表组件:

./assets/css/Component/file-list.css

.file-list { 
  background-color: var(--filelist-bg-color); 
  color: var(--filelist-fg-color); 
} 

和目录列表组件:

./assets/css/Component/dir-list.css

.dir-list { 
  background-color: var(--dirlist-bg-color); 
  color: var(--dirlist-fg-color); 
  border-right: 1px solid var(--separator-color); 
} 

我们可以运行应用程序来观察它看起来是一样的。所有颜色和大小都成功地从变量中推断出来。

固定标题栏和页眉

布局在没有任何内容的情况下看起来很好,但如果接收到太长的内容会发生什么?

实际上,当滚动时,我们会有一个头部和页脚移出视图。这看起来不够用户友好。幸运的是,我们可以轻松地使用 CSS 的另一个新功能Sticky positioningwww.w3.org/TR/css-position-3/#sticky-pos)来改变它。

我们需要做的就是稍微修改标题栏组件:

./assets/css/Component/titlebar.css

.titlebar { 
  ... 
  position: sticky; 
  top: 0; 
} 

和页脚:

./assets/css/Component/footer.css

.footer { 
  ... 
  position: sticky; 
  bottom: 0; 
} 

在前面的代码中,我们声明标题栏将固定在顶部,页脚将固定在底部。现在运行应用程序,你会注意到两个框始终可见,无论滚动如何:

样式化标题栏

说到视图内容,我们已经准备好填充布局插槽。我们将从标题栏开始:

./index.html

<header class="l-app__titlebar titlebar"> 
  <span class="titlebar__path">/home/sheiko/Sites/file-explorer</span> 
  <a class="titlebar__btn"  >_</a> 
  <a class="titlebar__btn is-hidden" > </a> 
  <a class="titlebar__btn" ></a> 
  <a class="titlebar__btn" ></a> 
</header> 

基本上,我们希望当前路径显示在左侧,窗口控件显示在右侧。这可以通过 Flexbox 实现。这是一个不会被重用的小型布局,因此如果我们将其混合到组件模块中,也不会有什么问题:

./assets/css/Component/titlebar.css

.titlebar { 
  ... 
  display: flex; 
  flex-flow: row nowrap; 
  align-items: stretch; 
} 
.titlebar__path { 
  flex: 1 1 auto; 
} 
.titlebar__btn { 
  flex: 0 0 25px; 
  cursor: pointer; 
} 

样式化目录列表

目录列表将用于浏览文件系统,因此我们将其包装在nav > ul结构中:

./index.html

<aside class="l-main__dir-list dir-list"> 
  <nav> 
    <ul> 
      <li class="dir-list__li">..</li> 
      <li class="dir-list__li">assets</li> 
      <li class="dir-list__li">js</li> 
      <li class="dir-list__li">node_modules</li> 
      <li class="dir-list__li">tests</li></ul> 
  </nav> 
</aside> 

为了支持它的样式,我们使用以下代码:

./assets/css/Component/dir-list.css

.dir-list__li { 
  padding: 0.8em 0.6em; 
  cursor: pointer; 
  white-space: nowrap; 
  overflow: hidden; 
  text-overflow: ellipsis; 
} 

.dir-list__li:hover { 
  background-color: var(--dirlist-bg-hover-color); 
  color: var(--dirlist-fg-hover-color); 
} 

请注意,我们只是引入了一些变量。让我们在定义模块中添加它们:

./assets/css/Base/definitions.css

  --dirlist-bg-hover-color: #d64937; 
  --dirlist-fg-hover-color: #ffffff; 

当我们运行应用程序时,我们可以观察到目录列表中的新内容:

样式化文件列表

文件列表将被表示为表格,但我们将使用无序列表构建它。./index.html文件包含以下代码:

<main class="l-main__file-list file-list"> 
  <nav> 
    <ul> 
      <li class="file-list__li file-list__head"> 
      <span class="file-list__li__name">Name</span> 
      <span class="file-list__li__size">Size</span> 
      <span class="file-list__li__time">Modified</span> 
      </li> 
      <li class="file-list__li"> 
        <span class="file-list__li__name">index.html</span> 
        <span class="file-list__li__size">1.71 KB</span> 
        <span class="file-list__li__time">3/3/2017, 15:44:19</span> 
      </li> 
      <li class="file-list__li"> 
        <span class="file-list__li__name">package.json</span> 
        <span class="file-list__li__size">539 B</span> 
        <span class="file-list__li__time">3/3/2017, 17:53:19</span> 
      </li> 
    </ul> 
  </nav> 
</main> 

实际上,这里Grid Layoutwww.w3.org/TR/css3-grid-layout/)可能更合适;然而,在撰写本文时,NW.js 中尚未提供此 CSS 模块。因此,我们继续使用 Flexbox:

./assets/css/Component/file-list.css

.file-list { 
  background-color: var(--filelist-bg-color); 
  color: var(--filelist-fg-color); 
  cursor: pointer; 
} 

.file-list__li { 
  display: flex; 
  flex-flow: row nowrap; 
} 

.file-list__li:not(.file-list__head){ 
  cursor: pointer; 
} 
.file-list__li:not(.file-list__head):hover { 
  color: var(--filelist-fg-hover-color); 
} 
.file-list__li > * { 
  flex: 1 1 auto; 
  padding: 0.8em 0.8em; 
  overflow: hidden; 
} 

.file-list__li__name { 
  white-space: nowrap; 
  text-overflow: ellipsis; 
  width: 50%; 
} 
.file-list__li__time { 
  width: 35%; 
} 
.file-list__li__size { 
  width: 15%; 
} 

我相信前面的代码都很清楚,除非你可能不熟悉伪类:not()。我想在悬停时改变所有文件列表项的颜色和鼠标指针,但不包括表头。因此,我使用了一个选择器,可以读作任何.file-list__li,但不是.file-list__head

以下任务分配给定义文件:

./assets/css/Base/definitions.css

--filelist-fg-hover-color: #d64937; 

当我们运行应用程序时,我们可以看到带有文件列表的表格:

样式化页脚

最后,我们现在到达了页脚:

./index.html

... 
<footer class="l-app__footer footer"> 
  <h2 class="footer__header">File Explorer</h2> 
  <select class="footer__select"> 
    <option value="en-US">English</option> 
    <option value="de-DE">Deutsch</option> 
  </select> 
</footer> 

我们将应用程序标题排列到左侧,语言选择器排列到右侧。我们用什么来布局?显然是 Flexbox:

./assets/css/Component/footer.css

.footer { 
 ... 
  display: flex; 
  flex-flow: row nowrap; 
  justify-content: flex-end; 
} 

.footer__header { 
  margin: 0.2em auto 0 0; 
  font-size: 1em; 
} 

这是一个特殊情况。通常我们设置项目右对齐,但已经重置了.footer__header项目,它紧贴着由margin-right: auto驱动的左边框:

在查看结果时,我认为强调一些 UI 元素的功能含义与图标会很好。我个人更喜欢Material Design 系统的图标字体(material.io/icons/)。因此,如开发人员指南中所述(google.github.io/material-design-icons/),我们将相应的 Google Web 字体包含到index.html中:

./index.html

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" 
      rel="stylesheet"> 

我建议您专门为代表图标的组件分配一个规则集,并使用 Material Design 建议的规则集填充它:

./assets/css/Component/icon.css

.icon { 
  font-family: 'Material Icons'; 
  font-weight: normal; 
  font-style: normal; 
  font-size: 16px; 
  display: inline-block; 
  line-height: 1; 
  text-transform: none; 
  letter-spacing: normal; 
  word-wrap: normal; 
  white-space: nowrap; 
  direction: ltr; 
  -webkit-font-smoothing: antialiased; 
  text-rendering: optimizeLegibility; 
} 

现在,我们可以在 HTML 中的任何位置添加一个图标,就是这么简单:

<i class="material-icons">thumb_up</i> 

那么为什么不在目录列表中的项目旁边加上一个文件夹图标呢?:

<li class="dir-list__li"><i class="icon">folder</i>assets</li> 

我相信地球仪图标会与语言选择器很好地配合。所以我们修改 HTML:

./index.html

... 
<footer class="l-app__footer footer"> 
   <h2 class="footer__header">File Explorer</h2> 
   <label class="icon footer__label">language</label> 
          .... 

然后我们在 CSS 中添加一个类:

./assets/css/Component/footer.css

... 
.footer__label { 
  margin-right: 0.2em; 
  font-size: 1.4em; 
  margin-top: 0.1em; 
} 

当我们运行应用程序时,我们可以看到一个图标呈现在语言选择器控件旁边:

如果在运行应用程序后出现问题,您可以随时调用开发者工具--只需按下* F12 *:

满足功能要求

我们已经用 HTML 描述了应用程序的语义结构。我们已经用 CSS 定义了我们的 UI 元素应该是什么样子。现在,我们将教会我们的应用程序检索和更新内容,并响应用户事件。实际上,我们将把以下任务分配给几个模块:

  • DirService:这提供了对目录导航的控制

  • FileService:这处理文件操作

  • FileListView:这使用 DirService 接收到的数据更新文件列表,使用 FileService 处理用户事件(打开文件,删除文件等)

  • DirListView:这使用 DirService 接收到的数据更新目录列表,并使用 DirService 处理导航事件

  • TitleBarPath:这使用 DirService 接收到的路径更新当前位置

  • TitleBarActions:这处理用户与标题栏按钮的交互

  • LangSelector:这处理用户与语言选择器的交互

但在我们开始编码之前,让我们看看我们的工具库中有什么。

NW.js 与最新稳定版本的 Node.js 一起分发,Node.js 对 ES2015/ES2016 有很好的支持(node.green)。这意味着我们可以使用任何内在的新 JavaScript 特性,但模块(bit.ly/2moblwB)。Node.js 有自己的 CommonJS 兼容模块加载系统。当我们按路径请求一个模块,例如require(“./foo”),运行时会搜索相应的文件(foo.jsfoo.jsonfoo.node)或目录(./foo/index.js)。然后,Node.js 评估模块代码并返回导出的类型。

例如,我们可以创建一个导出字符串的模块:

./foo.js

console.log( "foo runs" ); 
exports.message = "foo's export"; 

还有另一个,从第一个模块导入:

./bar.js

const foo = require( "./foo" ); 
console.log( foo.message ); 

如果我们运行它,我们会得到以下结果:

$node bar.js
foo runs
foo's export

这里应该注意的是,无论我们需要一个模块多少次,它只会执行一次,并且每次都会从缓存中获取其导出。

从 ES2015 开始

正如我已经提到的,NW.js 完全支持 ES2015 和 ES2016 版本的 JavaScript。要理解它的真正含义,我们需要简要回顾一下这种语言的历史。JavaScript 的标准规范首次发布于 1997 年(ECMA-262 第 1 版)。

从那时起,语言在 10 年内并没有真正改变。2007 年提出的第 4 版呼吁进行重大改变。然而,工作组(TC39)未能就功能集达成一致意见。一些提案被认为对 Web 不利,但有些被采纳到了一个名为 Harmony 的新项目中。该项目成为了语言规范的第 6 版,并于 2015 年以 ES2015 的官方名称发布。现在,委员会每年都会发布一个新的规范。

新的 JavaScript 向后兼容较早的版本。因此,您仍然可以使用 ECMAScript 第 5 版甚至第 3 版的语法编写代码,但为什么我们要放弃使用新的高级语法和功能集的机会呢?我认为如果我们现在了解一些将在应用程序中使用的新语言方面将会很有帮助。

作用域

在过去,我们总是使用var语句进行变量声明。ES2015 引入了两个新的声明变量--letconstvar语句在函数作用域中声明变量:

(function(){ 
    var foo = 1; 
    if ( true ) { 
            var foo = 2; 
            console.log( foo ); 
    } 
    console.log( foo ); 
}()); 

$ node es6.js
2
2

使用var声明的变量(foo)跨越整个函数作用域,这意味着每次我们通过名称引用它时,都会指向相同的变量。letconst都在块作用域(if语句,for/while循环等)中运行,如下所示:

 (function(){ 
    let foo = 1; 
    if ( true ) { 
            let foo = 2; 
            console.log( foo ); 
    } 
    console.log( foo ); 
}()); 

$ node es6.js
2
1

从上面的例子中可以看出,我们可以在块中声明一个新变量,它只存在于该块中。const语句的工作方式相同,只是它定义了一个在声明后不能重新分配的常量。

JavaScript 暗示了一种基于原型的面向对象编程风格。它与其他流行的编程语言(如 C++、C#、Objective-C、Java 和 PHP)中使用的基于类的 OOP 不同。这常常让新手开发人员感到困惑。ES2015 为原型提供了一种语法糖,看起来非常像经典类:


class Machine { 
    constructor( name ){ 
   this.name = name; 
  } 
} 
class Robot extends Machine { 
  constructor( name ){ 
   super( name ); 
  } 
  move( direction = "left" ){ 
   console.log( this.name + " moving ", Robot.normalizeDirection( direction ) ); 
  } 
  static normalizeDirection( direction ) { 
          return direction.toLowerCase(); 
  } 
} 

const robot = new Robot( "R2D2" ); 
robot.move(); 
robot.move( "RIGHT" ); 

$ node es6.js
R2D2 moving  left
R2D2 moving  right

在这里,我们声明了一个Machine类,在实例化期间为原型属性name分配一个值。Robot类扩展了Machine,因此继承了原型。在子类型中,我们可以使用super关键字调用父构造函数。

我们还定义了一个原型方法--move--和一个静态方法--normalizeDirectionmove方法具有所谓的默认函数参数。因此,如果我们在调用 move 方法时省略方向参数,参数将自动设置为"left"

在 ES2015 中,我们可以使用一种简短的语法来定义方法,而不需要在每个声明中重复函数关键字。它也适用于对象文字:


const R2D2 = { 
    name: "R2D2", 
    move(){ 
            console.log( "moving" ); 
    }, 
    fly(){       
            console.log( "flying" ); 
    } 
}; 

模板文字

JavaScript 的另一个重要补充是模板文字。这些是可以多行的字符串文字,可以包含插入的表达式(${expression})。例如,我们可以重构我们的 move 方法体,如下所示:


console.log( ` 
    ${this.name} moving  ${Robot.normalizeDirection( direction )} 
` ); 

获取器和设置器

获取器和设置器在 ES5.1 中被添加。在 ES2015 中,它被扩展为计算属性名称,并与短方法符号一起使用:


class Robot { 
  get nickname(){ 
   return "But you have to prove first that you belong to the Rebel 
         Alliance!"; 
  } 
  set nickname( nickname ){ 
   throw new Error( "Seriously?!" ); 
  } 
}; 

const robot = new Robot(); 
console.log( robot.nickname ); 
robot.nickname = "trashcan";  

$ node es6.js
But you have to prove first that you belong to the Rebel Alliance!
Error: Seriously?!

箭头函数

函数声明也获得了语法糖。我们现在使用更短的语法来编写它。值得注意的是,以这种方式定义的函数(箭头函数)会自动获取周围的上下文:

class Robot extends Machine { 
    //... 
  isRebel(){ 
          const ALLOWED_NAMES = [ "R2D2", "C3PO" ]; 
          return ALLOWED_NAMES.find(( name ) => { 
                return name === this.name; 
          }); 
  } 
} 

当使用旧的函数语法时,传递给数组方法find的回调函数会丢失Robot实例的上下文。然而,箭头函数不会创建自己的上下文,因此外部上下文(this)会进入闭包。

在这个特定的例子中,就像经常使用数组额外的情况一样,回调体非常简短。因此,我们可以使用更短的语法:

return ALLOWED_NAMES.find( name => name === this.name ); 

解构

在新的 JavaScript 中,我们可以从数组和对象中提取特定的数据。假设我们有一个数组,可以由外部函数构建,并且我们想要它的第一个和第二个元素。我们可以这样简单地提取它们:

const robots =  [ "R2D2", "C3PO", "BB8" ]; 
const [ r2d2, c3po ] = robots; 
console.log( r2d2, c3po ); 

在这里,我们声明了两个新的常量--r2d2c3po--并分别将第一个和第二个数组元素赋给它们。

我们可以对对象做同样的事情:

const meta = { 
    occupation: "Astromech droid", 
    homeworld: "Naboo" 
}; 

const { occupation, homeworld } = meta; 
console.log( occupation, homeworld ); 

我们做了什么?我们声明了两个常量--occupationhomeworld--分别从相应命名的对象成员中接收值。

而且,我们甚至可以在提取时给对象成员取别名:

const { occupation: affair, homeworld: home } = meta; 
console.log( affair, home ); 

在最后一个示例中,我们将对象成员occupationhomeworld的值委托给新创建的常量affairhome

处理窗口操作

回到file-explorer,我们可以从TitleBarActions模块开始,该模块监听标题栏按钮的用户点击事件并执行相应的窗口操作。首先,我们需要在 HTML 中标记动作节点。./index.html文件包含以下代码:

<header class="l-app__titlebar titlebar" data-bind="titlebar"> 
  ... 
  <a class="titlebar__btn" data-bind="close" > ;</a> 
</header> 

在这里,我们指定了我们的边界框(data-bind="titlebar")和关闭窗口按钮(data-bind="close")。让我们从唯一的按钮开始。./js/View/TitleBarActions.js文件包含以下代码:

class TitleBarActionsView { 

  constructor( boundingEl ){    
   this.closeEl = boundingEl.querySelector( "[data-bind=close]" ); 
          this.bindUi(); 
  } 

    bindUi(){ 
          this.closeEl.addEventListener( "click", this.onClose.bind( this ), false ); 
    } 

  onClose( e ) { 
   e.preventDefault(); 
   nw.Window.get().close(); 
  } 
} 

exports.TitleBarActionsView = TitleBarActionsView; 

在这里,我们定义了一个TitleBarActionView类,它接受一个 HTML 元素作为参数。这个元素表示视图的边界框,这意味着这个类的实例只会处理传入的元素及其后代。在构造过程中,该类将在边界框范围内搜索与selector [data-bind=close]匹配的第一个元素--标题栏的关闭窗口按钮。在bindUI方法中,我们订阅了关闭按钮的点击事件。当按钮被点击时,onClose方法将在TitleBarActionView实例的上下文中被调用,因为我们在bindUi中绑定了它(this.onClose.bind( this ))。onClose方法使用 NW.js Window API(docs.nwjs.io/en/latest/References/Window/)关闭窗口,即请求当前窗口对象nw.Window.get()并调用其 close 方法。

NW.js 没有为 API 提供模块,而是在全局范围内暴露了nw变量。

因此,我们有了第一个视图模块,并可以在主脚本中使用它:

./js/app.js

const { TitleBarActionsView } = require( "./js/View/TitleBarActions" ); 

new TitleBarActionsView( document.querySelector( "[data-bind=titlebar]" ) ); 

在这里,我们从./js/View/TitleBarActions模块中导入TileBarActionView类并创建一个实例。我们将第一个匹配选择器[data-bind=titlebar]的文档元素传递给类构造函数。

你注意到我们在从模块中导入时使用了解构吗?特别是,我们将TitleBarActionsView类提取到了一个相应命名的常量中。

现在,我们可以启动应用程序并观察,当点击关闭按钮时,窗口确实关闭了。

进一步,我们要处理其他标题栏按钮。因此,我们调整我们的index.html文件以识别按钮,使用data-bind属性的unmaximizemaximizeminimize值来标识节点。然后,在TileBarActionView构造函数中收集对应的 HTML 元素的引用:

this.unmaximizeEl = boundingEl.querySelector( "[data-bind=unmaximize]" ); 
this.maximizeEl = boundingEl.querySelector( "[data-bind=maximize]" ); 
this.minimizeEl = boundingEl.querySelector( "[data-bind=minimize]" ); 

当然,我们必须在bindUi模块中添加新的监听器:

this.minimizeEl.addEventListener( "click", this.onMinimize.bind( this ), false ); 
this.maximizeEl.addEventListener( "click", this.onMaximize.bind( this ), false ); 
this.unmaximizeEl.addEventListener( "click", this.onUnmaximize.bind( this ), false ); 

最小化窗口按钮的处理程序看起来与我们之前已经检查过的处理程序非常相似。它只是使用了 NW.js Window API 的相应方法:

onMinimize( e ) { 
    e.preventDefault(); 
    nw.Window.get().minimize(); 
} 

对于最大化和最小化(还原)窗口按钮,我们需要考虑到一个按钮可见时,另一个按钮应该隐藏的事实。我们可以通过toggleMaximize方法实现这一点:

toggleMaximize(){ 
    this.maximizeEl.classList.toggle( "is-hidden" ); 
    this.unmaximizeEl.classList.toggle( "is-hidden" ); 
} 

这些按钮的事件处理程序调用此方法来切换按钮视图:


onUnmaximize( e ) { 
    e.preventDefault(); 
    nw.Window.get().unmaximize(); 
    this.toggleMaximize(); 
} 

onMaximize( e ) { 
    e.preventDefault(); 
    nw.Window.get().maximize(); 
    this.toggleMaximize(); 
} 

编写一个用于浏览目录的服务

其他模块,如FileListViewDirListViewTitleBarPath,从文件系统中获取数据,如目录列表、文件列表和当前路径。因此,我们需要创建一个服务来提供这些数据:

./js/Service/Dir.js

const fs = require( "fs" ), 
      { join, parse } = require( "path" ); 

class DirService  { 

  constructor( dir = null ){ 
    this.dir = dir || process.cwd(); 
  } 

  static readDir( dir ) { 
    const fInfoArr = fs.readdirSync( dir, "utf-8" ).map(( fileName ) => { 
      const filePath = join( dir, fileName ), 
            stats = DirService.getStats( filePath ); 
      if ( stats === false ) { 
        return false; 
      } 
      return { 
        fileName, 
        stats 
      }; 
    }); 
    return fInfoArr.filter( item => item !== false ); 
  } 

  getDirList() { 
    const collection = DirService.readDir( this.dir ).filter(( fInfo ) 
         => fInfo.stats.isDirectory() ); 
    if ( !this.isRoot() ) { 
      collection.unshift({ fileName: ".." }); 
    } 
    return collection; 
  } 

  getFileList() { 
    return DirService.readDir( this.dir ).filter(( fInfo ) => 
        fInfo.stats.isFile() ); 
  } 

  isRoot(){ 
    const { root } = parse( this.dir ); 
    return ( root === this.dir ); 
  } 

  static getStats( filePath ) { 
    try { 
      return fs.statSync( filePath ); 
    } catch( e ) { 
      return false; 
    } 
  } 

}; 

exports.DirService = DirService; 

首先,我们导入 Node.js 核心模块fs,它为我们提供了对文件系统的访问。我们还从path模块中提取函数--joinparse。我们将需要它们来操作文件/目录路径。

然后,我们声明DirService类。在构造时,它创建一个dir属性,该属性接受传入的值或当前工作目录(process.cwd())。我们向类添加了一个静态方法--readDir--用于读取给定位置的目录内容。fs.readdirSync方法检索目录的内容,但我们扩展了文件/目录统计信息(https://nodejs.org/api/fs.html#fs_class_fs_stats)。如果无法获取统计信息,我们将其数组元素替换为false。为了避免输出数组中的这种间隙,我们将运行数组filter方法。因此,在退出点上,我们有一个干净的文件名和文件统计信息数组。

getFileList方法请求readDir获取当前目录内容,并过滤列表,只留下其中的文件。

getDirList方法显然是仅对目录进行过滤。此外,它在列表前面加上一个..目录,用于向上导航,但只有在我们不在系统根目录时才这样做。

因此,我们可以从使用它们的模块中获取这两个列表。当位置更改并且新的目录和文件列表可用时,这些模块中的每个模块都必须进行更新。为了实现它,我们将使用观察者模式:

./js/Service/Dir.js

//.... 
const EventEmitter = require( "events" ); 

class DirService extends EventEmitter { 

  constructor( dir = null ){ 
   super(); 
   this.dir = dir || process.cwd(); 
  } 
  setDir( dir = "" ){ 
   let newDir = path.join( this.dir, dir ); 
   // Early exit 
   if ( DirService.getStats( newDir ) === false ) { 
   return; 
   } 
   this.dir = newDir; 
   this.notify(); 
  } 

  notify(){ 
   this.emit( "update" ); 
  } 
  //... 
} 

我们从事件、核心模块中导出EventEmitter类(https://nodejs.org/api/events.html)。通过将其与DirService扩展,我们使服务成为事件发射器。这使我们有可能触发服务事件并订阅它们:

dirService.on( "customEvent", () => console.log( "fired customEvent" )); 
dirService.emit( "customEvent" ); 

因此,每当调用setDir方法更改当前位置时,它会触发类型为"update"的事件。假设消费模块已订阅,它们会通过更新其视图来响应事件。

单元测试服务

我们已经编写了一个服务,并假设它满足了功能要求,但我们还不确定。为了检查它,我们将创建一个单元测试。

到目前为止,我们还没有任何测试环境。我建议使用Jasmine测试框架(jasmine.github.io/)。我们将在tests/unit-tests子文件夹中创建一个专用的 NW.js 项目,用于测试。这样,我们就可以获得与应用程序中相同的测试运行环境。

因此,我们创建测试项目清单:

./tests/unit-tests/package.json

{ 
  "name": "file-explorer", 
  "main": "specs.html", 
  "chromium-args": "--mixed-context" 
} 

它指向 Jasmine 测试运行器页面,就是我们放在package.json旁边的那个:

./tests/unit-tests/specs.html

<!doctype html> 
<html> 
<head> 
    <meta charset="utf-8"> 
    <title>Jasmine Spec Runner</title> 
    <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.5.2/jasmine.css"> 
    <script src="img/jasmine.js"></script> 
    <script src="img/jasmine-
      html.js"></script> 
    <script src="img/boot.js"></script> 
</head> 
<body> 
  <div id="sandbox" style="display: none"></div> 
    <script> 
      // Catch exception and report them to the console. 
      process.on( "uncaughtException", ( err ) => console.error( err ) ); 
      const path = require( "path" ), 
            jetpack = require( "fs-jetpack" ), 
            matchingSpecs = jetpack.find( "../../js", { 
                matching: [ 
                  "*.spec.js", 
                  "!node_modules/**" 
                ] 
            }, "relativePath" ); 

      matchingSpecs.forEach(( file ) => { 
        require( path.join( __dirname, file ) ); 
      }); 
    </script> 
</body> 
</html> 

这个跑步者做什么?它加载 Jasmine,并借助fs-jetpack npm 模块(www.npmjs.com/package/fs-jetpack)递归遍历源目录,查找所有与"*.spec.js"模式匹配的文件。所有这些文件都被添加到测试套件中。因此,它假设我们将测试规范保存在目标源模块旁边。

fs-jetpack是一个外部模块,我们需要安装该包并将其添加到开发依赖列表中:

npm i -D fs-jetpack

Jasmine 实现了一个广泛使用的前端开发测试范式行为驱动开发BDD),可以用以下模式描述:


describe( "a context e.g. class or module", () => { 
  describe( "a context e.g. method or function", () => { 
    it( "does what expected", () => { 
       expect( returnValue ).toBe( expectedValue ); 
    }); 
  }); 
}); 

正如单元测试中通常接受的那样,一个套件可能有设置和拆卸:

beforeEach(() => { 
  // something to run before to every test 
}); 
afterEach(() => { 
  // something to run after to every test 
}); 

在测试涉及到触及文件系统、跨网络通信或与数据库交互的服务时,我们必须小心。一个好的单元测试是独立于环境的。因此,为了测试我们的DirService,我们必须模拟文件系统。让我们测试服务类的getFileList方法,看看它的作用:

./js/Service/Dir.spec.js

const { DirService } = require( "./Dir" ), 
      CWD = process.cwd(), 
      mock = require( "mock-fs" ), 
      { join } = require( "path" ); 

describe( "Service/Dir", () => { 

  beforeEach(() => { 
    mock({ 
     foo: { 
        bar: { 
          baz: "baz", // file contains text baz 
          qux: "qux" 
        } 
     } 
    }); 
  }); 
  afterEach( mock.restore ); 

  describe( "#getFileList", () => { 
    it( "receives intended file list", () => { 
       const service = new DirService( join( "foo", "bar" ) ); 
       service.setDir( "bar" ); 
       let files = service.getFileList(); 
       expect( files.length ).toBe( 2 ); 
    }); 
    it( "every file has expected properties", () => { 
       const service = new DirService( join( "foo", "bar" ) ); 
       const files = service.getFileList(); 
       console.log( files ); 
       const [ file ] = files; 
       expect( file.fileName ).toBe( "baz" ); 
       expect( file.stats.size ).toBe( 3 ); 
       expect( file.stats.isFile() ).toBe( true ); 
       expect( file.stats.isDirectory() ).toBe( false ); 
       expect( file.stats.mtime ).toBeTruthy(); 
    }); 
  }); 
}); 

在运行测试之前,我们将fs方法指向包含bazqux文件的/foo/bar/虚拟文件系统。每次测试后,我们都会恢复对原始文件系统的访问。在第一个测试中,我们在foo/bar位置实例化服务,并使用getFileList()方法读取内容。我们断言找到的文件数量为2(正如我们在beforeEach中定义的)。在第二个测试中,我们取列表的第一个元素,并断言它包含预期的文件名和状态。

由于我们使用了外部的 npm 包(www.npmjs.com/package/mock-fs)进行模拟,我们需要安装它:

npm i -D mock-fs

当我们想出第一个测试套件时,我们可以修改项目清单文件以获得适当的测试运行程序脚本。./package.json文件包含以下代码:

{ 
  ... 
  "scripts": { 
    ... 
    "test": "nw tests/unit-tests" 
  }, 
  ... 
} 

现在,我们可以运行测试:

npm test

NW.js 将加载并呈现以下屏幕:

理想情况下,单元测试覆盖上下文中所有可用的函数/方法。我相信从前面的代码中,你会对如何编写测试有所了解。但是,你可能会在测试EventEmitter接口时遇到困难;考虑这个例子:


describe( "#setDir", () => { 
  it( "fires update event", ( done ) => { 
     const service = new DirService( "foo" ); 
     service.on( "update", () => { 
       expect( true ).toBe( true ); 
       done(); 
     }); 
     service.notify(); 
  }); 
}); 

EventEmitter工作是异步的。当测试体中有异步调用时,我们必须明确告知 Jasmin 测试何时准备好,以便框架可以继续进行下一个测试。当我们调用传递给其函数的回调时,就会发生这种情况。在前面的示例中,我们订阅了服务上的"update"事件,并调用notify来触发事件。一旦捕获到事件,我们就调用done回调。

编写视图模块

好吧,我们有了服务,所以我们可以实现使用它的视图模块。但是,首先我们必须在 HTML 中标记视图的边界框:

./index.html

<span class="titlebar__path" data-bind="path"></span> 
.. 
<aside class="l-main__dir-list dir-list"> 
  <nav> 
    <ul data-bind="dirList"></ul> 
  </nav> 
</aside> 
<main class="l-main__file-list file-list"> 
  <nav> 
    <ul data-bind="fileList"></ul> 
  </nav> 
</main> 

DirList 模块

对于DirList视图,我们的要求是什么?它会呈现当前路径中的目录列表。当用户从列表中选择一个目录时,它会更改当前路径。随后,它会更新列表以匹配新位置的内容:

./js/View/DirList.js

class DirListView { 

  constructor( boundingEl, dirService ){ 
    this.el = boundingEl; 
    this.dir = dirService; 
    // Subscribe on DirService updates 
    dirService.on( "update", () => this.update( dirService.getDirList() ) ); 
  } 

  onOpenDir( e ){ 
    e.preventDefault(); 
    this.dir.setDir( e.target.dataset.file ); 
  } 

  update( collection ) { 
    this.el.innerHTML = ""; 
    collection.forEach(( fInfo ) => { 
      this.el.insertAdjacentHTML( "beforeend", 
        `<li class="dir-list__li" data-file="${fInfo.fileName}"> 
         <i class="icon">folder</i> ${fInfo.fileName}</li>` ); 
    }); 
    this.bindUi(); 
  } 

  bindUi(){ 
    const liArr = Array.from( this.el.querySelectorAll( "li[data-file]" ) ); 
    liArr.forEach(( el ) => { 
      el.addEventListener( "click", e => this.onOpenDir( e ), false ); 
    }); 
  } 
} 

exports.DirListView = DirListView; 

在类构造函数中,我们订阅了DirService"update"事件。因此,每次事件触发时视图都会更新。update方法执行视图更新。它用从DirService接收到的数据构建的列表项填充边界框。完成后,它调用bindUi方法来订阅openDir处理程序,以便在新创建的项目上进行单击事件。正如你所知,Element.querySelectorAll返回的不是数组,而是非实时的NodeList集合。它可以在for..of循环中迭代,但我更喜欢forEach数组方法。这就是为什么我将NodeList集合转换为数组的原因。

onOpenDir处理程序方法从data-file属性中提取目标目录名称,并将其传递给DirList以更改当前路径。

现在,我们有了新的模块,所以我们需要在app.js中初始化它们:

./js/app.js

const { DirService } = require( "./js/Service/Dir" ), 
      { DirListView } = require( "./js/View/DirList" ), 
      dirService = new DirService(); 

new DirListView( document.querySelector( "[data-bind=dirList]" ), dirService ); 

dirService.notify(); 

在这里,我们需要新的操作类,创建服务的实例,并将其与视图边界框元素一起传递给DirListView构造函数。在脚本的末尾,我们调用dirService.notify()来使所有可用的视图更新为当前路径。

现在,我们可以运行应用程序,并观察随着我们在文件系统中导航,目录列表如何更新:

npm start 

单元测试视图模块

看起来,我们期望编写单元测试,不仅仅是针对服务,还包括其他模块。在测试视图时,我们必须检查它是否对指定的事件做出了正确的响应:

./js/View/DirList.spec.js

const { DirListView } = require( "./DirList" ), 
      { DirService } = require( "../Service/Dir" ); 

describe( "View/DirList", function(){ 

  beforeEach(() => { 
    this.sandbox = document.getElementById( "sandbox" ); 
    this.sandbox.innerHTML = `<ul data-bind="dirList"></ul>`; 
  }); 

  afterEach(() => { 
    this.sandbox.innerHTML = ``; 
  }); 

  describe( "#update", function(){ 
    it( "updates from a given collection", () => { 
      const dirService = new DirService(), 
            view = new DirListView( this.sandbox.querySelector( "[data-bind=dirList]" ), dirService ); 
      view.update([ 
        { fileName: "foo" }, { fileName: "bar" } 
      ]); 
      expect( this.sandbox.querySelectorAll( ".dir-list__li" ).length ).toBe( 2 ); 
    }); 
  }); 
}); 

如果你还记得测试运行器 HTML 中,我们有一个带有sandboxid 的隐藏div元素。在每个测试之前,我们用视图期望的 HTML 片段填充该元素。因此,我们可以将视图指向带有 sandbox 的边界框。

创建视图实例后,我们可以调用其方法,并向其提供任意输入数据(这里是要更新的集合)。在测试结束时,我们断言该方法是否在沙盒内生成了预期的元素。

在前面的测试中,为了简单起见,我直接向视图的更新方法注入了一个固定的数组。一般来说,最好使用Sinon库(sinonjs.org/)来存根DirServicegetDirList。这样,我们也可以通过调用DirServicenotify方法来测试视图的行为--就像在应用程序中发生的那样。

文件列表模块

处理文件列表的模块与我们刚刚审查的模块非常相似:

./js/View/FileList.js

const filesize = require( "filesize" ); 

class FileListView { 

  constructor( boundingEl, dirService ){ 
    this.dir = dirService; 
    this.el = boundingEl; 
    // Subscribe on DirService updates 
    dirService.on( "update", () => this.update( 
    dirService.getFileList() ) ); 
  } 

  static formatTime( timeString ){ 
    const date = new Date( Date.parse( timeString ) ); 
    return date.toDateString(); 
  } 

  update( collection ) { 
    this.el.innerHTML = `<li class="file-list__li file-list__head"> 
        <span class="file-list__li__name">Name</span> 
        <span class="file-list__li__size">Size</span> 
        <span class="file-list__li__time">Modified</span> 
      </li>`; 
    collection.forEach(( fInfo ) => { 
      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 )}</span> 
      </li>` ); 
    }); 
    this.bindUi(); 
  } 

  bindUi(){ 
    Array.from( this.el.querySelectorAll( ".file-list__li" ) 
    ).forEach(( el ) => { 
      el.addEventListener( "click", ( e ) => { 
        e.preventDefault(); 
        nw.Shell.openItem( this.dir.getFile( el.dataset.file ) ); 
      }, false ); 
    }); 
  } 

} 

exports.FileListView = FileListView; 

在前面的代码中,在构造函数中,我们再次订阅了"update"事件,当捕获到该事件时,我们会对从DirServicegetFileList方法接收到的集合运行更新方法。它首先渲染文件表头,然后是包含文件信息的行。传入的集合包含原始文件大小和修改时间。因此,我们以人类可读的形式格式化这些内容。文件大小通过外部模块filesizewww.npmjs.com/package/filesize)进行美化处理,时间戳则通过formatTime静态方法进行整理。

当然,我们应该在主脚本中加载和初始化新创建的模块:

./js/app.js

const { FileListView } = require( "./js/View/FileList" ); 
new FileListView( document.querySelector( "[data-bind=fileList]" ), dirService ); 

标题栏路径模块

因此,我们有一个对导航事件做出响应的目录和文件列表,但是标题栏中的当前路径仍然没有受到影响。为了解决这个问题,我们将创建一个小的视图类:

./js/View/TitleBarPathView.js

class TitleBarPathView { 

  constructor( boundingEl, dirService ){ 
    this.el = boundingEl; 
    dirService.on( "update", () => this.render( dirService.getDir() ) ); 
  } 

  render( dir ) { 
    this.el.innerHTML = dir; 
  } 
} 

exports.TitleBarPathView = TitleBarPathView; 

您可以注意到,该类简单地订阅了更新事件,并根据DirService相应地修改了当前路径。

为了使其生效,我们将在主脚本中添加以下行:

./js/app.js

const { TitleBarPathView } = require( "./js/View/TitleBarPath" ); 
new TitleBarPathView( document.querySelector( "[data-bind=path]" ), dirService ); 

总结

因此,我们已经实现了一个工作版本的文件资源管理器,提供了基本功能。到目前为止,我们取得了什么成就?

我们一起经历了传统的开发流程:我们规划、草拟、设置、模板化、样式化和编程。在这个过程中,我们讨论了编写可维护和无冲突的 CSS 的最佳实践。我们发现 NW.js 可以实现最新 CSS 和 JavaScript 规范的功能。因此,在重构我们的 CSS 代码时,我们利用了新的方面,比如自定义属性和粘性定位。我们还对 ES2015 的基础知识进行了介绍,这有助于我们使用类、箭头函数、解构和块作用域声明来以更清晰的语法构建我们的 JavaScript 模块。

此外,我们探索了一些在浏览器中通常无法使用的好东西,比如 Node.js 核心和外部模块,以及桌面环境集成 API。因此,我们能够访问文件系统并实现窗口操作(关闭、最小化、最大化和恢复)。我们创建了一个扩展了 Node.js EventEmitter 的服务,并将基于事件的架构整合到我们的需求中。

我们没有忘记单元测试。我们设置了 Jasmine 测试运行器,并讨论了 BDD 规范的基本要点。在编写应用程序单元测试时,我们研究了模拟文件系统的方法以及测试文档对象模型DOM)操作的方法。

显然,第二章还有很多内容,我们将增强现有功能,深入了解 NW.js API,并进行预生产步骤。但是,我希望您已经对 NW.js 和 HTML5 桌面开发基础有所了解。看到了吗?它毕竟与传统的 Web 开发并没有太大的区别,只是解锁了新的令人兴奋的可能性。

第二章:使用 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 国际化 APInorbertlindenberg.com/2012/12/ecmascript-internationalization-api/index.html)。这个 API 描述了内置对象--StringDateNumber--的方法,旨在格式化和比较本地化数据。然而,它真正做的是使用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服务的实例,并将其传递给LangSelectorViewFileListView

./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,它接受两个参数:tokendefault翻译。第一个参数可以是字典中的键之一,比如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构造函数中,我们接收FileServiceI18nService的实例。在构造过程中,我们还调用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/)。不幸的是,它相当有限;我们无法在应用程序之间传输任意文件,这可能是您对文件管理器的期望。然而,对我们来说仍然有一些事情是可用的。

传输文本

为了检查使用剪贴板传输文本,我们修改了FileServicecopy方法:

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.Clipboardget方法访问。文本可以按如下方式检索:

 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/),我们可以为我们的应用程序提供一个图标和托盘中的下拉菜单,但我们还没有任何图标。因此,我已经创建了带有文本Feicon.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时,MinimizeMaximize项将被禁用,但我们如何知道窗口的当前模式?在构造时,我们订阅窗口事件最小化、最大化和还原。当事件发生时,我们使用相应的标志调用render。由于我们现在可以从TitleBarActionsTray视图中更改窗口模式,因此TitleBarActionstoggle方法不再是窗口模式的可靠来源。相反,我们更倾向于重构模块,依赖窗口事件,就像我们在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数组就没有意义了。它导出一个函数,该函数将所有不是选项或与选项相关联的参数收集到_(下划线)属性中。我们期望该类型的唯一参数是启动目录。它还在命令行上提供maximizeminimize属性时将它们设置为 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,我们将在titlebarfooter组件中使用它来使顶部和底部的角变圆:

./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-autoupdaterhttps://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包,它激活了热键F12F5,分别用于 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模块导入appBrowserWindow。第一个允许我们订阅应用程序生命周期事件。通过第二个,我们创建和控制浏览器窗口。我们还获得了对 NPM 模块pathurl的引用。第一个帮助创建与平台无关的路径,第二个帮助构建有效的 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 编译器和预设 reactbabeljs.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" 
  } 
} 

在前面的示例中,我们添加了 reactreact-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 配置了es2017react预设(以及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" ) );

在这里,我们导入了两个组件--HeaderCopycat--并在一个复合组件中使用它们,然后将其绑定到 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));
//..

现在,当我启动应用程序并打开DevToolsF12)时,我可以看到一个新的选项卡React,它将我带到相应的面板。现在,可以浏览 React 组件树,选择其节点,并检查相应的组件,编辑其 props 和 state:

静态原型

在这一点上,我们已经准备好开始使用聊天应用程序了。然而,如果我们先创建一个静态版本,然后再扩展它以实现预期的功能,那么理解起来会更容易。如今,开发人员通常不会从头开始编写 CSS,而是重用 HTML/CSS 框架(如 Bootstrap)的组件。有一个专门为 Electron 应用程序设计的框架——Photonkitphotonkit.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-loadercss-loader处理这些文件。第一个读取请求的文件,并通过注入样式块将其添加到 DOM 中。第二个将使用@importurl()请求的任何资源带到 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。第二个是ParticipantsConversation组件的布局。我们还将有一个根组件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的值,我们在标题和页脚之间渲染ChatPaneWelcome

顺便说一句,我们从 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>&nbsp;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 中渲染应用程序。

第四章:使用 Electron 和 React 创建聊天系统-增强、测试和交付

我们上一章以静态原型结束。我们了解了 React,组合了组件,但没有为它们提供任何状态。现在,我们将开始将应用窗口的状态绑定到标题组件。随着状态概念的澄清,我们将转向聊天服务。在简要介绍了 WebSockets 技术之后,我们将实现服务器和客户端。我们将把服务事件绑定到应用状态。最后,我们将拥有一个完全可用的聊天功能。我们不会停在这里,而是会处理技术债务。因此,我们将设置 Jest 测试框架,并对无状态和有状态组件进行单元测试。之后,我们将打包应用程序,并通过基本的 HTTP 服务器发布版本。我们将扩展应用程序以在有新版本可用时进行更新。

重振标题栏

直到现在,我们的标题栏并不是真正有用的。多亏了 Photon 框架,我们已经可以将其用作拖放窗口的手柄,但我们还缺少窗口操作,比如关闭、最大化和还原窗口。

让我们来实现它们:

./app/js/Components/Header.jsx

import { remote } from "electron"; 
const win = remote.getCurrentWindow(); 

export default class 

Header extends React.Component { 
//.... 
 onRestore = () => { 
    win.restore(); 
  } 

  onMaximize = () => { 
    win.maximize(); 
  } 

  onClose = () => { 
    win.close(); 

  } 
//... 
} 

我们不使用方法,而是使用将匿名函数绑定到对象范围的属性。这个技巧是可能的,多亏了我们在第三章中包含在清单和 Webpack 配置中的babel-plugin-transform-class-properties

我们扩展了组件,添加了关闭窗口、最大化和还原到原始大小的处理程序。我们在 JSX 中已经有了close按钮,所以我们只需要订阅相应的处理程序方法来处理click事件,使用onClick属性:

 <button className="btn btn-default pull-right" onClick={this.onClose}> 
     <span className="icon 

icon-cancel"></span> 
</button> 

然而,maximizerestore按钮是有条件地在 HTML 中渲染的,取决于当前窗口状态。因为我们将利用状态,让我们来定义它:

 constructor( props ) { 
    super( props ); 
    this.state = { isMaximized: win.isMaximized() }; 
  } 

isMaximized状态属性接收当前窗口实例的相应标志。现在,我们可以从 JSX 中提取这个值的状态:

..... 
render() { 
    const { isMaximized } = this.state; 
    return ( 
      <header 

className="toolbar toolbar-header"> 
          <div className="toolbar-actions"> 

<button className="btn btn-default pull-right" onClick={this.onClose}> 
                   <span 

className="icon icon-cancel"></span> 
               </button> 

               { 

isMaximized ? ( 
                 <button className="btn btn-default pull-right" onClick={this.onRestore}> 
                    <span className="icon icon-resize-small"></span> 
                 </button> ) 

: ( 
                 <button className="btn btn-default pull-right" onClick={this.onMaximize}> 

        <span className="icon icon-resize-full"></span> 
                 </button>) 

     } 

          </div> 
       </header> 
    ) 
  } 

因此,当restore为 true 时,我们渲染restore按钮,否则渲染maximize按钮。我们还订阅了两个按钮的click事件的处理程序,但是窗口最大化或还原后如何改变状态呢?

在组件呈现到 DOM 之前,我们可以订阅相应的窗口事件:

componentWillMount() { 
    win.on( "maximize", this.updateState ); 
    win.on( "unmaximize", 

this.updateState ); 
  } 

  updateState = () => { 
    this.setState({ 
      isMaximized: 

win.isMaximized() 
    }); 
  } 

当窗口改变其状态处理程序时,updateState会调用并更新组件状态。

利用 WebSockets

我们有一个静态原型,现在我们将使其功能。任何聊天都需要连接客户端之间的通信。通常,客户端不直接连接,而是通过服务器。服务器注册连接并转发消息。从客户端发送消息到服务器是很清楚的,但我们能否以相反的方式做呢?在过去,我们不得不处理长轮询技术。那样可以工作,但由于 HTTP 的开销,当我们需要低延迟的应用程序时,它并不是真正合适的。幸运的是,Electron 支持 WebSockets。通过该 API,我们可以在客户端和服务器之间建立全双工、双向的 TCP 连接。与 HTTP 相比,WebSockets 提供了更高的速度和效率。该技术可以将不必要的 HTTP 流量减少高达 500:1,并将延迟减少 3:1(bit.ly/2ptVzlk)。您可以在我的书JavaScript Unlocked中找到更多关于 WebSockets 的信息(www.packtpub.com/web-development/javascript-unlocked)。在这里,我们将通过一个小型演示简要了解该技术。我建议检查一个回声服务器和一个客户端。每当客户端向服务器发送文本时,服务器都会将其广播到所有连接的客户端。因此,在加载了客户端的每个页面上,我们都可以实时接收消息。

当然,我们不会为服务器编写协议实现,而是使用现有的 NPM 包--nodejs-websocket(www.npmjs.com/package/nodejs-websocket):

npm i -S nodejs-websocket 

使用包 API,我们可以快速编写代码来处理来自客户端的消息:

./server.js

const ws = require( "nodejs-websocket" ), 
      HOST = "127.0.0.1", 
      PORT = 8001; 

const 

server = ws.createServer(( conn ) => { 

  conn.on( "text", ( text ) => { 

server.connections.forEach( conn => { 
      conn.sendText( text ); 
    }); 
  }); 

conn.on( "error", ( err ) => { 
    console.error( "Server error", err ); 
  }); 

}); 

server.listen( PORT, HOST, () => { 
  console.info( "Server is ready" ); 
}); 

在这里,我们实例化一个代表 WebSockets 服务器(server)的对象。在createServer工厂的回调中,我们将接收连接对象。我们订阅每个连接的"text""error"事件。第一个事件发生在从客户端发送数据帧到服务器时。我们简单地将其转发到每个可用的连接。第二个事件在发生错误时触发,因此我们报告错误。最后,我们在给定的端口和主机上启动服务器,例如,我设置端口8001。如果您的环境中的任何其他程序占用了此端口,只需更改PORT常量的值即可。

我们可以将这个简化的聊天客户端组成一个单页面应用程序。因此,创建以下 HTML:

./index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <title>Echo</title> 

<meta charset="UTF-8"> 
    <meta name="viewport" content="width=device-width, initial- 
    scale=1.0"> 

</head> 
  <body> 
    <form id="form"> 
      <input id="input" placeholder="Enter you 

message..." /> 
      <button>Submit</button> 
    </form> 
    <output 

id="output"></output> 

<script> 
const HOST = "127.0.0.1", 
      PORT = 8001, 

    form = document.getElementById( "form" ), 
      input = document.getElementById( "input" ), 
      output = 

document.getElementById( "output" ); 

const ws = new WebSocket( `ws://${HOST}:${PORT}` ); 

ws.addEventListener( "error", ( e ) => { 
  console.error( "Client's error: ", e ); 
}); 

ws.addEventListener( "open", () => { 
  console.log( "Client connected" ); 
}); 

ws.addEventListener( "message", e => { 
  output.innerHTML = e.data + "<br \>" + output.innerHTML; 
}); 

form.addEventListener( "submit", ( e ) => { 
  e.preventDefault(); 
  ws.send( input.value 

); 
}); 

</script> 
  </body> 
</html> 

在 HTML 中,我们放置了一个带有输入控件和输出容器的表单。意图是在表单上发送输入值,将其提交到服务器,并在输出元素中显示服务器响应。

在 JavaScript 中,我们存储了对操作节点的引用,并创建了 WebSockets 客户端的实例。我们订阅了erroropenmessage客户端事件。前两个基本上报告正在发生的事情。最后一个接收来自服务器的事件。在我们的情况下,服务器发送文本消息,因此我们可以将它们作为e.data。我们还需要处理来自客户端的输入。因此,我们订阅了表单元素上的submit。我们使用 WebSockets 客户端的send方法将输入值发送到服务器。

要运行示例,我们可以使用http-server模块(www.npmjs.com/package/http-server)为我们的index.html启动一个静态 HTTP 服务器:

npm i -S http-server 

现在,我们可以将以下命令添加到package.json

{
  "scripts": {
    "start:client": "http-server . -o",
    "start:server": "node server.js"
  }

}

因此,我们可以运行服务器:

 npm run start:server 

然后客户端为:

 npm run start:client

实现聊天服务

我相信现在大致清楚了 WebSockets 的工作原理,我们可以将 API 应用于我们的聊天。然而,在实际应用中,我们需要的不仅仅是回显发送的文本。让我们把预期的事件场景写在纸上:

  • Welcome 组件处理用户输入,并通过客户端发送到 join 服务器事件,载荷中包含输入的用户名

  • 服务器接收 join 事件,将新用户添加到集合中,并广播带有更新集合的 participants 事件

  • 客户端接收 participants 事件,并将集合传递给 Participants 组件,该组件更新参与者列表

  • Conversation 组件处理用户输入,并将输入的消息通过客户端作为 text 事件发送到服务器,载荷中包含用户名、文本和时间戳

  • 服务器接收 text 事件并将其广播给所有聊天参与者

由于我们处理事件消息,我们需要一个统一的格式来发送和接收单一的真相来源。因此,我们实现了一个消息包装器--./app/js/Service/Message.js

class Message { 
  static toString( event, data ){ 
    return JSON.stringify({ 
      event, data 
    }); 
  } 
  static fromString( text ){ 
    return JSON.parse( text ); 
  } 
} 

exports.Message = Message; 

该模块公开了两个静态方法。一个将给定的事件名称和载荷转换为 JSON 字符串,可以通过 WebSockets 发送;另一个将接收到的字符串转换为消息对象。

现在我们编写服务器--./app/js/Service/Server.js

import * as ws from "nodejs-websocket"; 
import { Message } from "./Message"; 

export default class 

Server { 

  constructor() { 
    this.server = ws.createServer(( conn ) => { 

conn.on( "error", ( err ) => { 
        console.error( "Server error", err ); 
      }); 
      conn.on( 

"close", ( code, reason ) => { 
        console.log( "Server closes a connection", code, reason ); 
      }); 

      conn.on( "connection", () => { 
        console.info( "Server creates a new connection" ); 

}); 
    }); 

  } 

  broadcast( event, data ){ 
    const text = Message.toString( 

event, data ); 
    this.server.connections.forEach( conn => { 
      conn.sendText( text ); 
    }); 
  } 

  connect( host, port ) { 
     this.server.listen( port, host, () => { 

console.info( "Server is ready" );      }); 
  } 
} 

与回声服务器一样,这个服务器订阅连接事件以报告发生了什么,并公开了 broadcastconnect 方法。为了使其处理传入的消息,我们扩展了 createServer 回调:

constructor() { 

    this.server = ws.createServer(( conn ) => { 

      conn.on( "text", 

( text ) => { 
        const msg = Message.fromString( text ), 
              method = `on${msg.event}`; 
        if ( !this[ method ] ) { 
          return; 
        } 
        this method ; 

      }); 
      //... 
    }); 
    //... 
  } 

现在,当接收到消息时,服务器会尝试调用与事件名称匹配的处理程序方法。例如,当它接收到 join 事件时,它会调用 onjoin

onjoin( name, conn ){ 
    const datetime = new Date(); 
    this.participants.set( conn, { 

name: name, 
      time: datetime.toString() 
    }); 

    this.broadcast( "participants", 

Array.from( this.participants.values() )); 
  } 

该方法接受事件载荷(这里是用户名)作为第一个参数,连接引用作为第二个参数。它在 this.participant 映射中注册连接。因此,我们现在可以通过连接确定关联的用户名和注册时间戳。然后,该方法将映射的值作为数组广播(一组用户名和时间戳)。

但是,我们不应忘记在类构造函数中将 this.participants 定义为映射:


constructor() { 
    this.participants = new Map(); 
    //... 
} 

我们还为 text 事件添加了处理程序方法:


ontext( data, conn ){ 
    const name = this.participants.get( conn ).name; 
    this.broadcast( 

"text", { name, ...data } ); 
  } 

该方法从 this.participants 中提取与给定连接相关联的用户名,将消息载荷与之扩展,并广播派生消息。

现在,我们可以编写客户端--./app/js/Service/Client.js

const EventEmitter = require( "events" ), 
          READY_STATE_OPEN = 1; 
import { Message } from 

"./Message"; 

export default class Client extends EventEmitter { 

  connect( host, port ){ 

    return new Promise(( resolve, reject ) => { 
      this.socket = new WebSocket( `ws://${host}:${port}` ); 

      this.socket.addEventListener( "open", () => { 
        resolve(); 
      }); 

    this.socket.addEventListener( "error", ( e ) => { 
        if ( e.target.readyState > READY_STATE_OPEN ) { 

          reject(); 
        } 
      }); 

      this.socket.addEventListener( "message", e 

=> { 
        const msg = Message.fromString( e.data ), 
              method = `on${msg.event}`; 

 if ( !this[ method ] ) { 
          return; 
        } 
        this method ; 
      }); 

    }); 
  } 

  onparticipants( data ){ 
    this.emit( "participants", data ); 
  } 

  ontext( data ){ 
    this.emit( "text", data ); 
  } 

 getParticipants(){ 

return this.participants; 
  } 

  join( userName ) { 
    this.userName = userName; 

this.send( "join", userName ); 
  } 

  message( text ) { 
    this.send( "text", { 

userName: this.userName, 
      text, 
      dateTime: Date.now() 
    }); 
  } 

  send( 

event, data ){ 
    this.socket.send( Message.toString( event, data ) ); 
  } 
} 

客户端实现了与服务器相同的处理程序方法,但这次,我们让 connect 方法返回一个 Promise。因此,如果客户端无法连接服务器,我们可以调整执行流程。我们有两个处理程序:onparticipantsontext。它们都简单地将接收到的消息传递给应用程序。由于 Client 类扩展了 EventEmitter,我们可以使用 this.emit 来触发事件,任何订阅的应用程序模块都能够捕获它。此外,客户端公开了两个公共方法:joinmessage。其中一个 (join) 将被 Welcome 组件使用,用于在服务器上注册提供的用户名,另一个 (message) 则从 Participants 组件调用,将提交的文本传递给服务器。这两种方法都依赖于 send 私有方法,它实际上是分发消息。

Electron 包括 Node.js 运行时,因此允许我们运行服务器。因此,为了使其更简单,我们将服务器包含到应用程序中。为此,我们再次修改服务器代码:


  connect( host, port, client ) { 
    client.connect( host, port ).catch(() => { 

this.server.listen( port, host, () => { 
        console.info( "Server is ready" ); 
        client.connect( 

host, port ).catch(() => { 
          console.error( "Client's error" ); 
        }); 
      }); 

 }); 
  } 

现在它运行提供的 client.connect 来与我们的 WebSockets 服务器建立连接。如果这是应用程序运行的第一个实例,服务器尚不可用。因此,客户端无法连接,执行流程跳转到 catch 回调。在那里,我们启动服务器并重新连接客户端。

为组件带来功能

现在我们有了服务器和客户端服务,我们可以在应用程序中启用它们。最合适的地方是 App 容器--./app/js/Containers/App.jsx

import Server from "../Service/Server"; 
import Client from "../Service/Client"; 

const HOST = 

"127.0.0.1", 
      PORT = 8001; 

export default class App extends React.Component { 

constructor(){ 
    super(); 
    this.client = new Client(); 
    this.server = new Server(); 

this.server.connect( HOST, PORT, this.client ); 
  } 
//... 
} 

你还记得我们在静态原型中有条件地呈现 ChatPaneWelcome 组件吗?:

{ name ? 
            ( <ChatPane client={client} 
                /> ) : 
            ( 

<Welcome  onNameChange={this.onNameChange} /> ) } 

当时,我们将name硬编码,但它属于组件状态。因此,我们可以在类构造函数中初始化状态,如下所示:

constructor(){ 
    //... 
    this.state = { 
      name: "" 
    }; 
} 

嗯,name默认为空,因此我们显示Welcome组件。我们可以在那里输入一个新的名称。当提交时,我们需要以某种方式改变父组件中的状态。我们使用一种称为状态提升的技术来实现它。我们在App容器中声明一个处理name更改事件的处理程序,并将其与 props 一起传递给Welcome组件:


onNameChange = ( userName ) => { 
  this.setState({ name: userName }); 
  this.client.join( 

userName ); 
} 

render() { 
  const client = this.client, 
        name = this.state.name; 
  return ( 
    <div className="window"> 
      <Header></Header> 
      <div 

className="window-content"> 
        { name ? 
          ( <ChatPane client={client} 

/> ) : 
          ( <Welcome  onNameChange={this.onNameChange} /> ) } 
      </div> 

<Footer></Footer> 
    </div> 
  ); 
} 

因此,我们从状态中提取name并在表达式中使用它。最初,name为空,因此渲染Welcome组件。我们声明onNameChange处理程序,并将其与 props 一起传递给Welcome组件。处理程序接收提交的名称,在服务器上注册新连接(this.client.join),并更改组件状态。因此,ChatPane组件替换了Welcome

现在,我们将编辑Welcome组件--./app/js/Components/Welcome.jsx

import React from "react"; 
import PropTypes from "prop-types"; 

export default class Welcome extends 

React.Component { 

  onSubmit = ( e ) => { 
    e.preventDefault(); 
    this.props.onNameChange( 

this.nameEl.value || "Jon" ); 
  } 

  static defaultProps = { 
    onNameChange: () => {} 

} 

  static propTypes = { 
    onNameChange: PropTypes.func.isRequired 
  } 

  render() { 

    return ( 
      <div className="pane padded-more"> 
        <form onSubmit={this.onSubmit}> 

          <div className="form-group"> 
            <label>Tell me your name</label> 

        <input required className="form-control" placeholder="Name" 
              ref={(input) => { this.nameEl 

= input; }} /> 
          </div> 
          <div className="form-actions"> 

<button className="btn btn-form btn-primary">OK</button> 
          </div> 

</form> 
      </div> 
    ) 
  } 
} 

每当一个组件期望任何 props 时,通常意味着我们必须应用defaultPropspropTypes静态方法。这些方法属于React.ComponentAPI,并在组件初始化期间自动调用。第一个方法为 props 设置默认值,第二个方法验证它们。在 HTML 中,我们为表单的submit事件订阅onSubmit处理程序。在处理程序中,我们需要访问输入值。通过ref JSX 属性,我们将实例添加为对输入元素的引用。因此,从onSubmit处理程序中,我们可以将输入值获取为this.nameEl.value

现在,用户可以在聊天中注册,我们需要显示聊天 UI--./app/js/Components/ChatPane.jsx

export default function ChatPane( props ){ 
  const { client } = props; 
  return ( 
    <div 

className="pane-group"> 

      <Participants client={client} /> 

      <Conversation  

client={client} /> 

    </div> 
  ); 

} 

这是一个复合组件,它布局ParticipantsConversation子组件,并将client转发给它们。

第一个组件用于显示参与者列表--./app/js/Components/Participants.jsx

import React from "react"; 
import TimeAgo from "react-timeago"; 
import PropTypes from "prop-types"; 

export default class Participants extends React.Component { 

 constructor( props ){ 
    super( 
    props ); 
    this.state = { 
      participants: props.client.getParticipants() 
    } 

props.client.on( "participants", this.onClientParticipants ); 
  } 

  static defaultProps = { 
    client: null 
  } 

  static propTypes = { 
    client: PropTypes.object.isRequired 
  } 

onClientParticipants = ( participants ) => { 
    this.setState({ 
      participants: 

participants 
    }) 
  } 

  render(){ 
    return ( 
      <div className="pane pane-sm 
      sidebar"> 
        <ul className="list-group"> 
          {this.state.participants.map(( user ) => ( 

            <li className="list-group-item" key={user.name}> 
              <div className="media-
              body"> 
                <strong><span className="icon icon-user"></span>&nbsp;     
                {user.name}
                </strong> 
                <p>Joined <TimeAgo date={user.time} /></p> 
              </div> 
            </li> 
          ))} 
        </ul> 
      </div> 
    ); 
  } 
} 

在这里,我们需要一些构造工作。首先,我们定义状态,其中包括来自 props 的参与者列表。我们还订阅客户端的participants事件,并在服务器发送更新列表时每次更新状态。在渲染列表时,我们还显示参与者注册时间,例如 5 分钟前加入。为此,我们使用react-timeago NPM 包提供的第三方组件TimeAgo

最后,我们来到Conversation组件--./app/js/Components/Conversation.jsx

import React from "react"; 
import PropTypes from "prop-types"; 

export default class Conversation 

extends React.Component { 

  constructor( props ){ 
    super( props ); 
    this.messages = []; 

    this.state = { 
      messages: [] 
    } 
    props.client.on( "text",  this.onClientText ); 
  } 

  static defaultProps = { 
    client: null 
  } 

  static propTypes = { 
    client: PropTypes.object.isRequired 
  } 

onClientText = ( msg ) => { 
    msg.time = new 

Date( msg.dateTime ); 
    this.messages.unshift( msg ); 
    this.setState({ 
      messages: this.messages 

    }); 
  } 

 static normalizeTime( date, now, locale ){ 
    const isToday = ( 

now.toDateString() === date.toDateString() ); 
    // when local is undefined, toLocaleDateString/toLocaleTimeString 

use default locale 
    return isToday ? date.toLocaleTimeString( locale ) 
      : date.toLocaleDateString( 

locale ) + ` ` + date.toLocaleTimeString( locale ); 
  } 

  render(){ 
    const { messages } = 

this.state; 
    return ( 
        <div className="pane padded-more l-chat"> 
          <ul 

className="list-group l-chat-conversation"> 
            {messages.map(( msg, i ) => ( 

<li className="list-group-item" key={i}> 
                <div className="media-body"> 

    <time className="media-body__time">{Conversation.normalizeTime(  
    msg.time, new Date() )}</time> 

           <strong>{msg.userName}:</strong> 
                  {msg.text.split( "\n" ) .map(( line, 
                  inx ) => ( 
                    <p key={inx}>{line}</p> 
                  ))} 
              </div> 
              </li> 
            ))} 
          </ul> 
         </div> 
    ); 
  } 
} 

在构造过程中,我们订阅客户端的text事件,并将接收到的消息收集到this.messages数组中。我们使用这些消息来设置组件状态。在render方法中,我们从状态中提取消息列表,并遍历它以渲染每个项目。消息视图包括发送者的名称、文本和时间。我们直接输出名称。我们将文本按行拆分,并用段落元素包裹它们。为了显示时间,我们使用normalizeTime静态方法。该方法将Date对象转换为长字符串(日期和时间),当它比今天更旧时,否则转换为短字符串(日期)。

我们还需要一个用于向聊天发送消息的表单。理想的方法是将表单放入一个单独的组件中,但为了简洁起见,我们将其保留在会话视图旁边:

  render(){ 
    const { messages } = this.state; 
    return ( 
... 
        <form onSubmit=

{this.onSubmit} className="l-chat-form"> 
            <div className="form-group"> 

<textarea required placeholder="Say something..." 
                onKeyDown={this.onKeydown} 

className="form-control" ref={ el => { this.inputEl = el; }}></textarea> 
            </div> 

          <div className="form-actions"> 
              <button className="btn btn-form btn-

primary">OK</button> 
            </div> 
          </form> 
 ); 
} 
... 

Welcome组件一样,我们在本地引用文本区域节点,并为文本区域的表单submit事件订阅onSubmit处理程序。为了使其用户友好,我们设置onKeydown来监听文本区域上的键盘事件。在输入期间按下Enter时,我们提交表单。因此,我们现在必须向组件类添加新的处理程序:

const ENTER_KEY = 13; 
//... 
onKeydown = ( e ) => { 
    if ( e.which === ENTER_KEY && !

e.ctrlKey && !e.metaKey && !e.shiftKey ) { 
      e.preventDefault(); 
      this.submit(); 
    } 
  } 

  onSubmit = ( e ) => { 
    e.preventDefault(); 
    this.submit(); 

}  

  submit() { 
    this.props.client.message( this.inputEl.value ); 
    this.inputEl.value = ""; 

  } 

//.. 

当表单通过按下 OK 按钮或Enter提交时,我们通过客户端的message方法将消息传递给服务器,并重置表单。

我不知道你们,但我很想运行这个应用程序并看到它的运行情况。我们有两个选择。我们可以从同一台机器上启动多个实例,为每个实例注册不同的名称,并开始聊天:

或者,我们在 App 容器中设置一个公共 IP,使聊天在整个网络中可用。

编写单元测试

在现实生活中,我们使用单元测试来覆盖应用功能。当涉及到 React 时,Jest 测试框架是第一个浮现在人们脑海中的。这个框架是由 Facebook 以及 React 开发的。Jest 不仅针对 React;你可以测试任何 JavaScript。为了看看它是如何工作的,我们可以设置一个新项目:

npm init -y 

通过运行以下命令安装 Jest:

npm i -D jest 

编辑 package.json 中的 scripts 部分:

 "scripts": { 
    "test": "jest" 
  } 

放置用于测试的示例单元:

./unit.js

function double( x ){
  return x * 2;
}
exports.double = double;

这是一个简单的纯函数,它会将给定的数字加倍。现在我们需要做的就是放置一个与 *.(spec|test).js 模式匹配的 JavaScript 文件--./unit.spec.js

const { double } = require( "./unit" );
describe( "double", () => {
  it( "doubles a given number", () => {
    const x = 1;
    const res = double( x );
    expect( res ).toBe( 2 );
  });
});

如果你熟悉 Mocha 或者更好的 Jasmine,你将毫无问题地阅读这个测试套件。我们描述一个方面(describe()),声明我们的期望(it()),并断言被测试单元产生的结果是否满足要求(expect())。基本上,这种语法与我们在第二章中使用的语法没有区别,使用 NW.js 创建文件资源管理器-增强和交付

通过运行 npm test,我们得到以下报告:

Jest 在我们的情况下更可取的原因在于它与 React 哲学非常接近,并且包含了用于测试 React 应用的特定功能。例如,Jest 包括 toMatchSnapshot 断言方法。因此,我们可以在虚拟 DOM 中构建一个组件,并保存该元素的快照。然后,在重构后,我们运行测试。Jest 会获取修改后组件的实际快照,并将其与存储的快照进行比较。这是回归测试的常见方法。在实践之前,我们必须为我们的环境设置 Jest。我们在 webpack.config.js 中指定了我们的捆绑配置。Jest 不会考虑这个文件。我们必须单独为 Jest 编译源代码,我们可以使用 babel-jest 来实现:

npm i -D babel-jest 

这个插件从 Babel 运行时配置中获取代码转换指令--./.babelrc

{ 
  "presets": [ 
     ["env", { 
      "targets": { "node": 7 }, 
      "useBuiltIns": true 
    }], 
    "react" 
  ], 

  "plugins": [ 
     "transform-es2015-modules-commonjs", 

 "transform-class-properties", 
     "transform-object-rest-spread" 
  ] 
} 

在这里,我们使用预设的 env (babeljs.io/docs/plugins/preset-env/),它会自动确定并加载目标环境(Node.js 7)所需的插件。不要忘记安装预设:

npm i -D babel-preset-env 

我们还应用了 transform-class-propertiestransform-class-properties 插件,以便分别获得 rest、spread 和 ES 类字段和静态属性语法的访问权限(我们已经在第三章中为 Webpack 配置使用了这些插件,使用 Electron 和 React 创建聊天系统-规划、设计和开发)。

就像我们在 normalizeTime 测试示例中所做的那样,我们将修改清单--./package.json

{ 
 ... 
  "scripts": { 
     ... 
    "test": "jest" 
  }, 
  "jest": { 

"roots": [ 
      "<rootDir>/app/js" 
    ] 
  }, 
 ... 
} 

这一次,我们还明确指定了 Jest 的源目录,app/js

正如我之前解释的,我们将为 React 组件生成快照以进行进一步的断言。这可以通过 react-test-renderer 包实现:

npm i -D react-test-renderer 

现在我们可以编写我们的第一个组件回归测试--./app/js/Components/Footer.spec.jsx

import * as React from "react"; 
import Footer from "./Footer"; 
import * as renderer from "react-test-

renderer"; 

describe( "Footer", () => { 
  it( "matches previously saved snapshot", () => { 

 const tree = renderer.create( 
      <Footer /> 
    ); 

    expect( tree.toJSON() 

).toMatchSnapshot(); 
  }); 
}); 

是的,这很容易。我们使用 renderer.create 创建一个元素,并通过调用 toJSON 方法获得静态数据表示。当我们首次运行测试(npm test)时,它会创建一个 __snapshots__ 目录,其中包含与测试文件相邻的快照。每次之后,Jest 会将存储的快照与实际快照进行比较。

如果你想重置快照,只需运行 npm test -- -u

测试一个有状态的组件类似--./app/js/Components/Participants.spec.jsx

import * as React from "react"; 
import Client from "../Service/Client"; 
import Participants from 

"./Participants"; 
import * as renderer from "react-test-renderer"; 

describe( "Participants", () => { 

  it( "matches previously saved snapshot", () => { 
    const items = [{ 
            name: "Jon", 
            time: new Date( 2012, 2, 12, 5, 5, 5, 5 ) } 
          ], 
          client = new Client(), 

        component = renderer.create( <Participants client={client} /> 
        ); 

    component.getInstance

().onClientParticipants( items ); 
    expect( component.toJSON() ).toMatchSnapshot(); 
  }); 
}); 

我们使用创建的元素的getInstance方法来访问组件实例。 因此,我们可以调用实例的方法来设置具体的状态。 在这里,我们直接将参与者的固定列表传递给onClientParticipants处理程序。 组件呈现列表,我们进行快照。

回归测试很好,可以检查组件在重构过程中是否没有损坏,但不能保证组件在最初的行为是否符合预期。 React 通过react-dom/test-utils模块提供了一个 API(facebook.github.io/react/docs/test-utils.html),我们可以使用它来断言组件确实呈现了我们期望的一切。 使用第三方包 enzyme,我们甚至可以做得更多(airbnb.io/enzyme/docs/api/shallow.html)。 为了了解它,我们在Footer套件中添加了一个测试--./app/js/Components/Footer.spec.jsx

import { shallow } from "enzyme"; 
import * as manifest from "../../../package.json"; 

describe( 

"Footer", () => { 
  //... 
  it( "renders manifest name", () => { 
    const tree = shallow( 

   <Footer /> 
    ); 
    expect ( tree.find( "footer" ).length ).toBe( 1 ); 
    expect( tree.find( 

"footer" ).text().indexOf( manifest.name ) ).not.toBe( -1 ); 
  }); 
}); 

因此,我们假设该组件呈现 HTML 页脚元素(tree.find("footer"))。 我们还检查页脚是否包含清单中的项目名称:

打包和分发

当我们使用文件资源管理器和 NW.js 时,我们使用nwjs-builder工具打包我们的应用程序。 Electron 有一个更复杂的工具--electron-builder (github.com/electron-userland/electron-builder)。 实际上,它构建了一个应用程序安装程序。 electron-builder 支持的目标软件包格式范围令人印象深刻。 那么,为什么不尝试打包我们的应用程序呢? 首先,我们安装该工具:

npm i -D electron-builder 

我们在清单中添加一个新的脚本--./package.json

 "scripts": { 
    ...  
    "dist": "build" 
  }, 

我们还在构建字段中为应用程序设置了一个任意的 ID:

 "build": { 
    "appId": "com.example.chat" 
  }, 

我们肯定希望为应用程序提供图标,因此我们创建build子目录,并在其中放置icon.icns(macOS),icon.ico(Windows)的图标。 Linux 的图标将从icon.icns中提取。 或者,您可以将图标放在build/icons/中,以其大小命名--64x64.png

实际上,我们还没有为应用程序窗口分配图标。 为了解决这个问题,我们修改我们的主进程脚本--./app/main.js

mainWindow = new BrowserWindow({ 
     width: 1000, height: 600, frame: false, 
     icon: path.join( 

__dirname, "icon-64x64.png 
" ) 
});

一切似乎已经准备就绪,所以我们可以运行以下命令:

npm run dist

随着过程的完成,我们可以在新创建的dist文件夹中找到默认格式的生成软件包:

  • Ubuntu: chat-1.0.0-x86_64.AppImage

  • * Windows: chat Setup 1.0.0.exe

  • * MacOS: chat-1.0.0.dmg

当然,我们可以针对特定的目标格式进行设置:

build -l deb 
build -w nsis-web 
build -m pkg 

请注意,不同的软件包格式可能需要在清单中添加额外的元数据(github.com/electron-userland/electron-builder/wiki/Options)。 例如,打包为.deb需要填写homepageauthor字段。

部署和更新

自动更新的内置功能是 Electron 相对于 NW.js 的最显着优势之一。 Electron 的autoUpdater模块(bit.ly/1KKdNQs)利用了 Squirrel 框架(github.com/Squirrel),这使得静默成为可能。 它与现有的多平台发布服务器解决方案很好地配合使用;特别是,可以在 GitHub 上使用 Nuts(github.com/GitbookIO/nuts)运行它。 我们还可以快速设置一个基于electron-release-server的全功能节点服务器(github.com/ArekSredzki/electron-release-server),其中包括发布管理 UI。

Electron-updater 不支持 Linux。 项目维护者建议使用发行版的软件包管理器来更新应用程序。

为了简洁起见,我们将介绍一种简化的自动更新方法,它不需要真正的发布服务器,只需要通过 HTTP 访问静态发布。

我们首先安装包:

npm i -S electron-updater 

现在,我们在清单的build字段中添加--publish 属性:

"build": { 
    "appId": "com.example.chat", 
    "publish": [ 
      { 
        "provider": 

"generic", 
        "url": "http://127.0.0.1:8080/" 
      } 
    ] 
  }, 
... 

在这里,我们声明我们的dist文件夹将在127.0.0.1:8080上公开,然后我们继续使用generic提供程序。或者,提供程序可以设置为 Bintray(bintray.com/)或 GitHub。

我们修改主进程脚本以利用electron-updater API--./app/main.js

const { app, BrowserWindow, ipcMain } = require( "electron" ), 
          { autoUpdater } = require( "electron-

updater" ); 

function send( event, text = "" ) { 
  mainWindow && mainWindow.webContents.send( 

event, text ); 
} 

autoUpdater.on("checking-for-update", () => { 
  send( "info", "Checking for 

update..." ); 
}); 
autoUpdater.on("update-available", () => { 
  send( "info", "Update not available" ); 

}); 
autoUpdater.on("update-not-available", () => { 
  send( "info", "Update not available" ); 
}); 

autoUpdater.on("error", () => { 
  send( "info", "Error in auto-updater" ); 
}); 
autoUpdater.on

("download-progress", () => { 
  send( "info", "Download in progress..." ); 
}); 
autoUpdater.on

("update-downloaded", () => { 
  send( "info", "Update downloaded" ); 
  send( "update-downloaded" ); 
}); 

ipcMain.on( "restart", () => { 
  autoUpdater.quitAndInstall(); 
}); 

基本上,我们订阅autoUpdater事件并使用send函数将其报告给渲染器脚本。当触发update-downloaded时,我们将update-downloaded事件发送到渲染器。渲染器在此事件上报告给用户有一个新下载的版本,并询问是否方便重新启动应用程序。确认后,渲染器发送restart事件。从主进程中,我们使用ipcMainbit.ly/2pChUNg)订阅它。因此,当触发reset时,autoUpdater重新启动应用程序。

请注意,electron-debug在打包后将不可用,因此我们必须从主进程中将其删除:

// require( "electron-debug" )(); 

现在,我们对渲染器脚本进行一些更改--./app/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> 
    <i id="statusbar" 

class="statusbar"></i> 
  </body> 
  <script> 
   require( "./build/renderer.js" ); 

// Listen for messages 
const { ipcRenderer } = require( "electron" ), 
      statusbar = 

document.getElementById( "statusbar" ); 

ipcRenderer.on(  "info", ( ev, text ) => { 

statusbar.innerHTML = text; 
}); 
ipcRenderer.on(  "update-downloaded", () => { 
  const ok = confirm

('The application will automatically restart to finish installing the update'); 
  ok && ipcRenderer.send( 

"restart" ); 
}); 

  </script> 
</html> 

在 HTML 中,我们添加了 ID 为statusbar<i>元素,它将打印出主进程的报告。在 JavaScript 中,我们使用ipcRendererbit.ly/2p9xuwt)订阅主进程事件。在info事件上,我们使用事件载荷字符串更改statusbar元素的内容。当发生update-downloaded时,我们调用confirm来询问用户关于建议重新启动的意见。如果结果是积极的,我们将restart事件发送到主进程。

最终,我们编辑 CSS 将我们的statusbar元素固定在视口的左下角--./app/assets/css/custom.css

.statusbar { 
  position: absolute; 
  bottom: 1px; 
  left: 6px; 
} 

一切都完成了;让我们开始吧!所以,我们首先重新构建项目并发布它:

npm run build 
npm run dist 

我们通过 HTTP 使用http-serverwww.npmjs.com/package/http-server)提供发布:

http-server ./dist 

我们运行发布以安装应用程序。应用程序像往常一样启动,因为尚未有新版本可用,所以我们发布了一个新版本:

npm version patch 
npm run build 
npm run dist 

在页脚组件中,我们显示了从清单中的require函数获取的应用程序名称和版本。Webpack 在编译时检索它。因此,如果在构建应用程序后修改了package.json,更改不会反映在页脚中;我们需要重新构建项目。

或者,我们可以动态从 Electron 的appbit.ly/2qDmdXj)对象中获取名称和版本,并将其作为 IPC 事件转发到渲染器。

现在,我们将启动之前安装的发布,这次我们将在statusbar中观察autoUpdater的报告。随着新版本的下载,我们将得到以下确认窗口:

点击“确定”后,应用程序关闭,弹出一个显示安装过程的新窗口:

完成后,启动更新的应用程序。请注意,页脚现在包含了最新发布的版本:

总结

我们已经完成了我们的聊天应用程序。我们从编程标题栏的操作开始了本章。在这个过程中,我们学会了如何在 Electron 中控制应用程序窗口状态。我们通过简单的回声服务器和相应的客户端示例来了解了 WebSockets 技术。更深入地,我们设计了基于 WebSockets 的聊天服务。我们将客户端事件绑定到组件状态。我们介绍了 Jest 测试框架,并研究了对 React 组件进行单元测试的通用方法。此外,我们为无状态和有状态组件创建了回归测试。我们打包了我们的应用程序并构建了安装程序。我们对发布版本进行了调整,并使应用程序在有新版本可用时进行更新。

第五章:使用 NW.js、React 和 Redux 创建屏幕捕捉器-规划、设计和开发

在本章中,我们将开始一个新的应用程序屏幕捕捉器。使用这个工具,我们将能够截取屏幕截图和录制屏幕录像。我们将使用 Material UI 工具包的 React 组件构建应用程序,该工具包实现了 Google 的 Material Design 规范。在处理聊天示例时,我们已经积累了一些 React 的经验。现在,我们正在迈出一步,朝着可扩展和易于维护的应用程序开发迈进。我们将介绍当时最热门的库之一,名为 Redux,它管理应用程序状态。

在本章结束时,我们将拥有一个原型,它已经响应用户操作,但缺少捕获显示输入并将其保存到文件中的服务。

应用程序蓝图

这次,我们将开发一个屏幕捕捉工具,一个可以截取屏幕截图和录制屏幕录像的小工具。

核心思想可以用以下用户故事来表达:

  • 作为用户,我可以截取屏幕截图并将其保存为.png文件

  • 作为用户,我可以开始录制屏幕录像

  • 作为用户,我可以开始录制屏幕录像并将其保存为.webm文件

此外,我希望在保存屏幕截图或录像文件时出现通知。我还希望将应用程序显示在系统通知区域(托盘)中,并响应指定的全局热键。借助 WireframeSketcher(wireframesketcher.com/),我用以下线框图说明了我的设想:

线框图暗示了一个分页文档界面(TDI),有两个面板。第一个面板标记为屏幕截图,允许我们截取屏幕截图(照片图标)并设置输出文件的文件名模式。第二个面板(动画)看起来差不多,只是动作按钮用于开始录制屏幕录像。一旦用户点击按钮,它就会被停止录制按钮替换,反之亦然。

设置开发环境

我们将使用 NW.js 创建这个应用程序。正如你可能还记得第一章中所述,使用 NW.js 创建文件资源管理器-规划、设计和开发和第二章使用 NW.js 创建文件资源管理器-增强和交付,NW.js 查找启动页面链接和应用程序窗口元信息的清单文件:

./package.json

{ 
  "name": "screen-capturer", 
  "version": "1.0.0", 
  "description": "Screen Capturer", 
  "main": "index.html",   
  "chromium-args": "--mixed-context", 
  "window": { 
    "show": true, 
    "frame": false, 
    "width": 580, 
    "height": 320, 
    "min_width": 450, 
    "min_height": 320, 
    "position": "center", 
    "resizable": true, 
    "icon": "./assets/icon-48x48.png" 
  }   
} 

这次,我们不需要一个大窗口。我们选择580x320px,并允许将窗口大小缩小到450x320px。我们设置窗口在屏幕中心打开,没有框架和内置窗口控件。

当我们在前两章设置 NW.js 时,我们只有很少的依赖。现在,我们将利用 React,并且需要相应的包:

npm i -S react 
npm i -S react-dom 

至于开发依赖,显然,我们需要 NW.js 本身:

npm -i -D nw 

与基于 React 的聊天应用程序一样,我们将使用 Babel 编译器和 Webpack 打包工具。因此,它给了我们以下内容:

npm -i -D webpack 
npm -i -D babel-cli 
npm -i -D babel-core 
npm -i -D babel-loader 

正如我们记得的,Babel 本身是一个平台,我们需要指定它应用于编译我们源代码的确切预设。我们已经使用了这两个:

npm -i -D babel-preset-es2017 
npm -i -D babel-preset-react 

现在,我们使用stage-3预设扩展列表(babeljs.io/docs/plugins/preset-stage-3/):

npm -i -D babel-preset-stage-3 

这个插件集包括所谓的EcmaScript规范的Stage 3提案的所有功能。特别是,它包括了对象上的扩展/剩余运算符,这解锁了对象组合的最具表现力的语法。

此外,我们将应用两个不包括在 Stage 3 中的插件:

npm -i -D babel-plugin-transform-class-properties 
npm -i -D babel-plugin-transform-decorators-legacy 

我们已经熟悉了第一个(ES 类字段和静态属性—github.com/tc39/proposal-class-public-fields)。第二个允许我们使用装饰器(github.com/tc39/proposal-decorators)。

由于其他一切都准备就绪,我们将使用自动化脚本扩展清单文件:

package.json

... 
"scripts": { 
    "start": "nw .", 
    "build": "webpack", 
    "dev": "webpack -d --watch"     
  } 

这些目标已经在开发聊天应用程序时使用过。第一个启动应用程序。第二个编译和捆绑源代码。第三个持续运行,并在任何源文件更改时构建项目。

对于捆绑,我们必须配置 Webpack:

./webpack.config.js

const { join } = require( "path" ), 
      webpack = require( "webpack" ); 
      BUILD_DIR = join( __dirname, "build" ), 
      APP_DIR = join( __dirname, "js" ); 

module.exports = { 
  entry: join( APP_DIR, "app.jsx" ), 
  target: "node-webkit", 
  devtool: "source-map", 
  output: { 
      path: BUILD_DIR, 
      filename:  "app.js" 
  }, 
  module: { 
    rules: [ 
      { 
        test: /.jsx?$/, 
        exclude: /node_modules/, 
        use: [{ 
          loader: "babel-loader", 
          options: { 
            presets: [ "es2017", "react", "stage-3" ], 
            plugins: [ "transform-class-properties", "transform-decorators-legacy" ] 
          } 
        }] 
      } 
    ] 
  } 
}; 

因此,Webpack 将从./js/app.jsx开始递归捆绑 ES6 模块。它将把生成的 JavaScript 放在./build/app.js中。在此过程中,根据配置的预设和插件,任何请求导出的.js/.jsx文件都将使用 Babel 进行编译。

静态原型

我们使用 CSS 样式化的聊天应用程序由 Photon 框架提供。这一次,我们将使用 Material-UI 工具包的现成 React 组件(www.material-ui.com)。作为开发人员,我们得到的是符合 Google Material Design 指南的可重用单元(material.io/guidelines/)。它确保在不同平台和设备尺寸上提供统一的外观和感觉。我们可以使用npm安装 Material-UI:

npm i -S material-ui 

根据 Google Material Design 的要求,应用程序应支持包括移动设备在内的不同设备,在那里我们需要处理特定的事件,比如on-tap。目前,React 不支持它们;必须使用插件:

npm i -S react-tap-event-plugin 

我们不打算在移动设备上运行我们的应用程序,但是如果没有插件,我们将会收到警告。

现在,当我们完成准备工作后,我们可以开始搭建脚手架,如下所示:

  1. 我们添加了我们的启动 HTML:

./index.html

<!doctype html> 
<html class="no-js" lang=""> 

<head> 
  <meta charset="utf-8"> 
  <meta http-equiv="X-UA-Compatible" content="IE=edge"> 
  <title>Screen Capturer</title> 
  <meta 
    name="viewport" 
    content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1" 
  > 
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" 
      rel="stylesheet"> 
  <link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet"> 
  <link rel="stylesheet" type="text/css" href="./assets/main.css"> 
</head> 

<body> 
  <root></root> 
  <script src="img/app.js"></script> 
</body> 

</html> 

在这里,在head元素中,我们链接了三个外部样式表。第一个(https://fonts.googleapis.com/icon?family=Material+Icons)解锁了 Material Icons(material.io/icons/)。第二个(https://fonts.googleapis.com/css?family=Roboto)引入了 Material Design 中广泛使用的 Roboto 字体。最后一个(./assets/main.css)是我们的自定义 CSS。在 body 中,我们设置了应用程序的root容器。我决定,为了可读性,我们可以使用一个普通的div而不是自定义元素。最后,我们根据我们的配置加载由 Webpack 生成的 JavaScript(./build/app.js)。

  1. 我们添加了我们已经在main.css中引用的自定义样式:

./assets/main.css

html { 
  font-family: 'Roboto', sans-serif; 
} 

body { 
  font-size: 13px; 
  line-height: 20px; 
  margin: 0; 
} 

  1. 我们创建入口点脚本:

./js/app.jsx

import React from "react"; 
import { render } from "react-dom"; 
import App from "./Containers/App.jsx"; 

render( <App />, document.querySelector( "root" ) ); 

在这里,我们导入App容器组件并将其渲染到 DOM 的<root>元素中。组件本身将如下所示:

./js/Containers/App.jsx

import React, { Component } from "react"; 
import injectTapEventPlugin from "react-tap-event-plugin"; 
import Main from "../Components/Main.jsx"; 
import { deepOrange500 } from "material-ui/styles/colors"; 
import getMuiTheme from "material-ui/styles/getMuiTheme"; 
import MuiThemeProvider from "material-ui/styles/MuiThemeProvider"; 

injectTapEventPlugin(); 

const muiTheme = getMuiTheme({ 
  palette: { 
    accent1Color: deepOrange500 
  } 
}); 

export default class App extends Component { 
  render() { 
    return ( 
        <MuiThemeProvider muiTheme={muiTheme}> 
        <Main /> 
        </MuiThemeProvider> 
    ); 
  } 
} 

在这一点上,我们用 Material UI 主题提供程序包装应用程序窗格(Main)。通过从 Material UI 包中导入getMuiTheme函数,我们描述主题并将派生的配置传递给提供程序。如前所述,我们必须应用injectTapEventPlugin来启用 React 中框架使用的自定义事件。

现在是添加展示组件的时候了。我们从主要布局开始:

./js/Components/Main.jsx

import React, {Component} from "react"; 

import { Tabs, Tab } from "material-ui/Tabs"; 
import FontIcon from "material-ui/FontIcon"; 

import TitleBar from "./TitleBar.jsx"; 
import ScreenshotTab from "./ScreenshotTab.jsx"; 
import AnimationTab from "./AnimationTab.jsx"; 

class Main extends Component { 

  render() { 
    const ScreenshotIcon = <FontIcon className="material-icons">camera_alt</FontIcon>; 
    const AnimationIcon = <FontIcon className="material-icons">video_call</FontIcon>; 

    return ( 
      <div> 
        <TitleBar /> 
        <Tabs> 
          <Tab 
            icon={ScreenshotIcon} 
            label="SCREENSHOT" 
          /> 
          <Tab 
            icon={AnimationIcon} 
            label="ANIMATION" 
          /> 
        </Tabs> 
        <div> 

        { true 
            ? <ScreenshotTab  /> 
            : <AnimationTab /> 
          } 
        </div> 

      </div> 
    ); 
  } 
} 

export default Main; 

这个组件包括标题栏、两个选项卡(ScreenshotAnimation),以及有条件地,要么ScreenshotTab面板,要么AnimationTab。为了渲染选项卡菜单,我们应用了 Material UI 的Tabs容器和Tab组件作为子项。我们还使用FontIcon Material UI 组件来渲染 Material Design 图标。我们通过使用 props 将在渲染方法开头声明的图标分配给相应的选项卡:

./js/Components/TitleBar.jsx

import React, { Component } from "react"; 
import AppBar from 'material-ui/AppBar'; 
import IconButton from 'material-ui/IconButton'; 
const appWindow = nw.Window.get(); 

export default function TitleBar() { 
  const iconElementLeft = <IconButton 
      onClick={() => appWindow.hide()} 
      tooltip="Hide window" 
      iconClassName="material-icons">arrow_drop_down_circle</IconButton>, 
        iconElementRight= <IconButton 
      onClick={() => appWindow.close()} 
      tooltip="Quit" 
      iconClassName="material-icons">power_settings_new</IconButton>; 

  return (<AppBar 
    className="titlebar" 

    iconElementLeft={iconElementLeft} 
    iconElementRight={iconElementRight}> 
    </AppBar>); 

} 

我们使用AppBar Material UI 组件实现标题栏。与前面的示例一样,我们预先定义图标(这次使用IconButton组件),并将它们传递给AppBar作为 props。我们为IconButton的点击事件设置内联处理程序。第一个隐藏窗口,第二个关闭应用程序。此外,我们为AppBar设置了一个自定义 CSS 类titlebar,因为我们将使用这个区域作为拖放的窗口句柄。因此,我们扩展了我们的自定义样式表:

./assets/main.css

... 
.titlebar { 
  -webkit-user-select: none; 
  -webkit-app-region: drag; 
} 

.titlebar button { 
  -webkit-app-region: no-drag; 
} 

现在,我们需要一个代表选项卡面板的组件。我们从ScreenshotTab开始:

./js/Components/ScreenshotTab.jsx

import React, { Component } from "react"; 

import IconButton from "material-ui/IconButton"; 
import TextField from "material-ui/TextField"; 

const TAB_BUTTON_STYLE = { 
  fontSize: 90 
}; 

const SCREENSHOT_DEFAULT_FILENAME = "screenshot{N}.png"; 

export default class ScreenshotTab extends Component { 

  render(){ 
    return ( 
      <div className="tab-layout"> 
        <div className="tab-layout__item"> 
            <TextField 
                floatingLabelText="File name pattern" 
                defaultValue={SCREENSHOT_DEFAULT_FILENAME} 
              /> 

          </div> 
          <div className="tab-layout__item"> 

            <IconButton 
              tooltip="Take screenshot" 
              iconClassName="material-icons" 
              iconStyle={TAB_BUTTON_STYLE}>add_a_photo</IconButton> 
          </div> 
        </div> 
      ) 
  } 
} 

在这里,我们使用IconButton来执行“截图”操作。通过传递自定义样式(TAB_BUTTON_STYLE)使其变得特别大。此外,我们还应用TextField组件以 Material Design 风格呈现文本输入。

第二个选项卡面板将会非常相似:

./js/Components/AnimationTab.jsx

import React, { Component } from "react"; 
import IconButton from "material-ui/IconButton"; 
import TextField from "material-ui/TextField"; 

const TAB_BUTTON_STYLE = { 
  fontSize: 90 
}; 
const ANIMATION_DEFAULT_FILENAME = "animation{N}.webm"; 

export default class AnimationTab extends Component { 

  render(){ 
    return ( 
      <div className="tab-layout"> 
          <div className="tab-layout__item"> 
              <TextField 
                  floatingLabelText="File name pattern" 
                  defaultValue={ANIMATION_DEFAULT_FILENAME} 
                /> 
          </div> 
          <div className="tab-layout__item"> 

{ true ? <IconButton 
            tooltip="Stop recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam_off</IconButton> 
            : <IconButton 
            tooltip="Start recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam</IconButton> } 
          </div> 
        </div> 
      ) 
  } 
} 

它在这里的唯一区别是条件渲染“开始录制”按钮或“停止录制”按钮。

这基本上就是静态原型的全部内容。我们只需要打包应用程序:

npm run build 

然后启动它:

npm start

你将得到以下输出:

理解 redux

在聊天应用程序中,我们学会了管理组件状态。对于那个小例子来说,这已经足够了。然而,随着应用程序变得越来越大,你可能会注意到多个组件倾向于共享状态。我们知道如何提升状态。但是哪个组件应该管理状态?状态应该放在哪里?我们可以通过使用 Redux 来避免这种模糊不清。Redux 是一个被称为可预测状态容器的 JavaScript 库。Redux 意味着应用程序范围的状态树。当我们需要为一个组件设置状态时,我们更新全局状态树中的相应节点。所有订阅的模块立即接收更新后的状态树。因此,我们可以通过检查状态树轻松地找出应用程序的情况。我们可以随意保存和恢复整个应用程序状态。想象一下,只需稍加努力,我们就可以实现通过应用程序状态历史进行时间旅行。

我想你现在可能有点困惑。如果你没有使用过它或它的前身 Flux,这种方法可能看起来很奇怪。实际上,当你开始使用它时,你会发现它非常容易理解。所以,让我们开始吧。

Redux 有三个基本原则:

  1. 应用程序中发生的一切都由状态表示。

  2. 状态是只读的。

  3. 状态变化是通过纯函数进行的,这些函数接受先前的状态,分派动作,并返回下一个状态。

我们通过分派动作来接收新状态。动作是一个带有唯一强制字段类型的普通对象,它接受一个字符串。我们可以为有效载荷设置任意多的任意字段:

前面的图描述了以下流程:

  1. 我们有一个特定状态的存储;我们称之为 A。

  2. 我们分派一个由纯函数创建的动作(称为Action Creator)。

  3. 这会调用Reducer函数,并传入参数:表示状态 A 的状态对象和分派的动作对象。

  4. Reducer克隆提供的状态对象,并根据给定动作的定义修改克隆对象。

  5. Reducer返回表示新存储的对象,状态 B

  6. 与存储连接的任何组件都会接收新状态,并调用render方法以反映视图中的状态变化。

例如,在我们的应用程序中,我们将有选项卡。当用户点击它们时,相应的面板应该显示出来。因此,我们需要在状态中表示当前的activeTab。我们可以这样做:

const action = { 
  type: "SET_ACTIVE_TAB", 
  activeTab: "SCREENSHOT" 
}; 

然而,我们不是直接分派动作,而是通过一个名为actionCreator的函数:

const actionCreatorSetActiveTab = ( activeTab ) => { 
  return { 
    type: "SET_ACTIVE_TAB", 
    activeTab 
  }; 
}; 

该函数接受零个或多个输入参数,并生成动作对象。

动作表示发生了某事,但不改变状态。这是另一个名为Reducer的函数的任务。Reducer接收表示先前状态和最后分派的动作对象的对象作为参数。根据动作类型和有效负载,它产生一个新的状态对象并返回它:

const initialState = { 
  activeTab: "" 
}; 

const reducer = ( state = initialState, action ) => { 
  switch ( action.type ) { 
    case "SET_ACTIVE_TAB": 
      return { ...state, activeTab: action.activeTab }; 
    default: 
      return state; 
  } 
}; 

在前面的例子中,我们在常量initialState中定义了初始应用程序状态。我们将其作为默认函数参数(mzl.la/2qgdNr6in)与语句state = initialState一起使用。这意味着当参数没有传递时,stateinitialState的值。

注意我们如何获得新的状态对象。我们声明了一个新的对象文字。我们在其中解构了先前的状态对象,并用来自动作有效负载的activeTab键值对进行扩展。减少器必须是纯函数,因此我们不能改变传递给状态对象的值。您知道,通过参数,我们接收state作为引用,因此如果我们简单地改变state中的activeTab字段的值,通过链接会影响函数范围之外的相应对象。我们必须确保先前的状态是不可变的。因此,我们为此创建一个新对象。解构是一种相当新的方法。如果您对此感到不舒服,可以使用Object.assign

return Object.assign( {}, state, { activeTab: action.activeTab } ); 

对于我们的应用程序,我们将只使用一个减少器,但一般情况下,我们可能会有很多。我们可以使用redux导出的combineReducers函数来组合多个减少器,使每个减少器代表全局状态树的一个独立分支。

我们将reduxcreateStore函数传递给减少器(也可以是combineReducers的产物)。该函数生成存储:

import { createStore } from "redux"; 
const store = createStore( reducer ); 

如果我们在服务器端渲染 React 应用程序,我们可以将状态对象暴露到 JavaScript 全局作用域中(例如window.STATE_FROM_SERVER),并从客户端进行连接:

const store = createStore( reducer, window.STATE_FROM_SERVER );

现在是最激动人心的部分。我们订阅存储事件:

store.subscribe(() => { 
  console.log( store.getState() ); 
}); 

然后我们将分派一个动作:

store.dispatch( actionCreatorSetActiveTab( "SCREENSHOT" ) ); 

在分派时,我们创建了类型为SET_ACTIVE_TAB的动作,并在有效负载中将activeTab设置为SCREENSHOT。因此,存储更新处理程序中的console.log打印相应更新的新状态:

{ 
  activeTab: "SCREENSHOT" 
} 

引入应用程序状态

在对 Redux 进行了简要介绍之后,我们将把新获得的知识应用到实践中。首先,我们将安装redux包:

npm i -S redux 

我们还将使用额外的辅助库redux-actgithub.com/pauldijou/redux-act)来简化动作创建者和减少器的声明。通过使用这个库,我们可以在减少器中使用动作创建者函数作为引用,放弃switch( action.type )构造,而采用更短的映射语法:

npm i -S redux-act 

对于屏幕截图,我们应执行以下操作:

  • SET_ACTIVE_TAB:接收所选选项卡的标识符

  • TOGGLE_RECORDING:开始录屏时接收true,结束时接收false

  • SET_SCREENSHOT_FILENAME:在面板截图中接收输出文件名

  • SET_SCREENSHOT_INPUT_ERROR:当输入错误发生时接收消息

  • SET_ANIMATION_FILENAME:在面板动画中接收输出文件名

  • SET_ANIMATION_INPUT_ERROR:当输入错误发生时接收消息

实现如下:

./js/Actions/index.js

import { createStore } from "redux"; 
import { createAction } from "redux-act"; 

export const toggleRecording = createAction( "TOGGLE_RECORDING",  
  ( toggle ) => ({ toggle }) ); 
export const setActiveTab = createAction( "SET_ACTIVE_TAB",  
  ( activeTab ) => ({ activeTab }) ); 
export const setScreenshotFilename = createAction( "SET_SCREENSHOT_FILENAME",  
   ( filename ) => ({ filename }) ); 
export const setScreenshotInputError = createAction( "SET_SCREENSHOT_INPUT_ERROR",  
   ( msg ) => ({ msg }) ); 
export const setAnimationFilename = createAction( "SET_ANIMATION_FILENAME",  
   ( filename ) => ({ filename }) ); 
export const setAnimationInputError = createAction( "SET_ANIMATION_INPUT_ERROR",  
  ( msg ) => ({ msg }) ); 

而不是规范的语法,我们有:

export const setActiveTab =  ( activeTab ) => { 
  return { 
    type: "SET_ACTIVE_TAB", 
    activeTab 
  }; 
} 

我们使用了更简短的方法,通过redux-actcreateAction函数实现:

export const setActiveTab = createAction( "SET_ACTIVE_TAB",  
  ( activeTab ) => ({ activeTab }) ); 

另一个函数createReducerredux-act导出,使得减少声明更加简洁:

./js/Reducers/index.js

import { createStore } from "redux"; 
import { createReducer } from "redux-act"; 
import * as Actions from "../Actions"; 
import { TAB_SCREENSHOT, SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 

const DEFAULT_STATE = { 
  isRecording: false, 
  activeTab: TAB_SCREENSHOT, 
  screenshotFilename: SCREENSHOT_DEFAULT_FILENAME, 
  animationFilename: ANIMATION_DEFAULT_FILENAME, 
  screenshotInputError: "", 
  animationInputError: "" 
}; 

export const appReducer = createReducer({ 
  [ Actions.toggleRecording ]: ( state, action ) => ({ ...state, isRecording: action.toggle }), 
  [ Actions.setActiveTab ]: ( state, action ) => ({ ...state, activeTab: action.activeTab }), 
  [ Actions.setScreenshotFilename ]: ( state, action ) => ({ ...state, screenshotFilename: action.filename }), 
  [ Actions.setScreenshotInputError ]: ( state, action ) => ({ ...state, screenshotInputError: action.msg }), 
  [ Actions.setAnimationFilename ]: ( state, action ) => ({ ...state, animationFilename: action.filename }), 
  [ Actions.setAnimationInputError ]: ( state, action ) => ({ ...state, animationInputError: action.msg }) 
}, DEFAULT_STATE ); 

我们不需要像在 Redux 介绍中那样使用switch语句描述减少器条件:

const reducer = ( state = initialState, action ) => { 
  switch ( action.type ) { 
    case "SET_ACTIVE_TAB": 
      return { ...state, activeTab: action.activeTab }; 
    default: 
      return state; 
  } 
}; 

createReducer函数为我们做到了这一点:

export const appReducer = createReducer({ 
  [ Actions.setActiveTab ]: ( state, action ) => ({ ...state, activeTab: action.activeTab }), 
}, DEFAULT_STATE ); 

该函数接受一个类似映射的对象,在其中我们将操作创建函数用作键(例如,[ Actions.setActiveTab ])。是的,对于动态对象键,我们必须使用称为计算属性名称的语法mzl.la/2erqyrj。作为对象值,我们使用回调函数来生成新状态。

在此示例中,我们克隆了旧状态({...state})并在派生对象中更改了activeTab属性值。

如果您注意到了,我们使用了Constants/index.js中的导入。在该模块中,我们将封装应用程序范围的常量:

./js/Constants/index.js

export const TAB_SCREENSHOT = "TAB_SCREENSHOT"; 
export const TAB_ANIMATION = "TAB_ANIMATION"; 
export const SCREENSHOT_DEFAULT_FILENAME = "screenshot{N}.png"; 
export const ANIMATION_DEFAULT_FILENAME = "animation{N}.webm"; 

好了,我们有了操作和一个减速器。现在是创建存储并将其连接到应用程序的时候了:

./js/Containers/App.jsx

import React from "react"; 
import { render } from "react-dom"; 
import { createStore } from 'redux'; 
import { Provider } from "react-redux"; 
import App from "./Containers/App.jsx"; 
import { appReducer } from "./Reducers"; 

const store = createStore( appReducer ); 

render(<Provider store={store}> 
  <App /> 
 </Provider>, document.querySelector( "root" ) ); 

我们使用reduxcreateStore函数构建存储。然后,我们使用react-redux包提供的ProviderApp组件包装起来。不要忘记安装依赖:

npm i -S react-redux 

Provider接受之前创建的存储作为 props,并使其对另一个react-redux函数connect可用。我们将在我们的App容器组件中使用这个函数:

./js/Containers/App.jsx

//... 
import { connect } from "react-redux"; 
import { bindActionCreators } from "redux"; 
import * as Actions from "../Actions"; 

const mapStateToProps = ( state ) => ({ states: state }); 
const mapDispatchToProps = ( dispatch ) => ({ 
  actions: bindActionCreators( Actions, dispatch ) 
}); 

class App extends Component { 
  render() { 
    return ( 
        <MuiThemeProvider muiTheme={muiTheme}> 
        <Main {...this.props} /> 
        </MuiThemeProvider>    ); 
  } 
} 

export default connect( mapStateToProps, mapDispatchToProps)( App ); 

在这里,我们定义了两个connect接受的映射函数。第一个mapStateToProps将存储的状态映射到 props。通过语句( state ) => ({ states: state }),我们使存储状态在组件中作为this.props.states可用。第二个mapDispatchToProps将我们的操作映射到 props。回调函数自动从connect函数中接收到与存储绑定的dispatch。结合reduxbindActionCreators函数,我们可以将一组操作映射到 props。因此,我们将所有可用的操作作为普通对象Actions导入,并将其传递给bindActionCreators。返回值映射到actions字段,因此将在组件中作为this.props.actions可用。

最后,我们将组件传递给connect生成的函数。它扩展了组件,我们将其导出到上游。这个表达式可能看起来有点令人困惑。实际上,我们在这里做的是在不显式修改组件本身的情况下修改组件的行为。在面向对象编程语言中,传统上,我们使用装饰器模式来实现它(en.wikipedia.org/wiki/Decorator_pattern)。如今,许多语言都具有内置的功能,比如 C#中的属性,Java 中的注解和 Python 中的装饰器。ECMAScript 也有一个提案,tc39.github.io/proposal-decorators/,用于装饰器。因此,通过使用声明性语法,我们可以修改类或方法的形状而不触及其代码。我们在 Webpack 配置中使用的插件babel-plugin-transform-decorators-legacy为我们解锁了这个功能。因此,我们已经可以用它来连接组件到存储:

@connect( mapStateToProps, mapDispatchToProps ) 
export default class App extends Component { 
  render() { 
    return ( 
        <MuiThemeProvider muiTheme={muiTheme}> 
        <Main {...this.props} /> 
        </MuiThemeProvider>    ); 
  } 
} 

从容器中,我们渲染Main组件,并将容器的所有 props 传递给它(通过解构父 props{...this.props})。因此,Main在 props 中接收到了映射的状态和操作。我们可以使用以下内容:

./js/Components/Main.jsx

import React, {Component} from "react"; 
import { Tabs, Tab } from "material-ui/Tabs"; 
import FontIcon from "material-ui/FontIcon"; 

import TitleBar from "./TitleBar.jsx"; 
import ScreenshotTab from "./ScreenshotTab.jsx"; 
import AnimationTab from "./AnimationTab.jsx"; 
import { TAB_SCREENSHOT, TAB_ANIMATION } from "../Constants"; 

class Main extends Component { 

  onTabNav = ( tab ) => { 
    const { actions } = this.props; 
    return () => { 
      actions.setActiveTab( tab ); 
    }; 
  } 

  render() { 
    const ScreenshotIcon = <FontIcon className="material-icons">camera_alt</FontIcon>; 
    const AnimationIcon = <FontIcon className="material-icons">video_call</FontIcon>; 
    const { states, actions } = this.props; 

    return ( 
      <div> 
        <TitleBar /> 
        <Tabs> 
          <Tab 
            onClick={this.onTabNav( TAB_SCREENSHOT )} 
            icon={ScreenshotIcon} 
            label="SCREENSHOT" 
          /> 
          <Tab 
            onClick={this.onTabNav( TAB_ANIMATION )} 
            icon={AnimationIcon} 
            label="ANIMATION" 
          /> 
        </Tabs> 
        <div> 

        { states.activeTab === TAB_SCREENSHOT 
            ? <ScreenshotTab {...this.props} /> 
            : <AnimationTab {...this.props} /> 
          } 
        </div> 

      </div> 
    ); 
  } 
} 

export default Main; 

你还记得,这个组件用于标签菜单。我们在这里订阅了点击标签事件。我们不直接订阅处理程序,而是订阅了一个函数this.onTabNav,该函数绑定到实例范围,根据传入的标签键生成预期的处理程序。构造的处理程序接收闭包中的键,并将其传递给从this.props.actions中提取的setActiveTab动作创建者。动作被调度,全局状态发生变化。从组件的角度来看,这就像调用setState,导致组件更新。从this.props.state中提取的activeTab字段相应地改变其值,组件呈现与通过this.onTabNav传递的键匹配的面板。

至于面板,我们已经可以将文件名表单连接到状态:

./js/Components/ScreenshotTab.jsx

import React, { Component } from "react"; 
import IconButton from "material-ui/IconButton"; 
import TextField from "material-ui/TextField"; 
import { TAB_BUTTON_STYLE, SCREENSHOT_DEFAULT_FILENAME } from "../Constants"; 

export default class ScreenshotTab extends Component { 

  onFilenameChange = ( e ) => { 
    const { value } = e.target; 
    const { actions } = this.props; 
    if ( !value.endsWith( ".png" ) || value.length < 6 ) { 
      actions.setScreenshotInputError( "File name cannot be empty and must end with .png" ); 
      return; 
    } 
    actions.setScreenshotInputError( "" ); 
    actions.setScreenshotFilename( value ); 
  } 

  render(){ 
    const { states } = this.props; 
    return ( 
      <div className="tab-layout"> 
        <div className="tab-layout__item"> 
            <TextField 
                onChange={this.onFilenameChange} 
                floatingLabelText="File name pattern" 
                defaultValue={SCREENSHOT_DEFAULT_FILENAME} 
                errorText={states.screenshotInputError} 
              /> 

          </div> 
          <div className="tab-layout__item"> 

            <IconButton 
              tooltip="Take screenshot" 
              iconClassName="material-icons" 
              iconStyle={TAB_BUTTON_STYLE}>add_a_photo</IconButton> 
          </div> 
        </div> 
      ) 
  } 
} 

在这里,我们为TextFieldchange事件订阅了this.onFilenameChange处理程序。因此,如果用户输入this.onFilenameChange,它会调用并验证输入。如果当前值的长度小于六个字符或不以.png结尾,则被视为无效。因此,我们使用从this.props.actions中提取的setScreenshotInputError动作创建者来设置错误消息的值。一旦完成,状态的screenshotInputError字段以及TextField组件的errorText属性都会发生变化,错误消息就会显示出来。如果文件名有效,我们会调度setScreenshotInputError动作来重置错误消息。我们通过调用动作创建者setScreenshotFilename来改变状态树中的截图文件名。

如果你注意到了,我们将IconButton的自定义样式封装在常量模块中,这样它就可以在两个面板之间共享。但是我们必须将新的常量添加到模块中:

./js/Constants/index.js

export const TAB_BUTTON_STYLE = { 
  fontSize: 90 
}; 

第二个面板除了表单验证之外,还会改变状态字段isRecording

./js/Components/AnimationTab.jsx

import React, { Component } from "react"; 
import IconButton from "material-ui/IconButton"; 
import TextField from "material-ui/TextField"; 
import { TAB_BUTTON_STYLE, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 

export default class AnimationTab extends Component { 

  onRecord = () => { 
    const { states } = this.props; 
    this.props.actions.toggleRecording( true ); 
  } 

  onStop = () => { 
    this.props.actions.toggleRecording( false ); 
  } 

  onFilenameChange = ( e ) => { 
    const { value } = e.target; 
    const { actions } = this.props; 
    if ( !value.endsWith( ".webm" ) || value.length < 7 ) { 
      actions.setAnimationInputError( "File name cannot be empty and must end with .png" ); 
      return; 
    } 
    actions.setAnimationInputError( "" ); 
    actions.setAnimationFilename( value ); 
  } 

  render(){ 
    const { states } = this.props; 
    return ( 
      <div className="tab-layout"> 
          <div className="tab-layout__item"> 
              <TextField 
                  onChange={this.onFilenameChange} 
                  floatingLabelText="File name pattern" 
                  defaultValue={ANIMATION_DEFAULT_FILENAME} 
                  errorText={states.animationInputError} 
                /> 
          </div> 
          <div className="tab-layout__item"> 

{ states.isRecording ? <IconButton 
            onClick={this.onStop} 
            tooltip="Stop recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam_off</IconButton> 
            : <IconButton 
            onClick={this.onRecord} 
            tooltip="Start recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam</IconButton> } 
          </div> 
        </div> 
      ) 
  } 
}

我们订阅了开始录制和停止录制按钮的点击事件处理程序。当用户点击第一个按钮时,this.onRecord处理程序调用动作创建者toggleRecording,将状态字段isRecording设置为true。这导致组件更新。根据新的状态,它用停止录制按钮替换开始录制按钮。反之亦然,如果在this.onStop处理程序中点击停止录制,我们调用toggleRecording将状态属性isRecording设置为false。组件相应地更新。

现在,我们可以构建应用程序并运行它:

npm run build 
npm start 

注意到当我们切换标签、编辑文件名或切换开始/停止录制时,应用程序会按我们的意图做出响应。

总结

在本章中,我们熟悉了谷歌的 Material Design 的基础知识。我们使用 Material-UI 组件集中的现成的 React 组件构建了静态原型。我们对 Redux 状态容器进行了介绍。我们定义了应用程序状态树并设置了状态改变器。我们创建了全局状态存储并将其连接到容器组件。我们通过 props 将暴露的动作创建者和状态树主干传递给呈现组件。我们检查了redux-act库提供的更短的动作/减速器声明语法。我们通过使用 Redux 状态机动作来实现它,例如标签导航、录制切换和表单验证。

第六章:使用 NW.js 创建屏幕捕捉器:增强、工具和测试

在第五章中,使用 NW.js、React 和 Redux 创建屏幕捕捉器-规划、设计和开发,我们应用了 Redux 存储来管理应用程序状态。现在,我们将看看如何使用中间件来为工具化 Redux,并如何对 Redux 进行单元测试。

然而,本章的主要目标是最终教会我们的屏幕捕捉器如何拍摄截图和录制屏幕录像。为此,您将学习如何使用 WebRTC API 来捕获和记录媒体流。我们将通过使用画布从流中生成静止帧图像。我们将实践通知 API,以通知用户有关执行的操作,而不管焦点在哪个窗口。我们将向系统托盘添加菜单,并将其与应用程序状态绑定。我们将通过全局键盘快捷键使捕捉操作可用。

工具化 Redux

在第五章 使用 NW.js、React 和 Redux 创建屏幕捕捉器-规划、设计和开发,您已经学会了 Redux 状态容器的基本知识。我们使用 Redux 构建了一个功能原型。但是,在构建自己的应用程序时,您可能需要知道状态树的变化发生的时间和内容。

幸运的是,Redux 接受中间件模块来处理横切关注点。这个概念与 Express 框架的概念非常相似。我们可以通过挂接第三方模块来扩展 Redux,当一个操作被分派但尚未到达减速器时。编写自定义记录器并没有太多意义,因为已经有很多可用的记录器(bit.ly/2qINXML)。例如,为了跟踪状态树中的更改,我们可以使用redux-diff-logger模块,它只报告状态的差异,这样更容易阅读。因此,我们将安装该软件包(npm i -S redux-diff-logger)并在入口脚本中添加几行代码:

./js/app.jsx

import { createStore, applyMiddleware, compose } from "redux"; 
import logger from 'redux-diff-logger'; 
const storeEnhancer = compose( 
        applyMiddleware( logger ) 
      ); 

const store = createStore( appReducer, storeEnhancer ); 

在这里,我们从redux-diff-logger中导出logger,并将其传递给redux模块的applyMiddleware函数,以创建一个存储增强器。存储增强器将给定的中间件应用于存储的dispatch方法。使用reduxcompose函数,我们可以组合多个增强器。我们将导数作为第二个参数传递给createStore函数。

现在,我们可以构建项目并启动它。我们可以在 UI 中进行一些操作,并查看 DevTools。JavaScript 控制台面板将输出我们引起的状态差异:

通过 redux-diff-logger 中间件,我们在 DevTools 的 JavaScript 控制台中收到报告,当我们执行任何导致状态更改的操作时。例如,我们修改了截图文件名模板,这立即反映在控制台中。实际上,我们收到了一个全新的状态树对象,但 redux-diff-logger 足够聪明,只显示我们真正感兴趣的内容 - 状态的差异。

Redux DevTools

记录报告已经是一件事,但如果我们能够获得像DevTools这样的工具与状态进行交互,那将更有用。第三方软件包redux-devtools带来了一个可扩展的环境,支持状态实时编辑和时间旅行。我们将与另外两个模块redux-devtools-log-monitorredux-devtools-dock-monitor一起研究它。第一个允许我们检查状态和时间旅行。第二个是一个包装器,当我们按下相应的热键时,将 Redux DevTools UI 停靠到窗口边缘。为了看到它的效果,我们将创建一个新的组件来描述 DevTools:

./js/Components/DevTools.jsx

import React from "react"; 
import { createDevTools } from "redux-devtools"; 
import LogMonitor from "redux-devtools-log-monitor"; 
import DockMonitor from "redux-devtools-dock-monitor"; 

const DevTools = createDevTools( 
  <DockMonitor toggleVisibilityKey="ctrl-h" 
               changePositionKey="ctrl-q" 
               defaultPosition="bottom" 
               defaultIsVisible={true}> 
    <LogMonitor theme="tomorrow" /> 
  </DockMonitor> 
); 

export default DevTools; 

我们使用createDevTools函数来创建组件。它接受 JSX,我们可以通过DockMonitor的 props 配置 React DevTools UI 的可见性和位置,以及LogMonitor中的颜色主题。

派生的组件公开了instrument方法,它作为存储增强器返回。因此,我们可以将其传递给compose函数:

./js/app.jsx

import DevTools from "./Components/DevTools.jsx"; 

const storeEnhancer = compose( 
        applyMiddleware( logger ), 
        DevTools.instrument() 
      ); 

const store = createStore( appReducer, storeEnhancer ); 

DevTools组件本身中,我们必须将其添加到 DOM 中:

render(<Provider store={store}> 
  <div> 
    <App /> 
    <DevTools /> 
  </div> 
 </Provider>, document.querySelector( "root" ) ); 

现在,当我们运行应用程序时,我们可以看到 dock。我们可以按下Ctrl + Q来改变它的位置,按下Ctrl + H来隐藏或显示它:

单元测试 Redux

我们已经在第四章中玩过 Jest 测试框架,Chat System with Electron and React: Enhancement, Testing, and Delivery(编写单元测试部分)。Redux 引入了新的概念,比如动作和减速器。现在,我们要对它们进行单元测试。

正如你可能记得的,要运行 Jest,我们需要配置 Babel:

.babelrc

{ 
  "presets": [ 
     ["env", { 
      "targets": { "node": 7 }, 
      "useBuiltIns": true 
    }], 
    "react", 
    "stage-3" 
  ], 

  "plugins": [ 
    "transform-class-properties", 
    "transform-decorators-legacy" 
  ] 
} 

同样,使用env预设,我们针对 Node.js 7 上的 Babel,并启用了在 webpack 配置中使用的额外插件。

测试动作创建者

实际上,动作创建者非常简单,因为它们是纯函数。我们根据函数接口传入输入并验证输出:

./js/Actions/index.spec.js

import { createStore } from "redux"; 
import { toggleRecording } from "./index"; 

describe( "Action creators", () => { 
  describe( "toggleRecording", () => { 
    it( "should return a valid action", () => { 
      const FLAG = true, 
            action = toggleRecording( FLAG ); 
            expect( action.payload ).toEqual( { toggle: FLAG } ); 
    }); 
  }); 
}); 

我们已经为toggleRecording函数编写了一个测试。我们断言这个函数产生的动作对象在 payload 中有{ toggle: FLAG }。正如前一章所述,任何动作都应该有一个强制属性type。当我们在调用redux-act模块的createAction函数时省略描述时,派生的动作创建者将产生具有动态生成标识符的动作,这几乎无法测试。然而,我们给它一个字符串作为第一个参数,例如TOGGLE_RECORDING

  const toggleRecording = createAction( "TOGGLE_RECORDING", ( toggle ) => ({ toggle }) ); 

this becomes the unique identifier and therefore we can expect it in type property. 

expect( action.type ).toEqual( "TOGGLE_RECORDING" ); 

我们可以以几乎相同的方式测试当前应用程序中的每个动作创建者。

测试减速器

减速器和动作创建者都是纯函数。它们接受最后的状态树对象和分派的动作作为参数,并产生一个新的状态树对象。因此,在测试减速器时,我们正在检查给定的动作是否按预期修改了状态:

./js/Reducers/index.spec.js

import { createStore } from "redux"; 
import { createReducer } from "redux-act"; 
import { TAB_SCREENSHOT, SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 
import { appReducer } from "./index"; 

describe( "appReducer", () => { 
  it( "should return default state", () => { 
    const DEFAULT_STATE = { 
      isRecording: false, 
      activeTab: TAB_SCREENSHOT, 
      screenshotFilename: SCREENSHOT_DEFAULT_FILENAME, 
      animationFilename: ANIMATION_DEFAULT_FILENAME, 
      screenshotInputError: "", 
      animationInputError: "" 
    }; 
    expect( appReducer() ).toEqual( DEFAULT_STATE ); 
  }); 

 }); 

对于 Redux 来说,第一次调用我们的减速器时,状态是undefined。我们期望减速器接受一个预定义对象作为默认状态。因此,如果我们不带参数调用该函数,它应该在入口点接收默认状态并在没有给定动作的情况下返回它而不进行修改。

另一方面,我们可以导入一个动作创建者:

import { toggleRecording } from "../Actions"; 

创建一个动作并将其传递给减速器:

it( "should return a new state for toggleRecording action", () => { 
    const FLAG = true, 
          action = toggleRecording( FLAG ), 
          newState = appReducer( undefined, action ); 
    expect( newState.isRecording ).toEqual( FLAG ); 
  }); 

因此,我们测试减速器是否产生了一个新的状态,根据给定的动作进行了更改。调用toggleRecording(true)创建的动作应该将状态对象属性isRecording设置为 true。这就是我们在测试中断言的内容:

截取屏幕截图

先前创建的静态原型可能看起来很花哨,但用处不大。我们需要一个能够截取屏幕截图和录制屏幕录像的服务。

如果是关于应用程序窗口的屏幕截图,我们可以简单地使用 NW.js 的 API:

import * as fs from "fs"; 
function takeScreenshot( filePath ){ 
  appWindow.capturePage(( img ) => { 
    fs.writeFileSync( filePath, img, "base64" ); 
  }, { 
    format : "png", 
    datatype : "raw" 
  }); 
} 

但是我们需要屏幕截图,因此我们必须获得显示输入的访问权限。W3C 包括了一份规范草案,“媒体捕获和流”(bit.ly/2qTtLXX),其中描述了捕获显示媒体的 API(mediaDevices.getDisplayMedia)。不幸的是,在撰写本文时,它尚未得到 NW.js 或任何浏览器的支持。然而,我们仍然可以使用webkitGetUserMedia,它可以流式传输桌面输入。这个 API 曾经是被称为 WebRTC 的技术的一部分(webrtc.org),旨在实现实时视频、音频和数据通信。

然而,目前它已从规范中删除,但仍然在 NW.js 和 Electron 中可用。看起来我们真的没有选择,所以我们就这样做吧。

webkitGetUserMedia接受所谓的MediaStreamConstraints对象,描述我们想要捕获的内容,并返回一个 promise。在我们的情况下,约束对象可能如下所示:

{ 
    audio: false, 
    video: { 
     mandatory: { 
      chromeMediaSource: "desktop", 
      chromeMediaSourceId: desktopStreamId, 
      minWidth: 1280, 
      maxWidth: 1920, 
      minHeight: 720, 
      maxHeight: 1080 
     } 
   } 
} 

我们禁用音频录制,为视频设置边界(webkitGetUserMedia根据您的显示分辨率确定合适的大小。当分辨率不符合范围时,会导致OverconstrainedError),并描述媒体来源。但是我们需要一个有效的媒体流 ID。我们可以从 NW.js API 中获取,例如:

nw.Screen.chooseDesktopMedia([ "window", "screen" ], ( mediaStremId ) => { 
      // mediaStremId 
    }); 

当所有内容结合在一起时,我们得到以下的服务:

./js/Service/Capturer.js

import * as fs from "fs"; 
const appWindow = nw.Window.get(); 

export default class Capturer { 

  constructor(){  
    nw.Screen.chooseDesktopMedia([ "window", "screen" ], ( id) => { 
      this.start( id ); 
    }); 
  } 

  takeScreenshot( filename ){ 
    console.log( "Saving screensho" ); 
  } 

  start( desktopStreamId ){ 
    navigator.webkitGetUserMedia({ 
        audio: false, 
        video: { 
          mandatory: { 
            chromeMediaSource: "desktop", 
            chromeMediaSourceId: desktopStreamId, 
            minWidth: 1280, 
            maxWidth: 1920, 
            minHeight: 720, 
            maxHeight: 1080 
          } 
        } 
      }, ( stream ) => { 
        // stream to HTMLVideoElement 

      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 

  } 
} 

运行时,我们会得到一个对话框提示我们选择媒体来源:

我不太喜欢这个用户体验。我宁愿让它检测桌面媒体。我们可以通过以下方法实现:

static detectDesktopStreamId( done ){ 
    const dcm = nw.Screen.DesktopCaptureMonitor; 
    nw.Screen.Init(); 
    // New screen target detected 
    dcm.on("added", ( id, name, order, type ) => { 
      // We are interested only in screens 
      if ( type !== "screen" ){ 
        return; 
      } 
      done( dcm.registerStream( id ) ); 
      dcm.stop(); 
    }); 
    dcm.start( true, true ); 
  } 

我们使用 NW.js API 的DesktopCaptureMonitor来检测可用的媒体设备,拒绝应用窗口(类型为"screen"),并使用registerStream方法获取媒体流 ID。现在,我们用我们自定义的方法detectDesktopStreamId替换 NW.js API 的chooseDesktopMedia

  constructor(){ 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

好吧,我们设法接收到了流。我们必须将它指向某个地方。我们可以创建一个隐藏的HTMLVideoElement并将其用作视频流接收器。我们将这个功能封装在一个单独的模块中:

./js/Service/Capturer/Dom.js

export default class Dom { 

  constructor(){ 
    this.canvas = document.createElement("canvas") 
    this.video = Dom.createVideo(); 
  } 

   static createVideo(){ 
    const div = document.createElement( "div" ), 
          video = document.createElement( "video" ); 
    div.className = "preview"; 
    video.autoplay = true; 
    div.appendChild( video ); 
    document.body.appendChild( div ); 
    return video; 
  } 

 } 

在构造过程中,该类创建一个新的 DIV 容器和其中的视频元素。容器被附加到 DOM。我们还需要用 CSS 支持新元素:

./assets/main.css

.preview { 
  position: absolute; 
  left: -999px; 
  top: -999px; 
  width: 1px; 
  height: 1px; 
  overflow: hidden; 
}  

基本上,我们将容器移出视图。因此,视频将被流式传输到隐藏的HTMLVideoElement中。现在的任务是捕获静止帧并将其转换为图像。我们可以用以下的技巧来做到这一点:

  getVideoFrameAsBase64() { 
    const context = this.canvas.getContext("2d"), 
          width = this.video.offsetWidth, 
          height = this.video.offsetHeight; 

    this.canvas.width = width; 
    this.canvas.height = height; 

    context.drawImage( this.video, 0, 0, width, height ); 

    return this.canvas.toDataURL("image/png") 
      .replace( /^data:image\/png;base64,/, "" ); 

  } 

我们创建一个与视频大小匹配的画布上下文。通过使用上下文方法drawImage,我们从视频流中绘制图像。最后,我们将画布转换为数据 URI,并通过去除data:scheme前缀来获取 Base64 编码的图像。

我们将我们的Dom模块实例注入Capturer服务作为依赖项。为此,我们需要修改构造函数:

./js/Service/Capturer.js

constructor( dom ){     
     this.dom = dom; 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

我们还需要将媒体流转发到HTMLVideoElement中:

start( desktopStreamId ){ 
    navigator.webkitGetUserMedia( /* constaints */, ( stream ) => { 
        this.dom.video.srcObject = stream; 
      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 
} 

我们还添加了一个保存屏幕截图的方法:

takeScreenshot( filename ){ 
    const base64Data = this.dom.getVideoFrameAsBase64(); 
    fs.writeFileSync( filename, base64Data, "base64" ); 
  } 

现在,当在组件中调用这个方法时,图像会悄悄地保存。说实话,这并不是很用户友好。用户按下按钮,却没有收到关于图像是否真的保存了的信息。我们可以通过显示桌面通知来改善用户体验:

const ICON = `./assets/icon-48x48.png`; 
//...  
takeScreenshot( filename ){ 
    const base64Data = this.dom.getVideoFrameAsBase64(); 
    fs.writeFileSync( filename, base64Data, "base64" ); 
    new Notification( "Screenshot saved",  { 
      body: `The screenshot was saved as ${filename}`, 
      icon: `./assets/icon-48x48.png` 
    }); 

  } 

现在,当新创建的屏幕截图被保存时,相应的消息会在系统级别显示。因此,即使应用程序窗口被隐藏(例如,我们使用系统托盘或快捷方式),用户仍然会收到通知:

录制屏幕截图

实际上,在构建用于截图的服务时,我们已经完成了大部分录屏的工作。我们已经有了webkitGetUserMedia提供的MediaStream对象。我们只需要一种方法来定义录制的开始和结束,并将收集的帧保存在视频文件中。这就是我们可以从MediaStream Recording API 中受益的地方,它捕获由MedaStreamHTMLMediaElement(例如<video>)产生的数据,以便我们可以保存它。因此,我们再次修改服务:

./js/Service/Capturer.js

//... 
const toBuffer = require( "blob-to-buffer" ); 
//... 
start( desktopStreamId ){ 
    navigator.webkitGetUserMedia(/* constaints */, ( stream ) => { 
        let chunks = []; 
        this.dom.video.srcObject = stream; 
        this.mediaRecorder = new MediaRecorder( stream ); 
        this.mediaRecorder.onstop = ( e ) => { 
          const blob = new Blob( chunks, { type: "video/webm" }); 
          toBuffer( blob, ( err, buffer ) => { 
            if ( err ) { 
              throw err; 
            } 
            this.saveAnimationBuffer( buffer ); 
            chunks = []; 
          }); 
        } 
        this.mediaRecorder.ondataavailable = function( e ) { 
          chunks.push( e.data ); 
        } 

      }, ( error ) => { 
        console.log( "navigator.getUserMedia error: ", error ); 
      }); 

  } 

收到MediaStream后,我们使用它来创建MediaRecorder的实例。我们订阅了实例的dataavailable事件。处理程序接受一个 Blob(表示流的一帧的类似文件的对象)。为了制作视频,我们需要一系列的帧。因此,我们将每个接收到的 Blob 推送到 chunks 数组中。我们还为停止事件订阅了一个处理程序,它从收集到的 chunks 中创建了一个webm类型的新 Blob。因此,我们有一个表示屏幕录像的 Blob,但我们不能直接将其保存在文件中。

对于二进制数据流,Node.js 将期望我们提供一个 Buffer 类的实例。我们使用blob-to-buffer包将 Blob 转换为 Buffer。

在这段代码中,我们依赖于两个事件,dataavailablestop。第一个在我们启动录制时触发,第二个在我们停止时触发。这些操作是公开的:

record( filename ){ 
    this.mediaRecorder.start(); 
    this.saveAnimationBuffer = ( buffer ) => { 
      fs.writeFileSync( filename, buffer, "base64" ); 
      new Notification( "Animation saved",  { 
        body: `The animation was saved as ${filename}`, 
        icon: ICON 
      }); 
    } 
  } 

  stop(){ 
    this.mediaRecorder.stop(); 
  } 

当调用record方法时,MediaRecorder实例开始录制,相反,使用stop方法停止该过程。此外,我们定义了saveAnimationBuffer回调函数,当录制停止时将被调用(this.mediaRecorder.onstop)。回调函数(saveAnimationBuffer)接收到录制屏幕的二进制流buffer参数,并使用fs核心模块的writeFileSync方法保存它。与截图类似,在保存屏幕录像时,我们创建一个桌面通知,通知用户已执行的操作。

服务几乎准备好了。但是正如您从我们的线框图中记得的那样,屏幕捕获器接受文件名的模板,例如screenshot{N}.pnganimation{N}.webm,其中{N}是文件索引的占位符。因此,我想将文件系统操作封装在专用类Fsys中,我们可以根据需要处理模板:

./js/Service/Capturer/Fsys.js

import * as fs from "fs"; 

export default class Fsys { 

  static getStoredFiles( ext ){ 
    return fs.readdirSync( "." ) 
      .filter( (file) => fs.statSync( file ).isFile() 
          && file.endsWith( ext ) ) || [ ]; 
  } 

  saveFile( filenameRaw, data, ext ){ 
    const files = Fsys.getStoredFiles( ext ), 
          // Generate filename of the pattern like screenshot5.png 
          filename = filenameRaw.replace( "{N}", files.length + 1 ); 
    fs.writeFileSync( filename, data, "base64" ); 
    return filename; 
  } 
} 

这个类有一个静态方法getStoredFiles,它返回工作目录中给定类型(扩展名)的所有文件的数组。在saveFile方法中保存文件之前,我们获取之前存储的文件列表,并计算{N}的值为files.length + 1。因此,第一个截图将被保存为screenshot1.png,第二个为screenshot2.png,依此类推。

我们在Capturer服务中注入的Fsys实例:


export default class Capturer { 

  constructor( fsys, dom ){ 
    this.fsys = fsys; 
    this.dom = dom; 
    Capturer.detectDesktopStreamId(( id ) => { 
      this.start( id ); 
    }); 
  } 

我们将在入口脚本中实例化服务:

./func-services/js/app.jsx

import Fsys from "./Service/Capturer/Fsys"; 
import Dom from "./Service/Capturer/Dom"; 
import Capturer from "./Service/Capturer"; 

const capturer = new Capturer( new Fsys(), new Dom() ); 

render(<Provider store={store}> 
  <App capturer={capturer} /> 
 </Provider>, document.querySelector( "root" ) ); 

我们导入Capturer类和依赖项。在构造Capturer时,我们将FsysDom的实例传递给它。我们将派生的Capturer实例与 props 一起传递给App组件。

因此,服务的实例到达ScreenshotTab组件,我们可以用它来拍摄截图:

./js/Components/ScreenshotTab.jsx

// Handle when clicked CAPTURE 
 onCapture = () => { 
    const { states } = this.props; 
    this.props.capturer.takeScreenshot( states.screenshotFilename ); 
  } 

类似地,在AnimationTab中,我们应用了相应处理程序的实例的recordstop方法:

./js/Components/AnimationTab.jsx

// Handle when clicked RECORD 
onRecord = () => { 
    const { states } = this.props; 
    this.props.capturer.record( states.animationFilename ); 
    this.props.actions.toggleRecording( true ); 
  } 
 // Handle when clicked STOP 
  onStop = () => { 
    this.props.capturer.stop(); 
    this.props.actions.toggleRecording( false ); 
  } 

现在,在构建应用程序之后,我们可以使用它来进行截图和录制屏幕录像:

从我们的图像中,我们可以观察到拍摄截图和录制屏幕录像的按钮是窗口 UI 的一部分。但是,我们还需要提供隐藏窗口的功能。那么在应用程序隐藏时如何进行捕获操作呢?答案与系统托盘有关。

利用系统托盘

第二章,使用 NW.js 创建文件资源管理器-增强和交付中,我们已经研究了在系统托盘中添加和管理应用程序菜单。简而言之,我们使用nw.MenuItem创建菜单项,将它们添加到nw.Menu实例中,并将菜单附加到nw.Tray。因此,托盘菜单的样板可能如下所示:

./js/Service/Tray.js

const appWindow = nw.Window.get(); 

export default class Tray { 

  tray = null; 

  constructor( ) { 
    this.title = nw.App.manifest.description; 
    this.removeOnExit(); 
  } 

  getItems = () => { 
    return [ /* */ ]; 
  } 

  render(){ 
    if ( this.tray ) { 
      this.tray.remove(); 
    } 

    const icon = "./assets/" + 
      ( 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(); 
    this.getItems().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 ); 
    }); 
    // do not spawn Tray instances on page reload 
    window.addEventListener( "beforeunload", () => this.tray.remove(), 
    false ); 
  } 

} 

对于这个应用程序,我们需要以下菜单项:

Take screenshot 
Start recording 
Stop recording 
--- 
Open 
Exit 

在这里,Start recordingStop recording根据状态isRecording属性启用。此外,我们需要Capturer实例和状态属性screenshotFilenameanimationFilename来在用户请求时运行捕获操作。因此,我们在Tray构造函数中注入了这两个依赖项:

./js/Service/Tray.js

import { toggleRecording } from "../Actions"; 
import { SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 

export default class Tray { 
 // default file names 
  screenshotFilename = SCREENSHOT_DEFAULT_FILENAME; 
  animationFilename = ANIMATION_DEFAULT_FILENAME; 
  isRecording = false;  

  constructor( capturer, store ) { 
    this.capturer = capturer; 
    this.store = store; 
} 

此外,我们定义了一些实例属性。screenshotFilenameanimationFilename将从状态中接收最新的用户定义的文件名模板。当状态改变时,属性isRecording将接收相应的值。为了接收状态更新,我们订阅存储更改:

constructor( capturer, store ) { 
    //... 
    store.subscribe(() => { 
      const { isRecording, screenshotFilename, animationFilename } = 
      store.getState(); 
      this.screenshotFilename = screenshotFilename; 
      this.animationFilename = animationFilename; 

      if ( this.isRecording === isRecording ) { 
        return; 
      } 
      this.isRecording = isRecording; 
      this.render(); 
    });    

  } 

在回调中,我们将状态中的实际isRecording值与实例属性isRecording中的早期存储值进行比较。这样,我们就知道了isRecording何时真正改变。只有在这种情况下,我们才会更新菜单。

最后,我们可以在getItems方法中填充菜单项选项数组:

getItems = () => { 
    return [ 
      { 
        label: `Take screenshot`, 
        click: () => this.capturer.takeScreenshot( 
        this.screenshotFilename ) 
      }, 
      { 
        label: `Start recording`, 
        enabled: !this.isRecording, 
        click: () => { 
          this.capturer.record( this.animationFilename ); 
          this.store.dispatch( toggleRecording( true ) ); 
        } 
      }, 
      { 
        label: `Stop recording`, 
        enabled: this.isRecording, 
        click: () => { 
          this.capturer.stop(); 
          this.store.dispatch( toggleRecording( false ) ); 
        } 
      }, 
      { 
        type: "separator" 
      }, 
      { 
        label: "Open", 
        click: () => appWindow.show() 
      }, 
      { 
        label: "Exit", 
        click: () => appWindow.close() 
      } 
    ]; 
  } 

我们使用应用程序窗口的close方法退出,并使用show方法恢复窗口(如果它被隐藏)。我们依赖传入的Capturer实例来捕获操作。我们还通过分发(store.dispatchtoggleRecording动作来更新状态。

现在我们在入口脚本中实例化Tray类并调用render方法:

./js/app.jsx

import Shortcut from "./Service/Shortcut" 
const tray = new Tray( capturer, store ); 
tray.render(); 

运行应用程序时,我们可以在系统通知区域看到屏幕捕获菜单:

注册全局键盘快捷键

托盘中的菜单是一种解决方案,但实际上,我们有一个选项可以执行捕获操作,即使不打开菜单。NW.js 允许我们分配全局键盘快捷键:

  const shortcut = new nw.Shortcut({ 
      key: "Shift+Alt+4", 
      active: () => {} 
      failed: console.error 
    }); 

nw.App.registerGlobalHotKey( shortcut ); 
appWindow.on( "close", () => nw.App.unregisterGlobalHotKey( shortcut ) ); 
window.addEventListener( "beforeunload", () => nw.App.unregisterGlobalHotKey( shortcut ), false ); 

我们使用nw.Shortcut来创建代表快捷键的对象。使用nw.App.registerGlobalHotKey注册快捷键。当应用程序关闭或重新加载时,我们使用nw.App.unregisterGlobalHotKey取消注册快捷键。

这将引入以下服务:

./js/Service/Shortcut.js

const appWindow = nw.Window.get(); 
import { toggleRecording } from "../Actions"; 
import { SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME, 
  TAKE_SCREENSHOT_SHORTCUT, RECORD_SHORTCUT, STOP_SHORTCUT } from "../Constants"; 

export default class Shortcut { 

 screenshotFilename = SCREENSHOT_DEFAULT_FILENAME; 
 animationFilename = ANIMATION_DEFAULT_FILENAME; 
 isRecording = false; 

 constructor( capturer, store ) { 

    this.capturer = capturer; 
    this.store = store; 

    store.subscribe(() => { 
      const { isRecording, screenshotFilename, animationFilename } = 
      store.getState(); 
      this.screenshotFilename = screenshotFilename; 
      this.animationFilename = animationFilename; 
      this.isRecording = isRecording; 
    }); 
 } 

 registerOne( key, active ){ 
    const shortcut = new nw.Shortcut({ 
      key, 
      active, 
      failed: console.error 
    }); 
    // Register global desktop shortcut, which can work without focus. 
    nw.App.registerGlobalHotKey( shortcut ); 
    appWindow.on( "close", () => nw.App.unregisterGlobalHotKey( 
    shortcut ) ); 
    window.addEventListener( "beforeunload", () => 
    nw.App.unregisterGlobalHotKey( shortcut ), false ); 
 } 

 registerAll(){ 
  this.registerOne( TAKE_SCREENSHOT_SHORTCUT, () => 
  this.capturer.takeScreenshot( this.screenshotFilename ) ); 
  this.registerOne( RECORD_SHORTCUT, () => { 
    if ( this.isRecording ) { 
      return; 
    } 
    this.capturer.record( this.animationFilename ); 
    this.store.dispatch( toggleRecording( true ) ); 
  }); 
  this.registerOne( STOP_SHORTCUT, () => { 
    if ( !this.isRecording ) { 
      return; 
    } 
    this.capturer.stop(); 
    this.store.dispatch( toggleRecording( false ) ); 
  }); 
 } 

} 

Tray类中的情况非常相似,我们注入了捕捉器和存储实例。通过第一个,我们可以访问捕捉操作,并使用第二个来访问全局状态。我们订阅状态更改以获取文件名模板和isRecording的实际值。registerOne方法基于给定的键和回调创建并注册一个快捷键实例,并订阅closebeforeunload事件以取消注册快捷键。在registerAll方法中,我们声明了我们的动作快捷键。快捷键的键我们将在常量模块中定义:

./js/Constants/index.js

export const TAKE_SCREENSHOT_SHORTCUT = "Shift+Alt+4"; 
export const RECORD_SHORTCUT = "Shift+Alt+5"; 
export const STOP_SHORTCUT = "Shift+Alt+6"; 

现在,我们还可以将键附加到托盘菜单项:

getItems = () => { 
 return  
    { 
      label: `Take screenshot (${TAKE_SCREENSHOT_SHORTCUT})`, 
   //... 

现在,当我们运行应用程序时,我们会得到以下托盘菜单:

![

我们可以通过点击标题栏左侧的隐藏窗口按钮来隐藏应用程序,并通过按下Shift + Alt + 4来截取屏幕截图,按下Shift + Alt + 5Shift + Alt + 6来开始和停止录制屏幕录像。

摘要

我们通过介绍 Redux 中间件来开始本章。作为示例,我们使用redux-diff-logger来监视存储中的变化。我们还插入了一系列工具(redux-devtools),使得可以在页面上启用类似 DevTools 的面板,用于检查存储并使用取消操作来回溯时间。最后,我们通过 Redux 来检查了动作创建者和减速器的单元测试。

在本章中,我们创建了Capturer服务,负责拍摄屏幕截图和录制屏幕录像。我们通过使用webkitGetUserMedia API 在MediaStream中实现了对桌面视频输入的捕获。利用 Canvas API,我们成功地从视频流中获取静止帧并将其转换为图像。对于视频录制,我们选择了MediaRecorder API。我们为截图和屏幕录像操作提供了相应的桌面通知。我们在系统托盘中实现了一个应用菜单,并将其绑定到存储中。为了即使在没有打开托盘菜单的情况下也能访问捕获操作,我们注册了全局键盘快捷键。

第七章:使用 Electron、TypeScript、React 和 Redux 创建 RSS 聚合器:规划、设计和开发

通过前面的章节,我们使用纯 JavaScript、React 和 React + Redux 创建了一个应用程序。现在,我们将使用最佳技术栈来开发大型可扩展的 Web 应用程序--TypeScript + React + Redux。我们将开发 RSS 聚合器。我认为这是一个很好的例子,可以展示 TypeScript 的实际应用,以及检查异步操作。此外,您还将学习使用新的组件库 React MDL。我们还将使用 SASS 语言编写自定义样式来扩展它。

应用蓝图

我们开发一个典型的工具,从可管理的来源列表中聚合联合内容。如果我们将需求分解为用户故事,我们会得到类似于这样的东西:

  • 作为用户,我可以看到先前添加的来源列表

  • 作为用户,我可以看到汇总内容

  • 作为用户,我可以通过在菜单中选择来源来过滤内容项

让我们再次使用WireframeSketcherwireframesketcher.com/)并将其放在线框上:

  • 作为用户,我可以在列表旁边打开项目链接

  • 作为用户,我可以添加一个来源

  • 作为用户,我可以删除一个来源

  • 作为用户,我可以更新汇总内容

欢迎来到 TypeScript

在开发大型可扩展应用程序时,确保所有团队成员都遵循已建立的架构是至关重要的。在其他语言中,如 Java、C++、C#和 PHP,我们可以声明类型和接口。因此,除非新功能完全满足系统架构师预期的接口,否则无法使用。JavaScript 既没有严格的类型,也没有接口。因此,2012 年,微软的工程师开发了 JavaScript 的超集(ES2015)称为TypeScript。这种语言通过可选的静态类型扩展了 JavaScript,并编译回 JavaScript,因此可以被任何浏览器和操作系统接受。这类似于我们如何使用 Babel 将 ES.Next 编译为第五版 ECMAScript,但此外,它还为我们带来了一些不太可能在可预见的未来集成到 ECMAScript 中的功能。这种语言非常出色,并且在www.typescriptlang.org/docs/home.html有文档支持,并且提供了优秀的规范bit.ly/2qDmdXj。这种语言得到了主流 IDE 和代码编辑器的支持,并且可以通过插件集成到 Grunt、Gulp、Apache Maven、Gradle 等自动化工具中。一些主要的框架正在考虑迁移到 TypeScript,而 Angular 2+和 Dojo 2 已经采用了它。其他框架通过定义文件向 TypeScript 公开它们的接口。

作为静态类型检查的替代,可以选择使用 Facebook 的Flowflow.org)。与 TypeScript 不同,Flow 不是编译器,而是一个检查器。Flow 中的基本类型与 TypeScript 的类型非常相似,几乎使用相同的语法实现。Flow 还引入了高级类型,如数组、联合、交集和泛型,但是使用了自己的方式。根据 Facebook 的说法,他们创建 Flow 是因为“TypeScript 并没有像他们想要的那样建立在发现错误的基础上。”

为 TypeScript 设置开发环境

TypeScript 对开发体验做出了诱人的承诺。为什么不动动手,看看实际操作呢?首先,我们需要为即将到来的示例创建一个专用目录。我们通过运行npm init -y来初始化项目,并将typescript安装为开发依赖项:

npm i -D typescript

在清单的scripts部分,我们添加了一个用于使用 TypeScript 编译源代码的命令:

package.json

{ 
... 
"scripts": { 
    "build": "tsc" 
  }, 
... 
} 

我们需要让 TypeScript 知道我们究竟想要什么。我们将在配置文件中描述这一点:

tsconfig.json

{ 
  "compilerOptions": { 
    "target": "ES6", 
    "module": "CommonJS", 
    "moduleResolution": "node", 
    "sourceMap": true, 
    "outDir": "./build" 
  }, 

  "include": [ 
    "./**/*" 
  ], 
  "exclude": [ 
    "node_modules" 
  ] 
} 

在这里,我们将 TypeScript 编译器设置为在项目目录中的任何地方搜索ts源文件,但不包括node_modules。在compilerOptions中,我们指定了在编译期间希望如何处理我们的源文件。target字段设置为ES6,意味着 TypeScript 将编译为 ES6/ES2016 语法,这在所有现代浏览器中已经得到充分支持。在module字段中,我们使用CommonJS。因此,TypeScript 将源文件捆绑成符合 CommonJS 标准的模块,与 Node.js 环境兼容。在moduleResolution字段中,我们选择了 Node.js 模块解析风格。在outDir字段中,我们确定 TypeScript 将存储编译后的模块的位置。有关编译器选项的更多信息,请访问bit.ly/2t9fckV

基本类型

开发环境现在似乎已经准备好了,所以我们可以用一个基本的例子来试一试:

example.ts

let title: string = "RSS Aggregator"; 

我们使用 TypeScript 的类型注解功能来对变量设置约束。这很容易;我们只需扩展声明,使用所谓的声明空间,比如:type,其中 type 可以是基本类型(boolean、number、string、array、void、any 等),类、接口、类型别名、枚举和导入。在这里,我们应用了string,意味着 title 只接受字符串。

编译后使用npm run build,我们可以在./build目录中找到example.js文件,内容如下:

build/example.js

let title = "RSS Aggregator"; 

你会发现它并没有做太多事情;它只是移除了类型提示。这就是 TypeScript 的惊人之处 - 类型检查发生在编译时,并在运行时消失。因此,我们可以从 TypeScript 中受益,而不会对应用程序的性能产生任何影响。

好吧,让我们做一件不好的事,给变量设置一个违反给定约束的值:

example.ts

let title: string = "RSS Aggregator"; 
title = 1; 

编译时,我们收到了一个错误消息:

error TS2322: Type '1' is not assignable to type 'string'. 

嗯;TypeScript 在我们做错事时警告我们。更令人兴奋的是,如果你的 IDE 支持 TypeScript,你在输入时会立即得到通知。我建议对照列表bit.ly/2a8rmTl,选择最适合你的 IDE,如果你的 IDE 恰好不在列表中。我会推荐Alm(alm.tools),它是使用 TypeScript、React 和 Redux 的一个很好的例子。然而,我自己十年前就开始使用NetBeans(netbeans.org/),它从未让我失望过。它没有原生的 TypeScript 支持,但可以通过安装TypeScript Editor 插件(github.com/Everlaw/nbts)轻松获得。

让我们更多地使用类型注解。我们拿一个函数,并为入口和出口点定义一个契约:

example.ts

function sum( a: number, b: number ): number { 
  return a + b; 
} 
 let res = sum( 1, 1 ); 
console.log( res ); 

实际上,我们在这里声明函数接受两个数字,并应返回一个数字。现在,即使我们想给函数赋予与数字不同的任何类型,IDE 也会立即提醒我们:

数组、普通对象和可索引类型

我相信,对于原始类型,情况或多或少是清楚的,但其他类型呢,比如数组?通过将基本类型与[]结合,我们定义了一个数组类型:

let arr: string[]; 

在这里,我们声明了变量arr,它是一个字符串数组。我们可以使用以下语法实现相同的效果:

let arr: Array<string>; 

或者,我们可以使用接口来实现:

interface StringArray { 
  [ index: number ]: string; 
} 
 const arr: StringArray = [ "one", "two", "tree" ]; 

通过使用所谓的索引签名来声明StringArray接口,我们对类型结构设置了约束。它接受数字索引和字符串值。换句话说,它是一个字符串数组。我们还可以进一步对数组长度设置约束:

interface StringArray { 
  [ index: number ]: string; 
  length: number; 
} 

至于普通对象,我们可以使用描述预期形状的接口:

interface MyObj {  
  foo: string; 
  bar: number; 
} 
let obj: MyObj; 

另一方面,我们可以使用对象类型文字内联设置约束:

let obj: { foo: string, bar: number }; 
// or 
function request( options: { uri: string, method: string } ): void { 
}

如果我们能够声明一个值对象(bit.ly/2khKSBg),我们需要确保不可变性。幸运的是,TypeScript 允许我们指定对象的成员为readonly

interface RGB { 
    readonly red: number; 
    readonly green: number; 
    readonly blue: number; 
} 
 let green: RGB = { red: 0, green: 128, blue: 0 }; 

我们可以访问百分比,例如RGB类型的颜色中的红色。但我们不能更改已声明颜色的 RGB 级别。如果我们尝试这样做,将会得到以下错误:

error TS2540: Cannot assign to 'red' because it is a constant or a read-only property. 

对于任意属性的对象,我们可以使用索引签名来定位字符串键:

interface DataMap { 
  [ key: string ]: any; 
} 

const map: DataMap = { foo: "foo", bar: "bar" }; 

请注意,在DataMap中,我们为成员类型设置了any。通过这样做,我们允许任何值类型。

函数类型

我们可以通过使用函数类型文字在函数上设置约束:

const showModal: (toggle: boolean) => void =  
  function( toggle )  { 
    console.log( toggle ); 
  } 

我觉得这相当令人沮丧,更喜欢使用接口:

interface Switcher { 
  (toggle: boolean): void; 
} 

const showModal:Switcher = ( toggle ) => { 
  console.log( toggle ); 
} 

showModal( true ); 

现在你可能会问,如果函数有可选参数怎么办?TypeScript 使定义可选参数非常简单。您只需要在参数后面加上一个问号:

function addOgTags(title: string, description?: string): string { 
  return ` 
    <meta property="og:title" content="${title}" /> 
    <meta property="og:description" content="${description || ""}" /> 
    } 

我们将description设置为可选,因此我们可以以两种方式调用该函数:

addOgTags( "Title" ); 
addOgTags( "Title", "Description" ); 

这些都不违反已声明的接口;到目前为止,我们给它字符串。

以相同的方式,我们可以定义可选对象成员:

interface IMeta { 
  title: string; 
  description?: string; 
} 

function addOgTags( meta: IMeta ): string { 
} 

类类型

在其他语言中,我们习惯将接口视为与类密切相关。TypeScript 带来了类似的开发体验。而且,虽然 Java 和 PHP 接口不能包含实例属性,TypeScript 没有这样的限制:

interface Starship { 
  speed: number;  
  speedUp( increment: number ): void; 
} 

class LightFreighter implements Starship { 
  speed: number = 0; 
  speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

let millenniumFalcon = new LightFreighter(); 
millenniumFalcon.speedUp( 100 ); 

随着 ES2015/2016 的发展,类在 JavaScript 中被广泛使用。然而,TypeScript 允许我们设置成员的可访问性。因此,当我们允许从消费对象实例的代码中访问成员时,我们将成员声明为public。我们使用private来确保成员在其包含的类之外不可访问。此外,protected成员与private类似,只是它们可以在任何派生类实例中被访问:

class LightFreighter implements Starship { 
  private speed: number = 0; 
  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

正如你所看到的,speed的值是硬编码的。如果我们的类在初始化期间可以配置初始速度,那就更好了。让我们进行重构:

class LightFreighter implements Starship { 
  constructor( private speed: number = 0 ) { 
  } 
  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

在这里,我们使用了 TypeScript 的另一个我个人很激动的很好的特性。它被称为参数属性。我们经常声明私有属性,并从构造函数参数中填充它们。在 TypeScript 中,我们可以简单地在参数前面加上一个可访问性修饰符,它将导致一个相应命名的属性,接受参数的值。因此,在前面的代码中,使用private speed在参数列表中,我们声明了speed参数,并将传入的值赋给它。通过使用 ES6 语法来设置默认参数,当在构造函数constructor( speed = 0 )中没有传入任何值时,我们将speed设置为零。

抽象类

与您在其他语言中可能习惯的类似,在 TypeScript 中,我们可以使用抽象类和方法。抽象类仅用于扩展。不能创建抽象类的实例。定义为抽象的方法在任何子类中都需要实现:

abstract class Starship { 
  constructor( protected speed: number = 0 ) { 

  } 
  abstract speedUp( increment: number ): void; 
} 

class LightFreighter extends Starship { 

  public speedUp( increment: number ): void { 
    this.speed = this.speed + increment; 
  } 
} 

抽象类与接口非常相似,只是一个类可以实现多个接口,但只能扩展一个抽象类。

枚举类型

一次又一次,我们使用常量来定义一组逻辑相关的实体。使用 TypeScript,我们可以声明一个由不可变数据填充的枚举类型,然后通过类型引用整个集合:

const enum Status { 
    NEEDS_PATCH, 
    UP_TO_DATE, 
    NOT_INSTALLED 
} 

function setStatus( status: Status ) { 
  // ... 
} 

setStatus( Status.NEEDS_PATCH ); 

在这里,我们声明了一个类型Status,它接受预定义值之一(NEEDS_PATCHUP_TO_DATENOT_INSTALLED)。函数setStatus期望status参数是Status类型。如果传入任何其他值,TypeScript 会报告错误:

setStatus( "READY" ); 
//  error TS2345: Argument of type '"READY"' is not assignable to parameter of type 'STATUS'. 

或者,我们可以使用字符串字面类型,它指的是一组任何字符串值:

function setStatus( status: "NEEDS_PATCH" | "UP_TO_DATE" | "NOT_INSTALLED" ) { 
  // ... 
} 
setStatus( "NEEDS_PATCH" ); 

联合和交叉类型

到目前为止很有趣,不是吗?那么你对此怎么看:在 TypeScript 中,我们可以同时引用多种类型。例如,我们有两个接口AnakinPadmé,需要一个从它们两个继承的新类型(Luke)。我们可以像这样轻松实现它:

interface Anakin { 
  useLightSaber: () => void; 
  useForce: () => void; 
} 
interface Padmé { 
  leaderSkills: string[]; 
  useGun: () => void; 
} 
type Luke = Anakin & Padmé; 

此外,我们可以在不明确声明类型的情况下进行交集操作:

function joinRebelion( luke: Anakin & Padmé ){   
}

我们还可以定义一个允许任何类型的组的联合类型。你知道jQuery库,对吧?函数jQuery接受各种类型的选择器参数,并返回jQuery实例。如何可能用接口来覆盖它呢?

interface PlainObj { 
  [ key: string ]: string; 
} 
interface JQuery { 
} 

function jQuery( selector: string | Node | Node[] | PlainObj | JQuery ): JQuery { 
  let output: JQuery = {} 
  // ... 
  return output; 
} 

当函数返回依赖于传入类型的类型时,我们可以声明一个描述所有可能用例的接口:

interface CreateButton { 
  ( tagName: "button" ): HTMLButtonElement; 
  ( tagName: "a" ): HTMLAnchorElement; 
} 

实现这个接口的函数接受tagName参数的字符串。如果值是"button",函数返回Button元素。如果是"a",则返回Anchor元素。

可以在规范中找到可用的与 DOM 相关的接口www.w3.org/TR/DOM-Level-2-HTML/html.html

泛型类型

我们刚刚检查的类型是指具体类型组合。此外,TypeScript 支持所谓的泛型类型,它有助于在不同上下文中重用一次创建的接口。例如,如果我们想要一个数据映射的接口,我们可以这样做:

interface NumberDataMap { 
  [ key: string ]: number; 
} 

但是NumberDataMap只接受成员值为数字。假设对于字符串值,我们必须创建一个新的接口,比如StringDataMap。或者,我们可以声明一个泛型DataMap,在引用时设置任意值类型的约束:

interface DataMap<T> { 
  [ key: string ]: T; 
} 

const numberMap: DataMap<number> = { foo: 1, bar: 2 }, 
      stringMap: DataMap<string> = { foo: "foo", bar: "bar" }; 

全局库

是的,TypeScript 确实是一种令人印象深刻的语言,当涉及到编写新代码时。但是对于现有的非 TypeScript 库呢?例如,我们将使用 React 和 Redux 模块。它们是用 JavaScript 编写的,而不是 TypeScript。幸运的是,主流库已经提供了 TypeScript 声明文件。我们可以使用 npm 按模块安装这些文件:

npm i -D @types/react 
npm i -D @types/react-dom 

现在,当我们尝试对任何这些模块进行愚蠢的操作时,我们会立即收到有关问题的通知:

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 

ReactDOM.render( 
  <div></div>, 
  "root" 
); 

在编译或输入时,你会得到错误:

error TS2345: Argument of type '"root"' is not assignable to parameter of type 'Element'. 

公平地说,与其传递给ReactDOM.render的 HTML 元素(例如document.getElementById("root")),我传递了一个字符串作为第二个参数。

然而,老实说,并非每个库都提供了 TypeScript 声明。例如,在RSS 聚合器应用程序中,我将使用feedme库(www.npmjs.com/package/feedme)通过 URL 获取和解析 RSS。不过,该库没有声明文件。幸运的是,我们可以快速创建一个:

feedme.d.ts

declare class FeedMe { 
  new ( flag?: boolean ): NodeJS.WritableStream; 
  on( event: "title", onTitle: ( title: string ) => void): void; 
  on( event: "item", onItem: ( item: any ) => void ): void; 
} 

模块feedme公开了一个类FeedMe,但 TypeScript 并不知道这些模块;它还没有在 TypeScript 范围内声明。因此,我们在feedme.d.ts(declare class FeedMe)中使用环境声明来引入作用域中的新值。我们声明接受boolean类型的可选标志并返回 Node.jsWriteStream对象的类构造函数。我们使用重载来描述函数使用的两种情况。在第一种情况下,它接收"title"字符串作为event,并期望回调处理 RSS 标题。在第二种情况下,它接收"title"事件,然后期望回调处理 RSS 条目。

现在,我们可以从服务中使用新创建的声明文件:

/// <reference path="./feedme" /> 
import http = require( "http" ); 
var FeedMe = require( "feedme" ); 

http.get('http://feeds.feedburner.com/TechCrunch/startups', ( res ) => { 
  const parser = new FeedMe( true ); 
  parser.on( "title", ( title: string ) => { 
    console.log( title ); 
  }); 
  res.pipe( parser ); 
}); 

使用三斜杠指令,我们将feedme.d.ts包含在项目中。完成后,TypeScript 会验证FeedMe是否根据其接口使用。

创建静态原型

我认为,到这一点,我们已经足够了解 TypeScript,可以开始应用程序了。与之前的示例一样,首先我们做的是静态原型。

为应用程序设置开发环境

我们必须为项目设置开发环境。因此,我们专门为它分配一个目录,并将以下清单放在其中:

./package.json

{ 
  "name": "rss-aggregator", 
  "title": "RSS Aggregator", 
  "version": "1.0.0", 
  "main": "./app/main.js", 
  "scripts": { 
    "build": "webpack", 
    "start": "electron .", 
    "dev": "webpack -d --watch"  
  } 
} 

根据任何 Electron 应用程序的要求,我们在main字段中设置了主进程脚本的路径。我们还定义了运行 Webpack 进行构建和监视的脚本命令。我们设置了一个脚本命令来使用 Electron 运行应用程序。现在,我们可以安装依赖项。我们肯定需要 TypeScript,因为我们将使用它来构建应用程序:

npm i -D typescript 

对于打包,我们将使用 Webpack,就像我们为 Chat 和 Screen Capturer 应用程序所做的那样,但是这次,我们不再使用babel-loader,而是使用ts-loader,因为我们的源代码是 TypeScript 语法:

npm i -D webpack 
npm i -D ts-loader 

我们还安装了 Electron 和相关模块,这些模块我们在创建 Chat 应用程序时已经检查过:

npm i -D electron 
npm i -D electron-debug 
npm i -D electron-devtools-installer 

最后,我们安装了 React 声明文件:

npm i -D @types/react 
npm i -D @types/react-dom 

为了访问 Node.js 的接口,我们还安装了相应的声明:

npm i -D @types/node 

现在,我们可以配置 Webpack 了:

./webpack.config.js

const path = require( "path" ); 
module.exports = { 
  entry: "./app/ts/index.tsx", 
  output: { 
    path: path.resolve( __dirname, "./app/build/js/" ), 
    filename: "bundle.js" 
  }, 

  target: "electron-renderer", 
  devtool: "source-map", // enum 
  module: { 
    rules: [ 
      { 
        test: /\.tsx?$/, 
        use: "ts-loader" 
      } 
    ] 
  } 
}; 

在这里,我们将入口脚本设置为app/ts/index.tsx,输出为./app/build/js/bundle.js。我们将 Webpack 目标设置为 Electron(electron-renderer),并启用源映射生成。最后,我们指定了一个规则,使 Webpack 使用ts-loader插件处理任何.ts/.tsx文件。

因此,如果我们请求一个文件,比如require("./path/file.ts")import {member} from "./path/file.ts",Webpack 将在打包期间使用 TypeScript 进行编译。我们可以使用 Webpack 选项resolve使其更加方便:

./webpack.config.js

{ 
... 
resolve: { 
    modules: [ 
      "node_modules", 
      path.resolve(__dirname, "app/ts") 
    ], 

    extensions: [ ".ts", ".tsx", ".js"] 
  }, 
... 
} 

在这里,我们声明 Webpack 会尝试解析遇到的任何模块名,对node_modulesapp/ts目录进行解析。因此,如果我们访问一个模块,我们将得到以下结果:

import {member} from "file.ts" 

根据我们的配置,Webpack 首先检查node_modules/file.ts的存在,然后是app/ts/file.ts。由于我们将.ts扩展名列为可解析的,所以可以从模块名中省略它:

import {member} from "file" 

剩下的就是 TypeScript 的配置:

tsconfig.json

{ 
  "compilerOptions": { 
    "target": "es6", 
    "module": "commonjs", 
    "moduleResolution": "node", 
    "sourceMap": false, 
    "outDir": "../dist/", 
    "jsx": "react" 
  }, 

  "files": [ 
    "./app/ts/index.tsx" 
  ] 
} 

它基本上和我们为 TypeScript 介绍示例创建的配置是一样的,只是这里我们不是指向编译器一个目录,而是明确指向入口脚本。我们还告诉编译器它应该期望 JSX。

React-MDL

之前,在开发 Screen Capturer 时,我们研究了组件库 Material UI。这并不是 React 可用的唯一的 material design 实现。这次,让我们尝试另一个--React MDL (react-mdl.github.io/react-mdl/)。所以,我们安装了该库和相应的声明:

npm i -S react-mdl 
npm i -D @types/react-mdl 

根据文档,我们通过导入来启用库:

import "react-mdl/extra/material.css"; 
import "react-mdl/extra/material.js"; 

哦!哦!Webpack 无法解析 CSS 模块,直到我们相应地进行配置。首先,我们必须告诉 Webpack 在node_modules目录中查找react-mdl/extra/material.cssreact-mdl/extra/material.js

./webpack.config.js 
{ 
... 
resolve: { 
   modules: [ 
        "node_modules", 
        path.resolve(__dirname, "app/ts") 
      ], 
      extensions: [ ".ts", ".tsx", ".js", ".css"] 
  }, 
... 
} 

其次,我们添加了一个规则来处理 CSS,使用css-loader插件:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
      test: /\.css$/, 
      use: [ 
          "style-loader", 
          "css-loader" 
        ] 
    } 
  ] 
}, 

... 
} 

现在,当遇到import "react-mdl/extra/material.css"时,Webpack 会加载样式并将其嵌入页面中。但在 CSS 内容中,有链接到自定义的.woff字体。我们需要让 Webpack 加载所引用的字体文件:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
       test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 
       use: { 
         loader: "url-loader", 
         options: { 
           limit: 1000000, 
           mimetype: "application/font-woff" 
         } 
       } 
    } 
  ] 
}, 

... 
} 

现在,我们必须安装上述提到的加载器:

npm i -D css-loader 
npm i -D style-loader 

创建 index.html

在 Electron 应用程序中,我们通常首先处理的是主进程脚本,它基本上创建应用程序窗口。对于这个应用程序,我们不会介绍任何新的概念,所以我们可以重用 Chat 应用程序的main.js

index.html将非常简单:

app/index.html
<!DOCTYPE html>
<html lang="en">
   <head>
      <link rel="stylesheet" href="https://fonts.googleapis.com/icon?
      family=Material+Icons">
     <title>RSS Aggregator</title>
 </head>
   <body>
        <div id="root"></div> 
       <script src="img/bundle.js"></script>
  </body>
</html>

基本上,我们加载了 Google 的 Material Icons 字体并声明了边界元素(div#root)。当然,我们必须加载由 Webpack/TypeScipt 生成的 JavaScript。它位于build/js/bundle.js,就像我们在./webpack.config.js中配置的那样。

接下来,我们组成入口脚本:

./app/ts/index.tsx

import "react-mdl/extra/material.css"; 
import "react-mdl/extra/material.js"; 

import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import App from "./Containers/App"; 

ReactDOM.render( 
  <App />, 
  document.getElementById( "root" ) 
); 

正如你所看到的,它与我们在屏幕捕捉器静态原型中所拥有的相似,除了导入 React-MDL 资产。至于 TypeScript,在代码中并不真正需要任何更改。然而,现在我们确实为我们使用的模块拥有了类型化接口(./node_modules/@types/react-dom/index.d.ts),这意味着如果我们违反了约束,例如ReactDOM.render,我们会得到一个错误。

创建容器组件

现在让我们创建我们在入口脚本中提到的container组件:

./app/ts/Containers/App.tsx

import { Layout, Content } from "react-mdl"; 
import * as React from "react"; 

import TitleBar from "../Components/TitleBar"; 
import Menu from "../Components/Menu"; 
import Feed from "../Components/Feed"; 

export default class App extends React.Component<{}, {}> { 

  render() { 
    return ( 
      <div className="main-wrapper"> 
        <Layout fixedHeader fixedDrawer> 
          <TitleBar /> 
          <Menu /> 
          <Content> 
            <Feed  /> 
          </Content> 
        </Layout> 
      </div> 
    ); 
  } 
} 

在这里,我们从 React-MDL 库中导入LayoutContent组件。我们使用它们来布局我们的自定义组件TitleBarMenuFeed。根据 React 声明文件(./node_modules/@types/react/index.d.ts),React.Component是一个泛型类型,所以我们必须为状态和属性提供接口React.Component<IState, IProps>。在静态原型中,我们既没有状态也没有属性,所以我们可以使用空类型。

创建 TitleBar 组件

下一个组件将代表标题栏:

./app/ts/Components/TitleBar.tsx

import * as React from "react"; 
import { remote } from "electron"; 
import { Header, Navigation, Icon } from "react-mdl"; 

export default class TitleBar extends React.Component<{}, {}> { 

  private onClose = () => { 
    remote.getCurrentWindow().close(); 
  } 
  render() { 
    return ( 
     <Header  scroll> 
        <Navigation> 
            <a href="#" onClick={this.onClose}><Icon name="close" />
            </a> 
        </Navigation> 
    </Header> 
    ); 
  } 
} 

在这里,我们使用 React MDL 的HeaderNavigationIcon组件来设置外观和感觉,并订阅关闭图标的点击事件。此外,我们导入electron模块的remote对象,并通过使用getCurrentWindow方法访问当前窗口对象。它有一个close方法,我们用它来关闭窗口。

我们的Menu组件将包含聚合源的列表。用户可以使用addremove按钮来管理列表。autorenew按钮用于更新所有源。

创建菜单组件

我们将保持源菜单在 React MDL 的Drawer组件中,它会在宽屏上自动显示,并在较小屏幕上隐藏在汉堡菜单中:

./ts/Components/Menu.tsx

import * as React from "react"; 
import { Drawer, Navigation, Icon, FABButton } from "react-mdl"; 

export default class Menu extends React.Component<{}, {}> { 

  render (){ 

    return ( 
     <Drawer  className="mdl-color--blue-grey-900 mdl-
     color-text--blue-grey-50"> 
        <Navigation className="mdl-color--blue-grey-80"> 
          <a> 
             <Icon name="& #xE0E5;" /> 
             Link title 
          </a> 
        </Navigation> 
        <div className="mdl-layout-spacer"></div> 
        <div className="tools"> 
          <FABButton mini> 
              <Icon name="add" /> 
          </FABButton> 

          <FABButton mini> 
              <Icon name="delete" /> 
          </FABButton> 

          <FABButton mini> 
              <Icon name="autorenew" /> 
          </FABButton> 
        </div> 
      </Drawer> 
    ); 
  } 
} 

创建源组件

最后,我们来处理主要部分,我们将在其中显示活动源内容:

./app/ts/Components/Feed.tsx

import * as React from "react"; 
import { Card, CardTitle, CardActions, Button, CardText } from "react-mdl"; 

export default class Feed extends React.Component<{}, {}> { 
  render(){ 
    return ( 
      <div className="page-content feed-index"> 
        <div className="feed-list"> 

          <Card shadow={0} style={{width: "100%", height: "auto", 
          margin: "auto"}}> 
             <CardTitle expand style={{color: "#fff", backgroundColor: 
             "#46B6AC"}}> 
             Title 
             </CardTitle> 
             <CardText> 
                  Lorem ipsum dolor sit amet, consectetur adipiscing 
                  elit. Cras lobortis, mauris quis mollis porta 
             </CardText> 
             <CardActions border> 
                  <Button colored>Open</Button> 
             </CardActions> 
           </Card> 

        </div> 

        <div className="feed-contents"></div> 
      </div> 
    ); 
  } 
} 

.feed-list容器中,我们显示了 RSS 项的列表,每个都用 React MDL 的Card组件包装。容器.feed-contents是项目内容的占位符。

一切准备就绪。我们可以构建并启动:

npm run build
npm start

输出是:

使用 SASS 添加自定义样式

看起来,结果 UI 需要额外的样式。我建议我们在 SASS 中编写我们的自定义样式:

./app/sass/app.scss

 .main-wrapper { 
  height: 100vh; 
} 

首先,我们让顶层元素(./app/ts/Containers/App.tsx)始终适应实际的窗口高度。

接下来,我们声明一个变量来固定标题栏的高度,并设置源项和项目内容容器的布局:

./app/sass/app.scss

$headrHeight: 66px; 

.feed-index { 
  display: flex; 
  flex-flow: row nowrap; 
  overflow-y: auto; 
  height: calc(100vh - #{$headrHeight}); 
  &.is-open { 
    overflow-y: hidden; 
    .feed-list { 
      width: 50%; 
    } 
    .feed-contents { 
      width: 50%; 
    } 
  } 
} 
.feed-list { 
  flex: 1 0 auto; 
  width: 100%; 
  transition: width 200ms ease; 
} 
.feed-contents { 
  flex: 1 0 auto; 
  width: 0; 
  transition: width 200ms ease; 
} 

最初,源项容器(.feed-list)的宽度为 100%,而项目内容容器(.feed-contents)被隐藏(width:0)。当父容器(.feed-index)接收到带有is-open类的新状态时,两个子容器会优雅地将宽度移动到50%

最后,我们在菜单组件中布局操作按钮:

./app/sass/app.scss

.tools { 
  height: 60px; 
  display: flex; 
  flex-flow: row nowrap; 
  justify-content: space-between; 
} 

好吧,我们引入了一个新的源类型(SASS),所以我们必须调整 Webpack 配置:

./webpack.config.js

{ 
... 
resolve: { 
   modules: [ 
        "node_modules", 
        path.resolve(__dirname, "app/ts"), 
        path.resolve(__dirname, "app/sass") 
      ], 
      extensions: [ ".ts", ".tsx", ".js", ".scss", ".css"] 
  }, 
... 
} 

现在,Webpack 接受.scss模块名称,并在app/sass中查找源。我们还必须配置 Webpack 来将 SASS 编译为 CSS:

./webpack.config.js

{ 
... 
module: { 
  rules: [ 
    ... 
    { 
      test: /\.scss$/, 
      use: [ 
          "style-loader", 
          "css-loader", 
          "sass-loader" 
        ] 
    } 
  ] 
}, 

... 
} 

在这里,我们确定在解析.scss文件时,Webpack 使用sass-loader插件将 SASS 转换为 CSS,然后使用css-loaderstyle-loader加载生成的 CSS。所以,我们现在缺少一个依赖项 - sass-loader;让我们安装它:

npm i -D sass-loader 

这个模块依赖于node-sass编译器,所以我们也需要它:

npm i -D node-sass 

为什么不检查一下我们得到了什么。所以我们构建并启动:

npm run build
npm start

应用程序现在看起来更好了:

摘要

在这一章中,我们深入学习了 TypeScript。我们研究了变量声明和参数约束中的基本类型。我们使用接口来处理数组和普通对象。您学会了如何处理函数和类的接口。我们注意到了抽象特性,比如成员可访问性修饰符、参数属性、抽象类和方法。您学会了如何使用枚举类型和字符串字面量来处理组实体。我们研究了如何使用泛型类型重用接口。我们还看到了如何在全局库中安装 TypeScript 声明,以及在没有可用声明时如何编写我们自己的声明。我们开始着手应用程序。因此,我们设置了 Webpack 来查找和处理.ts/.tsx模块,以及加载 CSS 和 Web 字体。我们使用 React MDL 库的组件来创建用户界面。我们通过 SASS 加载器扩展了 Webpack 配置,以处理我们的自定义样式。最终我们得到了一个可工作的静态原型。

第八章:使用 Electron、TypeScript、React 和 Redux 创建 RSS 聚合器:开发

在上一章中,我们拥抱了 TypeScript,并提出了一个静态原型。现在,我们将释放语言的强大力量。我们将编写应用程序服务并用接口覆盖它们。我们将描述操作和 Reducers。在这个过程中,我们将研究基于 Promise 的异步操作的创建以及使用redux-promiseredux-actions模块进行乐观更新。我们将连接存储到应用程序并将预期的功能带到组件中。我们还将创建一个简单的路由器并将其绑定到存储中。

创建一个获取 RSS 的服务

简而言之,我们的应用程序是关于阅读 RSS 订阅。因此,从获取给定 URL 的 feed 并将其解析为我们可以附加到应用程序状态的结构的服务开始是正确的事情。我建议使用requestwww.npmjs.com/package/request)模块获取 feed XML,并使用feedme模块(www.npmjs.com/package/feedme)进行解析。让我们首先在纯 JavaScript 中做这件事。因此,我们需要安装这两个包:

npm i -S feedme
npm i -S request

我们将有一个名为rss的函数,它使用request通过 HTTP(s)获取 feed 内容。这个函数将接受两个参数:feed URL 和一个以 Node.js 的 thunk-like 方式编写的回调函数:

const request = require( "request" ); 

function rss( feedUrl, onDone ){ 
  const feed = { 
          title: "", 
          items: [] 
        }, 
        parser = createFeedParserStream( feed ); 

  request 
    .get( feedUrl ) 
    .on( "error", ( err ) => { 
      onDone( err ); 
    }) 
    .on( "end", () => { 
      onDone( null, feed ); 
    }) 
    .pipe( parser ); 
} 

在这里,我们将 feed 数据容器定义为一个普通对象(feed)。我们从尚未编写的createFeedParserStream函数中获取一个可写流(nodejs.org/api/stream.html),并将其传送到由request生成的可读流中,用于指定的 feed URL。现在,让我们添加缺失的函数:

const FeedMe = require( "feedme" ); 

function createFeedParserStream( feed ) { 
  const parser = new FeedMe( true ); 
  parser.on( "title", ( title ) => { 
    feed.title = title; 
  }); 
  parser.on( "item", ( item ) => { 
    feed.items.push( item ); 
  }); 
  return parser; 
} 

在这里,我们将流作为FeedMe实例,并订阅其解析事件。在接收到 feed 标题时,我们将其分配给feed.title。在接收到每个项目的详细信息时,我们将它们推送到feed.items数组中。该函数返回派生的解析流,并通过传入的引用修改feed对象。

现在,我们可以按以下方式使用rss函数:

rss( "http://feeds.feedburner.com/CssTricks", ( err, feed ) => { 
  if ( err ) { 
    return console.log( err ); 
  } 
  console.log( feed ); 
}); 

尽管默认情况下,Node.js 核心模块仍然意味着长时间嵌套的异步函数,但我们非常清楚所谓的回调地狱的不良影响。因此,我们将将服务转换为 Promise:

function rss( feedUrl ){ 
  return new Promise(( resolve, reject ) => { 
    const feed = { 
            title: "", 
            items: [] 
          }, 
          parser = createFeedParserStream( feed ); 

    request 
      .get( feedUrl ) 
      .on( "error", ( err ) => reject( err ) ) 
      .on( "end", () => resolve( feed ) ) 
      .pipe( parser ); 
  }); 
} 

现在,它导致了明显改进的开发体验:

rss( "http://feeds.feedburner.com/CssTricks") 
  .then(( feed ) => console.log( feed ) ) 
  .catch( err => console.log( err ) ); 

作为 Promise,它也可以通过async/await语法使用:

async function handler() { 
  try { 
    const feed = await rss( "http://feeds.feedburner.com/CssTricks"); 
  } catch( e ) { 
    // handle exception 
  } 
} 

handler(); 

在这一点上,我们可以回到 TypeScript 并描述代码中的类型。首先,我们期望声明的feed结构实现以下接口:

./app/ts/Interfaces/Rss.ts

export interface IRssItem { 
  description: string; 
  link: string; 
  pubdate: string; 
  title: string; 
} 

export interface IFeed { 
  title: string; 
  items: IRssItem[]; 
} 

但等等!模块feedme没有声明文件。看起来我们也必须为它提供一个接口。在上一章中,我展示了一种通过使用三斜杠指令和环境声明将全局库引入 TypeScript 范围的方法。这不是唯一可能的解决方案。我们可以在一个模块中声明接口:

./app/ts/Services/IFeedMe.ts

import { IRssItem } from "../Interfaces/Rss"; 
export interface IFeedMe { 
  new ( flag?: boolean ): NodeJS.WritableStream; 
  on( event: "title", onTitle: ( title: string ) => void): void; 
  on( event: "item", onItem: ( item: IRssItem ) => void ): void; 
} 

在服务中,我们导入IFeedMe接口,并将feedme导出分配给类型为IFeedMe的常量:

import { IFeedMe } from "./IFeedMe"; 
const FeedMe: IFeedMe = require( "feedme" ); 

在将我们的服务重写为 TypeScript 后,其源代码将如下所示:

/app/ts/Services/rss.ts

import { IRssItem, IFeed } from "../Interfaces/Rss"; 
import { IFeedMe } from "./IFeedMe"; 
import * as request from "request"; 
const FeedMe: IFeedMe = require( "feedme" ); 

function createFeedParserStream( feed: IFeed ): NodeJS.WritableStream { 
  const parser = new FeedMe( true ); 
  parser.on( "title", ( title: string ) => { 
    feed.title = title; 
  }); 
  parser.on( "item", ( item: IRssItem ) => { 
    feed.items.push( item ); 
  }); 
  return parser; 
} 

export default function rss( feedUrl: string ): Promise<IFeed> { 
  const feed: IFeed = { 
          title: "", 
          items: [] 
        }; 
  return new Promise<IFeed>(( resolve, reject ) => { 
    request.get( feedUrl ) 
      .on( "error", ( err: Error ) => { 
        reject( err ); 
      }) 
      .on( "end", () => { 
        resolve( feed ); 
      }) 
      .pipe( createFeedParserStream( feed ) ); 
  }); 
} 

有什么变化?我们用接口(FeedMe: IFeedMe)考虑了feedme模块的导出。我们为createFeedParserStream函数定义了合同。它接受IFeed类型作为输入,并返回NodeJS.WritableStream。我们对服务函数rss也做了同样的事情。它期望一个字符串并返回一个解析为IFeed类型的 Promise。

创建一个管理 feed 菜单的服务

现在我们可以获取 RSS feed 了。但计划是拥有一个可管理的 feed 菜单。我认为,我们可以用一个项目数组来表示菜单,其中每个项目可以用以下接口描述:

./app/ts/Interfaces/index.ts

export interface IMenuItem { 
  url: string; 
  title: string; 
  id: string; 
} 

至于服务本身,让我们也从接口开始:

./app/ts/Services/IMenu.ts

import { IMenuItem } from "../Interfaces"; 

export interface IMenu { 
  items: IMenuItem[]; 
  clear(): void; 
  remove( url: string ): IMenuItem[]; 
  add( url: string, title: string ): IMenuItem[]; 
  load(): IMenuItem[]; 
} 

在某种程度上,这有点像测试驱动开发。我们描述类的内容而不实现来获得整体图像。然后,我们逐一填充成员:

./app/ts/Services/Menu.ts

import sha1 = require( "sha1" ); 
import { IMenu } from "./IMenu"; 
import { IMenuItem } from "../Interfaces"; 

class Menu implements IMenu { 

  items: IMenuItem[] = []; 

  constructor( private ns: string ){ 
  } 

  clear(): void { 
    this.items =  []; 
    this.save(); 
  } 

  remove( url: string ): IMenuItem[] { 
    this.items =  this.items.filter(( item ) => item.url !== url ); 
    this.save(); 
    return this.items; 
  } 

  add( url: string, title: string ): IMenuItem[] { 
    const id = <string> sha1( url ); 
    this.items.push({ id, url, title }); 
    this.save(); 
    return this.items; 
  } 

  private save(): void { 
    localStorage.setItem( this.ns, JSON.stringify( this.items ) ); 
  } 

  load(): IMenuItem[] { 
    this.items = JSON.parse( localStorage.getItem( this.ns ) || "[]" ); 
    return this.items; 
  } 
} 

export default Menu; 

发生了什么?首先,我们导入了sha1模块(www.npmjs.com/package/sha1),我们将使用它来计算 feed URL 的 SHA1 哈希(en.wikipedia.org/wiki/SHA-1)。这是一个外部模块,它解析为非模块实体,因此不能使用 ES6 语法导入。这就是为什么我们使用require函数。但我们仍然希望 TypeScript 考虑模块声明文件(@types/sha1),所以我们将其容器声明为import sha1。我们还在模块范围内导入了服务接口(IMenu)和菜单项类型(IMenuItem)。我们的构造函数接受一个字符串作为命名空间。通过给参数加上可访问性修饰符,我们声明了ns属性并将参数的值赋给它。Menu的实例将在items属性中保持实际菜单状态。私有方法saveitems属性的值存储到localStorage中。addremoveclear方法都修改this.items数组,并使用 save 方法与localStorage同步。最后,load 方法更新this.item,使用存储在localStorage中的数组。

操作和减速器

因此,我们有了核心服务,可以开始设计 Redux 存储。我们可以在表格中描述预期的状态变化:

操作创建者 操作类型 状态影响
toggleOpenAddFeed TOGGLE_ADD_FEED state.isOpenAddFeed
addFeed ADD_FEED state.isOpenAddFeed``state.feedError state.items
setFeedError SET_FEED_ERROR state.feedError
removeFeed REMOVE_FEED state.feedError
fetchFeed FETCH_FEED state.items state.feedError
fetchMenu FETCH_MENU state.menu state.items``state.activeFeedUrl
setActiveFeed SET_ACTIVE_FEED state.activeFeedUrl

首先,我们需要填充我们的 feed 菜单。为此,我们将有一个带有表单的模态窗口来添加 feed。操作创建者函数toggleOpenAddFeed将用于切换模态窗口的可见性。

当模态窗口中的表单提交时,组件将调用addFeed函数。该函数通过提供的 URL 获取 feed,获取其标题,并向菜单添加新项。由于涉及用户输入和网络操作,我们必须覆盖失败场景。因此,我们引入了setFeedError函数,它在应用程序状态中设置消息。当我们更新菜单时,相应的服务将使用localStorage同步更改。这意味着我们需要一个读取菜单的操作。fetchMenu函数将负责此事。此外,它将利用rss服务来获取菜单中所有 feed 的项目,并以聚合列表的形式提供。此外,我们将提供通过菜单导航的选项。当用户点击一个项目时,组件将调用setActiveFeed来标记项目为活动状态,并调用fetchFeed函数来更新所选 feed 的Feed组件中的项目。

在编写操作创建者函数时,我们声明类型并将它们用作 Reducers 的引用。这意味着我们需要一个模块,其中包含一堆表示操作类型的常量:

./app/ts/Constants/index.ts

export const TOGGLE_ADD_FEED = "TOGGLE_ADD_FEED"; 
export const SET_ACTIVE_FEED = "SET_ACTIVE_FEED"; 
export const FETCH_MENU = "FETCH_MENU"; 
export const ADD_FEED = "ADD_FEED"; 
export const SET_ADD_FEED_ERROR = "SET_ADD_FEED_ERROR"; 
export const SET_FEED_ERROR = "SET_FEED_ERROR"; 
export const FETCH_FEED = "FETCH_FEED"; 
export const REMOVE_FEED = "REMOVE_FEED"; 

既然我们在这里,让我们也定义一些配置常量:

export const MENU_STORAGE_NS = "rssItems"; 
export const FEED_ITEM_PER_PAGE = 10; 

第一个(MENU_STORAGE_NS)指定了我们将在localStorage中使用的命名空间。第二个(FEED_ITEM_PER_PAGE)确定我们每页显示多少项。这适用于所选的 feed 和聚合 feed。

在第五章,使用 NW.js、React 和 Redux 创建屏幕捕捉器:规划、设计和开发中,我们使用第三方模块redux-act来抽象创建 actions 和 Reducers。这真的很方便,但如果需要异步操作,它就不适用了。因此,这一次,我们将使用redux-actions模块(github.com/acdlite/redux-actions)。让我们在 JavaScript 示例中检查一下。首先,我们通过调用redux-actionscreateAction函数创建一个同步 action:

import { createAction } from "redux-actions"; 
const toggleOpenAddFeed = createAction( "TOGGLE_ADD_FEED", ( toggle ) => toggle  ); 

到目前为止,它看起来与redux-act的语法非常相似。我们可以运行新创建的函数:

console.log( toggleOpenAddFeed( true ) ),

然后我们得到一个具有强制type属性和多用途payload属性的操作对象:

{ payload: "TOGGLE_ADD_FEED", type: true } 

现在,我们可以使用redux-actionshandleActions函数来创建一个 Reducer:

import { handleActions } from "redux-actions"; 
const app = handleActions({ 

  "TOGGLE_ADD_FEED": ( state, action ) => ({ 
    ...state, isOpenAddFeed: action.payload 
  }) 

}, defaultState ); 

handleActions函数期望一个普通对象,该对象使用 action 类型作为参考将处理程序映射到操作。每个处理程序回调都接收最新的状态对象和分派的操作,与经典的 Reducer 相同(redux.js.org/docs/basics/Reducers.html)。

但是异步操作呢?例如,我们将使用rss服务来获取 feeds。该服务返回一个 Promise。由于redux-actions,我们可以创建一个如下简单的 action:

const fetchFeed = createAction( "FETCH_FEED", async ( url: string ) => await rss( url ) ); 

是不是很美?我们只需传递一个异步函数作为处理程序。一旦处理程序的 Promise 解析,操作将被分派:

const app = handleActions({ 
  "FETCH_FEED": ( state, action ) => ( 
      ...state, 
      items: action.payload.items 
  }) 

}, defaultState ); 

等等!但是如果 Promise 被拒绝会怎么样?模块redux-actions依赖于乐观更新。在失败的情况下,传入的操作会获得额外的error属性,我们可以在其中找到错误消息:

const app = handleActions({ 

  "FETCH_FEED": ( state, action ) => ({ 
    if ( action.error ) { 
      return { ...state, feedError: `Cannot fetch feed: ${action.payload}` }; 
    } 
     return { 
      ...state, 
      items: action.payload.items 
    }; 
  }) 

}, defaultState ); 

现在在考虑如何实现 action creators 和 Reducers 之后,我们可以使用接口来覆盖存储资产。首先,我们声明状态的接口:

./app/ts/Interfaces/index.ts

//... 
export interface IAppState { 
  isOpenAddFeed: boolean; 
  menu: IMenuItem[]; 
  items: IRssItem[]; 
  feedError: string; 
  activeFeedUrl: string; 
} 

isOpenAddFeed属性是一个boolean,用于确定是否显示具有添加新 feed 表单的模态窗口。menu属性包含菜单项列表,并在Menu组件中用于构建菜单。items属性包含 RSS 项,并用于在Feed组件中构建列表。feedError属性存储最后的错误消息,activeFeedUrl保留最后请求的 feed URL。

接下来,我们描述 actions:

import { Action } from "redux-actions"; 

export interface IAppActions { 
  toggleOpenAddFeed: ( toggle: boolean ) => Action<boolean>; 
  setActiveFeed:  ( url: string ) => Action<string>; 
  setFeedError: ( msg: string ) => Action<string>; 
  fetchMenu: () => Promise<IMenuRssPayload>; 
  addFeed: ( url: string ) => Promise<IMenuItem[]>; 
  removeFeed: ( url: string ) => Promise<IMenuItem[]>; 
  fetchFeed: ( url: string ) => Promise<IFeed>; 
} 

模块redux-actions通过声明文件Action类型进行导出。因此,我们声明toggleOpenAddFeedsetActiveFeedsetFeedError函数返回符合Action类型约束的普通对象。换句话说,除了type属性之外,这些函数可能还有payloaderrorAction是一个通用类型,因此我们澄清了 payload 中预期的类型,例如,Action<boolean>表示{ type: string, payload: boolean }

异步操作fetchMenuaddFeedremoveFeedfetchFeed返回 Promises。再次,当 Promise 解析时,我们明确指定了预期的类型。说到这一点,函数fetchMenu引用了缺少的IMenuRssPayload类型。让我们添加它:

./app/ts/Interfaces/index.ts

export interface IMenuRssPayload { 
  menuItems: IMenuItem[]; 
  rssItems: IRssItem[]; 
} 

该功能解析为包含聚合列表的菜单项和 RSS 项的对象。

看起来我们已经准备好实现存储了。因此,我们将从 actions 开始:

./app/ts/Actions/actions.ts

import { createAction } from "redux-actions"; 
import * as vo from "../Constants"; 
import { IMenuItem, IRssItem, IFeed, IMenuRssPayload } from "../Interfaces"; 
import Menu from "../Services/Menu"; 
import rss from "../Services/rss"; 
const menu = new Menu( vo.MENU_STORAGE_NS ); 

首先,我们导入了createAction,之前定义的常量和接口,以及rssMenu构造函数等服务。我们在从配置常量导入的命名空间中创建了菜单的实例。接下来,我们添加了同步动作:

const feedActions = { 

  toggleOpenAddFeed: createAction<boolean, boolean>( 
    vo.TOGGLE_ADD_FEED, ( toggle: boolean ) => toggle 
  ), 

  setActiveFeed: createAction<string, string>( 
    vo.SET_ACTIVE_FEED, ( url: string ) => url 
  ), 

  setFeedError: createAction<string, string>( 
    vo.SET_FEED_ERROR, ( msg: string ) => msg 
  ), 

  removeFeed: createAction<IMenuItem[], string>( 
    vo.REMOVE_FEED, ( url: string ) => menu.remove( url ) 
  ), 
}; 

在这里,我们使用了我们在 JavaScript 示例中早些时候检查过的模式来创建createAction。唯一的区别是createAction在 TypeScript 范围内是一个泛型类型,因此我们必须指定动作创建者将在payload属性中传递什么类型,并且第一个参数期望什么类型。所有这些函数都接受一个参数。如果我们需要更多,我们可以将其表示为createAction<Payload, Arg1, Arg2>甚至createAction<Payload, Arg1, Arg2, Arg3, Arg4>

现在,我们用异步动作扩展了feedActions

const feedActions = { 
  //... 

  fetchFeed: createAction<Promise<IFeed>, string>( 
    vo.FETCH_FEED, async ( url: string ) => await rss( url ) 
  ), 

  addFeed: createAction<Promise<IMenuItem[]>, string>( 
    vo.ADD_FEED, 
    async ( url: string ) => { 
      if ( menu.items.find( item => item.url === url ) ) { 
        throw new Error( "This feed is already in the list" ); 
      } 
      const feed = await rss( url ); 
      if ( !feed.title ) { 
        throw new Error( "Unsupported format" ); 
      } 
      return menu.add( url, feed.title ); 
    } 
  ), 

  fetchMenu: createAction<Promise<IMenuRssPayload>>( 
    vo.FETCH_MENU, async () => { 
      menu.load(); 
      let promises = menu.items.map( item => rss( item.url ) ); 
      return Promise.all( promises ) 
        .then(( feeds: IFeed[] ) => { 
          if ( !feeds.length ) { 
            return { menuItems: [], rssItems: [] }; 
          } 
          let all = feeds 
              .map( feed => feed.items ) 
              // combine [[items],[item]] in a flat array 
              .reduce(( acc: IRssItem[], items: IRssItem[] ) => 
              acc.concat( items ) ) 
              // sort the list by publication date DESC 
              .sort(( a, b ) => { 
                let aDate = new Date( a.pubdate ), 
                    bDate = new Date( b.pubdate ); 
                return bDate.getTime() - aDate.getTime(); 
              }) 
              .slice( 0, vo.FEED_ITEM_PER_PAGE ); 
          return { menuItems: menu.items, rssItems: all }; 
        }); 
    } 
  ) 
}; 

export default feedActions; 

函数fetchFeed简单地委托了rss服务的 Promise。函数addFeed首先检查给定的 URL 是否已经存在于菜单中。如果是,它会抛出一个异常。然后,函数从rss服务获取 feed 并将项添加到菜单中。最后,fetchMenu执行了一些任务。它从localStorage重新加载菜单。这正是一个动作所期望的。但我希望这个函数也能生成聚合列表。因此,它收集了菜单中每个可用 feed 的rss服务的 Promise。它应用Promise.all来解析收集到的 Promise 集合。该方法的结果是 feed 列表。我们需要将所有项组合成一个扁平数组,按发布日期排序,并将其限制在我们在FEED_ITEM_PER_PAGE常量中设置的数量。

现在,我们开始 Reducer:

./app/ts/Reducers/app.ts

import { handleActions, Action } from "redux-actions"; 
import { IAppState, IMenuRssPayload } from "../Interfaces"; 
import * as vo from "../Constants"; 

const defaultState: IAppState = { 
  isOpenAddFeed: false, 
  menu: [], 
  items: [], 
  feedError: "", 
  activeFeedUrl: "" 
}; 

在这里,我们导入了handleActions函数和Action接口,以及来自redux-actions的接口和常量。我们还为 Reducer 定义了默认状态。

接下来,我们创建 Reducer:

const app = handleActions<IAppState>({ 

  [ vo.TOGGLE_ADD_FEED ]: ( state, action ) => ({ 
    ...state, isOpenAddFeed: action.payload 
  }), 

  [ vo.ADD_FEED ]: ( state, action ) => { 
    if ( action.error ) { 
      return { ...state, feedError: `Cannot add feed: 
      ${action.payload}` }; 
    } 
    return { ...state, feedError: "", isOpenAddFeed: false, menu: 
    action.payload }; 
  }, 

  [ vo.SET_FEED_ERROR ]: ( state, action ) => ({ 
    ...state, feedError: action.payload 
  }), 

  [ vo.REMOVE_FEED ]: ( state, action ) => { 
    if ( action.error ) { 
      return { ...state, feedError: `Cannot remove feed: 
      ${action.payload}` }; 
    } 
    return { ...state, menu: action.payload }; 
  }, 

  [ vo.FETCH_MENU ]: ( state, action: Action<IMenuRssPayload> ) => { 
    if ( action.error ) { 
      return { ...state, feedError: `Cannot fetch menu:      
      ${action.payload}` }; 
    } 
    return { 
      ...state, 
      menu: action.payload.menuItems, 
      items: action.payload.rssItems, 
      activeFeedUrl: "" 
    }; 
  }, 

  [ vo.FETCH_FEED ]: ( state, action ) => { 
    if ( action.error ) { 
      return { ...state, feedError: `Cannot fetch feed: 
      ${action.payload}` }; 
    } 
     return { 
      ...state, 
      items: action.payload.items 
    }; 
  }, 

  [ vo.SET_ACTIVE_FEED ]: ( state, action ) => ({ 
    ...state, activeFeedUrl: action.payload 
  }) 

}, defaultState ); 

export default app; 

handleActions是泛型类型,因此我们可以为它操作的state对象指定约束。在提供的对象中,我们描述了每个分发的动作如何修改状态。因此,toggleOpenAddFeedTOGGLE_ADD_FEED)切换isOpenAddFeed属性。函数addFeedADD_FEED)在成功的情况下,从动作有效负载中填充menu属性,并且重置feedErrorisOpenAddFeed。如果 Promise 被拒绝,它会用错误消息设置feedError。函数setFeedErrorSET_FEED_ERROR)简单地从动作有效负载中设置feedError。函数removeFeedREMOVE_FEED)更新菜单,因此在这里,它用更新后的列表填充了menu状态属性。函数fetchFeedFETCH_FEED)用刚刚获取的 feed 项更新了items属性。函数fetchMenuFETCH_MENU)重新加载菜单并生成聚合列表,因此它同时更新了menu和(RSS)items。最后,函数setActiveFeedSET_ACTIVE_FEED)简单地将选定的项 URL 保存在状态中。

在一个大型可扩展的应用程序中,我们使用多个 Reducer 与reduxcombineReducers函数组合在一起。对于这个小应用程序,只有 Reducer 就足够了。然而,我建议我们遵循这个做法:

./app/ts/Reducers/index.ts

import { combineReducers } from "redux"; 
import app from "./app"; 

const reducer = combineReducers({ state: app }); 
export default reducer; 

这改变了我们的状态树。因此,顶层状态对象现在可以用以下接口描述:

./app/ts/Interfaces/index.ts

export interface IRootState { 
  state: IAppState; 
} 

连接到 store

我们有动作创建者和减速器,现在,我们将使它们在整个应用程序中可用。正如您可以从第五章中记得的那样,使用 NW.js、React 和 Redux 创建屏幕捕捉器:规划、设计和开发,模块redux提供了函数createStore,它接受组合的减速器来生成存储。模块react-redux导出了提供程序高阶组件,它接受带有 props 的存储并通过connect在内部组件树中使其可用。函数createStore接受与redux的 compose 函数组合的中间件。正如我们在这个应用程序中已经讨论过的,我们需要异步操作。在这里,我们可以使用redux-thunkhttps://www.npmjs.com/package/redux-thunk)中间件,它允许我们编写动作创建者,这些动作创建者返回的是函数而不是普通对象。这些函数将dispatchgetState函数的引用作为参数。因此,我们可以派发延迟的动作。例如,我们需要通过 URL 读取 RSS 源,因此我们可以使用以下动作创建者在应用程序状态上反映它:

function fetchFeedAsync( url ) { 
  return dispatch => { 
    dispatch( fetchFeedRequest() ); 
    rss( url ) 
      .then( data => dispatch( fetchFeedSuccess( data ) )) 
      .catch( e  => dispatch( fetchFeedFailure( e ) )); 
  }; 
} 

在为 feed 内容进行异步 HTTP 请求之前,我们派发fetchFeedRequest,当请求解析时,派发fetchFeedSuccess,如果请求被拒绝,则派发fetchFeedFailure

这一切都很好,但太啰嗦了。仅仅为了获取通过 HTTP 检索的数据,我们写了四个(!)动作创建者。相反,我们可以采用乐观更新方法,并使用单个动作创建者。这涉及到一个额外的中间件redux-promisewww.npmjs.com/package/redux-promise),它与redux-actions很好地配合:

const fetchFeed = createAction( 
    "FETCH_FEED", async ( url ) => await rss( url ) 
  ) 

现在,当将所有内容组合在一起时,我们得到了入口脚本的以下更新:

./app/ts/index.tsx

import { Provider } from "react-redux"; 
import { createStore, applyMiddleware, compose } from "redux"; 
import thunkMiddleware from "redux-thunk"; 
import * as promiseMiddleware from "redux-promise"; 

const storeEnhancer = compose( 
  applyMiddleware( 
    thunkMiddleware, 
    promiseMiddleware 
  ) 
); 

const store = createStore( 
  appReducers,  storeEnhancer 
); 

ReactDOM.render( 
  <Provider store={store}> 
      <App {...this.props} /> 
  </Provider>, 
  document.getElementById( "root" ) 
); 

在容器组件中,我们需要添加两个函数,通知connect我们希望如何将状态和动作创建者映射到组件的 props 中:

./app/ts/Containers/App.tsx

// mapping state to the props 
const mapStateToProps = ( state: IRootState ) => state; 

import actions from "../Actions/actions"; 
// mapping actions to the props 
const mapDispatchToProps = { 
  ...actions 
}; 

在这里,我们将状态简单地一对一地映射到了 props。由于我们将存储表示为{ state: applicationStateTree },因此我们在 props 中接收到一个额外的指向实际状态树的state属性。至于动作创建者,我们解构命名空间并将每个可用函数作为新属性附加到 props 上。因此,容器组件的 props 现在可以用以下类型描述:

./app/ts/Interfaces/index.ts

export type TStore = IRootState & IAppActions; 

我们将在React.Component泛型中引用这种类型的 props。

我们通过解构store={this.props}将容器组件的属性向下传递。因此,每个子组件都会收到一个具有属性storeTStore类型的对象:

class App extends React.Component<TStore, {}> { 
  render() { 
    return ( 
      <div className="main-wrapper"> 
        <ErrorAlert store={this.props} /> 
        <Layout fixedHeader fixedDrawer> 
          <TitleBar /> 
          <Menu store={this.props} /> 
          <Content> 
            <Feed store={this.props} /> 
          </Content> 
        </Layout> 
      </div> 
    ); 
  } 
} 

// connect store to App 
export default connect( 
  mapStateToProps, 
  mapDispatchToProps 
)( App ); 

就我个人而言,我认为容器是引导逻辑的好地方。特别是,我希望在应用程序启动时从localStorage加载菜单。实际上,可以在容器组件挂载后立即完成:

 class App extends React.Component<TStore, {}> { 

  componentDidMount() { 
    this.props.fetchMenu(); 
  } 
} 

因此,我们调用了现在在 props 中可用的fetchMenu动作创建者。这将派发动作,减速器修改状态,任何组件,所有组件都反映状态变化。

从组件中使用存储。

如果你足够注意,你不会错过,在容器的 JSX 中,我们引入了一个新的组件ErrorAlert。由于我们有一个错误状态(state.feedError),我们需要将其可视化:

./app/ts/Components/ErrorAlert.tsx

import * as React from "react"; 
import { Dialog, DialogTitle, 
  DialogContent, DialogActions, Button } from "react-mdl"; 
import { TStore } from "../Interfaces"; 

interface IProps { 
  store: TStore; 
} 

export default class ErrorAlert extends React.Component<IProps, {}> { 

  private onClose = () => { 
    this.props.store.setFeedError( "" ); 
  } 

  render() { 
    const { feedError } = this.props.store.state; 
    return ( 
    <Dialog open={Boolean(feedError)}> 
          <DialogTitle>Houston, we have a problem</DialogTitle> 
          <DialogContent> 
            <p>{feedError}</p> 
          </DialogContent> 
          <DialogActions> 
            <Button type="button" onClick={this.onClose}>Close</Button> 
          </DialogActions> 
        </Dialog> 
    ); 
  } 
} 

通过使用 React MDL 库的Dialog和相关组件,我们描述了一个模态窗口,当state.feedError不为空时显示。窗口有一个Close按钮,它有一个onClose处理程序订阅了点击事件。处理程序调用setFeedError动作来重置state.feedError

现在,我们可以修改Menu组件以从状态中显示和管理 RSS 菜单:

./app/ts/Components/Menu.tsx

import * as React from "react"; 

import { Drawer, Navigation, Icon, FABButton } from "react-mdl"; 
import { IMenuItem, TStore } from "../Interfaces"; 
import AddFeedDialog from "./AddFeedDialog"; 

interface IProps { 
  store: TStore; 
} 

export default class Menu extends React.Component<IProps, {}> { 

  static makeClassName = ( toggle: boolean ) => { 
    const classList = [ "mdl-navigation__link" ]; 
    toggle && classList.push( "mdl-navigation__link--current" ); 
    return classList.join( " " ); 
  } 

  private onAddFeed = () => { 
     this.props.store.toggleOpenAddFeed( true ); 
  } 

  private onRemoveFeed = () => { 
    const { removeFeed, fetchMenu, state } = this.props.store; 
     removeFeed( state.activeFeedUrl ); 
     fetchMenu(); 
  } 

  private onRefresh = () => { 
    this.props.store.fetchMenu(); 
  } 

  render (){ 
    const { state } = this.props.store, 
          menu = state.menu || []; 

    return ( 
     <Drawer  className="mdl-color--blue-grey-900 mdl-
     color-text--blue-grey-50"> 
        <AddFeedDialog store={this.props.store} /> 
        <Navigation className="mdl-color--blue-grey-80"> 

        { menu.map(( item: IMenuItem ) => ( 
          <a key={item.id} href={`#${item.id}`} 
          className={Menu.makeClassName( item.url === 
          state.activeFeedUrl )}> 
           <Icon name="& #xE0E5;" /> 
           {item.title} 
          </a> 
        )) } 
        </Navigation> 
        <div className="mdl-layout-spacer"></div> 
        <div className="tools"> 
          <FABButton mini onClick={this.onAddFeed}> 
              <Icon name="add" /> 
          </FABButton> 
          { state.activeFeedUrl && ( 
          <FABButton mini> 
              <Icon name="delete" onClick={this.onRemoveFeed} /> 
          </FABButton> 
          )} 
          <FABButton mini onClick={this.onRefresh}> 
              <Icon name="autorenew" /> 
          </FABButton> 
        </div> 
      </Drawer> 
    ); 
  } 
} 

在这里,我们从store属性中获取state.menu,并将其映射到构建菜单项列表。我们将项目表示为带有item.title作为内容和item.id(URL 的 sha1)作为href的链接。我们使用静态方法makeClassName来构建项目的className。它通常是"mdl-navigation__link",当项目是活动项目时,它将是"mdl-navigation__link mdl-navigation__link--current"。我们还订阅了AddRemoveRefreshAutorenew图标)按钮的点击事件处理程序。第一个调用toggleOpenAddFeed动作,并传入true来显示添加反馈的模态窗口。第二个使用removeFeed动作,并使用来自状态的activeFeedUrl。它还调用fetchMenu动作来刷新聚合列表。最后一个简单地调用fetchMenu动作。

现在,我们必须创建一个代表带有添加反馈表单的模态窗口的组件:

./app/ts/Components/AddFeedDialog.tsx

import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Textfield } from "react-mdl"; 
import * as React from "react"; 
import { TStore } from "../Interfaces"; 

interface IProps { 
  store: TStore; 
} 
export default class AddFeedDialog extends React.Component<IProps, {}> { 

  private urlEl: Textfield; 
  private formEl: HTMLFormElement; 
  private onSubmit = ( e: React.MouseEvent<HTMLFormElement>  ) => { 
    // https://github.com/react-mdl/react-mdl/issues/465 
    const urlEl = this.urlEl as any; 
    e.preventDefault(); 
    this.save( urlEl.inputRef.value ); 
  } 
  async save( url: string ){ 
    const { addFeed, fetchMenu } = this.props.store; 
    await addFeed( url ); 
    await fetchMenu(); 
    if ( !this.props.store.state.feedError ){ 
      this.formEl.reset(); 
    } 
  } 
  private close = () => { 
     this.props.store.toggleOpenAddFeed( false ); 
     this.formEl.reset(); 
  } 

  render() { 
    const { isOpenAddFeed } = this.props.store.state; 

    return ( 
      <div> 

        <Dialog open={isOpenAddFeed}> 
          <DialogTitle>New Feed</DialogTitle> 
          <DialogContent> 
            <form onSubmit={this.onSubmit} ref={(el: HTMLFormElement)   
            => { this.formEl = el; }}> 

            <Textfield 
                label="URL" 
                required 
                floatingLabel 
                ref={(el: Textfield) => { this.urlEl = el; }} 
            /> 

            </form> 
          </DialogContent> 
          <DialogActions> 
            <Button type="button" onClick={this.onSubmit}>Save</Button> 
            <Button type="button" onClick={this.close}>Cancel</Button> 
          </DialogActions> 
        </Dialog> 
      </div> 
    ); 
  } 
} 

ErrorAlert类似,我们使用Dialog和 React MDL 的相关组件来渲染模态窗口。窗口中有一个表单和一个由 React MDL 的Textfield组件表示的输入。我们通过使用ref属性将这两个元素都放在实例范围内。我们订阅了表单submit事件的onSubmit方法。在处理程序中,我们通过引用从输入字段中获取值(Textfield被引用为this.urlEl;因此,根据 React MDL API,内部输入可以被访问为this.urlEl.inputRef),并将其传递给私有方法savesave方法调用addFeedfetchMenu来更新聚合列表。窗口还包括Close按钮,它在点击事件上调用toggleOpenAddFeed动作,并传入 false。

现在只剩下更新Feed组件了。

./app/ts/Components/Feed.tsx

import * as React from "react"; 
import { shell } from "electron"; 

import { Card, CardTitle, CardActions, Button, CardText } from "react-mdl"; 
import { IRssItem, TStore } from "../Interfaces"; 

interface IProps { 
  store: TStore; 
} 

export default class Feed extends React.Component<IProps, {}> { 

  private indexEl: HTMLElement; 
  private contentsEl: HTMLElement; 
  private webviewEl: Electron.WebviewTag; 

  // Convert HTML into plain text 
  static stripHtml( html: string ){ 
    var tmp = document.createElement( "DIV" ); 
    tmp.innerHTML = html; 
    return tmp.textContent || tmp.innerText || ""; 
  } 

  private onCloseLink = () => { 
    this.indexEl.classList.remove( "is-open" ); 
    this.webviewEl.src = "blank"; 
  } 

  private onOpenLink = ( e: React.MouseEvent<HTMLElement> ) => { 
    const btn = e.target as HTMLElement, 
          url = btn.dataset[ "link" ]; 
    e.preventDefault(); 
    this.indexEl.classList.add( "is-open" ); 
    this.webviewEl.src = url; 
  } 

  componentDidMount() { 
    this.webviewEl = this.contentsEl.firstChild as Electron.WebviewTag; 
    this.webviewEl.addEventListener( "new-window", ( e ) => { 
      e.preventDefault(); 
      shell.openExternal( e.url ); 
    }); 
  } 

  render(){ 
    const { items } =  this.props.store.state; 
    return ( 
      <div className="page-content feed-index" ref={(el: HTMLElement) 
      => { this.indexEl = el; }}> 
        <div className="feed-list"> 

        { items.map(( item: IRssItem, inx: number ) => ( 
          <Card key={inx} shadow={0} style={{width: "100%", height: 
         "auto", margin: "auto"}}> 
           <CardTitle expand style={{color: "#fff", backgroundColor: 
          "#46B6AC"}}> 
           {item.title} 
           </CardTitle> 
            <CardText onClick={this.onCloseLink}> 
                { item.description ? Feed.stripHtml( item.description ) 
            : "" } 
            </CardText> 
            <CardActions border> 
                <Button colored data-link={item.link} onClick=
             {this.onOpenLink}>Open</Button> 
            </CardActions> 
         </Card> 
        )) } 
        </div> 
        <div className="feed-contents" 
            ref={(el: HTMLElement) => { this.contentsEl = el; }} 
            dangerouslySetInnerHTML={{ 
            __html: `<webview class="feed-contents__src"></webview>` 
          }}></div> 
      </div> 
    ); 
  } 
}

在这里,我们将state.items映射到渲染 RSS 项目,同时我们使用stripHtml静态方法来清理项目描述。每个项目都配有一个Open按钮,它有一个订阅者onOpenLink。这个方法会使.feed-contents列可见,并改变WebViewsrc属性。这会导致WebView加载反馈项目的 URL。为什么我们使用WebView而不是 iFrame?因为WebView是 Electron 和 NW.js 中用于嵌入内容的容器(electron.atom.io/docs/api/webview-tag/)。WebView在一个单独的进程中运行,它没有与您的页面相同的权限。因此,它应该防止第三方页面和对您的应用程序有害的脚本。

我们无法直接引用WebView,因为 JSX 没有这样的元素,我们必须注入它。因此,我们使用componentDidMount生命周期方法通过 DOM 来访问它。此外,我们订阅了new-window事件,当在WebView中加载的页面尝试打开新窗口/标签时会触发该事件。我们阻止了这种情况发生,而是在外部浏览器中打开请求的页面。

干杯!现在是一个工作中的应用程序。所以,我们可以构建它:

npm build

然后我们可以运行:

npm start

输出将是:

如果我们点击 RSS 项目中的“打开”链接,内容面板会滑入,并将相应的内容加载到 WebView 中:

创建路由器服务

一切都很好,除了我们实际上无法从菜单中选择一个反馈。我们有状态属性activeFeedUrl,已经被Menu组件考虑到了,但到目前为止我们从未使用setActiveFeed动作来设置这个状态。尽管如此,在Menu组件中,我们为所有项目提供了哈希链接。为了提供浏览器位置导航,我们需要一个路由器。有许多可安装的模块可用的实现。然而,在这个简单的例子中,我们将创建我们自己的:

./app/ts/Services/Router.ts

import * as Redux from "redux"; 
import { IRootState, IMenuItem } from "../Interfaces"; 
import actions from "../Actions/actions"; 

export default class Router { 

  constructor( private store: Redux.Store<IRootState> ) { 
  } 

  getFeedUrlById( id: string ): string { 
    const { state } = this.store.getState(), 
          match = state.menu.find(( item: IMenuItem ) =>  item.id === 
          id ); 
      return match ? match.url : ""; 
  } 

  register(){ 
    window.addEventListener( "hashchange", () => { 
      const url =  this.getFeedUrlById( window.location.hash.substr( 1 ) ); 
      this.store.dispatch( actions.setActiveFeed( url ) ); 
      url && this.store.dispatch( actions.fetchFeed( url ) ); 
    }); 
  } 

} 

在构造过程中,服务接收存储实例并将其分配给私有属性store。通过register方法,我们订阅文档的hashchange事件,每当location.hash更改时触发。例如,当我们从地址栏请求类似#some-id的内容时。在处理函数中,我们从location.hash中提取 SHA1(跟在#符号后面的所有内容),并使用getFeedUrlById方法来查找相关的 feed URL(我们在Menu服务的add方法中为项目提供了 ID)。当我们有了 URL 时,我们会分派setActiveFeed操作来设置activeFeedUrl状态属性。此外,我们分派fetchFeed来获取所选的 feed。

现在我们可以在入口脚本中启用该服务,如下所示:

./app/ts/index.tsx

const router = new Router( store ); 
router.register(); 

摘要

我们通过实现rss服务开始了这一章。我们使用request模块来获取源内容。我们从feedme模块中获得了一个可写流,并配置它来解析输入到我们的 feed 容器对象中。我们将feedme解析器连接到request生成的可读流上。feedme模块缺少声明文件,所以我们为其提供了一个接口。

然后,我们创建了Menu服务,用于管理和持久化 feed 菜单。我们考虑了应用程序所需的操作和状态结构。我们应用了redux-actions模块来创建操作和 Reducer。在此过程中,我们研究了乐观更新方法。在创建存储时,我们使用了两个存储增强器redux-thunkredux-promise,这有助于处理异步操作。我们将现有组件连接到存储并相应地修改它们。除此之外,我们编写了两个新组件,都使用了 React MDL 库的Dialog组件。第一个显示应用程序错误(如果发生)。第二个显示并处理 feed 添加表单。除此之外,我们使Feed组件能够按需加载 feed 项的 URL。因此,您学会了使用WebView标签来显示来宾内容。此外,我们订阅了新窗口事件,以强制 WebView 中打开新窗口的任何请求在外部浏览器中打开。最后,我们创建了一个简单的路由器来为 feed 菜单提供导航。

posted @ 2024-05-23 16:02  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报