React16-基础知识第二版-二-

46 阅读1小时+

React16 基础知识第二版(二)

原文:zh.annas-archive.org/md5/3e3e14982ed4c5ebe5505c84fd2fdbb9

译者:飞龙

协议:CC BY-NC-SA 4.0

第八章:构建复杂的 React 组件

在本章中,我们将通过构建应用程序中最复杂的组件,也就是我们Collection组件的子组件,将你到目前为止学到的关于 React 组件的一切付诸实践。我们在本章的目标是获得扎实的 React 经验并增强我们的 React 能力。让我们开始吧!

创建 TweetList 组件

如你所知,我们的Collection组件有两个子组件:CollectionControlsTweetList

我们将首先构建TweetList组件。创建以下~/snapterest/source/components/TweetList.js文件:

import React, { Component } from 'react';
import Tweet from './Tweet'; 
import TweetUtils from '../utils/TweetUtils';

const listStyle = {
  padding: '0'
};

const listItemStyle = {
  display: 'inline-block',
  listStyle: 'none'
};

class TweetList extends Component {

  getTweetElement = (tweetId) => {
    const { tweets, onRemoveTweetFromCollection } = this.props;
    const tweet = tweets[tweetId];
    let tweetElement;

    if (onRemoveTweetFromCollection) {
      tweetElement = (
        <Tweet
          tweet={tweet}
          onImageClick={onRemoveTweetFromCollection}
        />
      );
    } else {
      tweetElement = <Tweet tweet={tweet}/>;
    }

    return (
      <li style={listItemStyle} key={tweet.id}>
        {tweetElement}
      </li>
    );
  }

  render() {
    const tweetElements = TweetUtils
      .getListOfTweetIds()
      .map(this.getTweetElement);

    return (
      <ul style={listStyle}>
        {tweetElements}
      </ul>
    );
  }
}

export default TweetList;

TweetList组件渲染推文列表:

render() {
  const tweetElements = TweetUtils
    .getListOfTweetIds()
    .map(this.getTweetElement);

  return (
    <ul style={listStyle}>
      {tweetElements}
    </ul>
  );
}

首先,我们创建一个Tweet元素列表:

const tweetElements = TweetUtils
  .getListOfTweetIds()
  .map(this.getTweetElement);

TweetUtils.getListOfTweetIds()方法返回一个推文 ID 数组。

然后,对于数组中的每个推文 ID,我们创建一个Tweet组件。为此,我们将在推文 ID 数组上调用map()方法,并将this.getTweetElement方法作为参数传递:

getTweetElement = (tweetId) => {
  const { tweets, onRemoveTweetFromCollection } = this.props;
  const tweet = tweets[tweetId];
  let tweetElement;

  if (onRemoveTweetFromCollection) {
    tweetElement = (
      <Tweet
        tweet={tweet}
        onImageClick={onRemoveTweetFromCollection}
      />
    );
  } else {
    tweetElement = <Tweet tweet={tweet} />;
  }

  return (
    <li style={listItemStyle} key={tweet.id}>
      {tweetElement}
    </li>
  );
}

getTweetElement()方法返回一个包裹在<li>元素中的Tweet元素。正如我们已经知道的,Tweet组件有一个可选的onImageClick属性。我们何时想要提供这个可选属性,何时不想要呢?

有两种情况。在第一种情况下,用户将点击推文图像以将其从推文集合中移除。在这种情况下,我们的Tweet组件将对click事件做出反应,因此我们需要提供onImageClick属性。在第二种情况下,用户将导出一个没有用户交互的静态推文集合。在这种情况下,我们不需要提供onImageClick属性。

这正是我们在getTweetElement()方法中所做的:

const { tweets, onRemoveTweetFromCollection } = this.props;
const tweet = tweets[tweetId];
let tweetElement;

if (onRemoveTweetFromCollection) {
  tweetElement = (
    <Tweet
      tweet={tweet}
      onImageClick={onRemoveTweetFromCollection}
    />
  );
} else {
  tweetElement = <Tweet tweet={tweet}/>;
}

我们创建一个tweet常量,其中存储了一个由tweetId参数提供的推文。然后,我们创建一个常量,其中存储了由父Collection组件传递的this.props.onRemoveTweetFromCollection属性。

接下来,我们检查this.props.onRemoveTweetFromCollection属性是否由Collection组件提供。如果是,则我们创建一个带有onImageClick属性的Tweet元素:

tweetElement = (
  <Tweet
    tweet={tweet}
    onImageClick={onRemoveTweetFromCollection}
  />
);

如果没有提供,则创建一个没有handleImageClick属性的Tweet元素:

tweetElement = <Tweet tweet={tweet} />;

我们在以下两种情况下使用TweetList组件:

  • 该组件用于在Collection组件中呈现推文集合。在这种情况下,提供了onRemoveTweetFromCollection属性。

  • 当渲染代表Collection组件中一系列推文的 HTML 标记字符串时,将使用这个组件。在这种情况下,onRemoveTweetFromCollection属性不会被提供。

一旦我们创建了我们的Tweet元素,并将其放入tweetElement变量中,我们就返回带有内联样式的<li>元素:

return (
  <li style={listItemStyle} key={tweet.id}>
    {tweetElement}
  </li>
);

除了style属性,我们的<li>元素还有一个key属性。它被 React 用来标识动态创建的每个子元素。我建议你阅读更多关于动态子元素的内容,网址是facebook.github.io/react/docs/lists-and-keys.html

这就是getTweetElement()方法的工作原理。因此,TweetList组件返回一个Tweet元素的无序列表:

return (
  <ul style={listStyle}>
    {tweetElements}
  </ul>
);

创建CollectionControls组件

现在,既然你了解了Collection组件渲染的内容,让我们讨论它的子组件。我们将从CollectionControls开始。创建以下~/snapterest/source/components/CollectionControls.js文件:

import React, { Component } from 'react';
import Header from './Header';
import Button from './Button';
import CollectionRenameForm from './CollectionRenameForm';
import CollectionExportForm from './CollectionExportForm';

class CollectionControls extends Component {
  state = {
    name: 'new',
    isEditingName: false
  };

  getHeaderText = () => {
    const { name } = this.state;
    const { numberOfTweetsInCollection } = this.props;
    let text = numberOfTweetsInCollection;

    if (numberOfTweetsInCollection === 1) {
      text = `${text} tweet in your`;
    } else {
      text = `${text} tweets in your`;
    }

    return (
      <span>
        {text} <strong>{name}</strong> collection
      </span>
    );
  }

  toggleEditCollectionName = () => {
    this.setState(prevState => ({
      isEditingName: !prevState.isEditingName
    }));
  }

  setCollectionName = (name) => {
    this.setState({
      name,
      isEditingName: false
    });
  }

  render() {
    const { name, isEditingName } = this.state;
    const {
      onRemoveAllTweetsFromCollection,
      htmlMarkup
    } = this.props;

    if (isEditingName) {
      return (
        <CollectionRenameForm
          name={name}
          onChangeCollectionName={this.setCollectionName}
          onCancelCollectionNameChange={this.toggleEditCollectionName}
        />
      );
    }

    return (
      <div>
        <Header text={this.getHeaderText()}/>

        <Button
          label="Rename collection"
          handleClick={this.toggleEditCollectionName}
        />

        <Button
          label="Empty collection"
          handleClick={onRemoveAllTweetsFromCollection}
        />

        <CollectionExportForm htmlMarkup={htmlMarkup} />
      </div>
    );
  }
}

export default CollectionControls;

CollectionControls组件,顾名思义,渲染一个用户界面来控制一个集合。这些控件允许用户执行以下操作:

  • 重命名一个集合

  • 清空一个集合

  • 导出一个集合

一个集合有一个名称。默认情况下,这个名称是new,用户可以更改它。集合名称显示在由CollectionControls组件渲染的标题中。这个组件是存储集合名称的完美候选者,由于更改名称将需要组件重新渲染,我们将把那个名称存储在组件的状态对象中:

state = {
  name: 'new',
  isEditingName: false
};

CollectionControls组件可以渲染集合控制元素,也可以渲染一个改变集合名称的表单。用户可以在两者之间切换。我们需要一种方式来表示这两种状态——我们将使用isEditingName属性来实现这个目的。默认情况下,isEditingName被设置为false;因此,当CollectionControls组件被挂载时,用户将看不到改变集合名称的表单。让我们来看一下它的render()方法:

render() {
  const { name, isEditingName } = this.state;
  const {
    onRemoveAllTweetsFromCollection,
    htmlMarkup
  } = this.props;

  if (isEditingName) {
    return (
      <CollectionRenameForm
        name={name}
        onChangeCollectionName={this.setCollectionName}
        onCancelCollectionNameChange={this.toggleEditCollectionName}
      />
    );
  }

  return (
    <div>
      <Header text={this.getHeaderText()}/>

      <Button
        label="Rename collection"
        handleClick={this.toggleEditCollectionName}
      />

      <Button
        label="Empty collection"
        handleClick={onRemoveAllTweetsFromCollection}
      />

      <CollectionExportForm htmlMarkup={htmlMarkup}/>
    </div>
  );
}

首先,我们检查组件状态的this.state.isEditingName属性是否设置为true。如果是,那么CollectionControls组件将返回CollectionRenameForm组件,它渲染一个改变集合名称的表单:

<CollectionRenameForm
  name={name}
  onChangeCollectionName={this.setCollectionName}
  onCancelCollectionNameChange={this.toggleEditCollectionName}
/>

CollectionRenameForm组件渲染一个改变集合名称的表单。它接收三个属性:

  • 引用当前集合名称的name属性

  • 引用组件方法的onChangeCollectionNameonCancelCollectionNameChange属性

我们将在本章后面实现CollectionRenameForm组件。现在让我们更仔细地看看setCollectionName方法:

setCollectionName = (name) => {
  this.setState({
    name,
    isEditingName: false
  });
}

setCollectionName()方法更新集合的名称,并通过更新组件的状态来隐藏编辑集合名称的表单。当用户提交新的集合名称时,我们将调用此方法。

现在,让我们看一下toggleEditCollectionName()方法:

toggleEditCollectionName = () => {
  this.setState(prevState => ({
    isEditingName: !prevState.isEditingName
  }));
}

通过使用!运算符将isEditingName属性设置为其当前布尔值的相反值,此方法显示或隐藏集合名称编辑表单。当用户单击重命名集合取消按钮时,我们将调用此方法,即显示或隐藏集合名称更改表单。

如果CollectionControls组件状态的this.state.isEditingName属性设置为false,那么它将返回集合控件:

return (
  <div>
    <Header text={this.getHeaderText()}/>

    <Button
      label="Rename collection"
      handleClick={this.toggleEditCollectionName}
    />

    <Button
      label="Empty collection"
      handleClick={onRemoveAllTweetsFromCollection}
    />

    <CollectionExportForm htmlMarkup={htmlMarkup}/>
  </div>
);

我们将Header组件、两个Button组件和CollectionExportForm组件包装在一个div元素中。您已经在上一章中熟悉了Header组件。它接收一个引用字符串的text属性。但是,在这种情况下,我们不直接传递一个字符串,而是调用this.getHeaderText()函数:

<Header text={this.getHeaderText()} />

反过来,this.getHeaderText()返回一个字符串。让我们更仔细地看看getHeaderText()方法:

getHeaderText = () => {
  const { name } = this.state;
  const { numberOfTweetsInCollection } = this.props;
  let text = numberOfTweetsInCollection;

  if (numberOfTweetsInCollection === 1) {
    text = `${text} tweet in your`;
  } else {
    text = `${text} tweets in your`;
  }

  return (
    <span>
      {text} <strong>{name}</strong> collection
    </span>
  );
}

该方法根据集合中的推文数量生成标题字符串。该方法的重要特点是它不仅返回一个字符串,而是封装该字符串的 React 元素树。首先,我们创建numberOfTweetsInCollection常量。它存储了集合中的推文数量。然后,我们创建一个text变量,并将其赋值为集合中的推文数量。此时,text变量存储一个整数值。我们的下一个任务是根据该整数值的内容将正确的字符串连接到它上:

  • 如果numberOfTweetsInCollection1,那么我们需要连接' tweet in your'

  • 否则,我们需要连接' tweets in your'

创建标题字符串后,我们将返回以下元素:

return (
  <span>
    {text} <strong>{name}</strong> collection
  </span>
);

最终字符串封装在<span>元素内,包括text变量的值、集合名称和collection关键字;考虑以下示例:

1 tweet in your new collection.

一旦getHeaderText()方法返回这个字符串,它就作为一个属性传递给Header组件。我们在CollectionControls组件的render()方法中的下一个收藏控制元素是Button

<Button
  label="Rename collection"
  handleClick={this.toggleEditCollectionName}
/>

我们将Rename collection字符串传递给它的label属性,将this.toggleEditCollectionName方法传递给它的handleClick属性。因此,这个按钮将有Rename collection标签,并且它将切换一个表单来改变收藏的名称。

下一个收藏控制元素是我们的第二个Button组件:

<Button
  label="Empty collection"
  handleClick={onRemoveAllTweetsFromCollection}
/>

你可以猜到,它将有一个Empty collection标签,并且它将从收藏中删除所有的推文。

我们的最终收藏控制元素是CollectionExportForm

<CollectionExportForm htmlMarkup={htmlMarkup} />

这个元素接收一个表示我们收藏的 HTML 标记字符串,并且它将渲染一个按钮。我们将在本章后面创建这个组件。

现在,既然你了解了CollectionControls组件将渲染什么,让我们更仔细地看一下它的子组件。我们将从CollectionRenameForm组件开始。

创建CollectionRenameForm组件

首先,让我们创建~/snapterest/source/components/CollectionRenameForm.js文件:

import React, { Component } from 'react';
import Header from './Header';
import Button from './Button';

const inputStyle = {
  marginRight: '5px'
};

class CollectionRenameForm extends Component {
  constructor(props) {
    super(props);

    const { name } = props;

    this.state = {
      inputValue: name
    };
  }

  setInputValue = (inputValue) => {
    this.setState({
      inputValue
    });
  }

  handleInputValueChange = (event) => {
    const inputValue = event.target.value;
    this.setInputValue(inputValue);
  }

  handleFormSubmit = (event) => {
    event.preventDefault();

    const { onChangeCollectionName } = this.props;
    const { inputValue: collectionName } = this.state;

    onChangeCollectionName(collectionName);
  }

  handleFormCancel = (event) => {
    event.preventDefault();

    const {
      name: collectionName,
      onCancelCollectionNameChange
    } = this.props;

    this.setInputValue(collectionName);
    onCancelCollectionNameChange();
  }

  componentDidMount() {
    this.collectionNameInput.focus();
  }

  render() {
    const { inputValue } = this.state;

    return (
      <form className="form-inline" onSubmit={this.handleSubmit}>

        <Header text="Collection name:"/>
        <div className="form-group">
          <input
            className="form-control"
            style={inputStyle}
            onChange={this.handleInputValueChange}
            value={inputValue}
            ref={input => { this.collectionNameInput = input; }}
          />
        </div>

        <Button
          label="Change"
          handleClick={this.handleFormSubmit}
        />
        <Button
          label="Cancel"
          handleClick={this.handleFormCancel}
        />
      </form>
    );
  }
}

export default CollectionRenameForm;

这个组件渲染一个表单来改变收藏的名称:

render() {
  const { inputValue } = this.state;

  return (
    <form className="form-inline" onSubmit={this.handleSubmit}>

      <Header text="Collection name:"/>
      <div className="form-group">
        <input
          className="form-control"
          style={inputStyle}
          onChange={this.handleInputValueChange}
          value={inputValue}
          ref={input => this.collectionNameInput = input}
        />
      </div>

      <Button
        label="Change"
        handleClick={this.handleFormSubmit}
      />
      <Button
        label="Cancel"
        handleClick={this.handleFormCancel}
      />
    </form>
  );
}

我们的<form>元素包裹着四个元素,它们分别是:

  • 一个Header组件

  • 一个<input>元素

  • 两个Button组件

Header组件渲染"Collection name:"字符串。<input>元素包裹在一个<div>元素内,该元素的className属性设置为form-group。这个名称是我们在第五章中讨论的 Bootstrap 框架的一部分。它用于布局和样式,并不是我们 React 应用程序逻辑的一部分。

<input>元素有相当多的属性。让我们仔细看一下它:

<input
  className="form-control"
  style={inputStyle}
  onChange={this.handleInputValueChange}
  value={inputValue}
  ref={input => { this.collectionNameInput = input; }}
/>

以下是前面代码中使用的属性的描述:

  • className属性设置为form-control。这是 Bootstrap 框架的一部分,我们将用它来进行样式设置。

  • 此外,我们使用style属性将我们自己的样式应用到这个input元素,该属性引用了一个包含单个样式规则的inputStyle对象,即marginRight

  • value属性设置为组件状态中存储的当前值,this.state.inputValue

  • onChange属性引用了一个handleInputValueChange方法,这是一个onchange事件处理程序。

  • ref属性是一个特殊的 React 属性,你可以附加到任何组件上。它接受一个回调函数,React 会在组件被挂载和卸载后立即执行。它允许我们访问我们的 React 组件渲染的 DOM input元素。

我希望你关注最后三个属性:valueonChangerefvalue属性设置为组件状态的属性,改变该值的唯一方法是更新其状态。另一方面,我们知道用户可以与输入字段交互并改变其值。这种行为会应用到我们的组件吗?不会。每当用户键入时,我们的输入字段的值不会改变。这是因为组件控制着<input>,而不是用户。在我们的CollectionRenameForm组件中,<input>的值始终反映this.state.inputValue属性的值,而不管用户键入了什么。用户没有控制权,而是CollectionRenameForm组件有。

那么,我们如何确保我们的输入字段对用户输入做出反应?我们需要监听用户输入,并更新CollectionRenameForm组件的状态,这将重新渲染带有更新值的输入字段。在每个输入的change事件上这样做将使我们的输入看起来像是正常工作的,用户可以自由地改变其值。

为此,我们为我们的<input>元素提供了引用组件的this.handleInputValueChange方法的onChange属性:

handleInputValueChange = (event) => {
  const inputValue = event.target.value;
  this.setInputValue(inputValue);
}

正如我们在第四章中讨论的那样,创建你的第一个 React 组件,React 将SyntheticEvent的实例传递给事件处理程序。handleInputValueChange()方法接收一个带有target属性的event对象,该属性具有一个value属性。这个value属性存储了用户在输入字段中键入的字符串。我们将这个字符串传递给我们的this.setInputValue()方法:

setInputValue = (inputValue) => {
  this.setState({
    inputValue
  });
}

setInputValue()方法是一个方便的方法,它使用新的输入值更新组件的状态。反过来,这个更新将重新渲染带有更新值的<input>元素。

CollectionRenameForm组件被挂载时,初始输入的值是多少?让我们来看一下:

constructor(props) {
  super(props);

  const { name } = props;

  this.state = {
    inputValue: name
  };
}

正如你所看到的,我们从父组件传递了集合的名称,并且我们用它来设置组件的初始状态。

在挂载此组件后,我们希望将焦点设置在输入字段上,以便用户可以立即开始编辑集合的名称。我们知道一旦组件插入到 DOM 中,React 就会调用它的componentDidMount()方法。这个方法是我们设置focus的最佳机会:

componentDidMount() {
  this.collectionNameInput.focus();
}

为了做到这一点,我们通过引用this.collectionNameInput获取我们的输入元素,并在其上调用focus()函数。

我们如何在componentDidMount()方法中引用 DOM 元素?记住,我们为我们的input元素提供了ref属性。然后我们将一个回调函数传递给该ref属性,该回调函数反过来将 DOM 输入元素的引用分配给this.collectionNameInput。所以现在我们可以通过访问this.collectionNameInput属性来获取该引用。

最后,让我们讨论一下我们的两个表单按钮:

  • Change按钮提交表单并更改集合名称

  • Cancel按钮提交表单,但不会更改集合名称

我们先从一个Change按钮开始:

<Button
  label="Change"
  handleClick={this.handleFormSubmit}
/>

当用户点击它时,将调用this.handleFormSubmit方法:

handleFormSubmit = (event) => {
  event.preventDefault();

  const { onChangeCollectionName } = this.props;
  const { inputValue: collectionName } = this.state;

  onChangeCollectionName(collectionName);
}

我们取消了submit事件,然后从组件的状态中获取集合名称,并将其传递给this.props.onChangeCollectionName()函数调用。onChangeCollectionName函数是由父CollectionControls组件传递的。调用此函数将更改我们的集合名称。

现在让我们讨论一下我们的第二个表单按钮:

<Button
  label="Cancel"
  handleClick={this.handleFormCancel}
/>

当用户点击它时,将调用this.handleFormCancel方法:

handleFormCancel = (event) => {
  event.preventDefault();

  const {
    name: collectionName,
    onCancelCollectionNameChange
  } = this.props;

  this.setInputValue(collectionName);
  onCancelCollectionNameChange();
}

再一次,我们取消了一个submit事件,然后获取由父CollectionControls组件作为属性传递的原始集合名称,并将其传递给我们的this.setInputValue()函数。然后,我们调用this.props.onCancelCollectionNameChange()函数,隐藏集合控件。

这是我们的CollectionRenameForm组件。接下来,让我们创建我们的Button组件,我们在CollectionRenameForm组件中重复使用了两次。

创建 Button 组件

创建以下~/snapterest/source/components/Button.js文件:

import React from 'react';

const buttonStyle = {
  margin: '10px 0'
};

const Button = ({ label, handleClick }) => (
  <button
    className="btn btn-default"
    style={buttonStyle}
    onClick={handleClick}
  >
    {label}
  </button>
);

export default Button;

Button组件渲染一个按钮。

请注意,我们没有声明一个类,而是定义了一个简单的名为Button的函数。这是创建 React 组件的功能性方式。实际上,当您的组件的目的纯粹是渲染一些用户界面元素,有或没有任何 props 时,建议您使用这种方法。

您可以将这个简单的 React 组件看作是一个“纯”函数,它以props对象的形式作为输入,并以 JSX 作为输出——无论您调用这个函数多少次,输出都是一致的。

理想情况下,大多数组件都应该以这种方式创建——作为“纯”JavaScript 函数。当然,当您的组件具有状态时,这是不可能的,但对于所有无状态组件——有机会!现在看看我们迄今为止创建的所有组件,看看您是否可以将它们重写为“纯”函数,而不是使用类。

我建议您阅读有关功能性与类组件的更多信息:facebook.github.io/r

您可能想知道为什么为按钮创建一个专用组件的好处,如果您可以直接使用<button>元素?将组件视为<button>元素和其他内容的包装器。在我们的情况下,大多数<button>元素都具有相同的样式,因此将<button>和样式对象封装在组件中,并重用该组件是有意义的。因此,有了专用的Button组件。它期望从父组件接收两个属性:

  • label属性是按钮的标签

  • handleClick属性是一个回调函数,当用户点击此按钮时调用

现在,是时候创建我们的CollectionExportForm组件了。

创建CollectionExportForm组件

CollectionExportForm组件负责将集合导出到第三方网站(codepen.io)。一旦您的集合在 CodePen 上,您可以保存它并与朋友分享。让我们看看如何做到这一点。

创建~/snapterest/source/components/CollectionExportForm.js文件:

import React from 'react';

const formStyle = {
  display: 'inline-block'
};

const CollectionExportForm = ({ htmlMarkup }) => (
  <form
      action="http://codepen.io/pen/define"
      method="POST"
      target="_blank"
      style={formStyle}
    >
      <input type="hidden" name="data" value={htmlMarkup}/>
      <button type="submit" className="btn btn-default">
        Export as HTML
      </button>
    </form>
);

export default CollectionExportForm;

CollectionExportForm组件呈现一个带有<input><button>元素的表单。<input>元素是隐藏的,其值设置为由父组件作为htmlMarkup属性传递的 HTML 标记字符串。<button>元素是此表单中唯一对用户可见的元素。当用户单击导出为 HTML按钮时,将提交一个集合到 CodePen,该集合将在新窗口中打开。然后用户可以修改和共享该集合。

恭喜!到目前为止,您已经使用 React 构建了一个完全功能的 Web 应用程序。让我们看看它是如何工作的。

首先,请确保我们在第二章中安装和配置的 Snapkite Engine 正在运行。导航到~/snapkite-engine/并运行以下命令:

**npm start**

然后,打开一个新的终端窗口,导航到~/snapterest/,并运行以下命令:

**npm start**

现在在您的 Web 浏览器中打开~/snapterest/build/index.html。您将看到新的推文出现。单击它们将其添加到您的收藏中。再次单击它们将单个推文从收藏中删除。单击清空收藏按钮可从收藏中删除所有推文。单击重命名收藏按钮,输入新的收藏名称,然后单击更改按钮。最后,单击导出为 HTML按钮将您的收藏导出到CodePen.io。如果您在本章或之前的章节中遇到任何问题,请转到github.com/fedosejev/react-essentials并创建一个新问题。

摘要

在这一章中,您创建了TweetListCollectionControlsCollectionRenameFormCollectionExportFormButton组件。您完成了构建一个完全功能的 React 应用程序。

在接下来的章节中,我们将使用 Jest 测试这个应用程序,并使用 Flux 和 Redux 进行增强。

第九章:使用 Jest 测试您的 React 应用程序

到目前为止,你已经创建了许多 React 组件。其中一些非常简单,但有些足够复杂。建立了这两种组件后,你可能已经获得了一定的信心,让你相信无论用户界面有多复杂,你都可以用 React 构建它,而不会遇到任何重大问题。这是一个很好的信心。毕竟,这就是我们投入时间学习 React 的原因。然而,许多有信心的 React 开发人员陷入的陷阱是不写单元测试。

什么是单元测试?顾名思义,它是对应用程序的单个单元进行测试。应用程序中的单个单元通常是一个函数,这意味着编写单元测试意味着为您的函数编写测试。

为什么要写单元测试?

你可能想知道为什么要写单元测试。让我给你讲一个我个人经历的故事。我最近发布了一个我建立的网站。几天后,使用该网站的同事给我发了一封电子邮件,附带了两个网站一直拒绝的文件。我仔细检查了这些文件,确保了它们的 ID 匹配的要求都得到满足。然而,文件仍然被拒绝,并且错误消息显示 ID 不匹配。你能猜到问题是什么吗?

我写了一个函数来检查这两个文件的 ID 是否匹配。该函数检查了 ID 的值和类型,因此如果值相同但类型不同,它将返回不匹配;结果证明这正是我同事的文件的情况。

重要的问题是,我如何防止这种情况发生?答案是为我的函数编写一些单元测试。

创建测试套件、规范和期望

如何为 JavaScript 函数编写测试?你需要一个测试框架,幸运的是,Facebook 为 JavaScript 构建了自己的单元测试框架,称为Jest。它受Jasmine的启发,这是另一个著名的 JavaScript 测试框架。熟悉 Jasmine 的人会发现 Jest 的测试方法非常相似。然而,我不会假设你之前有测试框架的经验,首先讨论基础知识。

单元测试的基本思想是,你只测试应用程序中的一个功能片段,通常由一个函数实现。你在隔离环境中测试它,这意味着函数依赖的应用程序的其他部分不会被测试使用。相反,它们会被测试模拟。模拟 JavaScript 对象是创建一个模拟真实对象行为的虚假对象。在单元测试中,虚假对象称为mock,创建它的过程称为mocking

当运行测试时,Jest 会自动模拟依赖项。它会自动找到要在存储库中执行的测试。让我们看下面的例子。

首先,在~/snapterest/source/utils/目录中创建一个新的TweetUtils.js文件:

function getListOfTweetIds(tweets) {
  return Object.keys(tweets);
}

export default { getListOfTweetIds };

TweetUtils.js文件是一个模块,包含我们的应用程序使用的getListOfTweetIds()实用函数。给定一个带有推文的对象,getListOfTweetIds()返回一个推文 ID 数组。

现在让我们用 Jest 编写我们的第一个单元测试。我们将测试我们的getListOfTweetIds()函数。

~/snapterest/source/utils/目录中创建一个TweetUtils.test.js文件:

import TweetUtils from './TweetUtils';

describe('TweetUtils', () => {
  test('getListOfTweetIds returns an array of tweet ids', () => {
    const tweetsMock = {
      tweet1: {},
      tweet2: {},
      tweet3: {}
    };
    const expectedListOfTweetIds = [
      'tweet1',
      'tweet2',
      'tweet3'
    ];
    const actualListOfTweetIds = TweetUtils.getListOfTweetIds(
      tweetsMock
    );

    expect(actualListOfTweetIds)
      .toEqual(expectedListOfTweetIds);
  });
});

首先,我们需要引入TweetUtils模块:

import TweetUtils from './TweetUtils';

接下来,我们调用全局的describe() Jest 函数。理解其背后的概念很重要。在我们的TweetUtils.test.js文件中,我们不只是创建一个单一的测试,而是创建了一组测试。一组测试是对一个更大的功能单元进行集体测试的集合。例如,一组测试可以包含多个测试,测试更大模块的所有单独部分。在我们的示例中,我们有一个TweetUtils模块,可能有多个实用函数。在这种情况下,我们会为TweetUtils模块创建一组测试,然后为每个单独的实用函数创建测试,比如getListOfTweetIds()

describe()函数定义了一个测试套件,并接受这两个参数:

  • 套件名称:这是描述此测试套件正在测试的标题

  • 套件实现:这是实现此套件的函数

在我们的示例中,套件如下:

describe('TweetUtils', () => {
  // Test suite implementation goes here
});

如何创建单独的测试?在 Jest 中,通过调用另一个全局的 Jest 函数test()来创建单独的测试。就像describe()一样,test()函数接受两个参数:

  • 测试名称:这是描述此测试正在测试的标题,例如:'getListOfTweetIds 返回推文 ID 数组'

  • 测试实现:这是实现此测试的函数

在我们的示例中,测试如下:

test('getListOfTweetIds returns an array of tweet ids', () => {
  // Test implementation goes here... });

让我们更仔细地看一下我们测试的实现:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {}
};
const expectedListOfTweetIds = [
  'tweet1',
  'tweet2',
  'tweet3'
];
const actualListOfTweetIds = TweetUtils.getListOfTweetIds(
  tweetsMock
);

expect(actualListOfTweetIds)
  .toEqual(expectedListOfTweetIds);

我们测试TweetUtils模块的getListOfTweetIds()方法是否在给定带有推文对象的对象时返回推文 ID 数组。

首先,我们将创建一个模拟真实推文对象的模拟对象:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {}
};

这个模拟对象的唯一要求是将推文 ID 作为对象键。值并不重要,所以我们选择空对象。键名也不重要,所以我们选择将它们命名为tweet1tweet2tweet3。这个模拟对象并不能完全模拟真实的推文对象——它的唯一目的是模拟其键是推文 ID 的事实。

下一步是创建预期的推文 ID 列表:

const expectedListOfTweetIds = [
  'tweet1',
  'tweet2',
  'tweet3'
];

我们知道要期望什么推文 ID,因为我们用相同的 ID 模拟了推文对象。

下一步是从我们模拟的推文对象中提取实际的推文 ID。为此,我们使用getListOfTweetIds()方法,该方法接受推文对象并返回推文 ID 数组:

const actualListOfTweetIds = TweetUtils.getListOfTweetIds(
  tweetsMock
);

我们将tweetsMock对象传递给该方法,并将结果存储在actualListOfTweetIds常量中。它被命名为actualListOfTweetIds的原因是这个推文 ID 列表是由我们正在测试的getListOfTweetIds()函数产生的。

最后一步将向我们介绍一个新的重要概念:

expect(actualListOfTweetIds)
  .toEqual(expectedListOfTweetIds);

让我们思考一下测试的过程。我们需要取得一个由我们正在测试的方法产生的实际值,即getListOfTweetIds(),并将其与我们预先知道的预期值进行匹配。匹配的结果将决定我们的测试是否通过或失败。

我们之所以能预先猜测getListOfTweetIds()将会返回什么是因为我们已经为它准备了输入;这就是我们的模拟对象:

const tweetsMock = {
  tweet1: {},
  tweet2: {},
  tweet3: {}
};

因此,我们可以通过调用TweetUtils.getListOfTweetIds(tweetsMock)来期望以下输出:

[ 'tweet1', 'tweet2', 'tweet3' ]

因为在getListOfTweetIds()内部可能出现问题,我们无法保证这个结果;我们只能期望它。

这就是为什么我们需要创建一个期望。在 Jest 中,期望是使用expect()函数构建的,该函数接受一个实际值;例如,actualListOfTweetIds对象:expect(actualListOfTweetIds)

然后,我们将它与一个匹配器函数链接起来,该函数比较实际值与期望值,并告诉 Jest 期望是否得到满足:

expect(actualListOfTweetIds)
  .toEqual(expectedListOfTweetIds);

在我们的示例中,我们使用toEqual()匹配器函数来比较两个数组。您可以在 Jest 的facebook.github.io/jest/docs/expect.html中找到所有内置匹配器函数的列表

这就是你编写测试的方式。一个测试包含一个或多个期望。每个期望测试您代码的状态。一个测试可以是通过的测试失败的测试。只有当所有期望都得到满足时,测试才是通过的测试;否则,它就是失败的测试。

干得好,您已经编写了您的第一个测试套件,其中包含一个期望的单个测试!您如何运行它?

安装和运行 Jest

首先,让我们安装Jest 命令行界面Jest CLI)模块:

**npm install --save-dev jest**

这个命令会将 Jest 模块安装并添加为~/snapterest/package.json文件的开发依赖项。

在第二章中,为您的项目安装强大的工具,我们安装并讨论了 Babel。我们使用 Babel 将我们的新 JavaScript 语法转译为旧的 JavaScript 语法,并将 JSX 语法编译为普通的 JavaScript 语法。在我们的测试中,我们将测试用 JSX 语法编写的 React 组件,但是 Jest 默认不理解 JSX 语法。我们需要告诉 Jest 自动使用 Babel 编译我们的测试。为此,我们需要安装babel-jest模块:

**npm install --save-dev babel-jest**

现在我们需要配置 Babel。为此,在~/snapterest/目录中创建以下.babelrc文件:

{
  "presets": ["es2015", "react"]

接下来,让我们编辑package.json文件。我们将替换现有的"scripts"对象:

"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
},

用以下对象替换前面的对象:

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

现在我们准备运行我们的测试套件。转到~/snapterest/目录,并运行以下命令:

**npm test**

您应该在终端窗口中看到以下消息:

**PASS  source/utils/TweetUtils.test.js**

此输出消息告诉您以下内容:

  • PASS:您的测试已通过

  • source/utils/TweetUtils.test.js:Jest 从这个文件运行测试

这就是编写和测试一个微小单元测试所需的全部。现在,让我们创建另一个!

创建多个测试和期望

这一次,我们将创建并测试集合实用程序模块。在~/snapterest/source/utils/目录中创建CollectionUtils.js文件:

import TweetUtils from './TweetUtils';

function getNumberOfTweetsInCollection(collection) {
  const listOfCollectionTweetIds = TweetUtils
    .getListOfTweetIds(collection);

  return listOfCollectionTweetIds.length;
}

function isEmptyCollection(collection) {
  return getNumberOfTweetsInCollection(collection) === 0;
}

export default {
  getNumberOfTweetsInCollection,
  isEmptyCollection
};

CollectionUtils模块有两个函数:getNumberOfTweetsInCollection()isEmptyCollection()

首先,让我们讨论getNumberOfTweetsInCollection()

function getNumberOfTweetsInCollection(collection) {
  const listOfCollectionTweetIds = TweetUtils
    .getListOfTweetIds(collection);

  return listOfCollectionTweetIds.length;
}

正如你所看到的,这个函数调用TweetUtils模块的getListOfTweetIds()方法,并将collection对象作为参数传递。getListOfTweetIds()返回的结果存储在listOfCollectionTweetIds常量中,由于它是一个数组,getNumberOfTweetsInCollection()返回该数组的length属性。

现在,让我们来看一下isEmptyCollection()方法:

function isEmptyCollection(collection) {
  return getNumberOfTweetsInCollection(collection) === 0;
}

这个方法重用了我们刚刚讨论的getNumberOfTweetsInCollection()方法。它检查调用getNumberOfTweetsInCollection()返回的结果是否等于零。然后,它返回该检查的结果,即truefalse

请注意,我们从这个模块导出了这两个方法:

export default {
  getNumberOfTweetsInCollection,
  isEmptyCollection
};

我们刚刚创建了我们的CollectionUtils模块。我们的下一个任务是测试它。

~/snapterest/source/utils/目录中,创建以下CollectionUtils.test.js文件:

import CollectionUtils from './CollectionUtils';

describe('CollectionUtils', () => {
  const collectionTweetsMock = {
    collectionTweet7: {},
    collectionTweet8: {},
    collectionTweet9: {}
  };

  test('getNumberOfTweetsInCollection returns a number of tweets in collection', () => {
    const actualNumberOfTweetsInCollection = CollectionUtils
    .getNumberOfTweetsInCollection(collectionTweetsMock);
    const expectedNumberOfTweetsInCollection = 3;

    expect(actualNumberOfTweetsInCollection)
    .toBe(expectedNumberOfTweetsInCollection);
    });

  test('isEmptyCollection checks if collection is not empty', () => {
    const actualIsEmptyCollectionValue = CollectionUtils
      .isEmptyCollection(collectionTweetsMock);

    expect(actualIsEmptyCollectionValue).toBeDefined();
    expect(actualIsEmptyCollectionValue).toBe(false);
    expect(actualIsEmptyCollectionValue).not.toBe(true);
  });
});

首先我们定义我们的测试套件:

describe('CollectionUtils', () => {
  const collectionTweetsMock = {
    collectionTweet7: {},
    collectionTweet8: {},
    collectionTweet9: {}
  };

// Tests go here... });

我们给我们的测试套件命名为我们正在测试的模块的名称—CollectionUtils。现在让我们来看一下这个测试套件的实现。与我们之前的测试套件不同,我们不是立即定义测试规范,而是创建了collectionTweetsMock对象。那么,我们允许这样做吗?当然可以。测试套件实现函数只是另一个 JavaScript 函数,在定义测试规范之前我们可以做一些工作。

这个测试套件将实现多个测试。我们所有的测试都将使用collectionTweetsMock对象,所以在规范范围之外定义它并在规范内重用它是有意义的。你可能已经猜到,collectionTweetsMock对象模拟了一组推文。

现在让我们实现单独的测试规范。

我们的第一个规范测试了CollectionUtils模块是否返回了集合中的推文数量:

test('getNumberOfTweetsInCollection returns a numberof tweets in collection', () => {
  const actualNumberOfTweetsInCollection = CollectionUtils
    .getNumberOfTweetsInCollection(collectionTweetsMock);
  const expectedNumberOfTweetsInCollection = 3;

  expect(actualNumberOfTweetsInCollection)
    .toBe(expectedNumberOfTweetsInCollection);
});

我们首先获取我们模拟集合中的实际推文数量:

const actualNumberOfTweetsInCollection = CollectionUtils
  .getNumberOfTweetsInCollection(collectionTweetsMock);

为此,我们调用getNumberOfTweetsInCollection()方法,并将collectionTweetsMock对象传递给它。然后,我们定义我们模拟集合中期望的推文数量:

const expectedNumberOfTweetsInCollection = 3;

最后,我们调用expect()全局函数来创建一个期望:

expect(actualNumberOfTweetsInCollection)
  .toBe(expectedNumberOfTweetsInCollection);

我们使用toBe()匹配器函数来匹配实际值和期望值。

如果你现在运行npm test命令,你会看到两个测试套件都通过了:

**PASS  source/utils/CollectionUtils.test.js**
**PASS  source/utils/TweetUtils.test.js**

请记住,要使测试套件通过,它必须只有通过的规范。要使规范通过,它必须满足所有的期望。到目前为止情况就是这样。

怎么样进行一个小小的邪恶实验?

打开你的~/snapterest/source/utils/CollectionUtils.js文件,并在getNumberOfTweetsInCollection()函数内,找到以下代码行:

return listOfCollectionTweetIds.length;

现在将其更改为这样:

return listOfCollectionTweetIds.length + 1;

这个微小的更新将返回任何给定集合中错误的推文数量。现在再次运行npm test。你应该看到CollectionUtils.test.js中的所有规范都失败了。这是我们感兴趣的一个:

**FAIL  source/utils/CollectionUtils.test.js**
 **CollectionUtils › getNumberOfTweetsInCollection returns a number of tweets in collection**

 **expect(received).toBe(expected)**

 **Expected value to be (using ===):**
 **3**
 **Received:**
 **4**

 **at Object.<anonymous> (source/utils/CollectionUtils.test.js:14:46)**

我们以前没有看到过失败的测试,所以让我们仔细看看它试图告诉我们什么。

首先,它告诉我们CollectionUtils.test.js测试失败了:

**FAIL  source/utils/CollectionUtils.test.js**

然后,以一种人性化的方式告诉我们哪个测试失败了:

 **CollectionUtils › getNumberOfTweetsInCollection returns a number of tweets in collection**

然后,出了什么问题-意外的测试结果:

**expect(received).toBe(expected)** 
 **Expected value to be (using ===):**
 **3**
 **Received:**
 **4**

最后,Jest 打印出一个堆栈跟踪,这应该给我们足够的技术细节,快速确定我们的代码的哪一部分产生了意外的结果:

**at Object.<anonymous> (source/utils/CollectionUtils.test.js:14:46)**

好了!不要再故意让我们的测试失败了。让我们把~/snapterest/source/utils/CollectionUtils.js文件恢复到这个状态:

return listOfCollectionTweetIds.length;

在 Jest 中,一个测试套件可以有许多规范,测试来自单个模块的不同方法。我们的CollectionUtils模块有两种方法。现在让我们讨论第二种方法。

我们在CollectionUtils.test.js中的下一个规范检查集合是否不为空:

test('isEmptyCollection checks if collection is not empty', () => {
  const actualIsEmptyCollectionValue = CollectionUtils
    .isEmptyCollection(collectionTweetsMock);

  expect(actualIsEmptyCollectionValue).toBeDefined();
  expect(actualIsEmptyCollectionValue).toBe(false);
  expect(actualIsEmptyCollectionValue).not.toBe(true);
});

首先,我们调用isEmptyCollection()方法,并将collectionTweetsMock对象传递给它。我们将结果存储在actualIsEmptyCollectionValue常量中。注意我们如何重复使用相同的collectionTweetsMock对象,就像在我们之前的规范中一样。

接下来,我们创建了不止一个期望:

expect(actualIsEmptyCollectionValue).toBeDefined();
expect(actualIsEmptyCollectionValue).toBe(false);
expect(actualIsEmptyCollectionValue).not.toBe(true);

你可能已经猜到我们对actualIsEmptyCollectionValue常量的期望。

首先,我们期望我们的集合被定义:

expect(actualIsEmptyCollectionValue).toBeDefined();

这意味着isEmptyCollection()函数必须返回除undefined之外的其他东西。

接下来,我们期望它的值是false

expect(actualIsEmptyCollectionValue).toBe(false);

早些时候,我们使用toEqual()匹配器函数来比较数组。toEqual()方法进行深度比较,非常适合比较数组,但对于false等原始值来说有些过度。

最后,我们期望actualIsEmptyCollectionValue不是true

expect(actualIsEmptyCollectionValue).not.toBe(true);

下一个比较是通过.not进行反转的。它将期望与toBe(true)的相反值false进行匹配。

注意toBe(false)not.toBe(true)产生相同的结果。

只有当所有三个期望都得到满足时,这个规范才会通过。

到目前为止,我们已经测试了实用模块,但是如何使用 Jest 测试 React 组件呢?

我们接下来会发现。

测试 React 组件

让我们暂时停下来不写代码,谈谈测试用户界面意味着什么。我们究竟在测试什么?我们测试的是我们的用户界面是否按预期呈现。换句话说,如果我们告诉 React 去呈现一个按钮,我们期望它呈现一个按钮,不多,也不少。

现在我们如何检查这一点呢?做到这一点的一种方法是编写一个 React 组件,捆绑我们的应用程序,在 Web 浏览器中运行它,并亲眼看到它显示我们想要显示的内容。这是手动测试,我们至少要做一次。但是这在长期内是耗时且不可靠的。

我们如何自动化这个过程呢?Jest 可以为我们做大部分工作,但是 Jest 没有自己的眼睛,所以它至少需要借用我们的眼睛来测试每个组件一次。如果 Jest“看不到”呈现 React 组件的结果,那么它如何甚至测试 React 组件呢?

在第三章中,创建您的第一个 React 元素,我们讨论了 React 元素。它们是描述我们想在屏幕上看到的内容的简单的 JavaScript 对象。

例如,考虑这个 HTML 标记:

<h1>Testing</h1>

这可以用以下简单的 JavaScript 对象表示:

{
  type: 'h1',
  children: 'Testing'
}

当我们呈现组件时,拥有代表我们组件产生的输出的简单的 JavaScript 对象,使我们能够描述关于我们组件及其行为的某些期望。让我们看看它的实际效果。

我们将测试的第一个 React 组件将是我们的Header组件。在~/snapterest/source/components/目录中创建Header.test.js文件:

import React from 'react';
import renderer from 'react-test-renderer';
import Header, { DEFAULT_HEADER_TEXT } from './Header';

describe('Header', () => {
  test('renders default header text', () => {
    const component = renderer.create(
      <Header/>
    );

    const tree = component.toJSON();
    const firstChild = tree.children[0];

    expect(firstChild).toBe(DEFAULT_HEADER_TEXT);
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';

    const component = renderer.create(
      <Header text={headerText} />
    );

    const tree = component.toJSON();
    const firstChild = tree.children[0];

    expect(firstChild).toBe(headerText);
  });
});

到目前为止,您可以认识到我们测试文件的结构。首先,我们定义了测试套件,并给它命名为Header。我们的测试套件有两个测试规范,分别命名为renders default header textrenders provided header text。正如它们的名称所示,它们测试我们的Header组件能够呈现默认文本和提供的文本。让我们更仔细地看看这个测试套件。

首先,我们导入 React 模块:

import React from 'react';

然后,我们导入react-test-renderer模块:

import renderer from 'react-test-renderer';

React 渲染器将 React 组件渲染为纯 JavaScript 对象。它不需要 DOM,因此我们可以使用它在 web 浏览器之外渲染 React 组件。它与 Jest 配合使用效果很好。让我们安装它:

**npm install --save-dev react-test-renderer**

接下来,为了测试我们的Header组件,我们需要导入它:

import Header, { DEFAULT_HEADER_TEXT } from './Header';

我们还从我们的Header模块中导入DEFAULT_HEADER_TEXT。我们这样做是因为我们不想硬编码实际的字符串值,即默认的标题文本。这会增加维护这个值的额外工作。相反,由于我们的Header组件知道这个值是什么,我们将在测试中导入并重用它。

让我们来看看我们的第一个名为renders default header text的测试。我们在这个测试中的第一个任务是将Header组件渲染为普通的 JavaScript 对象。react-test-renderer模块有一个create方法可以做到这一点:

const component = renderer.create(
  <Header/>
);

我们将<Header/>元素作为参数传递给create()函数,然后我们得到一个代表我们的Header组件实例的 JavaScript 对象。它还不是我们组件的简单表示,所以我们的下一步是使用toJSON方法将该对象转换为我们组件的简单树形表示:

const tree = component.toJSON();

现在,tree也是一个 JavaScript 对象,但它也是我们Header组件的简单表示,我们可以轻松阅读和理解:

{ type: 'h2', props: {}, children: [ 'Default header' ] }

我建议你记录componenttree对象,并看看它们有多不同:

console.log(component);
console.log(tree);

你会很快发现component对象是为了 React 的内部使用而设计的-很难阅读并且难以判断它代表什么。另一方面,tree对象非常容易阅读,并且清楚它代表什么。

正如你所看到的,我们目前测试 React 组件的方法是将<Header/>转换为{ type: 'h2', props: {}, children: [ 'Default header' ] }。现在我们有了一个简单的 JavaScript 对象来代表我们的组件,我们可以检查这个对象是否具有预期的值。如果是,我们可以得出结论,我们的组件将如预期般在 web 浏览器中渲染。如果不是,那么我们可能引入了一个 bug。

当我们渲染我们的Header组件没有任何属性时,<Header/>,我们期望它渲染出一个默认文本:'Default header'。为了检查这是否确实如此,我们需要从我们Header组件的树形表示中访问children属性:

const firstChild = tree.children[0];

我们期望我们的Header组件只有一个子元素,所以文本元素将是第一个子元素。

现在是时候写我们的期望了:

expect(firstChild).toBe(DEFAULT_HEADER_TEXT);

在这里,我们期望firstChild具有与DEFAULT_HEADER_TEXT相同的值。在幕后,toBe匹配器使用===进行比较。

这就是我们的第一个测试!

在我们名为“渲染提供的标题文本”的第二个测试中,我们正在测试我们的Header组件是否具有我们通过text属性提供的自定义测试:

test('renders provided header text', () => {
  const headerText = 'Testing';

  const component = renderer.create(
    <Header text={headerText}/>
  );

  const tree = component.toJSON();
  const firstChild = tree.children[0];

  expect(firstChild).toBe(headerText);
});

现在您理解了测试 React 组件的核心思想:

  1. 将您的组件呈现为 JavaScript 对象表示。

  2. 在该对象上找到一些值,并检查该值是否符合您的期望。

如您所见,当您的组件很简单时,这是非常直接的。但是,如果您需要测试由其他组件组成的组件等等,会怎样呢?想象一下代表该组件的 JavaScript 对象将会有多复杂。它将具有许多深度嵌套的属性。您可能最终会编写和维护大量用于访问和比较深度嵌套值的代码。这就是写单元测试变得太昂贵的时候,一些开发人员可能选择放弃对其组件进行测试的原因。

幸运的是,我们有两种解决方案可供选择。

以下是其中之一。记住,当直接遍历和修改 DOM 太麻烦时,jQuery 库被创建出来简化这个过程?嗯,对于 React 组件,我们有 Enzyme——这是来自 AirBnB 的 JavaScript 测试实用库,简化了遍历和操作渲染 React 组件产生的输出的过程。

Enzyme 是 Jest 之外的一个独立库。让我们安装它:

**npm install --save-dev enzyme jest-enzyme react-addons-test-utils**

要与 Jest 一起使用 Enzyme,我们需要安装三个模块。记住,Jest 运行我们的测试,而 Enzyme 将帮助我们编写我们的期望。

现在让我们使用 Enzyme 重写我们的Header组件的测试:

import React from 'react';
import { shallow } from 'enzyme';
import Header, { DEFAULT_HEADER_TEXT } from './Header';

describe('Header', () => {
  test('renders default header text', () => {
    const wrapper = shallow(
      <Header/>
    );

    expect(wrapper.find('h2')).toHaveLength(1);
    expect(wrapper.contains(DEFAULT_HEADER_TEXT)).toBe(true);
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';

    const wrapper = shallow(
      <Header text={headerText} />
    );

    expect(wrapper.find('h2')).toHaveLength(1);
    expect(wrapper.contains(headerText)).toBe(true);
  });
});

首先,我们从enzyme模块中导入shallow函数:

import { shallow } from 'enzyme';

然后,在我们的测试中,我们调用shallow函数并将我们的Header组件作为参数传递:

const wrapper = shallow(
  <Header/>
);

我们得到的是一个包装渲染我们的Header组件结果的对象。这个对象是由 Enzyme 的ShallowWrapper类创建的,并且对我们来说有一些非常有用的方法。我们将其称为wrapper

现在我们有了这个wrapper对象可供我们使用,我们准备写我们的期望。请注意,与react-test-renderer不同,使用 Enzyme 时我们不需要将wrapper对象转换为我们组件的简化表示。这是因为我们不会直接遍历我们的wrapper对象——它不是一个简单的对象,很难让我们阅读;尝试记录该对象并亲自看看。相反,我们将使用 Enzyme 的ShallowWrapper API 提供的方法。

让我们写我们的第一个期望:

expect(wrapper.find('h2')).toHaveLength(1);

正如您所看到的,我们在wrapper对象上调用了find方法。这就是 Enzyme 的强大之处。我们不需要直接遍历我们的 React 组件输出对象并找到嵌套的元素,我们只需调用find方法并告诉它我们要找什么。在这个例子中,我们告诉 Enzyme 在wrapper对象内查找所有的h2元素,因为它包裹了我们的Header组件的输出,我们期望wrapper对象有一个h2元素。我们使用 Jest 的toHaveLength匹配器来检查这一点。

这是我们的第二个期望:

**expect(wrapper.contains(DEFAULT_HEADER_TEXT)).toBe(true);**

您可以猜到,我们正在检查我们的 wrapper 对象是否包含DEFAULT_HEADER_TEXT。这个检查让我们得出结论,当我们没有提供任何自定义文本时,我们的Header组件呈现默认文本。我们使用 Enzyme 的contains方法,方便地检查我们的组件是否包含任何节点。在这种情况下,我们正在检查文本节点。

Enzyme 的 API 提供了更多方法,方便我们检查组件的输出。我建议您通过阅读官方文档熟悉这些方法:airbnb.io/enzyme/docs/api/shallow.html

您可能想知道如何测试您的 React 组件的行为。

这是我们接下来要讨论的内容!

~/snapterest/source/components/目录中创建Button.test.js文件:

import React from 'react';
import { shallow } from 'enzyme';
import Button from './Button';

describe('Button', () => {
  test('calls click handler function on click', () => {
    const handleClickMock = jest.fn();

    const wrapper = shallow(
      <Button handleClick={handleClickMock}/>
    );

    wrapper.find('button').simulate('click');

    expect(handleClickMock.mock.calls.length).toBe(1);
  });
});

Button.test.js文件将测试我们的Button组件,特别是检查当您点击它时是否触发点击事件处理程序函数。话不多说,让我们专注于'calls click handler function on click'规范的实现:

const handleClickMock = jest.fn();

const wrapper = shallow(
  <Button handleClick={handleClickMock} />
);

wrapper.find('button').simulate('click');

expect(handleClickMock.mock.calls.length).toBe(1);

在这个规范中,我们正在测试我们的Button组件是否调用我们通过handleClick属性提供的函数。这是我们的测试策略:

  1. 生成一个模拟函数。

  2. 使用我们的模拟函数渲染Button组件。

  3. 在由 Enzyme 创建的包装对象中找到button元素,这是渲染我们的Button组件的结果。

  4. button元素上模拟点击事件。

  5. 检查我们的模拟函数是否确实被调用了一次。

现在我们有了一个计划,让我们实施它。让我们首先创建一个模拟函数:

const handleClickMock = jest.fn();

jest.fn()函数调用返回新生成的 Jest 模拟函数;我们将其命名为handleClickMock

接下来,我们通过调用 Enzyme 的shallow函数来获取我们的Button组件的输出:

const wrapper = shallow(
  <Button handleClick={handleClickMock}/>
);

我们将我们的handleClickMock函数作为一个属性传递给我们的Button组件。

然后,我们找到button元素并在其上模拟点击事件:

wrapper.find('button').simulate('click');

在这一点上,我们期望我们的按钮元素调用它的onClick事件处理程序,这种情况下是我们的handleClickMock函数。这个模拟函数应该记录它被调用了一次,或者至少这是我们期望我们的Button组件的行为。让我们创建这个期望:

expect(handleClickMock.mock.calls.length).toBe(1);

我们如何检查我们的handleClickMock函数被调用了多少次?我们的handleClickMock函数有一个特殊的模拟属性,我们可以检查它来找出handleClickMock被调用了多少次:

handleClickMock.mock.calls.length

反过来,我们的mock对象有一个calls对象,它知道每次调用我们的handleClickMock函数的所有信息。calls对象是一个数组,在我们的情况下,我们期望它的length属性等于 1。

正如你所看到的,使用 Enzyme 更容易编写期望。我们的测试需要更少的工作来编写它们,并且长期维护它们。这很好,因为现在我们有更多的动力来编写更多的测试。

但是我们能让使用 Jest 编写测试变得更容易吗?

原来我们可以。

现在我们将一个 React 组件渲染为一个对象表示,然后使用 Jest 或 Enzyme 的帮助来检查该对象。这种检查要求我们作为开发人员编写额外的代码来使我们的测试工作。我们如何避免这种情况?

我们可以将一个 React 组件渲染为一个文本字符串,这样我们可以轻松地阅读和理解。然后我们可以将这个文本表示存储在我们的代码库中。稍后,当我们再次运行我们的测试时,我们可以简单地创建一个新的文本表示并将其与我们存储的进行比较。如果它们不同,那么这可能意味着我们有意更新了我们的组件,现在我们需要更新我们的文本表示,或者我们向我们的组件引入了一个错误,以至于它现在产生了一个意外的文本表示。

这个想法在 Jest 中被称为快照测试。让我们使用快照测试重写我们的Header组件的测试。用这段新代码替换你的Header.test.js文件中的现有代码:

import React from 'react';
import renderer from 'react-test-renderer';
import Header from './Header';

describe('Header', () => {
  test('renders default header text', () => {
    const component = renderer.create(
      <Header/>
    );

    const tree = component.toJSON();

    expect(tree).toMatchSnapshot();
  });

  test('renders provided header text', () => {
    const headerText = 'Testing';

    const component = renderer.create(
      <Header text={headerText} />
    );

    const tree = component.toJSON();

    expect(tree).toMatchSnapshot();
  });
});

正如你所看到的,我们在这种情况下没有使用 Enzyme,这对我们来说应该是有意义的,因为我们不再想要检查任何东西。

另一方面,我们再次使用react-test-renderer模块来渲染和转换我们的组件为一个名为tree的简单 JavaScript 对象:

const component = renderer.create(
  <Header/>
);

const tree = component.toJSON();

将快照测试付诸实践的关键代码行是这一行:

expect(tree).toMatchSnapshot();

我们只是告诉 Jest 我们期望我们的tree对象与现有的快照匹配。等一下,但我们没有现有的快照。很好的观察!那么在这种情况下会发生什么?Jest 找不到这个测试的现有快照,而是会为这个测试创建一个第一个快照。

让我们运行我们的测试命令:

**npm test**

所有测试都应该通过,你应该看到这个输出:

**Snapshot Summary**
 **› 2 snapshots written in 1 test suite.**

在这里,Jest 告诉我们它创建了两个快照——一个用于我们Header.test.js测试套件中找到的每个测试。Jest 把这两个快照存储在哪里?如果你检查~/snapterest/source/components/目录,你会发现一个新的文件夹:__snapshots__。在里面,你会找到Header.test.js.snap文件。打开这个文件并查看它的内容:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Header renders default header text 1`] = `
<h2
  style={
    Object {
      "display": "inline-block",
      "fontSize": "16px",
      "fontWeight": "300",
      "margin": "20px 10px",
    }
  }
>
  Default header
</h2>
`;

exports[`Header renders provided header text 1`] = `
<h2
  style={
    Object {
      "display": "inline-block",
      "fontSize": "16px",
      "fontWeight": "300",
      "margin": "20px 10px",
    }
  }
>
  Testing
</h2>
`;

在这个文件中,你可以看到我们的Header组件在使用 Jest 渲染时产生的输出的文本表示。我们很容易读取这个文件并确认这就是我们期望Header组件渲染的内容。现在我们的Header组件有了自己的快照。将这些快照视为源代码的一部分进行处理和存储是很重要的。

如果你有 Git 仓库,你应该提交它们,并且你应该注意你对它们所做的任何更改。

既然你已经看到了三种不同的编写 React 测试的方式,你需要自己选择如何测试你的 React 组件。现在我建议你使用快照测试和 Enzyme。

太好了,我们已经编写了四个测试套件。现在是时候运行我们所有的测试了。

导航到~/snapterest/并运行这个命令:

**npm test**

你所有的测试套件都应该通过

**PASS  source/components/Button.test.js** 
**PASS  source/components/Header.test.js** 
**PASS  source/utils/CollectionUtils.test.js** 
**PASS  source/utils/TweetUtils.test.js** 

**Snapshot Summary**
 **› 2 snapshots written in 1 test suite.** 

**Test Suites: 4 passed, 4 total** 
**Tests:       6 passed, 6 total** 
**Snapshots:   2 added, 2 total** 
**Time:        2.461s** 
**Ran all test suites.**

这样的日志消息会帮助你晚上睡得安稳,放假时也不需要不断检查工作邮件。

干得好!

总结

现在你知道如何创建 React 组件并对其进行单元测试了。

在本章中,您学习了 Jest 的基本知识——这是 Facebook 推出的一个与 React 配合良好的单元测试框架。您了解了 Enzyme 库,并学会了如何简化编写 React 组件的单元测试。我们讨论了测试套件、规范、期望和匹配器。我们创建了模拟和模拟点击事件。

在下一章中,您将学习 Flux 架构的基本知识,以及如何提高我们的 React 应用程序的可维护性。

第十章:使用 Flux 加强您的 React 架构

构建 Web 应用程序的过程具有一种与生命本身的演变过程有些相似的特质——它永远不会结束。与建造桥梁不同,构建 Web 应用程序没有代表开发过程结束的自然状态。由您或您的团队决定何时停止开发过程并发布您已经构建的内容。

在这本书中,我们已经达到了可以停止开发 Snapterest 的点。现在,我们有一个基本功能的小型 React.js 应用程序,它只是简单地运行。

这样就够了吗?

并不完全是这样。在本书的早些部分,我们讨论了维护 Web 应用程序的过程在时间和精力方面要比开发过程昂贵得多。如果我们选择在其当前状态下完成 Snapterest 的开发,我们也将选择开始维护它的过程。

我们准备好维护 Snapterest 了吗?我们知道它的当前状态是否允许我们在以后引入新功能而无需进行重大代码重构吗?

分析您的 Web 应用程序架构

为了回答这些问题,让我们从实现细节中放大,并探索我们应用程序的架构:

  • app.js文件呈现我们的Application组件

  • Application组件管理 tweet 集合并呈现我们的StreamCollection组件

  • Stream组件从SnapkiteStreamClient库接收新的 tweets 并呈现StreamTweetHeader组件

  • Collection组件呈现CollectionControlsTweetList组件

停在那里。您能告诉数据在我们的应用程序内部是如何流动的吗?您知道它是如何进入我们的应用程序的吗?新的 tweet 是如何最终进入我们的集合的?让我们更仔细地检查我们的数据流:

  1. 我们使用SnapkiteStreamClient库在Stream组件内接收新 tweet。

  2. 然后,这个新的 tweet 从Stream传递到StreamTweet组件。

  3. StreamTweet组件将其传递给Tweet组件,后者呈现 tweet 图像。

  4. 用户点击该 tweet 图像将其添加到其集合中。

  5. Tweet组件通过handleImageClick(tweet)回调函数将tweet对象传递给StreamTweet组件。

  6. StreamTweet组件通过onAddTweetToCollection(tweet)回调函数将tweet对象传递给Stream组件。

  7. Stream组件通过onAddTweetToCollection(tweet)回调函数将tweet对象传递给Application组件。

  8. Application组件将tweet添加到collectionTweets对象并更新其状态。

  9. 状态更新触发Application组件重新渲染,进而使用更新后的推文集合重新渲染Collection组件。

  10. 然后,Collection组件的子组件也可以改变我们的推文集合。

你感到困惑吗?你能长期依赖这种架构吗?你认为它容易维护吗?我不这么认为。

让我们识别当前架构的关键问题。我们可以看到新数据通过Stream组件进入我们的 React 应用程序。然后,它沿着组件层次结构一直传递到Tweet组件。然后,它一直传递到Application组件,那里存储和管理它。

为什么我们要在Application组件中存储和管理我们的推文集合?因为Application是另外两个组件StreamCollection的父组件:它们都需要能够改变我们的推文集合。为了适应这一点,我们的Application组件需要将回调函数传递给这两个组件:

  • Stream组件:
<Stream 
  onAddTweetToCollection={this.addTweetToCollection}
/>
  • Collection组件:
<Collection
  tweets={collectionTweets}
  onRemoveTweetFromCollection={this.removeTweetFromCollection} onRemoveAllTweetsFromCollection={this.removeAllTweetsFromCollection}
/>

Stream组件获取onAddTweetToCollection()函数以将推文添加到集合中。Collection组件获取onRemoveTweetFromCollection()函数以从集合中移除推文,并获取onRemoveAllTweetsFromCollection()函数以移除集合中的所有推文。

然后,这些回调函数会一直传播到组件层次结构的底部,直到它们到达实际调用它们的某个组件。在我们的应用程序中,onAddTweetToCollection()函数只在Tweet组件中被调用。让我们看看在它被调用之前需要从一个组件传递到另一个组件多少次:

Application > Stream > StreamTweet > Tweet

onAddTweetToCollection()函数在StreamStreamTweet组件中都没有被使用,但它们都将其作为属性传递下去,目的是将其传递给它们的子组件。

Snapterest 是一个小型的 React 应用程序,所以这个问题只是一个不便,但以后,如果你决定添加新功能,这个不便很快就会变成一个维护的噩梦:

Application > ComponentA > ComponentB > ComponentC > ComponentD > ComponentE > ComponentF > ComponentG > Tweet

为了防止这种情况发生,我们将解决两个问题:

  • 我们将改变新数据进入我们的应用程序的方式

  • 我们将改变组件如何获取和设置数据

我们将借助 Flux 重新思考应用程序内部数据流。

理解 Flux

Flux是 Facebook 提供的应用程序架构,它与 React 相辅相成。它不是一个框架或库,而是一个解决常见问题的解决方案——如何构建可扩展的客户端应用程序。

使用 Flux 架构,我们可以重新思考数据在我们的应用程序内部的流动方式。Flux 确保我们的所有数据只在一个单一方向中流动。这有助于我们理解我们的应用程序如何工作,无论它有多小或多大。使用 Flux,我们可以添加新功能,而不会使应用程序的复杂性或其心智模型爆炸。

您可能已经注意到,React 和 Flux 都共享相同的核心概念——单向数据流。这就是为什么它们自然而然地很好地配合在一起。我们知道数据在 React 组件内部如何流动,但 Flux 如何实现单向数据流呢?

使用 Flux,我们将应用程序的关注点分为四个逻辑实体:

  • 操作

  • 分发器

  • 存储器

  • 视图

操作是我们在想要改变应用程序状态时创建的对象。例如,当我们的应用程序接收到新推文时,我们创建一个新操作。操作对象有一个“类型”属性,用于标识它是什么操作,以及我们的应用程序需要过渡到新状态的任何其他属性。以下是一个操作对象的示例:

const action = {
  type: 'receive_tweet',
  tweet
};

如您所见,这是一个receive_tweet类型的操作,它有一个tweet属性,这是我们的应用程序接收到的新推文对象。通过查看操作的类型,您可以猜测这个操作代表了应用程序状态的什么变化。对于我们的应用程序接收到的每条新推文,它都会创建一个receive_tweet操作。

这个操作去哪里?我们的应用程序的哪个部分会接收到这个操作?操作被分发到存储器。

存储器负责管理应用程序的数据。它们提供了访问数据的方法,但不提供更改数据的方法。如果要更改存储器中的数据,必须创建并分发一个操作。

我们知道如何创建一个操作,但如何分发它呢?顾名思义,您可以使用分发器来做这件事。

分发器负责将所有操作分发到所有存储器:

  • 所有存储器都向分发器注册。它们提供一个回调函数。

  • 所有操作都由调度程序分派到所有已向调度程序注册的存储。

这就是 Flux 架构中数据流的样子:

Actions > Dispatcher > Stores

您可以看到调度程序在我们的数据流中扮演着一个中心元素的角色。所有操作都由它分派。存储与它注册。所有操作都是同步分派的。您不能在上一个操作分派的中间分派操作。在 Flux 架构中,没有操作可以跳过调度程序。

创建调度程序

现在让我们实现这个数据流。我们将首先创建一个调度程序。Facebook 提供了一个我们可以重用的调度程序的实现。让我们利用一下:

  1. 导航到 ~/snapterest 目录并运行以下命令:
**npm install --save flux**

flux 模块带有一个我们将重用的 Dispatcher 函数。

  1. 接下来,在我们项目的 ~/snapterest/source/dispatcher 目录中创建一个名为 dispatcher 的新文件夹。然后在其中创建 AppDispatcher.js 文件:
import { Dispatcher } from 'flux';
export default new Dispatcher();

首先,我们导入 Facebook 提供的 Dispatcher,然后创建并导出一个新的实例。现在我们可以在我们的应用程序中使用这个实例。

接下来,我们需要一种方便的方式来创建和分派操作。对于每个操作,让我们创建一个函数来创建和分派该操作。在 Flux 架构中,这些函数被称为操作创建者函数。

创建操作创建者

在我们项目的 ~/snapterest/source/actions 目录中创建一个名为 actions 的新文件夹。然后,在其中创建 TweetActionCreators.js 文件:

import AppDispatcher from '../dispatcher/AppDispatcher';

function receiveTweet(tweet) {
  const action = {
    type: 'receive_tweet',
    tweet
  };

  AppDispatcher.dispatch(action);
}

export { receiveTweet };

我们的操作创建者将需要一个调度程序来分派操作。我们将导入之前创建的 AppDispatcher

import AppDispatcher from '../dispatcher/AppDispatcher';

然后,我们将创建我们的第一个操作创建者 receiveTweet()

function receiveTweet(tweet) {
  const action = {
    type: 'receive_tweet',
    tweet
  };

  AppDispatcher.dispatch(action);
}

receiveTweet() 函数以 tweet 对象作为参数,并创建具有 type 属性设置为 receive_tweetaction 对象。它还将 tweet 对象添加到我们的 action 对象中,现在每个存储都将接收到这个 tweet 对象。

最后,receiveTweet() 操作创建者通过在 AppDispatcher 对象上调用 dispatch() 方法来分派我们的 action 对象:

AppDispatcher.dispatch(action);

dispatch() 方法将 action 对象分派到所有已向 AppDispatcher 调度程序注册的存储。

然后我们导出我们的 receiveTweet 方法:

export { receiveTweet };

到目前为止,我们已经创建了 AppDispatcherTweetActionCreators。接下来,让我们创建我们的第一个存储。

创建存储

正如您之前学到的,存储在您的 Flux 架构中管理数据。它们将这些数据提供给 React 组件。我们将创建一个简单的存储,用于管理我们的应用程序从 Twitter 接收到的新推文。

在项目的 ~/snapterest/source/stores 目录中创建一个名为 stores 的新文件夹。然后,在其中创建 TweetStore.js 文件:

import AppDispatcher from '../dispatcher/AppDispatcher';
import EventEmitter from 'events';

let tweet = null;

function setTweet(receivedTweet) {
  tweet = receivedTweet;
}

function emitChange() {
  TweetStore.emit('change');
}

const TweetStore = Object.assign({}, EventEmitter.prototype, {
  addChangeListener(callback) {
    this.on('change', callback);
  },

  removeChangeListener(callback) {
    this.removeListener('change', callback);
  },

  getTweet() {
    return tweet;
  }
});

function handleAction(action) {
  if (action.type === 'receive_tweet') {
    setTweet(action.tweet);
    emitChange();
  }
}

TweetStore.dispatchToken = AppDispatcher.register(handleAction);

export default TweetStore;

TweetStore.js 文件实现了一个简单的存储。我们可以将其分为四个逻辑部分:

  • 导入依赖模块并创建私有数据和方法

  • 创建具有公共方法的 TweetStore 对象

  • 创建一个操作处理程序并向调度程序注册存储

  • dispatchToken 分配给我们的 TweetStore 对象并导出它。

在我们存储的第一个逻辑部分中,我们只是导入存储所需的依赖模块:

import AppDispatcher from '../dispatcher/AppDispatcher';
import EventEmitter from 'events';

因为我们的存储将需要向调度程序注册,所以我们导入 AppDispatcher 模块。接下来,我们导入 EventEmitter 类,以便能够向我们的存储添加和移除事件监听器:

import EventEmitter from 'events';

导入所有依赖项后,我们定义存储管理的数据:

let tweet = null;

TweetStore 对象管理一个简单的推文对象,我们最初将其设置为 null,以标识我们尚未收到新的推文。

接下来,让我们创建两个私有方法:

function setTweet(receivedTweet) {
  tweet = receivedTweet;
}

function emitChange() {
  TweetStore.emit('change');
}

setTweet() 函数用 receiveTweet 对象更新 tweetemitChange 函数在 TweetStore 对象上发出 change 事件。这些方法对于 TweetStore 模块是私有的,外部无法访问。

TweetStore.js 文件的第二个逻辑部分是创建 TweetStore 对象:

const TweetStore = Object.assign({}, EventEmitter.prototype, {
  addChangeListener(callback) {
    this.on('change', callback);
  },

  removeChangeListener(callback) {
    this.removeListener('change', callback);
  },

  getTweet() {
    return tweet;
  }
});

我们希望我们的存储在状态发生变化时能够通知应用程序的其他部分。我们将使用事件来实现这一点。每当我们的存储更新其状态时,它会发出 change 事件。对存储状态变化感兴趣的任何人都可以监听这个 change 事件。他们需要添加他们的事件监听器函数,我们的存储将在每个 change 事件上触发。为此,我们的存储定义了 addChangeListener() 方法,用于添加监听 change 事件的事件监听器,以及 removeChangeListener() 方法,用于移除 change 事件监听器。但是,addChangeListener()removeChangeListener() 依赖于 EventEmitter.prototype 对象提供的方法。因此,我们需要将这些方法从 EventEmitter.prototype 对象复制到我们的 TweetStore 对象中。这就是 Object.assign() 函数的作用:

targetObject = Object.assign(
  targetObject, 
  sourceObject1,
  sourceObject2
);

Object.assign()sourceObject1sourceObject2拥有的属性复制到targetObject,然后返回targetObject。在我们的情况下,sourceObject1EventEmitter.prototypesourceObject2是一个定义了我们存储器方法的对象字面量:

{
  addChangeListener(callback) {
    this.on('change', callback);
  },

  removeChangeListener(callback) {
    this.removeListener('change', callback);
  },

  getTweet() {
    return tweet;
  }
}

Object.assign()方法返回从所有源对象复制的属性的targetObject。这就是我们的TweetStore对象所做的。

你是否注意到我们将getTweet()函数定义为TweetStore对象的一个方法,而对setTweet()函数却没有这样做。为什么呢?

稍后,我们将导出TweetStore对象,这意味着它的所有属性都将可供应用程序的其他部分使用。我们希望它们能够从TweetStore获取数据,但不能直接通过调用setTweet()来更新数据。相反,更新任何存储器中的数据的唯一方法是创建一个操作并将其分派(使用调度程序)到已向该调度程序注册的存储器。当存储器收到该操作时,它可以决定如何更新其数据。

这是 Flux 架构非常重要的一个方面。存储器完全控制管理它们的数据。它们只允许应用程序中的其他部分读取数据,但永远不会直接写入数据。只有操作应该改变存储器中的数据。

TweetStore.js文件的第三个逻辑部分是创建一个操作处理程序并向调度程序注册存储器。

首先,我们创建操作处理程序函数:

function handleAction(action) {
  if (action.type === 'receive_tweet') {
    setTweet(action.tweet);
    emitChange();
  }
}

handleAction()函数以action对象作为参数,并检查其类型属性。在 Flux 中,所有存储器都会收到所有操作,但并非所有存储器都对所有操作感兴趣,因此每个存储器必须决定自己感兴趣的操作。为此,存储器必须检查操作类型。在我们的TweetStore存储器中,我们检查操作类型是否为receive_tweet,这意味着我们的应用程序已收到一条新推文。如果是这样,那么我们的TweetStore调用其私有的setTweet()函数来使用来自action对象的新推文更新tweet对象,即action.tweet。当存储器更改其数据时,它需要告诉所有对数据更改感兴趣的人。为此,它调用其私有的emitChange()函数,发出change事件并触发应用程序中其他部分创建的所有事件侦听器。

我们的下一个任务是将TweetStore商店与调度程序注册。要将商店与调度程序注册,您需要调用调度程序的register()方法,并将商店的操作处理程序函数作为回调函数传递给它。每当调度程序分派一个操作时,它都会调用该回调函数并将操作对象传递给它。

让我们来看看我们的例子:

TweetStore.dispatchToken = AppDispatcher.register(handleAction);

我们在AppDispatcher对象上调用register()方法,并将handleAction函数作为参数传递。register()方法返回一个标识TweetStore商店的令牌。我们将该令牌保存为我们的TweetStore对象的属性。

TweetStore.js文件的第四个逻辑部分是导出TweetStore对象:

export default TweetStore;

这就是您创建一个简单商店的方式。现在,既然我们已经实现了我们的第一个操作创建者、调度程序和商店,让我们重新审视 Flux 架构,并看看它是如何工作的:

  1. 商店向调度程序注册自己。

  2. 操作创建者通过调度程序创建和分派操作到商店。

  3. 商店检查相关操作并相应地更改它们的数据。

  4. 商店通知所有正在听的人数据变化。

这是有道理的,你可能会说,但是是什么触发了操作创建者?谁在监听商店更新?这些都是非常好的问题。答案等着你在我们的下一章中。

总结

在本章中,您分析了我们的 React 应用程序的架构。您学习了 Flux 架构背后的核心概念,并实现了调度程序、操作创建者和商店。

在下一章中,我们将把它们整合到我们的 React 应用程序中,并让我们的架构准备好迎接维护的天堂。

第十一章:为 Flux 轻松维护准备您的 React 应用程序

我们决定在 React 应用程序中实现 Flux 架构的原因是我们希望拥有更容易维护的数据流。在上一章中,我们实现了AppDispatcherTweetActionCreatorsTweetStore。让我们快速回想一下它们的用途:

  • TweetActionCreators:这创建并分发动作

  • AppDispatcher:这将所有动作分发到所有存储

  • TweetStore:这存储和管理应用程序数据

我们数据流中唯一缺失的部分如下:

  • 使用TweetActionCreators创建动作并启动数据流

  • 使用TweetStore获取数据

以下是一些重要的问题要问:我们的应用程序中数据流从哪里开始?我们的数据是什么?如果我们回答了这些问题,我们将了解从哪里开始重构我们的应用程序以适应 Flux 架构。

Snapterest 允许用户接收和收集最新的推文。我们的应用程序关心的唯一数据是推文。因此,我们的数据流始于接收新推文。目前,我们的应用程序的哪个部分负责接收新推文?您可能还记得我们的Stream组件具有以下componentDidMount()方法:

componentDidMount() {
  SnapkiteStreamClient.initializeStream(this.handleNewTweet);
}

是的,目前,在渲染Stream组件后,我们启动了一系列新推文。等等,你可能会问,“我们不是学过 React 组件应该只关注渲染用户界面吗?”你是对的。不幸的是,目前,Stream组件负责两件不同的事情:

  • 渲染StreamTweet组件

  • 启动数据流

显然,这是未来潜在的维护问题。让我们借助 Flux 来解耦这两个不同的关注点。

使用 Flux 解耦关注点

首先,我们将创建一个名为WebAPIUtils的新实用程序模块。在~/snapterest/source/utils/目录中创建WebAPIUtils.js文件:

import SnapkiteStreamClient from ‘snapkite-stream-client’;
import { receiveTweet } from ‘../actions/TweetActionCreators’;

function initializeStreamOfTweets() {
  SnapkiteStreamClient.initializeStream(receiveTweet);
}

export { initializeStreamOfTweets };

在这个实用程序模块中,我们首先导入SnapkiteStreamClient库和TweetActionCreators。然后,我们创建initializeStreamOfTweets()函数,该函数初始化一系列新推文,就像Stream组件的componentDidMount()方法一样。除了一个关键的区别:每当SnapkiteStreamClient接收到新推文时,它调用TweetActionCreators.receiveTweet方法,并将新推文作为参数传递给它:

SnapkiteStreamClient.initializeStream(receiveTweet);

记住receiveTweet函数期望接收一个tweet参数:

function receiveTweet(tweet) {
  // ... create and dispatch ‘receive_tweet’ action
}

这个推文将作为一个新动作对象的属性被分发。receiveTweet()函数创建。

然后,WebAPIUtils模块导出我们的initializeStreamOfTweets()函数。

现在我们有一个模块,其中有一个方法来启动我们的 Flux 架构中的数据流。我们应该在哪里导入并调用它?由于它与Stream组件解耦,实际上,它根本不依赖于任何 React 组件,我们甚至可以在 React 渲染任何内容之前使用它。让我们在我们的app.js文件中使用它:

import React from ‘react’;
import ReactDOM from ‘react-dom’;
import Application from ‘./components/Application’;
import { initializeStreamOfTweets } from ‘./utils/WebAPIUtils’;

initializeStreamOfTweets();

ReactDOM.render(
  <Application/>,
  document.getElementById(‘react-application’)
);

正如你所看到的,我们所需要做的就是导入并调用initializeStreamOfTweets()方法:

import { initializeStreamOfTweets } from ‘./utils/WebAPIUtils’;

initializeStreamOfTweets();

在调用 React 的render()方法之前我们这样做:

ReactDOM.render(
  <Application/>,
  document.getElementById(‘react-application’)
);

实际上,作为一个实验,你可以完全删除ReactDOM.render()这行代码,并在TweetActionCreators.receiveTweet函数中放一个日志声明。例如,运行以下代码:

function receiveTweet(tweet) {

  console.log("I’ve received a new tweet and now will dispatch it together with a new action.");

  const action = {
    type: ‘receive_tweet’,
    tweet
  };

  AppDispatcher.dispatch(action);
}

现在运行npm start命令。然后,在 Web 浏览器中打开~/snapterest/build/index.html,你会看到以下文本呈现在页面上:

我即将学习 React.js 的基本知识。

现在打开 JavaScript 控制台,你会看到这个输出:

**[Snapkite Stream Client] Socket connected**
**I’ve received a new tweet and now will dispatch it together with a new action.**

这个日志消息将被打印出来,每当我们的应用程序接收到一个新的推文时。即使我们没有渲染任何 React 组件,我们的 Flux 架构仍然存在:

  1. 我们的应用程序接收到一个新的推文。

  2. 它创建并分发一个新的动作。

  3. 没有任何存储器已经向分发器注册,因此没有人可以接收新的动作;因此,什么也没有发生。

现在你可以清楚地看到 React 和 Flux 是两个完全不相互依赖的东西。

然而,我们确实希望渲染我们的 React 组件。毕竟,在前面的十章中,我们已经付出了很多努力来创建它们!为了做到这一点,我们需要让我们的TweetStore存储器发挥作用。你能猜到我们应该在哪里使用它吗?这里有一个提示:在一个需要推文来呈现自己的 React 组件中——我们的老朋友Stream组件。

重构 Stream 组件

现在有了 Flux 架构,我们将重新思考我们的 React 组件如何获取它们需要呈现的数据。如你所知,React 组件通常有两个数据来源:

  • 调用另一个库,例如调用jQuery.ajax()方法,或者在我们的情况下,SnapkiteStreamClient.initializeStream()

  • 通过props对象从父 React 组件接收数据

我们希望我们的 React 组件不使用任何外部库来接收数据。从现在开始,它们将从商店获取相同的数据。牢记这个计划,让我们重构我们的Stream组件。

现在它看起来是这样的:

import React from ‘react’;
import SnapkiteStreamClient from ‘snapkite-stream-client’;
import StreamTweet from ‘./StreamTweet’;
import Header from ‘./Header’;

class Stream extends React.Component {
  constructor() {
    super();

    this.state = {
      tweet: null
    };
  }

  componentDidMount() {
    SnapkiteStreamClient.initializeStream(this.handleNewTweet);
  }

  componentWillUnmount() {
    SnapkiteStreamClient.destroyStream();
  }

  handleNewTweet = tweet => {
    this.setState({
      tweet
    });
  }

  render() {
    const { tweet } = this.state;
    const { onAddTweetToCollection } = this.props;
    const headerText = "Waiting for public photos from Twitter...";

    if (tweet) {
      return (
        <StreamTweet
          tweet={tweet}
          onAddTweetToCollection={onAddTweetToCollection}
        />
      );
    }

    return (
      <Header text={headerText} />
    );
  }
}

export default Stream;

首先,让我们摆脱componentDidMount()componentWillUnmount()handleNewTweet()方法,并导入TweetStore商店:

import React from ‘react’;
import SnapkiteStreamClient from ‘snapkite-stream-client’;
import StreamTweet from ‘./StreamTweet’;
import Header from ‘./Header’;
import TweetStore from ‘../stores/TweetStore’;

class Stream extends React.Component {
  state = {
    tweet: null
  }

  render() {
    const { tweet } = this.state;
    const { onAddTweetToCollection } = this.props;
    const headerText = "Waiting for public photos from Twitter...";

    if (tweet) {
      return (
        <StreamTweet
          tweet={tweet}
          onAddTweetToCollection={onAddTweetToCollection}
        />
      );
    }

    return (
      <Header text={headerText} />
    );
  }
}

export default Stream;

也不再需要导入snapkite-stream-client模块。

接下来,我们需要改变Stream组件如何获取其初始推文。让我们更新它的初始状态:

state = {
  tweet: TweetStore.getTweet()
}

从代码上看,这可能看起来是一个小改变,但这是一个重大的架构改进。我们现在使用getTweet()方法从TweetStore商店获取数据。在上一章中,我们讨论了 Flux 中商店如何公开方法,以允许我们应用程序的其他部分从中获取数据。getTweet()方法是这些公共方法的一个例子,被称为getters

你可以从商店获取数据,但不能直接在商店上设置数据。商店没有公共的setter方法。它们是有意设计成这样的限制,这样当你用 Flux 编写应用程序时,你的数据只能单向流动。当你需要维护 Flux 应用程序时,这将极大地使你受益。

现在我们知道如何获取我们的初始推文,但是我们如何获取以后到达的所有其他新推文呢?我们可以创建一个定时器并重复调用TweetStore.getTweet();然而,这不是最好的解决方案,因为它假设我们不知道TweetStore何时更新其推文。然而,我们知道。

如何?记得在上一章中,我们在TweetStore对象上实现了以下公共方法,即addChangeListener()方法:

addChangeListener(callback) {
  this.on(‘change’, callback);
}

我们还实现了removeChangeListener()方法:

removeChangeListener(callback) {
  this.removeListener(‘change’, callback);
}

没错。我们可以要求TweetStore告诉我们它何时更改其数据。为此,我们需要调用它的addChangeListener()方法,并传递一个回调函数,TweetStore将为每个新推文调用它。问题是,在我们的Stream组件中,我们在哪里调用TweetStore.addChangeListener()方法?

由于我们需要在组件的生命周期中只一次向TweetStore添加change事件监听器,所以componentDidMount()是一个完美的选择。在Stream组件中添加以下componentDidMount()方法:

componentDidMount() {
  TweetStore.addChangeListener(this.onTweetChange);
}

在这里,我们向TweetStore添加了我们自己的change事件监听器this.onTweetChange。现在当TweetStore改变其数据时,它将触发我们的this.onTweetChange方法。我们将很快创建这个方法。

不要忘记在卸载 React 组件之前删除任何事件侦听器。为此,将以下componentWillUnmount()方法添加到Stream组件中:

componentWillUnmount() {
  TweetStore.removeChangeListener(this.onTweetChange);
}

删除事件侦听器与添加事件侦听器非常相似。我们调用TweetStore.removeChangeListener()方法,并将我们的this.onTweetChange方法作为参数传递。

现在,是时候在我们的Stream组件中创建onTweetChange方法了:

onTweetChange = () => {
  this.setState({
    tweet: TweetStore.getTweet()
  });
}

正如你所看到的,它使用TweetStore.getTweet()方法将新的推文存储在TweetStore中,并更新组件的状态。

我们需要在我们的Stream组件中进行最后一个更改。在本章的后面,您将了解到我们的StreamTweet组件不再需要handleAddTweetToCollection()回调函数;因此,在这个组件中,我们将更改以下代码片段:

return (
  <StreamTweet
    tweet={tweet}
    onAddTweetToCollection={onAddTweetToCollection}
  />
);

用以下代码替换它:

return (<StreamTweet tweet={tweet} />);

现在让我们来看看我们新重构的Stream组件:

import React from ‘react’;
import StreamTweet from ‘./StreamTweet’;
import Header from ‘./Header’;
import TweetStore from ‘../stores/TweetStore’;

class Stream extends React.Component {
  state = {
    tweet: TweetStore.getTweet()
  }

  componentDidMount() {
    TweetStore.addChangeListener(this.onTweetChange);
  }

  componentWillUnmount() {
    TweetStore.removeChangeListener(this.onTweetChange);
  }

  onTweetChange = () => {
    this.setState({
      tweet: TweetStore.getTweet()
    });
  }

  render() {
    const { tweet } = this.state;
    const { onAddTweetToCollection } = this.props;
    const headerText = "Waiting for public photos from Twitter...";

    if (tweet) {
      return (<StreamTweet tweet={tweet}/>);
    }

    return (<Header text={headerText}/>);
  }
}

export default Stream;

让我们回顾一下,看看我们的Stream组件如何始终具有最新的推文:

  1. 我们使用getTweet()方法将组件的初始推文设置为从TweetStore获取的最新推文。

  2. 然后,我们监听TweetStore的变化。

  3. TweetStore改变其推文时,我们使用getTweet()方法从TweetStore获取最新的推文,并更新组件的状态。

  4. 当组件即将卸载时,我们停止监听TweetStore的变化。

这就是 React 组件与 Flux 存储区交互的方式。

在我们继续使我们的应用程序其余部分变得更加 Flux 强大之前,让我们来看看我们当前的数据流:

  • app.js:这接收新推文并为每个推文调用TweetActionCreators

  • TweetActionCreators:这将创建并分发一个带有新推文的新操作

  • AppDispatcher:这将所有操作分发到所有存储区

  • TweetStore:这将向调度程序注册,并在从调度程序接收到新操作时发出更改事件

  • Stream:这监听TweetStore的变化,从TweetStore获取新的推文,更新状态并重新渲染

你能看到我们如何现在可以扩展 React 组件、动作创建者和存储的数量,仍然能够维护 Snapterest 吗?使用 Flux,它将始终是单向数据流。无论我们实现多少新功能,它都将是相同的思维模式。在长期来看,当我们需要维护我们的应用程序时,我们将获得巨大的好处。

我是否提到我们将在我们的应用程序中更多地使用 Flux?接下来,让我们确实这样做。

创建 CollectionStore

Snapterest 不仅存储最新的推文,还存储用户创建的推文集合。让我们用 Flux 重构这个功能。

首先,让我们创建一个集合存储。导航到~/snapterest/source/stores/目录并创建CollectionStore.js文件:

import AppDispatcher from ‘../dispatcher/AppDispatcher’;
import { EventEmitter } from ‘events’;

const CHANGE_EVENT = ‘change’;

let collectionTweets = {};
let collectionName = ‘new’;

function addTweetToCollection(tweet) {
  collectionTweets[tweet.id] = tweet;
}

function removeTweetFromCollection(tweetId) {
  delete collectionTweets[tweetId];
}

function removeAllTweetsFromCollection() {
  collectionTweets = {};
}

function setCollectionName(name) {
  collectionName = name;
}

function emitChange() {
  CollectionStore.emit(CHANGE_EVENT);
}

const CollectionStore = Object.assign(
  {}, EventEmitter.prototype, {
  addChangeListener(callback) {
    this.on(CHANGE_EVENT, callback);
  },

  removeChangeListener(callback) {
    this.removeListener(CHANGE_EVENT, callback);
  },

  getCollectionTweets() {
    return collectionTweets;
  },

  getCollectionName() {
    return collectionName;
  }
}
);

function handleAction(action) {

  switch (action.type) {
    case ‘add_tweet_to_collection’:
      addTweetToCollection(action.tweet);
      emitChange();
      break;

    case ‘remove_tweet_from_collection’:
      removeTweetFromCollection(action.tweetId);
      emitChange();
      break;

    case ‘remove_all_tweets_from_collection’:
      removeAllTweetsFromCollection();
      emitChange();
      break;

    case ‘set_collection_name’:
      setCollectionName(action.collectionName);
      emitChange();
      break;

    default: // ... do nothing

  }
}

CollectionStore.dispatchToken = AppDispatcher.register(handleAction);

export default CollectionStore;

CollectionStore 是一个更大的存储,但它具有与 TweetStore 相同的结构。

首先,我们导入依赖项并将CHANGE_EVENT变量分配给change事件名称:

import AppDispatcher from ‘../dispatcher/AppDispatcher’;
import { EventEmitter } from ‘events’;

const CHANGE_EVENT = ‘change’;

然后,我们定义我们的数据和四个私有方法来改变这些数据:

let collectionTweets = {};
let collectionName = ‘new’;

function addTweetToCollection(tweet) {
  collectionTweets[tweet.id] = tweet;
}

function removeTweetFromCollection(tweetId) {
  delete collectionTweets[tweetId];
}

function removeAllTweetsFromCollection() {
  collectionTweets = {};
}

function setCollectionName(name) {
  collectionName = name;
}

正如你所看到的,我们在一个最初为空的对象中存储了一系列推文,并且我们还存储了最初设置为new的集合名称。然后,我们创建了三个私有函数来改变collectionTweets

  • tweet对象添加到collectionTweets对象

  • collectionTweets对象中删除tweet对象

  • collectionTweets中删除所有tweet对象,将其设置为空对象

然后,我们定义一个私有函数来改变collectionName,名为setCollectionName,它将现有的集合名称更改为新的名称。

这些函数被视为私有,因为它们在 CollectionStore 模块之外是不可访问的;例如,你不能像在任何其他模块中那样访问它们:

CollectionStore.setCollectionName(‘impossible’);

正如我们之前讨论的,这是有意为之的,以强制在应用程序中实现单向数据流。

我们创建了emitChange()方法来发出change事件。

然后,我们创建 CollectionStore 对象:

const CollectionStore = Object.assign(
  {}, EventEmitter.prototype, {
  addChangeListener(callback) {
    this.on(CHANGE_EVENT, callback);
  },

  removeChangeListener(callback) {
    this.removeListener(CHANGE_EVENT, callback);
  },

  getCollectionTweets() {
    return collectionTweets;
  },

  getCollectionName() {
    return collectionName;
  }
});

这与 TweetStore 对象非常相似,只有两种方法不同:

  • 获取推文集合

  • 获取集合名称

这些方法可以在 CollectionStore.js 文件之外访问,并且应该在 React 组件中用于从 CollectionStore 获取数据。

然后,我们创建 handleAction()函数:

function handleAction(action) {
  switch (action.type) {

    case ‘add_tweet_to_collection’:
      addTweetToCollection(action.tweet);
      emitChange();
      break;

    case ‘remove_tweet_from_collection’:
      removeTweetFromCollection(action.tweetId);
      emitChange();
      break;

    case ‘remove_all_tweets_from_collection’:
      removeAllTweetsFromCollection();
      emitChange();
      break;

    case ‘set_collection_name’:
      setCollectionName(action.collectionName);
      emitChange();
      break;

    default: // ... do nothing

  }
}

该函数处理由 AppDispatcher 分发的操作,但与我们 CollectionStore 模块中的 TweetStore 不同,我们可以处理多个操作。实际上,我们可以处理与 Tweet 集合相关的四个操作:

  • add_tweet_to_collection:这将向集合中添加一条 Tweet

  • remove_tweet_from_collection:这将从集合中删除一条 Tweet

  • remove_all_tweets_from_collection:这将从集合中删除所有 Tweet

  • set_collection_name:这将设置集合名称

请记住,所有存储都会接收所有操作,因此 CollectionStore 也将接收 receive_tweet 操作,但是在这个存储中我们只是简单地忽略它,就像 TweetStore 忽略 add_tweet_to_collection,remove_tweet_from_collection,remove_all_tweets_from_collection 和 set_collection_name 一样。

然后,我们使用 AppDispatcher 注册 handleAction 回调,并将 dispatchToken 保存在 CollectionStore 对象中:

CollectionStore.dispatchToken = AppDispatcher.register(handleAction);

最后,我们将 CollectionStore 作为一个模块导出:

export default CollectionStore;

现在,由于我们已经准备好了集合存储,让我们创建动作创建函数。

创建 CollectionActionCreators

导航到~/snapterest/source/actions/并创建 CollectionActionCreators.js 文件:

import AppDispatcher from ‘../dispatcher/AppDispatcher’;

function addTweetToCollection(tweet) {
  const action = {
    type: ‘add_tweet_to_collection’,
    tweet
  };

  AppDispatcher.dispatch(action);
}

function removeTweetFromCollection(tweetId) {
  const action = {
    type: ‘remove_tweet_from_collection’,
    tweetId
  };

  AppDispatcher.dispatch(action);
}

function removeAllTweetsFromCollection() {
  const action = {
    type: ‘remove_all_tweets_from_collection’
  };

  AppDispatcher.dispatch(action);
}

function setCollectionName(collectionName) {
  const action = {
    type: ‘set_collection_name’,
    collectionName
  };

  AppDispatcher.dispatch(action);
}

export default {
  addTweetToCollection,
  removeTweetFromCollection,
  removeAllTweetsFromCollection,
  setCollectionName
};

对于我们在 CollectionStore 中处理的每个操作,我们都有一个操作创建函数:

  • 将 Tweet 添加到 Collection 中():这将创建并分发带有新 Tweet 的 add_tweet_to_collection 动作

  • removeTweetFromCollection():这将创建并分发带有必须从集合中删除的 Tweet 的 ID 的 remove_tweet_from_collection 动作

  • removeAllTweetsFromCollection():这将创建并分发 remove_all_tweets_from_collection 动作

  • setCollectionName():这将创建并分发带有新集合名称的 set_collection_name 动作

现在,当我们创建了 CollectionStore 和 CollectionActionCreators 模块时,我们可以开始重构我们的 React 组件以采用 Flux 架构。

重构 Application 组件

我们从哪里开始重构我们的 React 组件?让我们从组件层次结构中的顶层 React 组件 Application 开始。

目前,我们的 Application 组件存储和管理 Tweet 的集合。让我们删除这个功能,因为现在它由集合存储管理。

Application组件中删除constructor()addTweetToCollection()removeTweetFromCollection()removeAllTweetsFromCollection()方法:

import React from ‘react’;
import Stream from ‘./Stream’;
import Collection from ‘./Collection’;

class Application extends React.Component {
  render() {
    const {
      collectionTweets
    } = this.state;

    return (
      <div className="container-fluid">
        <div className="row">
          <div className="col-md-4 text-center">
            <Stream onAddTweetToCollection={this.addTweetToCollection}/>

          </div>
          <div className="col-md-8">
            <Collection
              tweets={collectionTweets}
              onRemoveTweetFromCollection={this.removeTweetFromCollection}
              onRemoveAllTweetsFromCollection={this.removeAllTweetsFromCollection}
            />
          </div>
        </div>
      </div>
    );
  }
}

export default Application;

现在Application组件只有render()方法来渲染StreamCollection组件。由于它不再管理推文集合,我们也不需要向StreamCollection组件传递任何属性。

更新Application组件的render()函数如下:

render() {
  return (
    <div className="container-fluid">
      <div className="row">
        <div className="col-md-4 text-center">
          <Stream/>
        </div>
        <div className="col-md-8">
          <Collection/>
        </div>
      </div>

    </div>
  );
}

Flux 架构的采用允许Stream组件管理最新的推文,Collection组件管理推文集合,而Application组件不再需要管理任何东西,因此它成为一个容器组件,用额外的 HTML 标记包装StreamCollection组件。

实际上,您可能已经注意到我们当前版本的Application组件是成为一个功能性 React 组件的一个很好的候选:

import React from ‘react’;
import Stream from ‘./Stream’;
import Collection from ‘./Collection’;

const Application = () =>(
  <div className="container-fluid">
    <div className="row">
      <div className="col-md-4 text-center">
        <Stream />
      </div>
      <div className="col-md-8">
        <Collection />
      </div>
    </div>
  </div>
);

export default Application;

我们的Application组件现在更简单,其标记看起来更清洁。这提高了组件的可维护性。干得好!

重构集合组件

接下来,让我们重构我们的Collection组件。用以下内容替换现有的Collection组件:

import React, { Component } from ‘react’;
import ReactDOMServer from ‘react-dom/server’;
import CollectionControls from ‘./CollectionControls’;
import TweetList from ‘./TweetList’;
import Header from ‘./Header’;
import CollectionUtils from ‘../utils/CollectionUtils’;
import CollectionStore from ‘../stores/CollectionStore’;

class Collection extends Component {
  state = {
    collectionTweets: CollectionStore.getCollectionTweets()
  }

  componentDidMount() {
    CollectionStore.addChangeListener(this.onCollectionChange);
  }

  componentWillUnmount() {
    CollectionStore.removeChangeListener(this.onCollectionChange);
  }

  onCollectionChange = () => {
    this.setState({
      collectionTweets: CollectionStore.getCollectionTweets()
    });
  }

  createHtmlMarkupStringOfTweetList() {
    const htmlString = ReactDOMServer.renderToStaticMarkup(
      <TweetList tweets={this.state.collectionTweets}/>
    );

    const htmlMarkup = {
      html: htmlString
    };

    return JSON.stringify(htmlMarkup);
  }

  render() {
    const { collectionTweets } = this.state;
    const numberOfTweetsInCollection = CollectionUtils
      .getNumberOfTweetsInCollection(collectionTweets);
    let htmlMarkup;

    if (numberOfTweetsInCollection > 0) {
      htmlMarkup = this.createHtmlMarkupStringOfTweetList();

      return (
        <div>
          <CollectionControls
            numberOfTweetsInCollection={numberOfTweetsInCollection}
            htmlMarkup={htmlMarkup}
          />

          <TweetList tweets={collectionTweets} />
        </div>
      );
    }

    return (<Header text="Your collection is empty" />);
  }
}

export default Collection;

我们在这里改变了什么?有几件事。首先,我们导入了两个新模块:

import CollectionUtils from ‘../utils/CollectionUtils’;
import CollectionStore from ‘../stores/CollectionStore’;

我们在第九章中创建了CollectionUtils模块,使用 Jest 测试您的 React 应用程序,在本章中,我们正在使用它。CollectionStore是我们获取数据的地方。

接下来,您应该能够发现这四种方法的熟悉模式:

  • 在初始状态下,我们将推文集合设置为CollectionStore中存储的内容。您可能还记得CollectionStore提供了getCollectionTweets()方法来获取其中的数据。

  • componentDidMount()方法中,我们向CollectionStore添加change事件监听器this.onCollectionChange。每当推文集合更新时,CollectionStore将调用我们的this.onCollectionChange回调函数来通知Collection组件该变化。

  • componentWillUnmount()方法中,我们移除了在componentDidMount()方法中添加的change事件监听器。

  • onCollectionChange()方法中,我们将组件的状态设置为当前存储在CollectionStore中的内容。更新组件的状态会触发重新渲染。

Collection组件的render()方法现在更简单、更清晰:

render() {
  const { collectionTweets } = this.state;
  const numberOfTweetsInCollection = CollectionUtils
    .getNumberOfTweetsInCollection(collectionTweets);
  let htmlMarkup;

  if (numberOfTweetsInCollection > 0) {
    htmlMarkup = this.createHtmlMarkupStringOfTweetList();

    return (
      <div>
        <CollectionControls
          numberOfTweetsInCollection={numberOfTweetsInCollection}
          htmlMarkup={htmlMarkup}
        />

        <TweetList tweets={collectionTweets}/>
      </div>
    );
  }

  return (<Header text="Your collection is empty"/>);
}

我们使用CollectionUtils模块来获取集合中的推文数量,并向子组件CollectionControlsTweetList传递更少的属性。

重构CollectionControls组件

CollectionControls组件也有一些重大改进。让我们先看一下重构后的版本,然后讨论更新了什么以及为什么更新:

import React, { Component } from ‘react’;
import Header from ‘./Header’;
import Button from ‘./Button’;
import CollectionRenameForm from ‘./CollectionRenameForm’;
import CollectionExportForm from ‘./CollectionExportForm’;
import CollectionActionCreators from ‘../actions/CollectionActionCreators’;
import CollectionStore from ‘../stores/CollectionStore’;

class CollectionControls extends Component {
  state = {
    isEditingName: false
  }

  getHeaderText = () => {
    const { numberOfTweetsInCollection } = this.props;
    let text = numberOfTweetsInCollection;
    const name = CollectionStore.getCollectionName();

    if (numberOfTweetsInCollection === 1) {
      text = `${text} tweet in your`;
    } else {
      text = `${text} tweets in your`;
    }

    return (
      <span>
        {text} <strong>{name}</strong> collection
      </span>
    );
  }

  toggleEditCollectionName = () => {
    this.setState(prevState => ({
      isEditingName: !prevState.isEditingName
    }));
  }

  removeAllTweetsFromCollection = () => {
    CollectionActionCreators.removeAllTweetsFromCollection();
  }

  render() {
    const { name, isEditingName } = this.state;
    const onRemoveAllTweetsFromCollection = this.removeAllTweetsFromCollection;
    const { htmlMarkup } = this.props;

    if (isEditingName) {
      return (
        <CollectionRenameForm
          name={name}
          onCancelCollectionNameChange={this.toggleEditCollectionName}
        />
      );
    }

    return (
      <div>
        <Header text={this.getHeaderText()} />

        <Button
          label="Rename collection"
          handleClick={this.toggleEditCollectionName}
        />

        <Button
          label="Empty collection"
          handleClick={onRemoveAllTweetsFromCollection}
        />

        <CollectionExportForm htmlMarkup={htmlMarkup} />
      </div>
    );
  }
}

export default CollectionControls;

首先,我们导入另外两个模块:

import CollectionActionCreators from ‘../actions/CollectionActionCreators’;
import CollectionStore from ‘../stores/CollectionStore’;

注意,我们不再在这个组件中管理集合名称。相反,我们从CollectionStore模块中获取它:

const name = CollectionStore.getCollectionName();

然后,我们进行了一个关键的改变。我们用一个新的removeAllTweetsFromCollection()方法替换了setCollectionName()方法:

removeAllTweetsFromCollection = () => {
  CollectionActionCreators.removeAllTweetsFromCollection();
}

当用户点击“清空集合”按钮时,将调用removeAllTweetsFromCollection()方法。这个用户操作会触发removeAllTweetsFromCollection()动作创建函数,它创建并分发动作到存储中。然后,CollectionStore会从集合中删除所有推文并发出change事件。

接下来,让我们重构我们的CollectionRenameForm组件。

重构CollectionRenameForm组件

CollectionRenameForm是一个受控表单组件。这意味着它的输入值存储在组件的状态中,更新该值的唯一方法是更新组件的状态。它具有应该从CollectionStore获取的初始值,所以让我们实现这一点。

首先,导入CollectionActionCreatorsCollectionStore模块:

import CollectionActionCreators from ‘../actions/CollectionActionCreators’;
import CollectionStore from ‘../stores/CollectionStore’;

现在,我们需要删除它现有的constructor()方法:

constructor(props) {
  super(props);

  const { name } = props;

  this.state = {
    inputValue: name
  };
}

用以下代码替换前面的代码:

state = {
  inputValue: CollectionStore.getCollectionName()
}

正如你所看到的,唯一的区别是现在我们从CollectionStore获取初始的inputValue

接下来,让我们更新handleFormSubmit()方法:

handleFormSubmit = event => {
  event.preventDefault();

  const { onChangeCollectionName } = this.props;
  const { inputValue: collectionName } = this.state;

  onChangeCollectionName(collectionName);
}

用以下代码更新前面的代码:

handleFormSubmit = event => {
  event.preventDefault();

  const { onCancelCollectionNameChange } = this.props;
  const { inputValue: collectionName } = this.state;

  CollectionActionCreators.setCollectionName(collectionName);

  onCancelCollectionNameChange();
}

这里的重要区别在于,当用户提交表单时,我们将创建一个新的操作,在我们的集合存储中设置一个新的名称:

CollectionActionCreators.setCollectionName(collectionName);

最后,我们需要在handleFormCancel()方法中更改集合名称的来源:

handleFormCancel = event => {
  event.preventDefault();

  const {
    name: collectionName,
    onCancelCollectionNameChange
  } = this.props;

  this.setInputValue(collectionName);
  onCancelCollectionNameChange();
}

用以下代码替换前面的代码:

handleFormCancel = event => {
  event.preventDefault();

  const {
    onCancelCollectionNameChange
  } = this.props;

  const collectionName = CollectionStore.getCollectionName();

  this.setInputValue(collectionName);
  onCancelCollectionNameChange();
}

再次,我们从集合存储中获取集合名称:

const collectionName = CollectionStore.getCollectionName();

这就是我们需要在CollectionRenameForm组件中更改的全部内容。让我们接下来重构TweetList组件。

重构TweetList组件

TweetList组件渲染了一系列推文。每个推文都是一个Tweet组件,用户可以点击以将其从集合中移除。听起来好像它可以利用CollectionActionCreators吗?

没错。让我们将CollectionActionCreators模块添加到其中:

import CollectionActionCreators from ‘../actions/CollectionActionCreators’;

然后,我们将创建removeTweetFromCollection()回调函数,当用户点击推文图片时将被调用:

removeTweetFromCollection = tweet => {
  CollectionActionCreators.removeTweetFromCollection(tweet.id);
}

正如您所看到的,它使用removeTweetFromCollection()函数创建了一个新的动作,并将推文 ID 作为参数传递给它。

最后,我们需要确保实际调用了removeTweetFromCollection()。在getTweetElement()方法中,找到以下行:

const { tweets, onRemoveTweetFromCollection } = this.props;

现在用以下代码替换它:

const { tweets } = this.props;
const onRemoveTweetFromCollection = this.removeTweetFromCollection;

我们已经完成了这个组件。接下来是我们重构之旅中的StreamTweet

重构StreamTweet组件

StreamTweet渲染了用户可以点击以将其添加到推文集合中的推文图片。您可能已经猜到,当用户点击该推文图片时,我们将创建并分发一个新的动作。

首先,将CollectionActionCreators模块导入StreamTweet组件:

import CollectionActionCreators from ‘../actions/CollectionActionCreators’;

然后,在其中添加一个新的addTweetToCollection()方法:

addTweetToCollection = tweet => {
  CollectionActionCreators.addTweetToCollection(tweet);
}

当用户点击推文图片时,应调用addTweetToCollection()回调函数。让我们看看render()方法中的这行代码:

<Tweet
  tweet={tweet}
  onImageClick={onAddTweetToCollection}
/>

用以下行代码替换前面的代码:

<Tweet
  tweet={tweet}
  onImageClick={this.addTweetToCollection}
/>

最后,我们需要替换以下行:

const { tweet, onAddTweetToCollection } = this.props; 

使用这个代替:

const { tweet } = this.props;

StreamTweet组件现在已经完成。

构建和超越

这就是将 Flux 架构集成到我们的 React 应用程序中所需的所有工作。如果您比较一下没有 Flux 的 React 应用程序和有 Flux 的 React 应用程序,您很快就会发现当 Flux 成为其中的一部分时,更容易理解应用程序的工作原理。您可以在facebook.github.io/flux/了解更多关于 Flux 的信息。

我认为现在是检查一切是否正常运行的好时机。让我们构建并运行 Snapterest!

导航到~/snapterest并在您的终端窗口中运行以下命令:

**npm start**

确保您正在运行我们在第二章中安装和配置的 Snapkite Engine 应用程序,为您的项目安装强大的工具。现在在您的网络浏览器中打开~/snapterest/build/index.html文件。您应该会看到新的推文逐个出现在左侧。单击推文将其添加到右侧出现的收藏中。

它是否有效?检查 JavaScript 控制台是否有任何错误。没有错误?

祝贺您将 Flux 架构整合到我们的 React 应用程序中!

总结

在这一章中,我们完成了重构我们的应用程序,以使用 Flux 架构。您了解了将 React 与 Flux 结合使用的要求,以及 Flux 所提供的优势。

在下一章中,我们将使用 Redux 库进一步简化我们应用程序的架构。