第 8 章 构建复杂的 React 组件

第 8 章 构建复杂的 React 组件

Flying
2018-10-24 / 0 评论 / 319 阅读 / 正在检测是否收录...

在本章中,我们将通过在我们的应用中构建最复杂的组件,即 Collection 组件的子组件,将你迄今为止所学到的关于 React 组件的一切知识付诸实践。我们在本章中的目标是获得坚实的 React 经验并健壮我们的 React 肌肉。让我们开始吧!

创建 TweetList 组件

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

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

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

const listStyle = {
  padding: '0',
};

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

class TweetList extends Component {
  getListOfTweetIds = () => Object.keys(this.props.tweets);

  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 = this.getListOfTweetIds().map(this.getTweetElement);

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

export default TweetList;

TweetList 组件渲染推文列表:

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

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

首先,我们创建一个 tweetElement 列表:

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

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 组件将对单击事件做出响应,因此我们需要提供 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 常量,该常量存储一个 ID 由 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 使用它来标识动态创建的每个子元素。关于动态子元素我建议你访问https://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: 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 组件渲染用于控制集合的用户界面。这些控件允许用户执行以下操作:

  • 重命名集合
  • 清空集合
  • 导出集合

集合具有名称。默认情况下,此名称是新的,用户可以更改它。集合名称显示在由 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 属性
  • 引用组件方法的 onChangeCollectionName 属性
  • 引用组件方法的 onCancelCollectionNameChange 属性

我们将在本章稍后部分实现 CollectionRenameForm 组件。现在,让我们仔细看看 setCollectionName 方法:

setCollectionName = (name) => {
  this.setState({
    name: 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() 函数:

<Headertext={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 常量,它将存储推文集合的数量。然后,我们创建一个文本变量,并将推文集合的数量分配给它。此时,文本变量存储一个整数值。我们的下一个任务是根据整数值连接正确的字符串:

  • 如果 numberOfTweetsInCollection1,那么我们需要连接 'tweet in your'
  • 否则,我们需要连接 'tweets in your' 创建标头字符串后,我们将返回以下元素:
return (
  <span>
    {text} <strong>{name}</strong> collection
  </span>
);

封装在 <span> 元素中的最终字符串由文本变量的值、集合名称和集合关键字组成;如以下示例:

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 标签字符串,并将渲染一个 Button。我们将在本章稍后创建该组件。

现在,既然你了解了 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} />
        Chapter 8 [ 119 ]
      </form>
    );
  }
}

export default CollectionRenameForm;

该组件渲染一个更改集合名称的表单:

render() {
  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={this.state.inputValue}
          ref="collectionName" />
      </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 框架的一部分,我们在第 5 章让你的 React 组件具有响应式中讨论过。它用于布局和样式,不是 React 应用逻辑的一部分。

<input>元素有很多属性。让我们仔细看看:

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

以下是前面代码中使用的属性的说明:

  • className 属性设置为 form-control。它是另一个类名,是 Bootstrap 框架的一部分。我们将使用它进行样式设置。
  • 此外,我们使用 style 属性将自己的样式应用于此 input 元素,该属性使用单个样式规则(即 marginRight)引用 inputStyle 对象。
  • value 属性设置为存储在组件状态 this.state.inputValue 中的当前值。
  • onChange 属性引用了一个 handleInputValueChange 方法,该方法是一个 onChange 事件处理程序。
  • ref 属性是一个特殊的 React 属性,可以附加到任何组件。它需要一个回调函数,React 将在安装和卸载组件后立即执行执行该函数。它允许我们访问 React 组件渲染的 DOM 输入元素。

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

那么,我们如何确保输入字段响应用户输入呢?我们需要监听用户输入,并更新 CollectionRenameForm 组件的状态,反过来将使用更新的值重新渲染输入字段。

对每个输入更改事件这样做将使我们的输入看起来像往常一样工作,用户可以随意更改其值。

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

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

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

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

setInputValue() 方法是一种使用新的输入值更新组件的状态的简便方法。相反的,此更新将使用更新值重新渲染 <input> 元素。

当挂载 CollectionRenameForm 组件时 <input> 的初始值时什么?让我们来看看:

constructor(props) {
  super(props);
  const { name } = props;
  this.state = {
    inputValue: name
  };
}

如你所见,我们从父组件传递集合名称,并使用它设置组件的初始状态。

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

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

要做到这一点,我们通过引用 this.collectionNameInput 来获得输入元素,并对其调用 focus() 函数。

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

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

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

我们将从 Change 按钮开始:

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

当用户单击它时会调用 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}" />

当用户单击它时,handleFormCancel 方法被调用:

handleFormCancel = (event) => {
  event.preventDefault();
  const { name: collectionName, onCancelCollectionNameChange } = this.props;
  this.setInputValue(collectionName);
  onCancelCollectionNameChange();
};

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

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

创建 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 函数的方式创建。当然,当你的组件有状态时,这是不可能的,但对于所有无状态组件,这是可能的!现在看看我们迄今为止创建的所有组件,看看是否可以将它们重写为“纯”函数而不是使用类。

我建议你通过以下链接阅读更多关于函数组件与类组件的信息:
https://facebook.github.io/react/docs/components-and-props.html

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

  • label 属性是按钮的标签
  • handleClick 属性是用户单击此按钮时调用的回调函数

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

创建 CollectionExportForm 组件

CollectionExportForm 组件负责将集合导出到第三方网站(http://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>元素是隐藏的,其值设置为 HTML 标签字符串,该字符串由父组件作为 htmlMarkup 属性传递。<button> 元素是此表单中唯一对用户可见的元素。当用户单击导出 HTML 按钮时,集合将提交到 CodePen,并在新窗口中打开。然后,用户可以修改和共享该集合。

祝贺!你现在已经使用 React 构建了一个功能齐全的 web 应用。让我们看看它是如何工作的。

首先,确保我们在第 2 章为你的项目安装强大的工具安装并配置的 Snapkite-engine 正在运行。转到 ~/snapkite-engine/ 并运行以下命令:

npm start

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

npm start

现在打开 ~/snapterest/build/index.html 。你将看到新的推文出现。单击它们可将它们添加到你的集合中。再次单击它们以从集合中删除单个推文。单击清空集合按钮,从集合中删除所有推文。单击重命名集合按钮,键入新集合名称,然后单击修改按钮。最后单击导出 HTML按钮,将集合导出到 CodePen.io。如果你对本章或前几章有任何问题,请转到https://github.com/PacktPublishing/React-Essentials-Second-Edition并新建问题。

总结

在本章中,你创建了 TweetListCollectionControlsCollectionRenameFormCollectionExportFormButton 组件。你已完成构建一个功能完备的 React 应用。

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

11

评论 (0)

取消