原文:
zh.annas-archive.org/md5/FAEC8292A2BD4C155C2816C53DE9AEF2
译者:飞龙
第四章:使用 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 状态机动作来实现它,例如标签导航、录制切换和表单验证。