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

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

重振标题栏

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

让我们来实现它们:

./app/js/Components/Header.jsx

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

export default class 

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

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

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

  } 
//... 
} 

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

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

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

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

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

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

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

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

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

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

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

               { 

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

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

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

     } 

          </div> 
       </header> 
    ) 
  } 

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

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

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

this.updateState ); 
  } 

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

win.isMaximized() 
    }); 
  } 

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

利用 WebSockets

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

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

npm i -S nodejs-websocket 

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

./server.js

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

const 

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

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

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

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

}); 

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

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

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

./index.html

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

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

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

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

id="output"></output> 

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

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

document.getElementById( "output" ); 

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

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

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

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

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

); 
}); 

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

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

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

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

npm i -S http-server 

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

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

}

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

 npm run start:server 

然后客户端为:

 npm run start:client

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

实现聊天服务

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

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

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

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

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

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

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

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

exports.Message = Message; 

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

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

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

export default class 

Server { 

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

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

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

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

}); 
    }); 

  } 

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

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

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

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

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

constructor() { 

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

      conn.on( "text", 

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

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

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

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

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

    this.broadcast( "participants", 

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

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

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


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

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


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

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

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

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

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

"./Message"; 

export default class Client extends EventEmitter { 

  connect( host, port ){ 

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

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

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

          reject(); 
        } 
      }); 

      this.socket.addEventListener( "message", e 

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

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

    }); 
  } 

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

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

 getParticipants(){ 

return this.participants; 
  } 

  join( userName ) { 
    this.userName = userName; 

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

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

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

  send( 

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

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

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


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

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

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

 }); 
  } 

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

为组件带来功能

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

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

const HOST = 

"127.0.0.1", 
      PORT = 8001; 

export default class App extends React.Component { 

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

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

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

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

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

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

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

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


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

userName ); 
} 

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

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

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

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

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

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

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

export default class Welcome extends 

React.Component { 

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

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

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

} 

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

  render() { 

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

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

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

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

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

</form> 
      </div> 
    ) 
  } 
} 

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

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

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

className="pane-group"> 

      <Participants client={client} /> 

      <Conversation  

client={client} /> 

    </div> 
  ); 

} 

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

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

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

export default class Participants extends React.Component { 

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

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

  static defaultProps = { 
    client: null 
  } 

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

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

participants 
    }) 
  } 

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

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

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

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

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

export default class Conversation 

extends React.Component { 

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

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

  static defaultProps = { 
    client: null 
  } 

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

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

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

    }); 
  } 

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

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

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

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

  render(){ 
    const { messages } = 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

}  

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

  } 

//.. 

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

编写单元测试

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

npm init -y 

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

npm i -D jest 

编辑 package.json 中的 scripts 部分:

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

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

./unit.js

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

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

npm i -D babel-jest 

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

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

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

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

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

npm i -D babel-preset-env 

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

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

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

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

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

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

npm i -D react-test-renderer 

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

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

renderer"; 

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

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

    expect( tree.toJSON() 

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

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

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

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

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

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

describe( "Participants", () => { 

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

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

    component.getInstance

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

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

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

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

describe( 

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

打包和分发

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

npm i -D electron-builder 

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

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

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

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

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

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

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

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

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

npm run dist

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

  • Ubuntu: chat-1.0.0-x86_64.AppImage

  • * Windows: chat Setup 1.0.0.exe

  • * MacOS: chat-1.0.0.dmg

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

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

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

部署和更新

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

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

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

我们首先安装包:

npm i -S electron-updater 

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

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

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

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

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

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

updater" ); 

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

event, text ); 
} 

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

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

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

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

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

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

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

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

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

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

现在,我们对渲染器脚本进行一些更改–./app/index.html

<!DOCTYPE html> 
<html> 
  <head> 
    <meta charset="UTF-8"> 

<title>Chat</title> 
    <link href="./assets/css/custom.css" rel="stylesheet" type="text/css"/> 
  </head> 
  <body> 
    <app></app> 
    <i id="statusbar" 

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

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

document.getElementById( "statusbar" ); 

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

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

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

"restart" ); 
}); 

  </script> 
</html> 

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

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

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

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

npm run build 
npm run dist 

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

http-server ./dist 

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

npm version patch 
npm run build 
npm run dist 

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

总结

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

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

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

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

应用程序蓝图

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

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

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

设置开发环境

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

./package.json

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

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

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

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

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

npm -i -D nw 

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

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

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

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

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

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

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

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

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

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

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

package.json

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

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

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

./webpack.config.js

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

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

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

静态原型

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

npm i -S material-ui 

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

npm i -S react-tap-event-plugin 

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

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

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

./index.html

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

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

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

</html> 

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

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

./assets/main.css

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

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

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

./js/app.jsx

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

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

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

./js/Containers/App.jsx

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

injectTapEventPlugin(); 

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

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

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

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

./js/Components/Main.jsx

import React, {Component} from "react"; 

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

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

class Main extends Component { 

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

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

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

      </div> 
    ); 
  } 
} 

export default Main; 

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

./js/Components/TitleBar.jsx

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

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

  return (<AppBar 
    className="titlebar" 

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

} 

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

./assets/main.css

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

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

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

./js/Components/ScreenshotTab.jsx

import React, { Component } from "react"; 

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

const TAB_BUTTON_STYLE = { 
  fontSize: 90 
}; 

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

export default class ScreenshotTab extends Component { 

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

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

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

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

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

./js/Components/AnimationTab.jsx

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

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

export default class AnimationTab extends Component { 

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

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

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

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

npm run build 

然后启动它:

npm start

你将得到以下输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

理解 redux

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

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

Redux 有三个基本原则:

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

  2. 状态是只读的。

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

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

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

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

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

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

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

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

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

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

const initialState = { 
  activeTab: "" 
}; 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

{ 
  activeTab: "SCREENSHOT" 
} 

引入应用程序状态

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

npm i -S redux 

我们还将使用额外的辅助库redux-actgithub.com/pauldijou/redux-act)来简化动作创建者和减少器的声明。通过使用这个库,我们可以在减少器中使用动作创建者函数作为引用,放弃switch( action.type )构造,而采用更短的映射语法:

npm i -S redux-act 

对于屏幕截图,我们应执行以下操作:

  • SET_ACTIVE_TAB:接收所选选项卡的标识符

  • TOGGLE_RECORDING:开始录屏时接收true,结束时接收false

  • SET_SCREENSHOT_FILENAME:在面板截图中接收输出文件名

  • SET_SCREENSHOT_INPUT_ERROR:当输入错误发生时接收消息

  • SET_ANIMATION_FILENAME:在面板动画中接收输出文件名

  • SET_ANIMATION_INPUT_ERROR:当输入错误发生时接收消息

实现如下:

./js/Actions/index.js

import { createStore } from "redux"; 
import { createAction } from "redux-act"; 

export const toggleRecording = createAction( "TOGGLE_RECORDING",  
  ( toggle ) => ({ toggle }) ); 
export const setActiveTab = createAction( "SET_ACTIVE_TAB",  
  ( activeTab ) => ({ activeTab }) ); 
export const setScreenshotFilename = createAction( "SET_SCREENSHOT_FILENAME",  
   ( filename ) => ({ filename }) ); 
export const setScreenshotInputError = createAction( "SET_SCREENSHOT_INPUT_ERROR",  
   ( msg ) => ({ msg }) ); 
export const setAnimationFilename = createAction( "SET_ANIMATION_FILENAME",  
   ( filename ) => ({ filename }) ); 
export const setAnimationInputError = createAction( "SET_ANIMATION_INPUT_ERROR",  
  ( msg ) => ({ msg }) ); 

而不是规范的语法,我们有:

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

我们使用了更简短的方法,通过redux-actcreateAction函数实现:

export const setActiveTab = createAction( "SET_ACTIVE_TAB",  
  ( activeTab ) => ({ activeTab }) ); 

另一个函数createReducerredux-act导出,使得减少声明更加简洁:

./js/Reducers/index.js

import { createStore } from "redux"; 
import { createReducer } from "redux-act"; 
import * as Actions from "../Actions"; 
import { TAB_SCREENSHOT, SCREENSHOT_DEFAULT_FILENAME, ANIMATION_DEFAULT_FILENAME } from "../Constants"; 

const DEFAULT_STATE = { 
  isRecording: false, 
  activeTab: TAB_SCREENSHOT, 
  screenshotFilename: SCREENSHOT_DEFAULT_FILENAME, 
  animationFilename: ANIMATION_DEFAULT_FILENAME, 
  screenshotInputError: "", 
  animationInputError: "" 
}; 

export const appReducer = createReducer({ 
  [ Actions.toggleRecording ]: ( state, action ) => ({ ...state, isRecording: action.toggle }), 
  [ Actions.setActiveTab ]: ( state, action ) => ({ ...state, activeTab: action.activeTab }), 
  [ Actions.setScreenshotFilename ]: ( state, action ) => ({ ...state, screenshotFilename: action.filename }), 
  [ Actions.setScreenshotInputError ]: ( state, action ) => ({ ...state, screenshotInputError: action.msg }), 
  [ Actions.setAnimationFilename ]: ( state, action ) => ({ ...state, animationFilename: action.filename }), 
  [ Actions.setAnimationInputError ]: ( state, action ) => ({ ...state, animationInputError: action.msg }) 
}, DEFAULT_STATE ); 

我们不需要像在 Redux 介绍中那样使用switch语句描述减少器条件:

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

createReducer函数为我们做到了这一点:

export const appReducer = createReducer({ 
  [ Actions.setActiveTab ]: ( state, action ) => ({ ...state, activeTab: action.activeTab }), 
}, DEFAULT_STATE ); 

该函数接受一个类似映射的对象,在其中我们将操作创建函数用作键(例如,[ Actions.setActiveTab ])。是的,对于动态对象键,我们必须使用称为计算属性名称的语法mzl.la/2erqyrj。作为对象值,我们使用回调函数来生成新状态。

在此示例中,我们克隆了旧状态({...state})并在派生对象中更改了activeTab属性值。

如果您注意到了,我们使用了Constants/index.js中的导入。在该模块中,我们将封装应用程序范围的常量:

./js/Constants/index.js

export const TAB_SCREENSHOT = "TAB_SCREENSHOT"; 
export const TAB_ANIMATION = "TAB_ANIMATION"; 
export const SCREENSHOT_DEFAULT_FILENAME = "screenshot{N}.png"; 
export const ANIMATION_DEFAULT_FILENAME = "animation{N}.webm"; 

好了,我们有了操作和一个减速器。现在是创建存储并将其连接到应用程序的时候了:

./js/Containers/App.jsx

import React from "react"; 
import { render } from "react-dom"; 
import { createStore } from 'redux'; 
import { Provider } from "react-redux"; 
import App from "./Containers/App.jsx"; 
import { appReducer } from "./Reducers"; 

const store = createStore( appReducer ); 

render(<Provider store={store}> 
  <App /> 
 </Provider>, document.querySelector( "root" ) ); 

我们使用reduxcreateStore函数构建存储。然后,我们使用react-redux包提供的ProviderApp组件包装起来。不要忘记安装依赖:

npm i -S react-redux 

Provider接受之前创建的存储作为 props,并使其对另一个react-redux函数connect可用。我们将在我们的App容器组件中使用这个函数:

./js/Containers/App.jsx

//... 
import { connect } from "react-redux"; 
import { bindActionCreators } from "redux"; 
import * as Actions from "../Actions"; 

const mapStateToProps = ( state ) => ({ states: state }); 
const mapDispatchToProps = ( dispatch ) => ({ 
  actions: bindActionCreators( Actions, dispatch ) 
}); 

class App extends Component { 
  render() { 
    return ( 
        <MuiThemeProvider muiTheme={muiTheme}> 
        <Main {...this.props} /> 
        </MuiThemeProvider>    ); 
  } 
} 

export default connect( mapStateToProps, mapDispatchToProps)( App ); 

在这里,我们定义了两个connect接受的映射函数。第一个mapStateToProps将存储的状态映射到 props。通过语句( state ) => ({ states: state }),我们使存储状态在组件中作为this.props.states可用。第二个mapDispatchToProps将我们的操作映射到 props。回调函数自动从connect函数中接收到与存储绑定的dispatch。结合reduxbindActionCreators函数,我们可以将一组操作映射到 props。因此,我们将所有可用的操作作为普通对象Actions导入,并将其传递给bindActionCreators。返回值映射到actions字段,因此将在组件中作为this.props.actions可用。

最后,我们将组件传递给connect生成的函数。它扩展了组件,我们将其导出到上游。这个表达式可能看起来有点令人困惑。实际上,我们在这里做的是在不显式修改组件本身的情况下修改组件的行为。在面向对象编程语言中,传统上,我们使用装饰器模式来实现它(en.wikipedia.org/wiki/Decorator_pattern)。如今,许多语言都具有内置的功能,比如 C#中的属性,Java 中的注解和 Python 中的装饰器。ECMAScript 也有一个提案,tc39.github.io/proposal-decorators/,用于装饰器。因此,通过使用声明性语法,我们可以修改类或方法的形状而不触及其代码。我们在 Webpack 配置中使用的插件babel-plugin-transform-decorators-legacy为我们解锁了这个功能。因此,我们已经可以用它来连接组件到存储:

@connect( mapStateToProps, mapDispatchToProps ) 
export default class App extends Component { 
  render() { 
    return ( 
        <MuiThemeProvider muiTheme={muiTheme}> 
        <Main {...this.props} /> 
        </MuiThemeProvider>    ); 
  } 
} 

从容器中,我们渲染Main组件,并将容器的所有 props 传递给它(通过解构父 props{...this.props})。因此,Main在 props 中接收到了映射的状态和操作。我们可以使用以下内容:

./js/Components/Main.jsx

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

import TitleBar from "./TitleBar.jsx"; 
import ScreenshotTab from "./ScreenshotTab.jsx"; 
import AnimationTab from "./AnimationTab.jsx"; 
import { TAB_SCREENSHOT, TAB_ANIMATION } from "../Constants"; 

class Main extends Component { 

  onTabNav = ( tab ) => { 
    const { actions } = this.props; 
    return () => { 
      actions.setActiveTab( tab ); 
    }; 
  } 

  render() { 
    const ScreenshotIcon = <FontIcon className="material-icons">camera_alt</FontIcon>; 
    const AnimationIcon = <FontIcon className="material-icons">video_call</FontIcon>; 
    const { states, actions } = this.props; 

    return ( 
      <div> 
        <TitleBar /> 
        <Tabs> 
          <Tab 
            onClick={this.onTabNav( TAB_SCREENSHOT )} 
            icon={ScreenshotIcon} 
            label="SCREENSHOT" 
          /> 
          <Tab 
            onClick={this.onTabNav( TAB_ANIMATION )} 
            icon={AnimationIcon} 
            label="ANIMATION" 
          /> 
        </Tabs> 
        <div> 

        { states.activeTab === TAB_SCREENSHOT 
            ? <ScreenshotTab {...this.props} /> 
            : <AnimationTab {...this.props} /> 
          } 
        </div> 

      </div> 
    ); 
  } 
} 

export default Main; 

你还记得,这个组件用于标签菜单。我们在这里订阅了点击标签事件。我们不直接订阅处理程序,而是订阅了一个函数this.onTabNav,该函数绑定到实例范围,根据传入的标签键生成预期的处理程序。构造的处理程序接收闭包中的键,并将其传递给从this.props.actions中提取的setActiveTab动作创建者。动作被调度,全局状态发生变化。从组件的角度来看,这就像调用setState,导致组件更新。从this.props.state中提取的activeTab字段相应地改变其值,组件呈现与通过this.onTabNav传递的键匹配的面板。

至于面板,我们已经可以将文件名表单连接到状态:

./js/Components/ScreenshotTab.jsx

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

export default class ScreenshotTab extends Component { 

  onFilenameChange = ( e ) => { 
    const { value } = e.target; 
    const { actions } = this.props; 
    if ( !value.endsWith( ".png" ) || value.length < 6 ) { 
      actions.setScreenshotInputError( "File name cannot be empty and must end with .png" ); 
      return; 
    } 
    actions.setScreenshotInputError( "" ); 
    actions.setScreenshotFilename( value ); 
  } 

  render(){ 
    const { states } = this.props; 
    return ( 
      <div className="tab-layout"> 
        <div className="tab-layout__item"> 
            <TextField 
                onChange={this.onFilenameChange} 
                floatingLabelText="File name pattern" 
                defaultValue={SCREENSHOT_DEFAULT_FILENAME} 
                errorText={states.screenshotInputError} 
              /> 

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

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

在这里,我们为TextFieldchange事件订阅了this.onFilenameChange处理程序。因此,如果用户输入this.onFilenameChange,它会调用并验证输入。如果当前值的长度小于六个字符或不以.png结尾,则被视为无效。因此,我们使用从this.props.actions中提取的setScreenshotInputError动作创建者来设置错误消息的值。一旦完成,状态的screenshotInputError字段以及TextField组件的errorText属性都会发生变化,错误消息就会显示出来。如果文件名有效,我们会调度setScreenshotInputError动作来重置错误消息。我们通过调用动作创建者setScreenshotFilename来改变状态树中的截图文件名。

如果你注意到了,我们将IconButton的自定义样式封装在常量模块中,这样它就可以在两个面板之间共享。但是我们必须将新的常量添加到模块中:

./js/Constants/index.js

export const TAB_BUTTON_STYLE = { 
  fontSize: 90 
}; 

第二个面板除了表单验证之外,还会改变状态字段isRecording

./js/Components/AnimationTab.jsx

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

export default class AnimationTab extends Component { 

  onRecord = () => { 
    const { states } = this.props; 
    this.props.actions.toggleRecording( true ); 
  } 

  onStop = () => { 
    this.props.actions.toggleRecording( false ); 
  } 

  onFilenameChange = ( e ) => { 
    const { value } = e.target; 
    const { actions } = this.props; 
    if ( !value.endsWith( ".webm" ) || value.length < 7 ) { 
      actions.setAnimationInputError( "File name cannot be empty and must end with .png" ); 
      return; 
    } 
    actions.setAnimationInputError( "" ); 
    actions.setAnimationFilename( value ); 
  } 

  render(){ 
    const { states } = this.props; 
    return ( 
      <div className="tab-layout"> 
          <div className="tab-layout__item"> 
              <TextField 
                  onChange={this.onFilenameChange} 
                  floatingLabelText="File name pattern" 
                  defaultValue={ANIMATION_DEFAULT_FILENAME} 
                  errorText={states.animationInputError} 
                /> 
          </div> 
          <div className="tab-layout__item"> 

{ states.isRecording ? <IconButton 
            onClick={this.onStop} 
            tooltip="Stop recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam_off</IconButton> 
            : <IconButton 
            onClick={this.onRecord} 
            tooltip="Start recording" 
            iconClassName="material-icons" 
            iconStyle={TAB_BUTTON_STYLE}>videocam</IconButton> } 
          </div> 
        </div> 
      ) 
  } 
}

我们订阅了开始录制和停止录制按钮的点击事件处理程序。当用户点击第一个按钮时,this.onRecord处理程序调用动作创建者toggleRecording,将状态字段isRecording设置为true。这导致组件更新。根据新的状态,它用停止录制按钮替换开始录制按钮。反之亦然,如果在this.onStop处理程序中点击停止录制,我们调用toggleRecording将状态属性isRecording设置为false。组件相应地更新。

现在,我们可以构建应用程序并运行它:

npm run build 
npm start 

注意到当我们切换标签、编辑文件名或切换开始/停止录制时,应用程序会按我们的意图做出响应。

总结

在本章中,我们熟悉了谷歌的 Material Design 的基础知识。我们使用 Material-UI 组件集中的现成的 React 组件构建了静态原型。我们对 Redux 状态容器进行了介绍。我们定义了应用程序状态树并设置了状态改变器。我们创建了全局状态存储并将其连接到容器组件。我们通过 props 将暴露的动作创建者和状态树主干传递给呈现组件。我们检查了redux-act库提供的更短的动作/减速器声明语法。我们通过使用 Redux 状态机动作来实现它,例如标签导航、录制切换和表单验证。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值