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