首页 > 其他分享 >React16-基础知识第二版-全-

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

时间:2024-05-16 14:51:43浏览次数:16  
标签:const 第二 基础知识 React tweet 组件 import React16 我们

React16 基础知识第二版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自第一版 React Essentials 以来,React 生态系统发生了很多变化。越来越多的人正在构建 React 应用程序,有成熟的库和框架支持 React 应用程序,React 16 也已发布。React 在如此短的时间内的爆炸式增长可以归因于许多因素:优秀的社区和相关资源,React 生态系统的广泛性以及某些重要项目的成熟,当然还有 React 团队及其愿意将开发者反馈作为项目持续发展的优先事项。

我很荣幸能参与这样一本重要的 React 书籍。正如书名所示,本书旨在教授 React 的基础知识。这个最新版本反映了 React 最新版本的变化,使用 Redux 来管理状态,以及 JavaScript 语言本身的变化。

加入我吧。让我们成为 React 成为构建用户界面的标准的专家。

本书内容

第一章,React 16 有什么新变化,介绍了 React 16 的重大变化。这包括了底层渲染和协调工作的基本变化,以及通过 API 公开的其他新功能。

第二章,为你的项目安装强大的工具,概述了本书的目标,并解释了你需要安装哪些现代工具才能有效构建 React 应用程序。它介绍了每个工具,并提供了逐步说明如何安装每个工具。然后,它为本书中将要构建的项目创建了一个结构。

第三章,创建你的第一个 React 元素,解释了如何安装 React 并介绍了虚拟 DOM。然后,它解释了什么是 React 元素,以及如何使用原生 JavaScript 语法创建和渲染一个。最后,它介绍了 JSX 语法,并展示了如何使用 JSX 创建 React 元素。

第四章,创建你的第一个 React 组件,介绍了 React 组件。它解释了无状态和有状态 React 组件之间的区别,以及如何决定使用哪种。然后,它指导你完成创建这两种组件的过程。

第五章,使您的 React 组件具有响应性,解释了如何解决 React 的问题,并引导您规划 React 应用程序的过程。它创建了一个封装了整个本书中构建的 React 应用程序的 React 组件。它解释了父子 React 组件之间的关系。

第六章,使用另一个库使用您的 React 组件,探讨了如何在 React 组件中使用第三方 JavaScript 库。它介绍了 React 组件的生命周期,演示了如何使用挂载方法,并展示了如何为本书的项目创建新的 React 组件。

第七章,更新您的 React 组件,介绍了 React 组件生命周期的更新方法。这涵盖了如何在 JavaScript 中使用 CSS 样式。它解释了如何验证和设置组件的默认属性。

第八章,构建复杂的 React 组件,着重于构建更复杂的 React 组件。它探讨了如何实现不同的 React 组件以及如何将它们组合成一个连贯且完全功能的 React 应用程序的细节。

第九章,使用 Jest 测试您的 React 应用程序,解释了单元测试的概念,以及如何使用 Jest 编写和运行单元测试。它还演示了如何测试您的 React 组件。它讨论了测试套件、规范、期望和匹配器。

第十章,使用 Flux 加速您的 React 架构,讨论了如何改进我们的 React 应用程序的架构。它介绍了 Flux 架构,并解释了调度程序、存储和操作创建者的作用。

第十一章,使用 Flux 为您的 React 应用程序做好无痛维护的准备,解释了如何使用 Flux 在您的 React 应用程序中解耦关注点。它重构了我们的 React 应用程序,以便将来可以轻松地进行维护。

第十二章,用 Redux 完善您的 Flux 应用,将带您了解 Flux 库的主要特性,然后完全重构一个应用程序,以使用 Redux 作为控制状态的主要机制。

本书所需内容

首先,您需要最新版本的现代 Web 浏览器,如 Google Chrome 或 Mozilla Firefox:

其次,您需要安装 Git、Node.js 和 npm。您将在第二章中找到详细的安装和使用说明,为您的项目安装强大的工具

最后,您需要一个代码编辑器。我推荐Sublime Textwww.sublimetext.com)。或者,您可以使用Atomatom.io)、Bracketsbrackets.io)、Visual Studio Codecode.visualstudio.com)或者您选择的任何其他编辑器。

这本书是为谁准备的

本书面向希望为 Web 构建可扩展和可维护用户界面的前端开发人员。了解 JavaScript、HTML 和 CSS 的一些核心知识是您开始从 React.js 带入 Web 开发世界的革命性思想中受益的唯一所需。如果您之前有 jQuery 或 Angular.js 的经验,那么您将受益于了解 React.js 的不同之处以及如何利用集成不同库与它。

约定

在本书中,您将找到一些区分不同信息类型的文本样式。以下是一些示例以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:文本中的代码单词显示如下:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

import React from 'react';
import { render } from 'react-dom';

const reactElement = React.createElement(
  'h1', 
  { className: 'header' }
);

render(
  reactElement,
  document.getElementById('react-application')
);

当我们希望引起您对代码块的特定部分的注意时,相关行或项将以粗体显示:

<!doctype html>
  <html lang="en">
    <head>
      <title>Snapterest</title>
    </head>
    <body>
 **<div id="react-application">**
 **I am about to learn the essentials of React.js.**
 **</div>** <script src="./snapterest.js"></script>
    </body>
  </html>

任何命令行输入或输出都以以下方式编写:

**cd ~**
**git clone https://github.com/snapkite/snapkite-engine.git**

新术语重要词汇以粗体显示。屏幕上看到的词语,比如菜单或对话框中的词语,会以这样的方式出现在文本中:"点击下一步按钮会将您移动到下一个屏幕。"

注意

警告或重要提示会出现在这样的框中。

提示

提示和技巧会出现在这样。

第一章 React 16 的新特性

React 16 的发布包含了足够重要的变化,值得专门撰写一章来讨论。这个特定的发布花了相对较长的时间来完成。这是因为协调内部——React 中负责高效渲染组件变化的部分——是从头开始重写的。兼容性是另一个因素:这次重写没有主要的破坏性 API 变化。

在本章中,您将了解 React 16 中引入的重大变化:

  • 对协调内部所做的重大变化,以及对 React 项目的意义,未来的发展

  • 通过设置错误边界将错误限制在应用程序的各个部分

  • 创建渲染多个元素和渲染字符串的组件

  • 渲染到门户

重新思考渲染

您不需要深入了解 React 协调内部的工作原理。这样会违背 React 的初衷,以及它如何为我们封装所有这些工作。然而,了解 React 16 中发生的重大内部变化的动机以及它们在更高层次上的工作方式,将有助于您思考如何最好地设计您的组件,无论是今天还是未来的 React 应用。

现状

React 已经确立自己作为选择帮助构建用户界面的库的标准之一。这其中的两个关键因素是它的简单性和性能。React 之所以简单,是因为它有一个小的 API 表面,易于上手和实验。React 之所以高性能,是因为它通过协调渲染树中的变化,最小化了需要调用的 DOM 操作数量。

这两个因素之间存在相互作用,这导致了 React 的飞速流行。如果 API 难以使用,React 提供的良好性能就不会有价值。React 的最大价值在于它简单易用,并且开箱即用性能良好。

随着 React 的广泛采用,人们意识到它的内部协调机制可以得到改进。例如,一些 React 应用程序更新组件状态的速度比渲染完成的速度更快。再举一个例子:对于渲染树的一部分的更改,如果在屏幕上看不到,那么它们的优先级应该比用户可以看到的元素低。这些问题足以降低用户体验,使其感觉不如可能的那样流畅。

如何在不破坏 API 和渲染树协调的情况下解决这些问题呢?

运行到完成

JavaScript 是单线程的,并且运行到完成。这意味着默认情况下,你运行的任何 JavaScript 代码都会阻止浏览器运行其他任务,比如绘制屏幕。这就是为什么 JavaScript 代码特别重要的原因。然而,在某些情况下,即使 React 协调代码的性能也无法掩盖用户的瓶颈。当面对一个新的树时,React 别无选择,只能阻止 DOM 更新和事件监听器,同时计算新的渲染树。

一个可能的解决方案是将协调工作分成更小的块,并安排它们以防止 JavaScript 运行到完成线程阻塞重要的 DOM 更新。这意味着协调器不必渲染完整的树,然后再次进行渲染,因为在第一次渲染时发生了事件。

让我们来看一个这个问题的视觉示例:

运行到完成

这个图表表明,当 React 组件中的状态发生变化时,直到渲染完成之前都不会发生其他任何事情。正如你所看到的,随着状态变化的不断堆积,协调整个树的成本会变得很高,与此同时,DOM 被阻止做任何事情。

协调渲染树与 JavaScript 的运行到完成语义是一致的。换句话说,React 不能暂停正在进行的工作来让 DOM 更新。现在让我们看看 React 16 如何试图改变前面的图表:

运行到完成

这个版本的 React 渲染/协调过程看起来与之前的版本相似。实际上,左侧组件的任何内容都没有改变——这反映了 React 16 中不变的 API。不过,有一些微妙但重要的区别。

让我们先看看协调器。它不是在每次组件状态改变时构建一个新的渲染树,而是渲染一个部分树。换句话说,它执行一部分工作,导致部分渲染树的创建。它不完成整个树的原因是为了让协调过程暂停,让任何 DOM 更新运行——你可以在图像的右侧看到 DOM 的差异。

当协调器恢复构建渲染树时,它首先检查是否自暂停以来发生了新的状态变化。如果是这样,它会获取部分完成的渲染树,并根据新的状态变化重复使用它可以的部分。然后,它继续进行,直到下一次暂停。最终,协调完成。在协调过程中,DOM 有机会响应事件并渲染任何未完成的更改。在 React 16 之前,这是不可能的——在整个树被渲染之前,DOM 中的任何事情都不会发生。

什么是 fiber?

为了将组件的渲染工作分解为更小的工作单元,React 创建了一个名为fiber的抽象。Fiber 代表可以暂停和恢复的渲染工作单元。它还具有其他低级属性,如优先级以及完成后应该返回到的 fiber 的输出位置。

React 16 在开发过程中的代号是 React Fiber,因为这个基本抽象使得调度整体渲染工作的片段,以提供更好的用户体验。React 16 标志着这种新的协调架构的初始发布,但它还没有完成。例如,一切仍然是同步的。

异步和未来

React 16 为下一个主要版本的异步渲染奠定了基础。这个功能没有包含在 React 16 中的主要原因是因为团队希望将基本的协调变化发布到公众中。还有一些其他需要发布的新功能,我们将在接下来的部分中介绍。

一旦异步渲染功能引入到 React 中,您不应该修改任何代码。相反,您可能会注意到应用程序中某些区域的性能得到改善,这些区域将受益于优先和计划的渲染。

更好的组件错误处理

React 16 为组件引入了更好的错误处理能力。这个概念被称为错误边界,它被实现为一个生命周期方法,当任何子组件抛出异常时被调用。实现componentDidCatch()的父类就是错误边界。根据您的功能组织方式,您可以在应用程序中有不同的边界。

这种功能的动机是为应用程序提供从某些错误中恢复的机会。在 React 16 之前,如果组件抛出错误,整个应用程序将停止。这可能并不理想,特别是如果一个次要组件的问题导致关键组件停止工作。

让我们创建一个带有错误边界的App组件:

class App extends Component {
  state = {}

  componentDidCatch(err) {
    this.setState({ err: err.message });
  }

  render() {
    return (<p><MyError err={this.state.err}/></p>);
  }
}

App组件除了渲染MyError之外什么也不做——一个故意抛出错误的组件。当这种情况发生时,componentDidCatch()方法将被调用,并将错误作为参数传递。然后,您可以使用这个值来改变组件的状态。在这个例子中,它将错误消息设置为err状态。然后,App将尝试重新渲染。

正如您所看到的,this.state.err被传递给MyError作为属性。在第一次渲染期间,这个值是未定义的。当App捕获到MyError抛出的错误时,错误将被传递回组件。现在让我们看看MyError

const MyError = (props) => {
  if (props.err) {
    return <b style={{color: 'red'}}>{props.err}</b>;
  }

  throw new Error('epic fail');
};

这个组件抛出一个带有消息'epic fail'的错误。当App捕获到这个错误时,它会使用一个err属性来渲染MyError。当这种情况发生时,它只是以红色呈现错误字符串。这恰好是我为这个应用程序选择的策略;在再次调用错误行为之前,始终检查错误状态。在MyError中,通过不执行throw new Error('epic fail')来第二次恢复整个应用程序。

使用componentDidCatch(),您可以自由地设置任何您喜欢的错误恢复策略。通常,您无法恢复失败的特定组件。

渲染多个元素和字符串

自 React 首次发布以来,规则是组件只能渲染一个元素。在 React 16 中有两个重要的变化。首先,您现在可以从组件返回一组元素。这简化了渲染兄弟元素会极大简化事情的情况。其次,您现在可以渲染纯文本内容。

这两个变化都导致页面上的元素减少。通过允许组件渲染兄弟元素,您不必为了返回单个元素而将它们包装起来。通过渲染字符串,您可以将测试内容作为子元素或另一个组件进行渲染,而无需将其包装在元素中。

以下是渲染多个元素的样子:

const Multi = () => [
  'first sibling',
  'second sibling'
].map((v, i) => <p key={i}>{v}</p>);

请注意,您必须为集合中的元素提供一个key属性。现在让我们添加一个返回字符串值的元素:

const Label = () => 'Name:';

const MultiWithString = () => [
  'first sibling',
  'second sibling'
].map((v, i) => <p key={i}><Label/> {v}</p>);

Label组件只是将一个字符串作为其渲染内容返回。p元素将Label作为子元素呈现,与{v}值相邻。当组件可以返回字符串时,您有更多选项来组合构成 UI 的元素。

呈现到门户

我想介绍的 React 16 的最终新功能是门户的概念。通常,组件的呈现输出放置在树中 JSX 元素所在的位置。然而,有时我们需要更大的控制权来决定组件的呈现输出最终放在哪里。例如,如果您想要在根 React 元素之外呈现组件怎么办?

门户允许组件在渲染时指定其容器元素。想象一下,您想在应用程序中显示通知。屏幕上不同位置的几个组件需要能够在屏幕上的一个特定位置呈现通知。让我们看看如何使用门户来定位元素:

import React, { Component } from 'react';
import { createPortal } from 'react-dom';
class MyPortal extends Component {
  constructor(...args) {
    super(...args);
    this.el = document.createElement('strong');
  }

  componentWillMount() {
    document.body.appendChild(this.el);
  }

  componentWillUnmount() {
    document.body.removeChild(this.el);
  }

  render() {
    return createPortal(
      this.props.children,
      this.el
    );
  }
};

在这个组件的构造函数中,目标元素被创建并存储在el属性中。然后,在componentWillMount()中,该元素被附加到文档主体。实际上,您不需要在组件中创建目标元素——您可以使用现有元素。componentWillUnmount()方法会删除此元素。

render()方法中,使用createPortal()函数创建门户。它接受两个参数——要呈现的内容和目标 DOM 元素。在这种情况下,它传递了其子属性。让我们看看MyPortal是如何使用的:

class App extends Component {
  render() {
    return (
      <div>
        <p>Main content</p>
        <MyPortal>Bro, you just notified me!</MyPortal>
      </div>
    );
  }
}

最终的结果是传递给MyPortal的文本作为一个强元素呈现在根 React 元素之外。在使用门户之前,您必须采取某种命令式的解决方法才能使这样的事情起作用。现在,我们可以在需要的上下文中呈现通知——它只是碰巧被插入到 DOM 的其他位置以正确显示。

总结

本章的目标是向您介绍 React 16 的重大变化。值得注意的是,与之前的 React 版本几乎没有兼容性问题。这是因为大部分变化是内部的,不需要更改 API。还添加了一些新功能。

React 16 的头条是它的新协调内部。现在,协调工作被分解成更小的单元,而不是在组件改变状态时尝试协调所有内容。这些单元可以被优先处理、调度、暂停和恢复。在不久的将来,React 将充分利用这种新架构,并开始异步地渲染工作单元。

您还学会了如何在 React 组件中使用新的错误边界功能。使用错误边界可以让您从组件错误中恢复,而不会使整个应用程序崩溃。然后,您了解到 React 组件现在可以返回组件集合。就像渲染一组组件一样。现在您可以直接从组件中执行此操作。最后,您学会了如何使用门户将组件渲染到非标准位置。

在下一章中,您将学习如何构建响应式组件。

第二章:为您的项目安装强大的工具

这里有一句查尔斯·F·凯特林的名言:

“我对未来感兴趣,因为我将在那里度过余生。”

这位杰出的发明家在我们甚至开始思考如何编写软件之前就给软件工程师留下了最重要的建议。然而,半个世纪后,我们仍在弄清楚为什么最终会得到意大利面代码或“意大利面心智模型”。

你是否曾经处于这样一种情况:你继承了前任开发者的代码,并花费了数周的时间试图理解一切是如何工作的,因为没有提供蓝图,而伪自解释的代码变得太难以调试?更糟糕的是,项目不断增长,复杂性也在增加。做出改变或破坏性的改变是危险的,没有人愿意去碰那些“丑陋”的遗留代码。重写整个代码库成本太高,因此目前的代码通过引入新的错误修复和补丁来支持。维护软件的成本远高于最初开发的成本。

写软件是为了未来而今天就开始。我认为关键在于创建一个简单的心智模型,无论项目在未来变得多么庞大,它都不会改变。当项目规模增长时,复杂性始终保持不变。这个心智模型就是你的蓝图,一旦你理解了它,你就会明白你的软件是如何工作的。

如果你看一下现代的 Web 开发,特别是前端开发,你会注意到我们生活在激动人心的时代。互联网公司和个人开发者正在解决速度和开发成本与代码和用户体验质量之间的问题。

2013 年,Facebook 发布了 React——一个用于构建用户界面的开源 JavaScript 库。您可以在facebook.github.io/react/上阅读更多信息。2015 年初,来自 Facebook 的 Tom Occhino 总结了 React 的强大之处:

“React 用声明式 API 包装了一个命令式 API。React 的真正力量在于它让你编写代码。”

声明式编程会导致代码量减少。它告诉计算机要做什么,而不指定如何做,而命令式编程风格描述了如何做。JavaScript 调用 DOM API 就是命令式编程的一个例子。jQuery 就是另一个例子。

Facebook 多年来一直在生产中使用 React,还有 Instagram 和其他公司。它也适用于小型项目;这里有一个使用 React 构建的购物清单的示例:fedosejev.github.io/shopping-list-react。我认为 React 是今天开发人员可以使用的构建用户界面的最好的 JavaScript 库之一。

我的目标是让你理解 React 的基本原则。为了实现这一目标,我将逐步向您介绍 React 的一个概念,解释它,并展示您如何应用它。我们将逐步构建一个实时 Web 应用程序,沿途提出重要问题,并讨论 React 为我们提供的解决方案。

您将了解 Flux/Redux 和数据的单向流动。与 Flux/Redux 和 React 一起,我们将创建一个可预测和可管理的代码库,您将能够通过添加新功能来扩展它,而不会增加其复杂性。您的 Web 应用程序的心智模型将保持不变,无论以后添加了多少新功能。

与任何新技术一样,有些东西的工作方式与您习惯的方式非常不同。React 也不例外。事实上,React 的一些核心概念可能看起来违反直觉,引发思考,甚至看起来像是一种倒退。不要草率下结论。正如您所期望的那样,Facebook 的经验丰富的工程师们在构建和使用 React 的过程中进行了大量思考,这些应用程序在业务关键应用中进行了生产。我给你的建议是,在学习 React 的过程中保持开放的心态,我相信在本书结束时,这些新概念将会让你感到很有意义。

加入我一起学习 React,并遵循查尔斯·F·凯特林的建议。让我们照顾好我们的未来!

接近我们的项目

我坚信学习新技术的最好动力是一个激发你兴趣、让你迫不及待地想要构建的项目。作为一名经验丰富的开发者,你可能已经构建了许多成功的商业项目,这些项目共享某些产品特性、设计模式,甚至目标受众。在这本书中,我希望你能建立一个感觉焕然一新的项目。一个你在日常工作中很可能不会构建的项目。它必须是一个有趣的尝试,不仅能教育你,还能满足你的好奇心并拓展你的想象力。然而,假设你是一个忙碌的专业人士,这个项目也不应该成为你长时间的、耗时的承诺。

输入Snapterest—一个允许你发现和收集 Twitter 上发布的公共照片的网络应用。把它想象成一个 Pinterest(www.pinterest.com),唯一的图片来源就是 Twitter。我们将实现一个具有以下核心功能的完全功能的网站:

  • 实时接收和显示推文

  • 向/从收藏中添加和删除推文

  • 审查收集的推文

  • 将推文收藏导出为可以分享的 HTML 片段

当你开始着手一个新项目时,你要做的第一件事就是准备好你的工具。对于这个项目,我们将使用一些你可能不熟悉的工具,所以让我们讨论一下它们是什么,以及你如何安装和配置它们。

如果你在安装和配置本章中的工具和模块时遇到任何问题,请访问github.com/PacktPublishing/React-Essentials-Second-Edition并创建一个新的问题;描述你正在做什么以及你遇到了什么错误消息。我相信我们的社区会帮助你解决问题。

在这本书中,我假设你正在使用 Macintosh 或 Windows 计算机。如果你是 Unix 用户,那么你很可能非常了解你的软件包管理器,并且应该很容易为你安装本章中将要学习的工具。

让我们从安装 Node.js 开始。

安装 Node.js 和 npm

Node.js是一个平台,允许我们使用我们都熟悉的客户端语言 JavaScript 编写服务器端应用程序。然而,Node.js 的真正好处在于它使用事件驱动的、非阻塞的 I/O 模型,非常适合构建数据密集型、实时应用程序。这意味着使用 Node.js,我们应该能够处理传入的推文流,并在其到达时立即处理它们;这正是我们项目所需要的。

让我们安装 Node.js。我们将使用 8.7.0 版本,因为在撰写本书时,这是 Node.js 的最新版本。Jest 是 Facebook 的一个测试框架,您将在第九章中了解到,使用 Jest 测试您的 React 应用程序

从以下链接之一下载适用于您操作系统的安装包:

运行下载的安装包,并按照 Node.js 提示的安装步骤进行操作。完成后,检查是否成功安装了 Node.js。打开终端/命令提示符,并键入以下命令:

**node -v**

以下是输出结果(如果您的版本不完全匹配,不要担心):

**V8.7.0**

Node.js 拥有一个非常丰富的模块生态系统,可供我们使用。模块是一个可以在您自己的 Node.js 应用程序中重复使用的 Node.js 应用程序。在撰写本文时,已有超过 50 万个模块。您如何管理这么广泛的 Node.js 模块?认识一下npm,这是一个管理 Node.js 模块的包管理器。事实上,npm 与 Node.js 一起发布,因此您已经安装了它。在终端/命令提示符中键入以下内容:

**npm -v**

您应该看到以下输出(如果您的版本不完全匹配,不要担心):

**5.5.1**

您可以在www.npmjs.com了解更多关于 npm 的信息。现在我们准备开始安装 Node.js 应用程序。

安装 Git

在本书中,我们将使用 Git 来安装 Node.js 模块。如果您还没有安装 Git,请访问git-scm.com/book/en/v2/Getting-Started-Installing-Git并按照您的操作系统的安装说明进行安装。

从 Twitter Streaming API 获取数据

我们的 React 应用程序的数据将来自 Twitter。Twitter 有一个Streaming API,任何人都可以接入并开始以 JSON 格式接收无尽的公共推文流。

要开始使用 Twitter Streaming API,您需要执行以下步骤:

  1. 创建一个 Twitter 账户。为此,转到twitter.com并注册;或者如果您已经有账户,请登录。

  2. 通过转到apps.twitter.com创建一个新的 Twitter 应用程序,并点击创建新应用程序。您需要填写应用程序详细信息表格,同意开发者协议,然后点击创建您的 Twitter 应用程序。现在您应该看到您的应用程序页面。切换到Keys and Access Tokens选项卡。

在本页的应用程序设置部分,您会找到两个重要的信息:

  • Consumer Key (API Key),例如,jqRDrAlKQCbCbu2o4iclpnvem

  • Consumer Secret (API Secret),例如,wJcdogJih7uLpjzcs2JtAvdSyCVlqHIRUWI70aHOAf7E3wWIgD

记下这些;我们以后会用到它们。

现在我们需要生成一个访问令牌。在同一页上,您会看到空的您的访问令牌部分。点击创建我的访问令牌按钮。它会创建两个信息:

  • Access Token,例如,12736172-R017ah2pE2OCtmi46IAE2n0z3u2DV6IqsEcPa0THR

  • Access Token Secret,例如,4RTJJWIezIDcs5VX1PMVZolXGZG7L3Ez7Iz1gMdZucDaM

也记下这些。访问令牌是唯一的,您不应该与任何人分享。保持私密。

现在我们已经拥有了开始使用 Twitter 的 Streaming API 所需的一切。

使用 Snapkite Engine 过滤数据

通过 Twitter Streaming API 接收的推文数量超过您所能消费的数量,因此我们需要找到一种方法将数据流过滤为一组有意义的推文,以便我们可以显示和交互。我建议您快速查看 Twitter Streaming API 文档,特别是查看描述如何过滤传入流的页面。您会注意到 Twitter 提供的过滤器非常少,因此我们需要找到一种方法进一步过滤数据流。

幸运的是,有一个专门用于此目的的 Node.js 应用程序。它被称为Snapkite Engine。它连接到 Twitter Streaming API,使用可用的过滤器进行过滤,并根据您定义的规则输出经过过滤的推文到 Web 套接字连接。我们提出的 React 应用程序可以监听该套接字连接上的事件,并在推文到达时处理推文。

让我们安装 Snapkite Engine。首先,您需要克隆 Snapkite Engine 存储库。克隆意味着您正在将源代码从 GitHub 服务器复制到本地目录。在本书中,我将假设您的本地目录是您的主目录。打开终端/命令提示符并输入以下命令:

**cd ~**
**git clone https://github.com/snapkite/snapkite-engine.git**

这应该创建~/snapkite-engine/文件夹。现在我们将安装snapkite-engine依赖的所有其他节点模块。其中之一是node-gyp模块。根据您使用的平台,Unix 或 Windows,您将需要安装列在此网页上的其他工具:

安装完毕后,您可以安装node-gyp模块:

**npm install -g node-gyp**

现在导航到~/snapkite-engine目录:

**cd snapkite-engine/**

然后运行以下命令:

**npm install**

这个命令将安装 Snapkite Engine 依赖的 Node.js 模块。现在让我们配置 Snapkite Engine。假设你在~/snapkite-engine/目录中,通过运行以下命令将./example.config.json文件复制到./config.json

**cp example.config.json config.json**

或者,如果您使用 Windows,请运行此命令:

**copy example.config.json config.json**

在您喜欢的文本编辑器中打开config.json。我们现在将编辑配置属性。让我们从trackKeywords开始。这是我们将告诉要跟踪哪些关键字的地方。如果我们想跟踪"my"关键字,那么设置如下:

"trackKeywords": "my"

接下来,我们需要设置 Twitter Streaming API 密钥。将consumerKeyconsumerSecretaccessTokenKeyaccessTokenSecret设置为创建 Twitter 应用程序时保存的密钥。其他属性可以设置为它们的默认值。如果你想了解它们是什么,请查看 Snapkite Engine 文档github.com/snapkite/snapkite-engine

我们的下一步是安装 Snapkite 过滤器。Snapkite Filter是一个根据一组规则验证推文的 Node.js 模块。有许多 Snapkite 过滤器可供使用,我们可以根据需要使用任意组合来过滤我们的推文流。您可以在github.com/snapkite/snapkite-filters找到所有可用的 Snapkite 过滤器的列表。

在我们的应用程序中,我们将使用以下 Snapkite 过滤器:

让我们安装它们。导航到~/snapkite-engine/filters/目录:

**cd ~/snapkite-engine/filters/**

然后通过运行以下命令克隆所有 Snapkite 过滤器:

**git clone https://github.com/snapkite/snapkite-filter-is-possibly-sensitive.git**
**git clone https://github.com/snapkite/snapkite-filter-has-mobile-photo.git**
**git clone https://github.com/snapkite/snapkite-filter-is-retweet.git**
**git clone https://github.com/snapkite/snapkite-filter-has-text.git**

下一步是配置它们。为了这样做,您需要为每个 Snapkite 过滤器创建一个JSON格式的配置文件,并在其中定义一些属性。幸运的是,每个 Snapkite 过滤器都附带了一个示例配置文件,我们可以根据需要复制和编辑。假设您在~/snapkite-engine/filters/目录中,运行以下命令(在 Windows 上使用copy并将正斜杠替换为反斜杠):

**cp snapkite-filter-is-possibly-sensitive/example.config.json snapkite-filter-is-possibly-sensitive/config.json**
**cp snapkite-filter-has-mobile-photo/example.config.json snapkite-filter-has-mobile-photo/config.json**
**cp snapkite-filter-is-retweet/example.config.json snapkite-filter-is-retweet/config.json**
**cp snapkite-filter-has-text/example.config.json snapkite-filter-has-text/config.json**

我们不需要更改这些config.json文件中的任何默认设置,因为它们已经配置好以适应我们的目的。

最后,我们需要告诉 Snapkite Engine 应该使用哪些 Snapkite Filters。在文本编辑器中打开~/snapkite-engine/config.json文件,查找这个:

"filters": []

现在用以下内容替换它:

"filters": [
  "snapkite-filter-is-possibly-sensitive",
  "snapkite-filter-has-mobile-photo",
  "snapkite-filter-is-retweet",
  "snapkite-filter-has-text"
]

干得好!你已经成功安装了带有多个 Snapkite Filters 的 Snapkite Engine。现在让我们检查一下是否可以运行它。导航到~/snapkite-engine/并运行以下命令:

**npm start**

你应该看不到错误消息,但如果你看到了并且不确定如何解决,那么去github.com/fedosejev/react-essentials/issues,创建一个新的问题,并复制粘贴你得到的错误消息。

接下来,让我们设置项目的结构。

创建项目结构

现在是时候创建我们的项目结构了。组织源文件可能听起来像一个简单的任务,但深思熟虑的项目结构组织帮助我们理解我们应用的基础架构。在本书的后面,当我们谈论 Flux 应用程序架构时,你将看到这方面的一个例子。让我们从在你的主目录~/snapterest/内创建我们的根项目目录snapterest开始。

然后,在其中,我们将创建另外两个目录:

  • ~/snapterest/source/:在这里,我们将存储我们的源 JavaScript 文件

  • ~/snapterest/build/:在这里,我们将放置编译后的 JavaScript 文件和一个 HTML 文件

现在,在~/snapterest/source/中,创建components/文件夹,使得你的项目结构看起来像这样:

  • ~/snapterest/source/components/

  • ~/snapterest/build/

现在我们的基本项目结构准备好了,让我们开始用我们的应用文件填充它。首先,我们需要在~/snapterest/source/目录中创建我们的主应用文件app.js。这个文件将是我们应用的入口点,~/snapterest/source/app.js

现在先留空,因为我们有一个更紧迫的问题要讨论。

创建 package.json

你以前听说过D.R.Y.吗?它代表不要重复自己,并且它提倡软件开发中的核心原则之一——代码重用。最好的代码是你不需要写的代码。事实上,我们在这个项目中的一个目标就是尽可能少地编写代码。你可能还没有意识到,但 React 帮助我们实现了这个目标。它不仅节省了我们的时间,而且如果我们决定在将来维护和改进我们的项目,它将在长远来看节省我们更多的时间。

当涉及到不编写我们的代码时,我们可以应用以下策略:

  • 以声明式编程风格编写我们的代码

  • 重用他人编写的代码

在这个项目中,我们将使用两种技术。第一种技术由 React 本身提供。React 只能让我们以声明式风格编写 JavaScript 代码。这意味着我们不是告诉网页浏览器如何做我们想要的事情(就像我们用 jQuery 做的那样),而是告诉它我们想要它做什么,而 React 解释了如何做。这对我们来说是一个胜利。

Node.js 和 npm 涵盖了第二种技术。我在本章前面提到,有数十万不同的 Node.js 应用程序可供我们使用。这意味着很可能有人已经实现了我们的应用程序所依赖的功能。

问题是,我们从哪里获取所有这些我们想要重用的 Node.js 应用程序?我们可以通过npm install <package-name>命令安装它们。在 npm 上下文中,一个 Node.js 应用程序被称为,每个npm 包都有一个描述该包相关元数据的package.json文件。您可以在docs.npmjs.com/files/package.json了解有关存储在package.json中的字段的更多信息。

在安装依赖包之前,我们将为我们自己的项目初始化一个包。通常,只有当您想要将您的包提交到 npm 注册表以便其他人可以重用您的 Node.js 应用程序时,才需要package.json。我们不打算构建 Node.js 应用程序,也不打算将我们的项目提交到 npm。请记住,package.json从技术上讲只是npm命令理解的元数据文件,因此我们可以使用它来存储我们的应用程序所需的依赖项列表。一旦我们在package.json中存储了依赖项列表,我们就可以随时使用npm install命令轻松安装它们;npm 将自动找到它们的位置。

我们如何为我们自己的应用程序创建package.json文件?幸运的是,npm 带有一个交互式工具,询问我们一系列问题,然后根据我们的答案为我们的项目创建package.json

确保您位于~/snapterest/目录中。在终端/命令提示符中,运行以下命令:

**npm init**

它将首先询问您的软件包名称。 它将建议一个默认名称,即您所在目录的名称。 在我们的情况下,它应该建议name:(snapterest)。 按Enter接受建议的默认名称(snapterest)。 下一个问题是您软件包的版本,即version:(1.0.0)。 按Enter。 如果我们计划将软件包提交给 npm 供其他人重用,这两个将是最重要的字段。 因为我们不打算将其提交给 npm,所以我们可以自信地接受我们被问到的所有问题的默认值。 继续按Enter,直到npm init完成执行并退出。 然后,如果您转到〜/snapterest/目录,您将在那里找到一个新文件-package.json

现在我们准备安装其他我们将要重用的 Node.js 应用程序。 由多个单独应用程序构建的应用程序称为模块化,而单独的应用程序称为模块。 从现在开始,这就是我们将称之为我们的 Node.js 依赖项-Node.js 模块。

重用 Node.js 模块

正如我之前提到的,我们的开发过程中将有一个称为构建的步骤。 在此步骤中,我们的构建脚本将获取我们的源文件和所有 Node.js 依赖包,并将它们转换为 Web 浏览器可以成功执行的单个文件。 这个构建过程中最重要的部分称为打包。 但是我们需要打包什么以及为什么呢? 让我们考虑一下。 我之前简要提到过,我们并不是在创建一个 Node.js 应用程序,但我们正在谈论重用 Node.js 模块。 这是否意味着我们将在非 Node.js 应用程序中重用 Node.js 模块? 这可能吗? 原来有一种方法可以做到这一点。

Webpack是一种工具,用于以这样一种方式捆绑所有依赖文件,以便您可以在客户端 JavaScript 应用程序中重用 Node.js 模块。 您可以在webpack.js.org了解有关 Webpack 的更多信息。 要安装 Webpack,请从〜/snapterest/目录内运行以下命令:

**npm install --save-dev webpack**

注意--save-dev标志。它告诉 npm 将 Webpack 添加到我们的package.json文件中作为开发依赖项。将模块名称添加到我们的package.json文件中作为依赖项允许我们记录我们正在使用的依赖项,并且如果需要的话,我们可以很容易地使用npm install命令稍后安装它们。运行应用程序所需的依赖项与开发应用程序所需的依赖项之间有区别。Webpack 在构建时使用,而不是在运行时,因此它是开发依赖项。因此,使用--save-dev标志。如果您现在检查您的package.json文件的内容,您会看到这个(如果您的 Webpack 版本不完全匹配,不要担心):

"devDependencies": {
  "webpack": "².2.1"
}

npm 在您的〜/snapterest/目录中创建了一个名为node_modules的新文件夹。这是它放置所有本地依赖模块的地方。

恭喜您安装了您的第一个 Node.js 模块!Webpack 将允许我们在客户端 JavaScript 应用程序中使用 Node.js 模块。它将成为我们构建过程的一部分。现在让我们更仔细地看看我们的构建过程。

使用 Webpack 构建

今天,任何现代的客户端应用程序都代表了许多由各种技术单独解决的问题的混合。单独解决每个问题简化了管理项目复杂性的整个过程。这种方法的缺点是,在项目的某个时候,您需要将所有单独的部分组合成一个连贯的应用程序。就像汽车工厂中的机器人从单独的零件组装汽车一样,开发人员有一种称为构建工具的东西,可以从单独的模块中组装他们的项目。这个过程被称为构建过程,根据项目的大小和复杂性,构建过程可能需要从毫秒到几个小时不等的时间。

Webpack 将帮助我们自动化我们的构建过程。首先,我们需要配置 Webpack。假设您在〜/snapterest/目录中,创建一个新的webpack.config.js文件。

现在让我们在webpack.config.js文件中描述我们的构建过程。在这个文件中,我们将创建一个描述如何捆绑我们的源文件的 JavaScript 对象。我们希望将该配置对象导出为一个 Node.js 模块。是的,我们将把我们的webpack.config.js文件视为一个 Node.js 模块。为了做到这一点,我们将把我们的空配置对象分配给一个特殊的module.exports属性:

const path = require('path'); 
module.exports = {};

module.exports属性是 Node.js API 的一部分。这是告诉 Node.js,每当有人导入我们的模块时,他们将获得对该对象的访问权限。那么这个对象应该是什么样子的呢?这就是我建议你去查看 Webpack 文档并阅读关于 Webpack 核心概念的链接:webpack.js.org/concepts/

我们配置对象的第一个属性将是entry属性:

module.exports = {
  entry: './source/app.js',
};

顾名思义,entry属性描述了我们 web 应用的入口点。在我们的例子中,这个属性的值是./source/app.js—这是启动我们应用的第一个文件。

我们配置对象的第二个属性将是output属性:

output: {
  path: path.resolve(__dirname, 'build'),
  filename: 'snapterest.js'
},

output属性告诉 Webpack 在哪里输出生成的捆绑文件。在我们的例子中,我们说我们希望生成的捆绑文件叫做snapterest.js,并且应该保存到./build目录中。

Webpack 将每个源文件视为一个模块,这意味着所有我们的 JavaScript 源文件将被视为 Webpack 需要捆绑在一起的模块。我们如何向 Webpack 解释这一点呢?

我们通过配置对象的第三个属性module来实现这一点:

module: {
  rules: [
    {
      test: /\.js$/,
      use: [
        {
          loader: 'babel-loader',
          options: {
            presets: ['react', 'latest'],
            plugins: ['transform-class-properties']
          }
        }
      ],
      exclude: path.resolve(__dirname, 'node_modules')
    }
  ]
}

正如你所看到的,我们的module属性得到一个对象作为它的值。这个对象有一个叫做rules的属性—一个规则数组,其中每个规则描述了如何从不同的源文件创建 Webpack 模块。让我们更仔细地看看我们的规则。

我们有一个单一规则告诉 Webpack 如何处理我们的源 JavaScript 文件:

{
  test: /\.js$/,
  use: [
    {
      loader: 'babel-loader',
      options: {
        presets: ['react', 'latest'],
        plugins: ['transform-class-properties']
      }
    }
  ],
  exclude: path.resolve(__dirname, 'node_modules')
}

这个规则有三个属性:testuseexcludetest属性告诉 Webpack 这个规则适用于哪些文件。它通过将我们的源文件名与我们指定为test属性值的正则表达式进行匹配来实现:/\.js$/。如果你熟悉正则表达式,你会认识到/\.js$/将匹配所有以.js结尾的文件名。这正是我们想要的:打包所有的 JavaScript 文件。

当 Webpack 找到并加载所有源 JavaScript 文件时,它会尝试将它们解释为普通的 JavaScript 文件。然而,我们的 JavaScript 文件不会是普通的 JavaScript 文件,而是具有 ECMAScript 2016 语法以及 React 特定语法。

Webpack 如何理解所有非普通的 JavaScript 语法?借助于 Webpack 加载器,我们可以将非普通的 JavaScript 语法转换为普通的 JavaScript。Webpack 加载器是应用于源文件的转换。我们的use属性描述了我们想要应用的转换列表:

use: [
  {
    loader: 'babel-loader',
    options: {
      presets: ['react', 'latest'],
      plugins: ['transform-class-properties']
    }
  }
],

我们有一个转换负责将我们的 React 特定语法和 ECMAScript 2016 语法转换为普通 JavaScript:

{
  loader: 'babel-loader',
  options: {
    presets: ['react', 'latest'],
    plugins: ['transform-class-properties']
  }
}

Webpack 转换是用具有loaderoptions属性的对象来描述的。loader属性告诉 Webpack 哪个加载器执行转换,options属性告诉它应该传递给该加载器哪些选项。将我们的 ECMAScript 2016 和特定于 React 的语法转换为普通 JavaScript 的加载器称为babel-loader。这个特定的转换过程称为转译源到源编译——它将用一种语法编写的源代码转换为另一种语法编写的源代码。我们今天使用的是最流行的 JavaScript 转译器之一,叫做Babelbabeljs.io。Webpack 有一个使用 Babel 转译器来转换我们源代码的 Babel 加载器。Babel 加载器作为一个独立的 Node.js 模块。让我们安装这个模块并将其添加到我们的开发依赖列表中。假设你在~/snapterest/目录中,运行以下命令:

**npm install babel-core babel-loader --save-dev**

我们的 Webpack 加载器的options属性有一些 Babel 预设:latestreact以及一个 Babeltransform-class-properties插件:

options: {
  presets: ['react', 'latest'],
  plugins: ['transform-class-properties']
}

这些是负责转换不同语法的 Babel 插件:latest插件将 ECMAScript 2015、ECMAScript 2016 和 ECMAScript 2017 的语法转换为旧的 JavaScript 语法,react插件将 React 特定的语法转换为普通的 JavaScript 语法,而transform-class-properties插件将类属性转换为普通的 JavaScript 语法。

这些 Babel 插件是作为独立的 Node.js 模块分发的,我们需要单独安装它们。假设你在~/snapterest/目录中,运行以下命令:

**npm install babel-preset-latest babel-preset-react babel-plugin-transform-class-properties --save-dev**

最后,我们在 Webpack 规则中有第三个属性叫做exclude

exclude: path.resolve(__dirname, 'node_modules')

这个属性告诉 Webpack 在转换过程中排除node_modules目录。

现在我们的webpack.config.js文件已经准备好了。在我们第一次运行打包过程之前,让我们在package.json文件中添加一个名为start的新脚本:

"scripts": {
  "start": "webpack -p --config webpack.config.js",
  "test": "echo \"Error: no test specified\" && exit 1"
},

现在如果你运行npm run start或者npm start,npm 会运行webpack -p --config webpack.config.js命令。这个命令会运行 Webpack,用webpack.config.js文件打包我们的源文件以供生产使用。

我们已经准备好打包我们的源文件了!转到你的~/snapterest/目录并运行这个命令:

**npm start**

在输出中,你应该会看到以下内容:

**Version: webpack 2.2.1**
**Time: 1151ms**
 **Asset       Size  Chunks             Chunk Names**
**app.js  519 bytes       0  [emitted]  main**
 **[0] ./source/app.js 24 bytes {0} [built]**

更重要的是,如果你检查你的项目的~/snapterest/build/目录,你会注意到现在有一个snapterest.js文件,里面已经有一些代码了——那就是我们(空的)JavaScript 应用程序,里面有一些 Node.js 模块,可以在 web 浏览器中运行!

创建一个网页

如果你渴望一些 React 的好处,那么我有个好消息告诉你!我们快要完成了。剩下要做的就是创建一个带有指向我们snapterest.js脚本的index.html

~/snapterest/build/目录中创建index.html文件。添加以下 HTML 标记:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="x-ua-compatible" content="ie=edge, chrome=1" />
    <title>Snapterest</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
  </head>
  <body>
    <div id="react-application">
      I am about to learn the essentials of React.js. </div>
    <script src="./snapterest.js"></script>
  </body>
</html>

在 web 浏览器中打开~/snapterest/build/index.html。你应该会看到以下文字:我即将学习 React.js 的基本知识。没错,我们已经完成了项目的设置,现在是时候了解 React 了!

摘要

在本章中,你学到了为什么我们应该使用 React 来构建现代 web 应用程序的用户界面。然后,我们讨论了这本书中我们将要构建的项目。最后,我们安装了所有正确的工具,并创建了项目的结构。

在下一章中,我们将安装 React,更仔细地了解 React 的工作原理,并创建我们的第一个 React 元素。

第三章:创建你的第一个 React 元素

今天创建一个简单的网页应用程序涉及编写 HTML、CSS 和 JavaScript 代码。我们使用三种不同的技术的原因是我们想要分离三种不同的关注点:

  • 内容(HTML)

  • 样式(CSS)

  • 逻辑(JavaScript)

这种分离对于创建网页非常有效,因为传统上,我们有不同的人在网页的不同部分工作:一个人使用 HTML 结构化内容并使用 CSS 进行样式设置,然后另一个人使用 JavaScript 实现网页上各种元素的动态行为。这是一种以内容为中心的方法。

今天,我们大多数时候不再把网站看作是一组网页了。相反,我们构建的是可能只有一个网页的网页应用程序,而这个网页并不代表我们内容的布局,而是代表我们网页应用程序的容器。这样一个只有一个网页的网页应用程序称为(不出所料的)单页应用程序SPA)。你可能会想知道在 SPA 中如何表示其余的内容?当然,我们需要使用 HTML 标签创建额外的布局。否则,浏览器怎么知道要渲染什么呢?

这些都是合理的问题。让我们看看它是如何工作的。一旦你在浏览器中加载你的网页,它会创建该网页的文档对象模型DOM)。DOM 以树结构表示你的网页,此时它反映了你仅使用 HTML 标签创建的布局结构。无论你是在构建传统网页还是 SPA,这都是发生的事情。两者之间的区别在于接下来会发生什么。如果你正在构建传统网页,那么你会完成创建网页的布局。另一方面,如果你正在构建 SPA,那么你需要开始通过 JavaScript 操纵 DOM 来创建额外的元素。浏览器提供了JavaScript DOM API来做到这一点。你可以在developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model了解更多信息。

然而,用 JavaScript 操纵(或改变)DOM 有两个问题:

  • 如果你决定直接使用 JavaScript DOM API,你的编程风格将是命令式的。正如我们在上一章讨论的那样,这种编程风格会导致更难维护的代码库。

  • DOM 突变很慢,因为它们无法像其他 JavaScript 代码那样进行速度优化。

幸运的是,React 为我们解决了这两个问题。

理解虚拟 DOM

我们为什么需要首先操作 DOM 呢?因为我们的 Web 应用程序不是静态的。它们有一个由用户界面UI)表示的状态,Web 浏览器呈现,并且当事件发生时,该状态可以改变。我们在谈论什么样的事件?我们感兴趣的有两种类型的事件:

  • 用户事件:当用户输入、点击、滚动、调整大小等时

  • 服务器事件:当应用程序从服务器接收数据或错误时,等等

处理这些事件时会发生什么?通常情况下,我们会更新应用程序依赖的数据,并且这些数据代表我们数据模型的状态。反过来,当我们的数据模型状态发生变化时,我们可能希望通过更新 UI 状态来反映这种变化。看起来我们想要的是一种同步两种不同状态的方法:UI 状态和数据模型状态。我们希望其中一种对另一种的变化做出反应,反之亦然。我们如何才能实现这一点?

将应用程序的 UI 状态与基础数据模型状态同步的一种方法是双向数据绑定。有不同类型的双向数据绑定。其中之一是键值观察KVO),它在Ember.js、Knockout、Backbone 和 iOS 等中使用。另一个是脏检查,它在 Angular 中使用。

React 提供了一种名为虚拟 DOM的不同解决方案,而不是双向数据绑定。虚拟 DOM 是真实 DOM 的快速内存表示,它是一种抽象,允许我们将 JavaScript 和 DOM 视为响应式的。让我们看看它是如何工作的:

  1. 每当数据模型的状态发生变化时,虚拟 DOM 和 React 将重新渲染您的 UI 以获得虚拟 DOM 表示。

  2. 然后计算两个虚拟 DOM 表示之间的差异:在数据改变之前计算的先前虚拟 DOM 表示和在数据改变之后计算的当前虚拟 DOM 表示。这两个虚拟 DOM 表示之间的差异实际上是真实 DOM 中需要改变的部分。

  3. 只更新真实 DOM 中需要更新的部分。

在真实 DOM 中查找虚拟 DOM 的两个表示之间的差异,并且只重新渲染更新的补丁是很快的。而且,最好的部分是——作为 React 开发人员——您不需要担心实际需要重新渲染什么。React 允许您编写代码,就好像每次应用程序状态发生变化时都重新渲染整个 DOM 一样。

如果您想了解更多关于虚拟 DOM、其背后的原理以及如何与数据绑定进行比较,那么我强烈建议您观看 Facebook 的 Pete Hunt 在www.youtube.com/watch?v=-DX3vJiqxm4上的这个非常信息丰富的讲座。

现在您已经了解了虚拟 DOM,让我们通过安装 React 并创建我们的第一个 React 元素来改变真实 DOM。

安装 React

要开始使用 React 库,我们首先需要安装它。

在撰写本文时,React 库的最新版本是 16.0.0。随着时间的推移,React 会得到更新,因此请确保您使用的是最新版本,除非它引入了与本书提供的代码示例不兼容的破坏性更改。访问github.com/PacktPublishing/React-Essentials-Second-Edition了解代码示例与 React 最新版本之间的任何兼容性问题。

在第二章中,为您的项目安装强大的工具,我向您介绍了Webpack,它允许我们使用import函数导入应用程序的所有依赖模块。我们将使用import来导入 React 库,这意味着我们不再需要向index.html文件添加<script>标签,而是使用npm install命令来安装 React:

  1. 转到~/snapterest/目录并运行此命令:
**npm install --save react react-dom**

  1. 然后,打开您的文本编辑器中的~/snapterest/source/app.js文件,并将 React 和 ReactDOM 库分别导入到ReactReactDOM变量中:
import React from 'react';
import ReactDOM from 'react-dom';

react包含了与 React 背后的关键思想有关的方法,即以声明方式描述您想要渲染的内容。另一方面,react-dom包含了负责渲染到 DOM 的方法。您可以在facebook.github.io/react/blog/2015/07/03/react-v0.14-beta-1.html#two-packages上阅读更多关于为什么 Facebook 的开发人员认为将 React 库分成两个包是一个好主意的内容。

现在我们准备在我们的项目中开始使用 React 库。接下来,让我们创建我们的第一个 React 元素!

使用 JavaScript 创建 React 元素

我们将首先熟悉基本的 React 术语。这将帮助我们清晰地了解 React 库的组成。这些术语很可能会随着时间的推移而更新,因此请密切关注官方文档facebook.github.io/react/docs/react-api.html

就像 DOM 是节点树一样,React 的虚拟 DOM 是 React 节点树。React 中的核心类型之一称为ReactNode。它是虚拟 DOM 的构建块,可以是以下任何一种核心类型之一:

  • ReactElement:这是 React 中的主要类型。它是一个轻量级的、无状态的、不可变的、虚拟表示的DOMElement

  • ReactText:这是一个字符串或数字。它表示文本内容,是 DOM 中文本节点的虚拟表示。

ReactElementReactText都是ReactNodeReactNode的数组称为ReactFragment。您将在本章中看到所有这些的示例。

让我们从ReactElement的示例开始:

  1. 将以下代码添加到您的~/snapterest/source/app.js文件中:
const reactElement = React.createElement('h1');
ReactDOM.render(reactElement, document.getElementById('react-application'));
  1. 现在您的app.js文件应该完全像这样:
import React from 'react';
import ReactDOM from 'react-dom';

const reactElement = React.createElement('h1');
ReactDOM.render(
  reactElement,
  document.getElementById('react-application')
);
  1. 转到~/snapterest/目录并运行此命令:
**npm start**

您将看到以下输出:

**Hash: 826f512cf95a44d01d39**
**Version: webpack 3.8.1**
**Time: 1851ms**

  1. 转到~/snapterest/build/目录,并在 Web 浏览器中打开index.html。您将看到一个空白的网页。在 Web 浏览器中打开开发者工具,并检查空白网页的 HTML 标记。您应该在其他内容中看到这一行:
<h1 data-reactroot></h1>

干得好!我们刚刚渲染了您的第一个 React 元素。让我们看看我们是如何做到的。

React 库的入口点是React对象。该对象有一个名为createElement()的方法,它接受三个参数:typepropschildren

React.createElement(type, props, children);

让我们更详细地看看每个参数。

type 参数

type参数可以是字符串或ReactClass

  • 字符串可以是 HTML 标记名称,例如'div''p''h1'。React 支持所有常见的 HTML 标记和属性。有关 React 支持的所有 HTML 标记和属性的完整列表,您可以参考facebook.github.io/react/docs/dom-elements.html

  • 通过React.createClass()方法创建了一个ReactClass类。我将在第四章中更详细地介绍这个问题,创建您的第一个 React 组件

type参数描述了 HTML 标记或ReactClass类将如何呈现。在我们的例子中,我们正在呈现h1 HTML 标记。

props 参数

props参数是从父元素传递给子元素(而不是反过来)的 JavaScript 对象,具有一些被视为不可变的属性,即不应更改的属性。

在使用 React 创建 DOM 元素时,我们可以传递props对象,其中包含代表 HTML 属性的属性,例如classstyle。例如,运行以下代码:

import React from 'react';
import ReactDOM from 'react-dom';

const reactElement = React.createElement(
  'h1', { className: 'header' }
);
ReactDOM.render(
  reactElement,
  document.getElementById('react-application')
);

上述代码将创建一个class属性设置为headerh1 HTML 元素:

<h1 data-reactroot class="header"></h1>

请注意,我们将属性命名为className而不是class。这样做的原因是class关键字在 JavaScript 中是保留的。如果您将class用作属性名称,React 将忽略它,并在 Web 浏览器的控制台上打印有用的警告消息:

警告:未知的 DOM 属性类。您是指 className 吗?

请改用 className。

您可能想知道我们的h1标签中的data-reactroot属性是做什么的?我们没有将其传递给我们的props对象,那它是从哪里来的?它是由 React 添加并使用的,用于跟踪 DOM 节点。

children 参数

children参数描述了此 HTML 元素应具有哪些子元素(如果有)。子元素可以是任何类型的ReactNode:由ReactElement表示的虚拟 DOM 元素,由ReactText表示的字符串或数字,或者其他ReactNode节点的数组,也称为ReactFragment

让我们看看这个例子:

import React from 'react';
import ReactDOM from 'react-dom';

const reactElement = React.createElement(
  'h1',
  { className: 'header' },
  'This is React'
);
ReactDOM.render(
  reactElement,
  document.getElementById('react-application')
);

上述代码将创建一个带有class属性和文本节点This is Reacth1 HTML 元素:

<h1 data-reactroot class="header">This is React</h1>

h1标签由ReactElement表示,而This is React字符串由ReactText表示。

接下来,让我们创建一个 React 元素,它的子元素是一些其他的 React 元素:

import React from 'react';
import ReactDOM from 'react-dom';

const h1 = React.createElement(
  'h1',
  { className: 'header', key: 'header' },
  'This is React'
);
const p = React.createElement(
  'p', 
  { className: 'content', key: 'content' },
  'And that is how it works.' );
const reactFragment = [ h1, p ];
const section = React.createElement(
  'section',
  { className: 'container' },
  reactFragment
);

ReactDOM.render(
  section,
  document.getElementById('react-application')
);

我们创建了三个 React 元素:h1psectionh1p都有子文本节点,分别是'This is React''And that is how it works.'section标签有一个子元素,是两个ReactElement类型的数组,h1p,称为reactFragment。这也是一个ReactNode数组。reactFragment数组中的每个ReactElement类型都必须有一个key属性,帮助 React 识别该ReactElement类型。结果,我们得到以下 HTML 标记:

<section data-reactroot class="container">
  <h1 class="header">This is React</h1>
  <p class="content">And that is how it works.</p>
</section>

现在我们明白了如何创建 React 元素。如果我们想要创建多个相同类型的 React 元素呢?这意味着我们需要为每个相同类型的元素一遍又一遍地调用React.createElement('type')吗?我们可以,但我们不需要,因为 React 为我们提供了一个名为React.createFactory()的工厂函数。工厂函数是一个创建其他函数的函数。这正是React.createFactory(type)所做的:它创建一个产生给定类型的ReactElement的函数。

考虑以下例子:

import React from 'react';
import ReactDOM from 'react-dom';

const listItemElement1 = React.createElement(
  'li',
  { className: 'item-1', key: 'item-1' },
  'Item 1'
);
const listItemElement2 = React.createElement(
  'li',
  { className: 'item-2', key: 'item-2' },
  'Item 2'
);
const listItemElement3 = React.createElement(
  'li',
  { className:   'item-3', key: 'item-3' },
  'Item 3'
);

const reactFragment = [
  listItemElement1,
  listItemElement2,
  listItemElement3
];
const listOfItems = React.createElement(
  'ul',
  { className: 'list-of-items' },
  reactFragment
);

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

前面的例子产生了这个 HTML:

<ul data-reactroot class="list-of-items">
  <li class="item-1">Item 1</li>
  <li class="item-2">Item 2</li>
  <li class="item-3">Item 3</li>
</ul>

我们可以通过首先创建一个工厂函数来简化它:

import React from 'react';
import ReactDOM from 'react-dom';

const createListItemElement = React.createFactory('li');

const listItemElement1 = createListItemElement(
  { className: 'item-1', key: 'item-1' },
  'Item 1'
);
const listItemElement2 = createListItemElement(
  { className: 'item-2', key: 'item-2' },
  'Item 2'
);
const listItemElement3 = createListItemElement(
  { className: 'item-3', key: 'item-3' },
  'Item 3'
);

const reactFragment = [
  listItemElement1,
  listItemElement2,
  listItemElement3
];
const listOfItems = React.createElement(
  'ul',
  { className: 'list-of-items' },
  reactFragment
);

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

在前面的例子中,我们首先调用了React.createFactory()函数,并将li HTML 标签名称作为类型参数传递。然后,React.createFactory()函数返回一个新的函数,我们可以将其用作创建li类型元素的便捷缩写。我们将这个函数的引用存储在一个名为createListItemElement的变量中。然后,我们调用这个函数三次,每次只传递propschildren参数,这些参数对于每个元素都是唯一的。请注意,React.createElement()React.createFactory()都期望一个 HTML 标签名称字符串(如li)或ReactClass对象作为类型参数。

React 为我们提供了许多内置的工厂函数来创建常见的 HTML 标签。您可以从React.DOM对象中调用它们;例如,React.DOM.ul()React.DOM.li()React.DOM.div()。使用它们,我们甚至可以进一步简化我们之前的例子:

import React from 'react';
import ReactDOM from 'react-dom';

const listItemElement1 = React.DOM.li(
  { className: 'item-1', key: 'item-1' },
  'Item 1'
);
const listItemElement2 = React.DOM.li(
  { className: 'item-2', key: 'item-2' },
  'Item 2'
);
const listItemElement3 = React.DOM.li(
  { className: 'item-3', key: 'item-3' },
  'Item 3'
);

const reactFragment = [
  listItemElement1,
  listItemElement2,
  listItemElement3
];
const listOfItems = React.DOM.ul(
  { className: 'list-of-items' },
  reactFragment
);

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

现在,我们知道如何创建ReactNode的树。然而,在我们继续之前,有一行重要的代码需要讨论:

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

您可能已经猜到了,它将我们的 ReactNode 树呈现到 DOM。让我们更仔细地看看它是如何工作的。

渲染 React 元素

ReactDOM.render() 方法接受三个参数:ReactElement、一个常规的 DOMElement 容器和一个 callback 函数:

ReactDOM.render(ReactElement, DOMElement, callback);

ReactElement 类型是您创建的 ReactNode 树中的根元素。常规的 DOMElement 参数是该树的容器 DOM 节点。callback 参数是在树被渲染或更新后执行的函数。重要的是要注意,如果此 ReactElement 类型先前已呈现到父 DOMElement 容器,则 ReactDOM.render() 将对已呈现的 DOM 树执行更新,并且仅会改变 DOM,因为需要反映 ReactElement 类型的最新版本。这就是为什么虚拟 DOM 需要较少的 DOM 变化。

到目前为止,我们假设我们总是在 web 浏览器中创建我们的虚拟 DOM。这是可以理解的,因为毕竟 React 是一个用户界面库,所有用户界面都是在 web 浏览器中呈现的。您能想到在客户端渲染用户界面会很慢的情况吗?你们中的一些人可能已经猜到了,我说的是初始页面加载。初始页面加载的问题是我在本章开头提到的一个问题——我们不再创建静态网页了。相反,当 web 浏览器加载我们的 web 应用程序时,它只会收到通常用作我们的 web 应用程序的容器或父元素的最少 HTML 标记。然后,我们的 JavaScript 代码创建其余的 DOM,但为了这样做,它通常需要从服务器请求额外的数据。然而,获取这些数据需要时间。一旦收到这些数据,我们的 JavaScript 代码开始改变 DOM。我们知道 DOM 变化很慢。我们如何解决这个问题?

解决方案有些出乎意料。我们不是在 web 浏览器中改变 DOM,而是在服务器上改变它,就像我们在静态网页上做的那样。然后,web 浏览器将接收一个 HTML,它完全代表了我们的 web 应用程序在初始页面加载时的用户界面。听起来很简单,但我们不能在服务器上改变 DOM,因为它在 web 浏览器之外不存在。或者我们可以吗?

我们有一个只是 JavaScript 的虚拟 DOM,并且使用 Node.js,我们可以在服务器上运行 JavaScript。因此,从技术上讲,我们可以在服务器上使用 React 库,并且可以在服务器上创建我们的ReactNode树。问题是我们如何将其渲染为一个可以发送给客户端的字符串?

React 有一个名为ReactDOMServer.renderToString()的方法来做到这一点:

import ReactDOMServer from 'react-dom/server';
ReactDOMServer.renderToString(ReactElement);

ReactDOMServer.renderToString()方法以ReactElement作为参数,并将其渲染为初始 HTML。这不仅比在客户端上改变 DOM 更快,而且还提高了您的 Web 应用的搜索引擎优化(SEO)。

说到生成静态网页,我们也可以用 React 来做到这一点:

import ReactDOMServer from 'react-dom/server';
ReactDOMServer.renderToStaticMarkup(ReactElement);

ReactDOMServer.renderToString()类似,这个方法也以ReactElement作为参数,并输出一个 HTML 字符串。然而,它不会创建 React 在内部使用的额外 DOM 属性,从而产生较短的 HTML 字符串,我们可以快速传输到网络。

现在你不仅知道如何使用 React 元素创建虚拟 DOM 树,还知道如何将其渲染到客户端和服务器。我们接下来的问题是是否可以快速且更直观地完成这个过程。

使用 JSX 创建 React 元素

当我们通过不断调用React.createElement()方法来构建我们的虚拟 DOM 时,将这些多个函数调用视觉上转换为 HTML 标签的层次结构变得非常困难。不要忘记,即使我们正在使用虚拟 DOM,我们仍然在为我们的内容和用户界面创建一个结构布局。通过简单地查看我们的 React 代码,能够轻松地可视化该布局,这不是很好吗?

JSX是一种可选的类似 HTML 的语法,允许我们创建虚拟 DOM 树,而不使用React.createElement()方法。

让我们来看看我们之前创建的不使用 JSX 的示例:

import React from 'react';
import ReactDOM from 'react-dom';

const listItemElement1 = React.DOM.li(
  { className: 'item-1', key: 'item-1' },
  'Item 1'
);
const listItemElement2 = React.DOM.li(
  { className: 'item-2', key: 'item-2' },
  'Item 2'
);
const listItemElement3 = React.DOM.li(
  { className: 'item-3', key: 'item-3' },
  'Item 3'
);

const reactFragment = [
  listItemElement1,
  listItemElement2,
  listItemElement3
];
const listOfItems = React.DOM.ul(
  { className: 'list-of-items' },
  reactFragment
);

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

将此转换为 JSX:

import React from 'react';
import ReactDOM from 'react-dom';

const listOfItems = (
  <ul className="list-of-items">
    <li className="item-1">Item 1</li>
    <li className="item-2">Item 2</li>
    <li className="item-3">Item 3</li>
  </ul>
);

ReactDOM.render(
  listOfItems,
  document.getElementById('react-application')
);

正如你所看到的,JSX 允许我们在 JavaScript 代码中编写类似 HTML 的语法。更重要的是,我们现在可以清楚地看到我们的 HTML 布局在渲染后会是什么样子。JSX 是一个方便的工具,但它也有一个额外的转换步骤的代价。在我们的“无效”JavaScript 代码被解释之前,必须将 JSX 语法转换为有效的 JavaScript 语法。

在上一章中,我们安装了babel-preset-react模块,将我们的 JSX 语法转换为有效的 JavaScript。这种转换发生在我们运行 Webpack 时。转到~/snapterest/并运行以下命令:

**npm start**

为了更好地理解 JSX 语法,我建议您使用 Babel REPL 工具进行实验:babeljs.io/repl/——它可以将您的 JSX 语法即时转换为普通的 JavaScript。

使用 JSX,起初可能会感到非常不同寻常,但它可以成为一个非常直观和方便的工具。最好的部分是您可以选择是否使用它。我发现 JSX 可以节省我的开发时间,所以我选择在我们正在构建的项目中使用它。

如果您对我们在本章讨论的内容有疑问,那么您可以参考github.com/fedosejev/react-essentials并创建一个新的问题。

总结

我们从讨论单页面应用程序的问题以及如何解决它们开始了本章。然后,您了解了虚拟 DOM 是什么,以及 React 如何允许我们构建一个虚拟 DOM。我们还安装了 React,并且仅使用 JavaScript 创建了我们的第一个 React 元素。然后,您还学会了如何在 Web 浏览器和服务器上渲染 React 元素。最后,我们看了一种更简单的使用 JSX 创建 React 元素的方法。

在下一章中,我们将更深入地了解 React 组件的世界。

第四章:创建您的第一个 React 组件

在上一章中,您学习了如何创建 React 元素以及如何使用它们来渲染 HTML 标记。您看到使用 JSX 生成 React 元素是多么容易。在这一点上,您已经了解了足够多的关于 React 的知识,可以创建静态网页,我们在第三章中讨论了这一点,创建您的第一个 React 元素。然而,我敢打赌这不是您决定学习 React 的原因。您不只是想构建由静态 HTML 元素组成的网站。您想要构建对用户和服务器事件做出反应的交互式用户界面。对事件做出反应意味着什么?静态 HTML 元素如何反应?React 元素如何反应?在本章中,我们将回答这些问题以及许多其他问题,同时向 React 组件介绍自己。

无状态与有状态

反应意味着从一种状态切换到另一种状态。这意味着你需要首先有一个状态,以及改变该状态的能力。我们在 React 元素中提到了状态或改变状态的能力吗?没有。它们是无状态的。它们的唯一目的是构建和渲染虚拟 DOM 元素。事实上,我们希望它们以完全相同的方式渲染,只要我们为它们提供完全相同的参数。我们希望它们保持一致,因为这样可以方便我们理解它们。这是使用 React 的关键好处之一——方便我们理解我们的 Web 应用程序的工作原理。

我们如何向我们的无状态 React 元素添加状态?如果我们不能在 React 元素中封装状态,那么我们应该将 React 元素封装在已经具有状态的东西中。想象一个代表用户界面的简单状态机。每个用户操作都会触发该状态机中的状态变化。每个状态由不同的 React 元素表示。在 React 中,这个状态机被称为React 组件

创建您的第一个无状态 React 组件

让我们看看如何创建一个 React 组件的以下示例:

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class ReactClass extends Component {
  render () {
    return (
      <h1 className="header">React Component</h1>
    );
  }
}

const reactComponent = ReactDOM.render(
  <ReactClass/>,
  document.getElementById('react-application')
);
export default ReactClass;

之前的一些代码对你来说可能已经很熟悉了,其余部分可以分解为两个简单的步骤:

  1. 创建一个 React 组件类。

  2. 创建一个 React 组件。

让我们更仔细地看一下如何创建一个 React 组件:

  1. 创建一个ReactClass类作为Component类的子类。在本章中,我们将重点学习如何更详细地创建 React 组件类。

  2. 通过调用ReactDOM.render()函数并将我们的ReactClass元素作为其元素参数提供来创建reactComponent

我强烈建议您阅读 Dan Abramov 的这篇博文,其中更详细地解释了 React 组件、元素和实例之间的区别:facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html

React 组件的外观和感觉在ReactClass中声明。

Component类封装了组件的状态并描述了组件的呈现方式。至少,React 组件类需要有一个render()方法,以便返回nullfalse。以下是一个最简单形式的render()方法的示例:

class ReactClass extends Component {
  render() {
    return null;
  }
}

正如您可以猜到的,render()方法负责告诉 React 这个组件应该呈现什么。它可以返回null,就像前面的例子中一样,屏幕上将不会呈现任何内容。或者,它可以返回我们在第三章中学习如何创建的 JSX 元素,创建您的第一个 React 元素

class ReactClass extends Component {
  render() {
    return (
      <h1 className="header">React Component</h1>
    );
  }
}

这个例子展示了我们如何将 React 元素封装在 React 组件中。我们创建了一个带有className属性和一些文本作为其子元素的h1元素。然后,在调用render()方法时返回它。我们将 React 元素封装在 React 组件中的事实并不影响它的呈现方式:

<h1 data-reactroot class="header">React Component</h1>

正如您所看到的,生成的 HTML 标记与我们在第三章中创建的标记相同,创建您的第一个 React 元素,而不使用 React 组件。在这种情况下,您可能会想知道,如果我们可以在没有它的情况下呈现完全相同的标记,那么拥有render()方法的好处是什么?

拥有render()方法的优势在于,与任何其他函数一样,在返回值之前,它可以选择返回什么值。到目前为止,您已经看到了两个render()方法的例子:一个返回null,另一个返回一个 React 元素。我们可以合并这两个并添加一个条件来决定要渲染什么:

class ReactClass extends Component {
  render() {
    const componentState = {
      isHidden: true
    };

    if (componentState.isHidden) {
      return null;
    }

    return (
      <h1 className="header">React Component</h1>
    );
  }
}

在这个例子中,我们创建了componentState常量,它引用了一个具有单个isHidden属性的对象。这个对象充当我们的 React 组件的状态。如果我们想要隐藏我们的 React 组件,那么我们需要将componentState.isHidden的值设置为true,我们的render函数将返回null。在这种情况下,React 将不渲染任何内容。从逻辑上讲,将componentState.isHidden设置为false,将返回我们的 React 元素并渲染预期的 HTML 标记。您可能会问的问题是,我们如何将componentState.isHidden的值设置为false?或者设置为true?或者如何一般地改变它?

让我们想想我们可能想要改变状态的情况。其中之一是当用户与我们的用户界面交互时。另一个是当服务器发送数据时。或者,当一定时间过去后,现在我们想要渲染其他东西。我们的render()方法并不知道所有这些事件,也不应该知道,因为它的唯一目的是根据我们传递给它的数据返回一个 React 元素。我们如何将数据传递给它?

有两种方法可以使用 React API 将数据传递给render()方法:

  • this.props

  • this.state

在这里,this.props对您来说应该很熟悉。在第三章创建您的第一个 React 元素中,您学习了React.createElement()函数接受props参数。我们用它来传递属性给我们的 HTML 元素,但我们没有讨论发生了什么以及为什么传递给props对象的属性会被渲染。

您放入props对象并传递给 JSX 元素的任何数据都可以通过this.propsrender()方法中访问。一旦您从this.props访问数据,您可以渲染它:

class ReactClass extends Component {
  render() {
    const componentState = {
      isHidden: false
    };

    if (componentState.isHidden) {
      return null;
    }

    return (
      <h1 className="header">{this.props.header}</h1>
    );
  }
}

在这个例子中,我们在render()方法中使用this.props来访问header属性。然后,我们直接将this.props.header作为子元素传递给h1 元素

在前面的例子中,我们可以将isHidden的值作为this.props对象的另一个属性传递:

class ReactClass extends Component {
  render() {
    if (this.props.isHidden) {
      return null;
    }

    return (
      <h1 className="header">{this.props.header}</h1>
    );
  }
}

注意,在这个例子中,我们重复了两次this.props。一个this.props对象通常有我们想要在render方法中多次访问的属性。因此,我建议你首先解构this.props

class ReactClass extends Component {
  render() {
    const {
      isHidden,
      header
    } = this.props;

    if (isHidden) {
      return null;
    }

    return (
      <h1 className="header">{this.header}</h1>
    );
  }
}

你是否注意到在前面的例子中,我们不是将isHidden存储在render()方法中,而是通过this.props传递它?我们移除了我们的componentState对象,因为我们不需要在render()方法中担心组件的状态。render()方法不应该改变组件的状态或访问真实的 DOM,或以其他方式与 Web 浏览器交互。我们可能希望在服务器上渲染我们的 React 组件,在那里我们没有 Web 浏览器,并且我们应该期望render()方法在任何环境下都能产生相同的结果。

如果我们的render()方法不管理状态,那么我们如何管理它?我们如何设置状态,以及在处理 React 中的用户或浏览器事件时如何更新它?

在本章的前面,你学到了在 React 中,我们可以用 React 组件表示用户界面。有两种类型的 React 组件:

  • 有一个状态

  • 没有状态

等一下!我们不是说 React 组件是状态机吗?当然,每个状态机都需要有一个状态。你是对的,但是尽可能保持尽可能多的 React 组件无状态是一个好习惯。

React 组件是可组合的。因此,我们可以有一个 React 组件的层次结构。想象一下,我们有一个父 React 组件,它有两个子组件,每个子组件又有另外两个子组件。所有组件都是有状态的,它们可以管理自己的状态:

创建你的第一个无状态 React 组件

如果层次结构中顶部组件更新其状态,要弄清楚最后一个子组件将渲染什么,会有多容易?不容易。有一种设计模式可以消除这种不必要的复杂性。这个想法是通过两个关注点来分离你的组件:如何处理用户界面交互逻辑和如何渲染数据。

  • 你的 React 组件中少数是有状态的。它们应该位于组件层次结构的顶部。它们封装了所有的交互逻辑,管理用户界面状态,并将该状态通过props传递到无状态组件的层次结构中。

  • 你的 React 组件中大多数是无状态的。它们通过this.props接收其父组件的状态数据,并相应地渲染该数据。

在我们之前的例子中,我们通过this.props接收了isHidden状态数据,然后渲染了该数据。我们的组件是无状态的。

接下来,让我们创建我们的第一个有状态组件。

创建您的第一个有状态的 React 组件

有状态的组件是应用程序处理交互逻辑和管理状态的最合适的地方。它们使您更容易理解应用程序的工作原理。这种推理在构建可维护的 Web 应用程序中起着关键作用。

React 将组件的状态存储在this.state对象中。我们将this.state的初始值分配为Component类的公共类字段:

class ReactClass extends React.Component {
  state = {
    isHidden: false
  };

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

    if (isHidden) {
      return null;
    }

    return (
      <h1 className="header">React Component</h1>
    );
  }
}

现在,{ isHidden: false }是我们的 React 组件和用户界面的初始状态。请注意,在我们的render()方法中,我们现在从this.state而不是this.props中解构isHidden属性。

在本章的前面,您已经学习到我们可以通过this.propsthis.state将数据传递给组件的render()函数。这两者之间有什么区别呢?

  • this.props:这存储了从父级传递的只读数据。它属于父级,不能被其子级更改。这些数据应被视为不可变的。

  • this.state:这存储了对组件私有的数据。它可以被组件更改。当状态更新时,组件将重新渲染自身。

如何更新组件的状态?您可以使用setState(nextState, callback)通知 React 状态变化。此函数接受两个参数:

  • 代表下一个状态的nextState对象。它也可以是一个带有function(prevState, props) => newState签名的函数。此函数接受两个参数:先前的状态和属性,并返回表示新状态的对象。

  • callback函数,您很少需要使用,因为 React 会为您保持用户界面的更新。

React 如何保持用户界面的更新?每次更新组件的状态时,包括任何子组件,它都会调用组件的render()函数。实际上,每次调用我们的render()函数时,它都会重新渲染整个虚拟 DOM。

当您调用this.setState()函数并传递表示下一个状态的数据对象时,React 将将下一个状态与当前状态合并。在合并过程中,React 将用下一个状态覆盖当前状态。未被下一个状态覆盖的当前状态将成为下一个状态的一部分。

想象一下这是我们当前的状态:

{
  isHidden: true,
  title: 'Stateful React Component'
}

我们调用this.setState(nextState),其中nextState如下:

{
  isHidden: false
}

React 将这两个状态合并为一个新的状态:

{
  isHidden: false,
  title: 'Stateful React Component'
}

isHidden属性已更新,title属性未被删除或以任何方式更新。

现在我们知道如何更新我们组件的状态,让我们创建一个对用户事件做出反应的有状态组件:

在这个例子中,我们正在创建一个切换按钮,用于显示和隐藏标题。我们首先设置我们的初始状态对象。我们的初始状态有两个属性:isHeaderHidden设置为false,标题设置为Stateful React Component。现在,我们可以通过this.state在我们的render()方法中访问这个状态对象。在我们的render()方法中,我们创建三个 React 元素:h1buttondiv。我们的div元素充当我们的h1button元素的父元素。然而,在某种情况下,我们创建我们的div元素有两个子元素,headerbutton元素,而在另一种情况下,我们只创建一个子元素,button。我们选择的情况取决于this.state.isHeaderHidden的值。我们组件的当前状态直接影响render()函数将渲染什么。虽然这对您来说应该很熟悉,但在这个例子中有一些新的东西是我们以前没有见过的。

请注意,我们在组件类中添加了一个名为handleClick()的新方法。handleClick()方法对 React 没有特殊意义。它是我们应用逻辑的一部分,我们用它来处理onClick事件。您也可以向 React 组件类添加自定义方法,因为它只是一个 JavaScript 类。所有这些方法都将通过this引用可用,您可以在组件类中的任何方法中访问它。例如,我们在render()handleClick()方法中都通过this.state访问状态对象。

我们的handleClick()方法做什么?它通过切换isHeaderHidden属性来更新我们组件的状态:

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

我们的handleClick()方法对用户与用户界面的交互做出反应。我们的用户界面是一个button元素,用户可以点击它,我们可以将事件处理程序附加到它上面。在 React 中,您可以通过将它们传递给 JSX 属性来将事件处理程序附加到组件上:

<button onClick={this.handleClick}>
  Toggle Header
</button>

React 使用驼峰命名约定来命名事件处理程序,例如,onClick。您可以在facebook.github.io/react/docs/events.html#supported-events找到所有支持的事件列表。

默认情况下,React 在冒泡阶段触发事件处理程序,但您可以告诉 React 在捕获阶段触发它们,方法是在事件名称后附加Capture,例如onClickCapture

React 将浏览器的原生事件封装到SyntheticEvent对象中,以确保所有支持的事件在 Internet Explorer 8 及以上版本中表现一致。

SyntheticEvent对象提供与原生浏览器事件相同的 API,这意味着您可以像往常一样使用stopPropagation()preventDefault()方法。如果出于某种原因,您需要访问原生浏览器事件,那么可以通过nativeEvent属性来实现。

请注意,在上一个示例中,将onClick属性传递给我们的createElement()函数并不会在呈现的 HTML 标记中创建内联事件处理程序:

<button class="btn btn-default">Toggle header</button>

这是因为 React 实际上并没有将事件处理程序附加到 DOM 节点本身。相反,React 在顶层监听所有事件,使用单个事件侦听器并将它们委托给它们适当的事件处理程序。

在上一个示例中,您学习了如何创建一个有状态的 React 组件,用户可以与之交互并更改其状态。我们创建并附加了一个事件处理程序到click事件,以更新isHeaderHidden属性的值。但您是否注意到用户交互不会更新我们在状态中存储的另一个属性title的值。这对您来说是否奇怪?我们的状态中有一些数据永远不会改变。这个观察引发了一个重要的问题;我们不应该将什么放在我们的状态中?

问问自己,“我可以从组件的状态中删除哪些数据,而仍然保持其用户界面始终更新?”继续问,继续删除数据,直到您绝对确定没有剩下任何要删除的东西,而不会破坏用户界面。

在我们的示例中,我们在状态对象中有title属性,我们可以将其移动到我们的render()方法中,而不会破坏我们的切换按钮的交互性。组件仍将按预期工作:

class ReactClass extends Component {
  state = {
    isHeaderHidden: false
  }

  handleClick = () => {
    this.setState(prevState => ({
      isHeaderHidden: !prevState.isHeaderHidden
    }));
  }

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

    if (isHeaderHidden) {
      return (
        <button
          className="btn ban-default"
          onClick={this.handleClick}
        >
          Toggle Header
        </button>
      );
    }

    return (
      <div>
        <h1 className="header">Stateful React Component</h1>
        <button
          className="btn ban-default"
          onClick={this.handleClick}
        >
          Toggle Header
        </button>
      </div>
    );
  }
}

另一方面,如果我们将isHeaderHidden属性移出状态对象,那么我们将破坏组件的交互性,因为我们的render()方法将不再被 React 自动触发,每当用户点击我们的按钮时。这是一个破坏交互性的例子。

class ReactClass extends Component {
  state = {}
  isHeaderHidden = false

  handleClick = () => {
    this.isHeaderHidden = !this.isHeaderHidden;
  }

  render() {
    if (this.isHeaderHidden) {
      return (
        <button
          className="btn ban-default"
          onClick={this.handleClick}
        >
          Toggle Header
        </button>
      );
    }

    return (
      <div>
        <h1 className="header">Stateful React Component</h1>
        <button
          className="btn ban-default"
          onClick={this.handleClick}
        >
          Toggle Header
        </button>
      </div>
    );
  }
}

注意

注意:为了获得更好的输出结果,请参考代码文件。

这是一个反模式。

请记住这个经验法则:组件的状态应该存储组件的事件处理程序可能随时间改变的数据,以便重新渲染组件的用户界面并保持其最新状态。在state对象中保持组件状态的最小可能表示,并根据stateprops中的内容在组件的render()方法中计算其余数据。利用 React 会在其状态改变时重新渲染组件的特性。

总结

在这一章中,您达到了一个重要的里程碑:您学会了如何封装状态并通过创建 React 组件来创建交互式用户界面。我们讨论了无状态和有状态的 React 组件以及它们之间的区别。我们谈到了浏览器事件以及如何在 React 中处理它们。

在下一章中,您将了解 React 16 中的新功能。

第五章:使您的 React 组件具有反应性

现在您知道如何创建具有状态和无状态的 React 组件,我们可以开始组合 React 组件并构建更复杂的用户界面。实际上,是时候开始构建我们在第二章中讨论的名为Snapterest的 Web 应用程序,为您的项目安装强大的工具。在此过程中,您将学习如何规划您的 React 应用程序并创建可组合的 React 组件。让我们开始吧。

使用 React 解决问题

在开始编写您的 Web 应用程序代码之前,您需要考虑您的 Web 应用程序将解决的问题。清晰地定义问题并尽早理解问题是通往成功解决方案——一个有用的 Web 应用程序的最重要步骤。如果您在开发过程中未能早期定义问题,或者定义不准确,那么以后您将不得不停下来,重新思考您正在做的事情,放弃您已经编写的一部分代码,并编写新的代码。这是一种浪费的方法,作为专业软件开发人员,您的时间对您和您的组织都非常宝贵,因此明智地投资时间符合您的最佳利益。在本书的前面,我强调了使用 React 的好处之一是代码重用,这意味着您将能够在更短的时间内做更多的事情。然而,在我们查看 React 代码之前,让我们首先讨论问题,牢记 React。

我们将构建 Snapterest——一个 Web 应用程序,以实时方式从 Snapkite Engine 服务器接收推文,并将它们一次显示给用户。实际上,我们并不知道 Snapterest 何时会收到新的推文,但是当它收到时,它将至少显示该新推文 1.5 秒,以便用户有足够的时间查看并单击它。单击推文将将其添加到现有推文集合中或创建一个新的推文集合。最后,用户将能够将其集合导出为 HTML 标记代码。

这是我们将要构建的内容的一个非常高层次的描述。让我们将其分解为一系列较小的任务列表:

使用 React 解决问题

以下是步骤:

  1. 实时从 Snapkite Engine 服务器接收推文。

  2. 一次显示一条推文,持续至少 1.5 秒。

  3. 在用户点击事件发生时,将推文添加到集合中。

  4. 在集合中显示推文列表。

  5. 为集合创建 HTML 标记代码并导出它。

  6. 从集合中删除推文,当用户点击事件发生时。

您能否确定哪些任务可以使用 React 解决?请记住,React 是一个用户界面库,因此任何描述用户界面和与用户界面交互的内容都可以用 React 解决。在前面的列表中,React 可以处理除第一个任务之外的所有任务,因为它描述的是数据获取而不是用户界面。第一步将使用我们将在下一章讨论的另一个库来解决。第 2 步和第 4 步描述了需要显示的内容。它们是 React 组件的完美候选者。第 3 步和第 6 步描述了用户事件,正如我们在第四章中所看到的,用户事件处理也可以封装在 React 组件中。您能想到如何使用 React 解决第 5 步吗?请记住,在第三章中,我们讨论了ReactDOMServer.renderToStaticMarkup()方法,该方法将 React 元素呈现为静态 HTML 标记字符串。这正是我们需要解决第 5 步的方法。

现在,当我们已经为每个单独的任务确定了潜在的解决方案时,让我们考虑如何将它们组合在一起,创建一个完全功能的 Web 应用程序。

构建可组合的 React 应用程序有两种方法:

  • 首先,您可以开始构建单独的 React 组件,然后将它们组合成更高级别的 React 组件,沿着组件层次结构向上移动

  • 您可以从最顶层的 React 元素开始,然后实现其子组件,沿着组件层次结构向下移动

第二种策略有一个优势,可以看到和理解应用程序架构的整体情况,我认为在我们考虑如何实现各个功能部分之前,了解一切是如何组合在一起的很重要。

规划您的 React 应用程序

在规划您的 React 应用程序时,有两个简单的准则需要遵循:

  • 每个 React 组件应该代表 Web 应用程序中的单个用户界面元素。它应该封装可能被重用的最小元素。

  • 多个 React 组件应该组合成一个单独的 React 组件。最终,您的整个用户界面应该封装在一个 React 组件中。

规划您的 React 应用程序

我们的 React 组件层次结构图

我们将从我们最顶层的 React 组件Application开始。它将封装我们整个的 React 应用程序,并且它将有两个子组件:StreamCollection组件。Stream组件将负责连接到一系列 tweets,接收并显示最新的 tweet。Stream组件将有两个子组件:StreamTweetHeaderStreamTweet组件将负责显示最新的 tweet。它将由HeaderTweet组件组成。Header组件将渲染一个标题。它将没有子组件。Tweet组件将渲染一条 tweet 的图片。请注意我们计划重复使用Header组件两次。

Collection组件将负责显示集合控件和一系列 tweets。它将有两个子组件:CollectionControlsTweetListCollectionControls组件将有两个子组件:CollectionRenameForm组件,它将渲染一个重命名集合的表单,以及CollectionExportForm组件,它将渲染一个将集合导出到名为CodePen的服务的表单,这是一个 HTML、CSS 和 JavaScript 的游乐场网站。您可以在codepen.io了解更多关于 CodePen 的信息。正如您可能已经注意到的,我们将在CollectionRenameFormCollectionControls组件中重用HeaderButton组件。我们的TweetList组件将渲染一系列 tweets。每条 tweet 将由一个Tweet组件渲染。事实上,总共我们将在Collection组件中再次重用Header组件。事实上,总共我们将在Collection组件中再次重用Header组件五次。这对我们来说是一个胜利。正如我们在前一章讨论的那样,我们应该尽可能地保持尽可能多的 React 组件无状态。因此,只有 11 个组件中的 5 个将存储状态,它们分别是:

  • Application

  • CollectionControls

  • CollectionRenameForm

  • StreamTweet

现在我们有了一个计划,我们可以开始实施它。

创建一个容器 React 组件

让我们从编辑我们应用的主 JavaScript 文件开始。用以下代码片段替换~/snapterest/source/app.js文件的内容:

import React from 'react';
import ReactDOM from 'react-dom';
import Application from './components/Application';

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

这个文件只有四行代码,你可以猜到,它们提供了document.getElementById('react-application')作为<Application/>组件的部署目标,并将<Application/>渲染到 DOM 中。我们的 Web 应用程序的整个用户界面将被封装在一个 React 组件Application中。

接下来,导航到~/snapterest/source/components/并在这个目录中创建Application.js文件:

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

class Application extends Component {
  state = {
    collectionTweets: {}
  }

  addTweetToCollection = (tweet) => {
    const { collectionTweets } = this.state;

    collectionTweets[tweet.id] = tweet;

    this.setState({
      collectionTweets: collectionTweets
    });
  }

  removeTweetFromCollection = (tweet) => {
    const { collectionTweets } = this.state;

    delete collectionTweets[tweet.id];

    this.setState({
      collectionTweets: collectionTweets
    });
  }

  removeAllTweetsFromCollection = () => {
    this.setState({
      collectionTweets: {}
    });
  }

  render() {
    const {
      addTweetToCollection,
      removeTweetFromCollection,
      removeAllTweetsFromCollection
    } = this;

    return (
      <div className="container-fluid">
        <div className="row">
          <div className="col-md-4 text-center">
            <Stream onAddTweetToCollection={addTweetToCollection}/>
          </div>
          <div className="col-md-8">
            <Collection
              tweets={this.state.collectionTweets}
              onRemoveTweetFromCollection={removeTweetFromCollection}
              onRemoveAllTweetsFromCollection={removeAllTweetsFromCollection}
            />
          </div>
        </div>
      </div>
    );
  }
}

export default Application;

这个组件的代码比我们的app.js文件要多得多,但这段代码可以很容易地分成三个逻辑部分:

  • 导入依赖模块

  • 定义一个 React 组件类

  • 将 React 组件类作为模块导出

Application.js文件的第一个逻辑部分中,我们使用require()函数导入了依赖模块:

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

我们的Application组件将有两个子组件,我们需要导入它们:

  • Stream组件将渲染我们用户界面的流部分

  • Collection组件将渲染我们用户界面的收藏部分

我们还需要将React库作为另一个模块导入。

Application.js文件的第二个逻辑部分创建了 ReactApplication组件类,并包含以下方法:

  • addTweetToCollection()

  • removeTweetFromCollection()

  • removeAllTweetsFromCollection()

  • render()

只有render()方法是 React API 的一部分。所有其他方法都是我们应用逻辑的一部分,这个组件封装了这些方法。我们将在讨论这个组件在render()方法中渲染的内容之后更仔细地看一下每一个方法:

render() {
  const {
    addTweetToCollection,
    removeTweetFromCollection,
    removeAllTweetsFromCollection
  } = this;

  return (
    <div className="container-fluid">
      <div className="row">
        <div className="col-md-4 text-center">
          <Stream onAddTweetToCollection={addTweetToCollection}/>
        </div>
        <div className="col-md-8">
          <Collection
            tweets={this.state.collectionTweets}
            onRemoveTweetFromCollection={removeTweetFromCollection}
            onRemoveAllTweetsFromCollection={removeAllTweetsFromCollection}
          />
        </div>
      </div>
    </div>
  );
}

如你所见,它使用 Bootstrap 框架定义了我们网页的布局。如果你不熟悉 Bootstrap,我强烈建议你访问getbootstrap.com并阅读文档。学习这个框架将使你能够快速轻松地原型化用户界面。即使你不懂 Bootstrap,也很容易理解发生了什么。我们将网页分成两列:一个较小的列和一个较大的列。较小的列包含我们的Stream React 组件,较大的列包含我们的Collection组件。你可以想象我们的网页被分成了两个不等的部分,它们都包含了 React 组件。

这就是我们如何使用我们的Stream组件:

<Stream onAddTweetToCollection={addTweetToCollection} />

Stream组件有一个onAddTweetToCollection属性,我们的Application组件将自己的addTweetToCollection()方法作为这个属性的值传递。addTweetToCollection()方法将一条推文添加到集合中。这是我们在Application组件中定义的自定义方法之一。我们不需要使用this关键字,因为该方法被定义为箭头函数,所以函数的作用域自动成为我们的组件。

让我们看看addTweetToCollection()方法做了什么:

addTweetToCollection = (tweet) => {
  const { collectionTweets } = this.state;

  collectionTweets[tweet.id] = tweet;

  this.setState({
    collectionTweets: collectionTweets
  });
}

该方法引用存储在当前状态中的集合推文,将新推文添加到collectionTweets对象,并通过调用setState()方法更新状态。当在Stream组件内部调用addTweetToCollection()方法时,会传递一个新推文作为参数。这是子组件如何更新其父组件状态的一个例子。

这是 React 中的一个重要机制,它的工作方式如下:

  1. 父组件将回调函数作为属性传递给其子组件。子组件可以通过this.props引用访问这个回调函数。

  2. 每当子组件想要更新父组件的状态时,它调用该回调函数并将所有必要的数据传递给新的父组件状态。

  3. 父组件更新其状态,正如你已经知道的,这个状态更新并触发render()方法,根据需要重新渲染所有子组件。

这就是子组件与父组件交互的方式。这种交互允许子组件将应用程序的状态管理委托给其父组件,并且只关注如何渲染自身。现在当你学会了这种模式,你会一遍又一遍地使用它,因为大多数 React 组件应该保持无状态。只有少数父组件应该存储和管理应用程序的状态。这种最佳实践允许我们通过两种不同的关注点逻辑地对 React 组件进行分组:

  • 管理应用程序的状态并渲染它

  • 只渲染并将应用程序的状态管理委托给父组件

我们的Application组件有一个第二个子组件,Collection

<Collection
  tweets={this.state.collectionTweets}
  onRemoveTweetFromCollection={removeTweetFromCollection}
  onRemoveAllTweetsFromCollection={removeAllTweetsFromCollection}
/>

这个组件有一些属性:

  • tweets:这指的是我们当前的推文集合

  • onRemoveTweetFromCollection:这是指从我们的收藏中删除特定推文的函数

  • onRemoveAllTweetsFromCollection:这是指从我们的收藏中删除所有推文的函数

你可以看到 Collection 组件的属性只关心如何执行以下操作:

  • 访问应用程序的状态

  • 改变应用程序的状态

可以猜到,onRemoveTweetFromCollectiononRemoveAllTweetsFromCollection 函数允许 Collection 组件改变 Application 组件的状态。另一方面,tweets 属性将 Application 组件的状态传播到 Collection 组件,以便它可以以只读方式访问该状态。

你能认识到 ApplicationCollection 组件之间数据流的单向性吗?它是如何工作的:

  1. collectionTweets 数据在 Application 组件的 constructor() 方法中初始化。

  2. collectionTweets 数据作为 tweets 属性传递给 Collection 组件。

  3. Collection 组件调用 removeTweetFromCollectionremoveAllTweetsFromCollection 函数来更新 Application 组件中的 collectionTweets 数据,然后循环再次开始。

请注意,Collection 组件不能直接改变 Application 组件的状态。Collection 组件通过 this.props 对象只能以只读方式访问该状态,并且更新父组件状态的唯一方法是调用父组件传递的回调函数。在 Collection 组件中,这些回调函数是 this.props.onRemoveTweetFromCollectionthis.props.onRemoveAllTweetsFromCollection

我们 React 组件层次结构中数据流的简单心智模型将帮助我们增加所使用的组件数量,而不增加用户界面工作方式的复杂性。例如,它可以有多达 10 层嵌套的 React 组件,如下所示:

创建一个容器 React 组件

如果Component G想要改变根Component A的状态,它会以与Component BComponent F或此层次结构中的任何其他组件完全相同的方式来做。但是,在 React 中,您不应该直接将数据从Component A传递给Component G。相反,您应该首先将其传递给Component B,然后传递给Component C,然后传递给Component D,依此类推,直到最终到达Component GComponent BComponent F将不得不携带一些实际上只是为Component G准备的“中转”属性。这可能看起来像是浪费时间,但这种设计使我们能够轻松调试我们的应用程序并推理出其工作原理。始终有优化应用程序架构的策略。其中之一是使用Flux 设计模式。另一个是使用Redux库。我们将在本书的后面讨论这两种方法。

在我们结束讨论Application组件之前,让我们看一下改变其状态的两种方法:

removeTweetFromCollection = (tweet) => {
  const { collectionTweets } = this.state;

  delete collectionTweets[tweet.id];

  this.setState({
     collectionTweets: collectionTweets
  });
}

removeTweetFromCollection()方法从我们存储在Application组件状态中的 tweet 集合中删除一个 tweet。它从组件状态中获取当前的collectionTweets对象,从该对象中删除具有给定id的 tweet,并使用更新后的collectionTweets对象更新组件状态。

另一方面,removeAllTweetsFromCollection()方法从组件状态中删除所有 tweet:

removeAllTweetsFromCollection = () => {
  this.setState({
    collectionTweets: {}
  });
}

这两种方法都是从子Collection组件中调用的,因为该组件没有其他方法可以改变Application组件的状态。

摘要

在本章中,您学会了如何使用 React 解决问题。我们首先将问题分解为较小的单独问题,然后讨论如何使用 React 来解决这些问题。然后,我们创建了一个需要实现的 React 组件列表。最后,我们创建了我们的第一个可组合的 React 组件,并了解了父组件如何与其子组件交互。

在下一章中,我们将实现我们的子组件,并了解 React 的生命周期方法。

第六章:使用 React 组件与另一个库

React 是一个用于构建用户界面的优秀库。如果我们想将其与负责接收数据的另一个库集成呢?在上一章中,我们概述了我们的 Snapterest web 应用程序应该能够执行的五项任务。我们决定其中四项与用户界面有关,但其中一项完全是关于接收数据的:实时从 Snapkite Engine 服务器接收推文。

在本章中,您将学习如何将 React 与外部 JavaScript 库集成,以及 React 组件生命周期方法是什么,同时解决接收数据的重要任务。

在您的 React 组件中使用另一个库

正如我们在本书前面讨论过的,我们的 Snapterest web 应用程序将消费实时推文流。在第二章中,为您的项目安装强大的工具,您安装了Snapkite Engine库,该库连接到 Twitter 流 API,过滤传入的推文,并将它们发送到我们的客户端应用程序。反过来,我们的客户端应用程序需要一种连接到该实时流并监听新推文的方法。

幸运的是,我们不需要自己实现这个功能,因为我们可以重用另一个 Snapkite 模块叫做 snapkite-stream-client。让我们安装这个模块:

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

  1. 这将安装 snapkite-stream-client 模块,并将其添加到 package.json 作为一个依赖项。

  2. 现在我们已经准备好在我们的一个 React 组件中重用 snapkite-stream-client 模块了。

在上一章中,我们创建了 Application 组件,其中包含两个子组件:StreamCollection。在本章中,我们将创建我们的 Stream 组件。

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

import React, { Component } from 'react';
import SnapkiteStreamClient from 'snapkite-stream-client';
import StreamTweet from './StreamTweet';
import Header from './Header.react';

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

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

  componentWillUnmount() {
    SnapkiteStreamClient.destroyStream();
  }

  handleNewTweet = (tweet) => {
    this.setState({
      tweet: 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;

首先,我们将导入我们的 Stream 组件依赖的以下模块:

  • ReactReactDOM: 这是 React 库的一部分

  • StreamTweetHeader: 这些是 React 组件

  • snapkite-stream-client: 这是一个实用库

然后,我们将定义我们的 React 组件。让我们来看看我们的 Stream 组件实现了哪些方法:

  • componentDidMount()

  • componentWillUnmount()

  • handleNewTweet()

  • render()

我们已经熟悉了 render() 方法。render() 方法是 React API 的一部分。你已经知道任何 React 组件都必须实现至少 render() 方法。让我们来看看我们的 Stream 组件的 render() 方法:

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}/>
  );
}

正如你所看到的,我们创建了一个新的 tweet 常量,引用了组件状态对象的 tweet 属性。然后我们将检查该变量是否引用了一个实际的 tweet 对象,如果是,我们的 render() 方法将返回 StreamTweet 组件,否则返回 Header 组件。

StreamTweet 组件渲染了一个标题和来自流的最新推文,而 Header 组件只渲染了一个标题。

你是否注意到我们的 Stream 组件本身并不渲染任何东西,而是返回另外两个实际进行渲染的组件之一?Stream 组件的目的是封装我们应用的逻辑,并将渲染委托给其他 React 组件。在 React 中,你应该至少有一个组件来封装你应用的逻辑,并存储和管理你应用的状态。这通常是你组件层次结构中的根组件或高级组件之一。所有其他子 React 组件应尽可能不具有状态。如果你将所有的 React 组件都视为 Views,那么我们的 Stream 组件就是一个 ControllerView 组件。

我们的 Stream 组件将接收一个无尽的新推文流,并且需要在每次接收到新推文时重新渲染其子组件。为了实现这一点,我们需要将当前推文存储在组件的状态中。一旦我们更新了它的状态,React 将调用它的 render() 方法并重新渲染所有的子组件。为此,我们将实现 handleNewTweet() 方法:

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

handleNewTweet() 方法接受一个 tweet 对象,并将其设置为组件状态的 tweet 属性的新值。

新的推文是从哪里来的,什么时候来的?让我们来看看我们的 componentDidMount() 方法:

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

该方法调用 SnapkiteStreamClient 对象的 initializeStream() 属性,并将 this.handleNewTweet 回调函数作为其参数传递。SnapkiteStreamClient 是一个外部库,具有我们用来初始化推文流的 API。this.handleNewTweet 方法将被调用以处理 SnapkiteStreamClient 接收到的每条新推文。

为什么我们将这个方法命名为componentDidMount()?其实不是我们命名的,是 React 命名的。事实上,componentDidMount()方法是 React API 的一部分。它只被调用一次,在 React 完成组件的初始渲染后立即调用。此时,React 已经创建了一个 DOM 树,由我们的组件表示,现在我们可以使用另一个 JavaScript 库访问该 DOM。

componentDidMount()库是将 React 与另一个 JavaScript 库集成的完美场所。这是我们使用外部SnapkiteStreamClient库连接到推文流的地方。

现在我们知道了在 React 组件中初始化外部 JavaScript 库的时机,但是反过来呢——我们应该在什么时候取消初始化并清理掉在componentDidMount()方法中所做的一切呢?在卸载组件之前清理一切是个好主意。为此,React API 为我们提供了另一个组件生命周期方法——componentWillUnmount()

componentWillUnmount() {
  SnapkiteStreamClient.destroyStream();
}

componentWillUnmount()方法在 React 卸载组件之前被调用。正如你在componentWillUnmount()方法中所看到的,你正在调用SnapkiteStreamClient对象的destroyStream()属性。destroyStream()属性清理了我们与SnapkiteStreamClient的连接,我们可以安全地卸载我们的Stream组件。

你可能想知道组件的生命周期方法是什么,以及为什么我们需要它们。

了解 React 组件的生命周期方法

想想 React 组件是做什么的?它描述了要渲染什么。我们知道它使用render()方法来实现这一点。然而,有时仅有render()方法是不够的,因为如果我们想在组件渲染之前或之后做一些事情怎么办?如果我们想决定是否应该调用组件的render()方法呢?

看起来我们描述的是 React 组件被渲染的过程。这个过程有各种阶段,例如在渲染之前,渲染和渲染之后。在 React 中,这个过程被称为组件的生命周期。每个 React 组件都经历这个过程。我们想要的是一种方法来连接到这个过程,并在这个过程的不同阶段调用我们自己的函数,以便更好地控制它。为此,React 提供了一些方法,我们可以使用这些方法在组件的生命周期过程的不同阶段得到通知。这些方法被称为组件的生命周期方法。它们按照可预测的顺序被调用。

所有 React 组件的生命周期方法可以分为三个阶段:

  • 挂载:当组件被插入 DOM 时发生

  • 更新:当组件被重新渲染到虚拟 DOM 中以确定实际 DOM 是否需要更新时发生

  • 卸载:当组件被从 DOM 中移除时发生:

理解 React 组件的生命周期方法

在 React 的术语中,将组件插入 DOM 称为"挂载",而将组件从 DOM 中移除称为"卸载"。

了解 React 组件的生命周期方法最好的方法是看它们在实际中的应用。让我们创建我们在本章前面讨论过的StreamTweet组件。这个组件将实现大部分 React 的生命周期方法。

导航到~/snapterest/source/components/并创建StreamTweet.js文件:

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

class StreamTweet extends Component {

  // define other component lifecycle methods here

  render() {
    console.log('[Snapterest] StreamTweet: Running render()');

    const { headerText } = this.state;
    const { tweet, onAddTweetToCollection } = this.props;

    return (
      <section>
        <Header text={headerText} />
        <Tweet
          tweet={tweet}
          onImageClick={onAddTweetToCollection}
        />
      </section>
    );
  }
}

export default StreamTweet;

正如你所看到的,StreamTweet组件除了render()之外还没有生命周期方法。随着我们的进展,我们将逐一创建并讨论它们。

这四种方法在组件的挂载阶段被调用,如下图所示:

理解 React 组件的生命周期方法

正如你从前面的图中所看到的,被调用的方法如下:

  • 构造函数()

  • componentWillMount()

  • render()

  • componentDidMount()

在本章中,我们将讨论这四种方法中的两种(除了render())。它们在组件插入 DOM 时只被调用一次。让我们更仔细地看看每一个。

挂载方法

现在让我们看一些有用的挂载方法。

componentWillMount 方法

componentWillMount()方法被第二次调用。它在 React 将组件插入 DOM 之前立即调用。在您的StreamTweet组件的constructor()方法之后立即添加此代码:

componentWillMount() {
  console.log('[Snapterest] StreamTweet: 1\. Running componentWillMount()');

  this.setState({
    numberOfCharactersIsIncreasing: true,
    headerText: 'Latest public photo from Twitter'
  });

  window.snapterest = {
    numberOfReceivedTweets: 1,
    numberOfDisplayedTweets: 1
  };
}

在此方法中,我们做了许多事情。首先,我们记录了调用此方法的事实。实际上,为了演示目的,我们将记录此组件的每个生命周期方法。当您在 Web 浏览器中运行此代码时,应该能够打开 JavaScript 控制台,并看到这些日志消息按预期的升序打印出来。

接下来,我们使用this.setState()方法更新组件的状态:

  • numberOfCharactersIsIncreasing属性设置为true

  • headerText属性设置为“来自 Twitter 的最新公共照片”

因为这是此组件将呈现的第一条推文,我们知道字符数肯定是从零增加到第一条推文中的字符数。因此,我们将其设置为true。我们还将默认文本分配给我们的标题,“来自 Twitter 的最新公共照片”。

如您所知,调用this.setState()方法应该触发组件的render()方法,因此在组件的挂载阶段似乎会调用两次render()。但是,在这种情况下,React 知道尚未呈现任何内容,因此它只会调用一次render()方法。

最后,在此方法中,我们使用以下两个属性定义了一个snapterest全局对象:

  • 接收到的推文数量:此属性计算所有接收到的推文的数量

  • numberOfDisplayedTweets:此属性计算仅显示的推文的数量

我们将numberOfReceivedTweets设置为1,因为我们知道componentWillMount()方法仅在接收到第一条推文时调用一次。我们还知道我们的render()方法将为这条第一条推文调用,因此我们也将numberOfDisplayedTweets设置为1

window.snapterest = {
  numberOfReceivedTweets: 1,
  numberOfDisplayedTweets: 1
};

这个全局对象不是 React 或我们的 Web 应用程序逻辑的一部分;我们可以删除它,一切仍将按预期工作。在前面的代码中,window.snapterest是一个方便的工具,用于跟踪我们在任何时间点处理了多少推文。我们仅出于演示目的使用全局window.snapterest对象。我强烈建议您不要在实际项目中向全局对象添加自己的属性,因为您可能会覆盖现有属性,和/或您的属性可能会被稍后由您不拥有的其他 JavaScript 代码覆盖。稍后,如果您决定将 Snapterest 部署到生产环境中,请确保删除全局window.snapterest对象以及与StreamTweet组件相关的代码。

在网络浏览器中查看 Snapterest 几分钟后,您可以打开 JavaScript 控制台并输入snapterest.numberOfReceivedTweetssnapterest.numberOfDisplayedTweets命令。这些命令将输出数字,帮助您更好地了解新推文的到达速度以及有多少推文未被显示。在我们的下一个组件生命周期方法中,我们将向window.snapterest对象添加更多属性。

componentDidMount 方法

componentDidMount()方法在 React 将组件插入 DOM 后立即调用。更新后的 DOM 现在可以访问,这意味着这个方法是初始化其他需要访问该 DOM 的 JavaScript 库的最佳位置。

在本章的早些时候,我们使用了componentDidMount()方法创建了我们的Stream组件,该方法初始化了外部的snapkite-stream-client JavaScript 库。

让我们来看看这个组件的componentDidMount()方法。在componentWillMount()方法之后,向您的StreamTweet组件添加以下代码:

componentDidMount = () => {
  console.log('[Snapterest] StreamTweet: 3\. Running componentDidMount()');

  const componentDOMRepresentation = ReactDOM.findDOMNode(this);

  window.snapterest.headerHtml = componentDOMRepresentation.children[0].outerHTML;
  window.snapterest.tweetHtml = componentDOMRepresentation.children[1].outerHTML;
}

在这里,我们使用ReactDOM.findDOMNode()方法引用表示我们的StreamTweet组件的 DOM。我们传递this参数,引用当前组件(在本例中为StreamTweet)。componentDOMRepresentation常量引用了我们可以遍历的 DOM 树,从而访问其各种属性。为了更好地了解这个 DOM 树的样子,让我们更仔细地看一下我们的StreamTweet组件的render()方法:

render() {
  console.log('[Snapterest] StreamTweet: Running render()');

  const { headerText } = this.state;
  const { tweet, onAddTweetToCollection } = this.props;

  return (
    <section>
      <Header text={headerText} />
      <Tweet
        tweet={tweet}
        onImageClick={onAddTweetToCollection}
      />
    </section>
  );
}

使用 JSX 的最大好处之一是,我们可以通过查看组件的render()方法轻松地确定组件将有多少子元素。在这里,我们可以看到父<section>元素有两个子组件:<Header/><Tweet/>

因此,当我们使用 DOM API 的children属性遍历生成的 DOM 树时,我们可以确保它也将有两个子元素:

  • componentDOMRepresentation.children[0]:这是我们<Header />组件的 DOM 表示

  • componentDOMRepresentation.children[1]:这是我们<Tweet />组件的 DOM 表示

每个元素的outerHTML属性都会得到表示该元素 DOM 树的 HTML 字符串。我们将这个 HTML 字符串分配给我们的全局window.snapterest对象,以方便起见,正如我们在本章前面讨论过的那样。

如果您正在使用其他 JavaScript 库,例如jQuery,以及 React 一起使用,则可以使用componentDidMount()方法作为集成两者的机会。如果您想发送 AJAX 请求,或者使用setTimeout()setInterval()函数设置定时器,那么您也可以在这个方法中执行。一般来说,componentDidMount()应该是您首选的组件生命周期方法,用于将 React 库与非 React 库和 API 集成。

到目前为止,在本章中,您已经学会了 React 组件提供给我们的基本挂载方法。我们在StreamTweet组件中使用了所有三种方法。我们还讨论了StreamTweet组件的render()方法。这就是我们需要了解的所有内容,以了解 React 将如何最初渲染StreamTweet组件。在其第一次渲染时,React 将执行以下方法序列:

  • componentWillMount()

  • render()

  • componentDidMount()

这被称为 React 组件的挂载阶段。它只执行一次,除非我们卸载一个组件并再次挂载它。

接下来,让我们讨论 React 组件的卸载阶段

卸载方法

现在让我们来看一下流行的卸载方法之一。

componentWillUnmount方法

React 仅为此阶段提供了一种方法,即componentWillUnmount()。它在 React 从 DOM 中移除组件并销毁之前立即调用。此方法对清理在组件挂载或更新阶段创建的任何数据非常有用。这正是我们在StreamTweet组件中所做的。在componentDidMount()方法之后,将此代码添加到您的StreamTweet组件中:

componentWillUnmount() {
  console.log('[Snapterest] StreamTweet: 8\. Running componentWillUnmount()');

  delete window.snapterest;
}

componentWillUnmount()方法中,我们使用delete运算符删除全局的window.snapterest对象:

delete window.snapterest;

删除window.snapterest将保持我们的全局对象清洁。如果您在componentDidMount()方法中创建了任何其他 DOM 元素,则componentWillUnmount()方法是删除它们的好地方。您可以将componentDidMount()componentWillUnmount()方法视为将 React 组件与另一个 JavaScript API 集成的两步机制。

  1. componentDidMount()方法中初始化它。

  2. componentWillUnmount()方法中终止它。

通过这种方式,需要与 DOM 一起工作的外部 JavaScript 库将与 React 渲染的 DOM 保持同步。

这就是我们需要知道的有关有效卸载 React 组件的全部内容。

总结

在本章中,我们创建了我们的Stream组件,并学习了如何将 React 组件与外部 JavaScript 库集成。您还了解了 React 组件的生命周期方法。我们还着重讨论了挂载和卸载方法,并开始实现StreamTweet组件。

在我们的下一章中,我们将看一下组件生命周期的更新方法。我们还将实现我们的HeaderTweet组件,并学习如何设置组件的默认属性。

第七章:更新您的 React 组件

在上一章中,您已经了解到 React 组件可以经历三个阶段:

  • 挂载

  • 更新

  • 卸载

我们已经讨论了挂载和卸载阶段。在本章中,我们将专注于更新阶段。在此阶段,React 组件已经插入到 DOM 中。这个 DOM 代表了组件的当前状态,当状态发生变化时,React 需要评估新状态将如何改变先前呈现的 DOM。

React 为我们提供了影响更新期间将要呈现的内容以及了解更新发生时的方法。这些方法允许我们控制从当前组件状态到下一个组件状态的过渡。让我们更多地了解 React 组件更新方法的强大性质。

理解组件生命周期更新方法

React 组件有五个生命周期方法属于组件的更新阶段:

  • componentWillReceiveProps()

  • shouldComponentUpdate()

  • componentWillUpdate()

  • render()

  • componentDidUpdate()

请参见以下图以获得更好的视图:

理解组件生命周期更新方法

您已经熟悉了render()方法。现在让我们讨论其他四种方法。

componentWillReceiveProps 方法

我们将从StreamTweet组件中的componentWillReceiveProps()方法开始。在StreamTweet.js文件的componentDidMount()方法之后添加以下代码:

componentWillReceiveProps(nextProps) {
  console.log('[Snapterest] StreamTweet: 4\. Running componentWillReceiveProps()');

  const { tweet: currentTweet } = this.props;
  const { tweet: nextTweet } = nextProps;

  const currentTweetLength = currentTweet.text.length;
  const nextTweetLength = nextTweet.text.length;
  const isNumberOfCharactersIncreasing = (nextTweetLength > currentTweetLength);
  let headerText;

  this.setState({
    numberOfCharactersIsIncreasing: isNumberOfCharactersIncreasing
  });

  if (isNumberOfCharactersIncreasing) {
    headerText = 'Number of characters is increasing';
  } else {
    headerText = 'Latest public photo from Twitter';
  }

  this.setState({
    headerText
  });

  window.snapterest.numberOfReceivedTweets++;
}

这个方法首先在组件生命周期的更新阶段被调用。当组件从其父组件接收新属性时,它被调用。

这个方法是一个机会,让我们使用this.props对象比较当前组件的属性和使用nextProps对象比较下一个组件的属性。基于这个比较,我们可以选择使用this.setState()函数来更新组件的状态,在这种情况下不会触发额外的渲染。

让我们看看它的实际应用:

const { tweet: currentTweet } = this.props;
const { tweet: nextTweet } = nextProps;

const currentTweetLength = currentTweet.text.length;
const nextTweetLength = nextTweet.text.length;
const isNumberOfCharactersIncreasing = (nextTweetLength > currentTweetLength);
let headerText;

this.setState({
  numberOfCharactersIsIncreasing: isNumberOfCharactersIncreasing
});

我们首先获取当前推文和下一条推文的长度。当前推文可以通过this.props.tweet获得,下一条推文可以通过nextProps.tweet获得。然后,我们通过检查下一条推文是否比当前推文更长来比较它们的长度。比较的结果存储在isNumberOfCharactersIncreasing变量中。最后,我们通过将numberOfCharactersIsIncreasing属性设置为isNumberOfCharactersIncreasing变量的值来更新组件的状态。

然后我们将我们的标题文本设置如下:

if (isNumberOfCharactersIncreasing) {
  headerText = 'Number of characters is increasing';
} else {
  headerText = 'Latest public photo from Twitter';
}

this.setState({
  headerText
});

如果下一条推文更长,我们将把标题文本设置为'字符数正在增加',否则,我们将把它设置为'来自 Twitter 的最新公共照片'。然后,我们通过将headerText属性设置为headerText变量的值来再次更新组件的状态。

请注意,在我们的componentWillReceiveProps()方法中调用了this.setState()函数两次。这是为了说明一个观点,即无论在componentWillReceiveProps()方法中调用this.setState()多少次,都不会触发该组件的额外渲染。React 进行了内部优化,将状态更新批处理在一起。

由于componentWillReceiveProps()方法将为StreamTweet组件接收到的每条新推文调用一次,因此它是一个很好的地方来计算接收到的推文总数:

window.snapterest.numberOfReceivedTweets++;

现在我们知道如何检查下一条推文是否比我们当前显示的推文更长,但是我们如何选择根本不渲染下一条推文呢?

shouldComponentUpdate 方法

shouldComponentUpdate()方法允许我们决定下一个组件状态是否应该触发组件的重新渲染。该方法返回一个布尔值,默认为true,但您可以返回false,那么以下组件方法将不会被调用:

  • componentWillUpdate()

  • render()

  • componentDidUpdate()

跳过对组件的render()方法的调用将阻止该组件重新渲染,从而提高应用程序的性能,因为不会进行额外的 DOM 变化。

这个方法在组件生命周期的更新阶段中第二次被调用。

这个方法非常适合我们防止显示下一条推文长度为一或更少字符。在componentWillReceiveProps()方法之后,将此代码添加到StreamTweet组件中:

shouldComponentUpdate(nextProps, nextState) {
  console.log('[Snapterest] StreamTweet: 5\. Running shouldComponentUpdate()');

  return (nextProps.tweet.text.length > 1);
}

如果下一个 tweet 的长度大于 1,则 shouldComponentUpdate() 返回 true,并且 StreamTweet 组件渲染下一个 tweet。否则,它返回 false,并且 StreamTweet 组件不渲染下一个状态。

componentWillUpdate 方法

componentWillUpdate() 方法在 React 更新 DOM 之前立即 被调用。它接收以下两个参数:

  • nextProps: 下一个属性对象

  • nextState: 下一个状态对象

您可以使用这些参数来准备 DOM 更新。但是,您不能在 componentWillUpdate() 方法中使用 this.setState()。如果您想要在响应属性更改时更新组件的状态,则在 componentWillReceiveProps() 方法中执行此操作,React 在属性更改时会调用该方法。

为了演示 componentWillUpdate() 方法何时被调用,我们需要在 StreamTweet 组件中记录它。在 shouldComponentUpdate() 方法之后添加以下代码:

componentWillUpdate(nextProps, nextState) {
  console.log('[Snapterest] StreamTweet: 6\. Running componentWillUpdate()');
}

在调用 componentWillUpdate() 方法后,React 调用执行 DOM 更新的 render() 方法。然后,调用 componentDidUpdate() 方法。

componentDidUpdate 方法

componentDidUpdate() 方法在 React 更新 DOM 之后立即 被调用。它接收这两个参数:

  • prevProps: 先前的属性对象

  • prevState: 先前的状态对象

我们将使用这个方法与更新后的 DOM 进行交互或执行任何后渲染操作。在我们的 StreamTweet 组件中,我们将使用 componentDidUpdate() 来增加全局对象中显示的推文数量。在 componentWillUpdate() 方法之后添加以下代码:

componentDidUpdate(prevProps, prevState) {
  console.log('[Snapterest] StreamTweet: 7\. Running componentDidUpdate()');

  window.snapterest.numberOfDisplayedTweets++;
}

在调用 componentDidUpdate() 后,更新周期结束。当组件的状态更新或父组件传递新属性时,会启动新的周期。或者当您调用 forceUpdate() 方法时,它会触发新的更新周期,但会跳过触发更新的组件上的 shouldComponentUpdate() 方法。然而,shouldComponentUpdate() 会按照通常的更新阶段在所有子组件上调用。尽量避免使用 forceUpdate() 方法;这将提高应用程序的可维护性。

这结束了我们对 React 组件生命周期方法的讨论。

设置默认的 React 组件属性

正如您从上一章所知,我们的 StreamTweet 组件渲染了两个子组件:HeaderTweet

让我们创建这些组件。要做到这一点,导航到~/snapterest/source/components/并创建Header.js文件:

import React from 'react';

export const DEFAULT_HEADER_TEXT = 'Default header';

const headerStyle = {
  fontSize: '16px',
  fontWeight: '300',
  display: 'inline-block',
  margin: '20px 10px'
};

class Header extends React.Component {

  render() {
    const { text } = this.props;

    return (
      <h2 style={headerStyle}>{text}</h2>
    );
  }
}

Header.defaultProps = {
  text: DEFAULT_HEADER_TEXT
};

export default Header;

正如您所看到的,我们的Header组件是一个无状态组件,渲染h2元素。标题文本作为this.props.text属性从父组件传递,这使得该组件灵活,可以在需要标题的任何地方重用。我们稍后将在本书中再次重用此组件。

注意h2元素有一个style属性。

在 React 中,我们可以在 JavaScript 对象中定义 CSS 规则,然后将该对象作为值传递给 React 元素的style属性。例如,在这个组件中,我们定义了headerStyle变量,引用了一个对象,其中:

  • 每个对象键都是一个 CSS 属性。

  • 每个对象值都是一个 CSS 值。

包含连字符的 CSS 属性应转换为驼峰式风格;例如,font-size变成fontSizefont-weight变成fontWeight

将 CSS 规则定义在 React 组件内部的优势如下:

  • 可移植性:您可以轻松地共享一个组件以及其样式,全部在一个 JavaScript 文件中。

  • 封装性:内联样式可以限制其影响范围。

  • 灵活性:CSS 规则可以使用 JavaScript 的强大功能进行计算。

使用这种技术的一个显著缺点是内容安全策略CSP)可能会阻止内联样式产生任何效果。您可以在developer.mozilla.org/en-US/docs/Web/HTTP/CSP了解更多关于 CSP 的信息。

我们的Header组件有一个我们尚未讨论的属性,即defaultProps。如果忘记传递一个 React 组件依赖的属性会怎么样?在这种情况下,组件可以使用defaultProps属性设置默认属性;请考虑以下示例:

Header.defaultProps = {
  text: DEFAULT_HEADER_TEXT
};

在这个例子中,我们将text属性的默认值设置为'Default header'。如果父组件传递了this.props.text属性,那么它将覆盖默认值。

接下来,让我们创建我们的Tweet组件。要做到这一点,导航到~/snapterest/source/components/并创建Tweet.js文件:

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

const tweetStyle = {
  position: 'relative',
  display: 'inline-block',
  width: '300px',
  height: '400px',
  margin: '10px'
};

const imageStyle = {
  maxHeight: '400px',
  maxWidth: '100%',
  boxShadow: '0px 1px 1px 0px #aaa',
  border: '1px solid #fff'
};

class Tweet extends React.Component {
  handleImageClick() {
    const { tweet, onImageClick } = this.props;

    if (onImageClick) {
      onImageClick(tweet);
    }
  }

  render() {
    const { tweet } = this.props;
    const tweetMediaUrl = tweet.media[0].url;

    return (
      <div style={tweetStyle}>
        <img
          src={tweetMediaUrl}
          onClick={this.handleImageClick}
          style={imageStyle}
        />
      </div>
    );
  }
}

Tweet.propTypes = {
  tweet: (properties, propertyName, componentName) => {
    const tweet = properties[propertyName];

    if (! tweet) {
      return new Error('Tweet must be set.');
    }

    if (! tweet.media) {
      return new Error('Tweet must have an image.');
    }
  },
  onImageClick: PropTypes.func
};

export default Tweet;

该组件渲染一个带有子<img>元素的<div>元素。这两个元素都有内联样式,而<img>元素有一个点击事件处理程序,即this.handleImageClick

handleImageClick() {
  const { tweet, onImageClick } = this.props;

  if (onImageClick) {
    onImageClick(tweet);
  }
}

当用户点击推文的图片时,Tweet组件会检查父组件是否将this.props.onImageClick回调函数作为属性传递,并调用该函数。this.props.onImageClick属性是一个可选的Tweet组件属性,因此我们需要检查它是否被传递才能使用它。另一方面,tweet是一个必需的属性。

我们如何确保组件接收到所有必需的属性?

验证 React 组件属性

在 React 中,有一种方法可以使用组件的propTypes对象来验证组件属性:

Component.propTypes = {
  propertyName: validator
};

在此对象中,您需要指定属性名称和验证函数,该函数将确定属性是否有效。React 为您提供了一些预定义的验证器供您重用。它们都在prop-types包的PropTypes对象中可用:

  • PropTypes.number:这将验证属性是否是数字

  • PropTypes.string:这将验证属性是否是字符串

  • PropTypes.bool:这将验证属性是否是布尔值

  • PropTypes.object:这将验证属性是否是对象

  • PropTypes.element:这将验证属性是否是 React 元素

要获取PropTypes验证器的完整列表,您可以在facebook.github.io/react/docs/typechecking-with-proptypes.html上查看文档。

默认情况下,您使用PropTypes验证器验证的所有属性都是可选的。您可以将它们中的任何一个与isRequired链接在一起,以确保在属性丢失时在 JavaScript 控制台上显示警告消息:

Component.propTypes = {
  propertyName: PropTypes.number.isRequired
};

您还可以指定自己的自定义验证器函数,如果验证失败,应该返回一个Error对象:

Component.propTypes = {
  propertyName(properties, propertyName, componentName) {
    // ... validation failed
    return new Error('A property is not valid.');
  }
};

让我们看看我们Tweet组件中的propTypes对象:

Tweet.propTypes = {
  tweet(properties, propertyName, componentName) {
    const tweet = properties[propertyName];

    if (!tweet) {
      return new Error('Tweet must be set.');
    }

    if (!tweet.media) {
      return new Error('Tweet must have an image.');
    }
  },
  onImageClick: PropTypes.func
};

如您所见,我们正在验证两个Tweet组件属性:tweetonImageClick

我们使用自定义验证器函数来验证tweet属性。React 向此函数传递三个参数:

  • properties:这是组件属性对象

  • propertyName:这是我们正在验证的属性的名称

  • componentName:这是组件的名称

我们首先检查我们的Tweet组件是否收到了tweet属性:

const tweet = properties[propertyName];

if (!tweet) {
  return new Error('Tweet must be set.');
}

然后,我们假设tweet属性是一个对象,并检查该对象是否没有media属性:

if (!tweet.media) {
  return new Error('Tweet must have an image.');
}

这两个检查都返回一个Error对象,将在 JavaScript 控制台中记录。

我们将验证另一个Tweet组件的属性onImageClick

onImageClick: PropTypes.func

我们验证onImageClick属性的值是否为函数。在这种情况下,我们重用了PropTypes对象提供的验证函数。正如您所看到的,onImageClick是一个可选属性,因为我们没有添加isRequired

最后,出于性能原因,propTypes仅在 React 的开发版本中进行检查。

创建一个 Collection 组件

您可能还记得我们的最顶层层次结构Application组件有两个子组件:StreamCollection

到目前为止,我们已经讨论并实现了我们的Stream组件及其子组件。接下来,我们将专注于我们的Collection组件。

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

import React, { Component } from 'react';
import ReactDOMServer from 'react-dom/server';
import CollectionControls from './CollectionControls';
import TweetList from './TweetList';
import Header from './Header';

class Collection extends Component {
  createHtmlMarkupStringOfTweetList = () => {
    const { tweets } = this.props;

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

    const htmlMarkup = {
      html: htmlString
    };

    return JSON.stringify(htmlMarkup);
  }

  getListOfTweetIds = () =>
    Object.keys(this.props.tweets)

  getNumberOfTweetsInCollection = () =>
    this.getListOfTweetIds().length

  render() {
    const numberOfTweetsInCollection = this.getNumberOfTweetsInCollection();

    if (numberOfTweetsInCollection > 0) {
      const {
        tweets,
        onRemoveAllTweetsFromCollection,
        onRemoveTweetFromCollection
      } = this.props;

      const htmlMarkup = this.createHtmlMarkupStringOfTweetList();

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

          <TweetList
            tweets={tweets}
            onRemoveTweetFromCollection={onRemoveTweetFromCollection}
          />

        </div>
      );
    }

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

export default Collection;

我们的Collection组件负责渲染两件事:

  • 用户收集的推文

  • 用于操作该收藏的用户界面控制元素

让我们来看看组件的render()方法:

render() {
  const numberOfTweetsInCollection = this.getNumberOfTweetsInCollection();

  if (numberOfTweetsInCollection > 0) {
    const {
      tweets,
      onRemoveAllTweetsFromCollection,
      onRemoveTweetFromCollection
    } = this.props;

    const htmlMarkup = this.createHtmlMarkupStringOfTweetList();

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

        <TweetList
          tweets={tweets}
          onRemoveTweetFromCollection={onRemoveTweetFromCollection}
        />

      </div>
    );
  }

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

我们首先使用this.getNumberOfTweetsInCollection()方法获取收藏中的推文数量:

getNumberOfTweetsInCollection = () =>this.getListOfTweetIds().length

这种方法又使用另一种方法来获取推文 ID 列表:

getListOfTweetIds = () => Object.keys(this.props.tweets);

this.getListOfTweetIds()函数调用返回一个推文 ID 数组,然后this.getNumberOfTweetsInCollection()返回该数组的长度。

在我们的render()方法中,一旦我们知道收藏中的推文数量,我们必须做出选择:

  • 如果收藏为空,则渲染CollectionControlsTweetList组件

  • 否则,渲染Header组件

所有这些组件都渲染什么?

  • CollectionControls组件渲染一个带有收藏名称和一组按钮的标题,允许用户重命名、清空和导出收藏

  • TweetList组件渲染推文列表

  • Header组件只是渲染一个消息头,说明收藏是空的

想法是只有在收藏不为空时才显示收藏。在这种情况下,我们创建了四个变量:

const {
  tweets,
  onRemoveAllTweetsFromCollection,
  onRemoveTweetFromCollection
} = this.props;

const htmlMarkup = this.createHtmlMarkupStringOfTweetList();
  • tweets变量引用了我们从父组件传递的tweets属性

  • htmlMarkup变量引用了组件的this.createHtmlMarkupStringOfTweetList()函数调用返回的字符串

  • onRemoveAllTweetsFromCollectiononRemoveTweetFromCollection变量引用了从父组件传递的函数

正如其名称所示,this.createHtmlMarkupStringOfTweetList()方法创建一个代表通过渲染TweetList组件创建的 HTML 标记的字符串:

createHtmlMarkupStringOfTweetList = () => {
  const { tweets } = this.props;

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

  const htmlMarkup = {
    html: htmlString
  };

  return JSON.stringify(htmlMarkup);
}

createHtmlMarkupStringOfTweetList()方法使用了我们在第三章中讨论过的ReactDOMServer.renderToStaticMarkup()函数,创建你的第一个 React 元素。我们将TweetList组件作为其参数传递:

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

这个TweetList组件有一个tweets属性,引用了父组件传递的tweets属性。

ReactDOMServer.renderToStaticMarkup()函数产生的结果 HTML 字符串存储在htmlString变量中。然后,我们创建一个新的htmlMarkup对象,其html属性引用了我们的htmlString变量。最后,我们使用JSON.stringify()函数将我们的htmlMarkup JavaScript 对象转换为 JSON 字符串。JSON.stringify(htmlMarkup)函数调用的结果就是我们的createHtmlMarkupStringOfTweetList()方法返回的内容。

这个方法展示了 React 组件有多么灵活;你可以使用相同的 React 组件来渲染 DOM 元素,也可以生成一个 HTML 标记字符串,可以传递给第三方 API。

另一个有趣的观察是在render()方法之外使用 JSX 语法。事实上,你可以在源文件的任何地方使用 JSX,甚至在组件类声明之外。

让我们更仔细地看一下当我们的集合为空时,Collection组件返回了什么:

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

    <TweetList
      tweets={tweets}
      onRemoveTweetFromCollection={onRemoveTweetFromCollection}
    />

  </div>
);

我们将CollectionControlsTweetList组件包裹在<div>元素中,因为 React 只允许一个根元素。让我们看看每个组件并讨论它的属性。

我们将以下三个属性传递给CollectionControls组件:

  • numberOfTweetsInCollection属性引用了我们集合中当前的推文数量。

  • htmlMarkup属性引用了我们在这个组件中使用createHtmlMarkupStringOfTweetList()方法产生的 HTML 标记字符串。

  • onRemoveAllTweetsFromCollection属性引用了一个从我们的集合中移除所有推文的函数。这个函数是在Application组件中实现的,并在第五章中讨论,使你的 React 组件响应式

我们将这两个属性传递给TweetList组件:

  • tweets属性引用了从父Application组件传递的 tweets。

  • onRemoveTweetFromCollection属性引用了一个函数,该函数从我们在Application组件的状态中存储的一组 tweets 中移除一个 tweet。我们已经在第五章中讨论过这个函数,使您的 React 组件响应式

这就是我们的Collection组件。

总结

在本章中,您了解了组件生命周期的更新方法。我们还讨论了如何验证组件属性并设置默认属性值。我们还在我们的 Snapterest 应用程序中取得了良好的进展;我们创建并讨论了HeaderTweetCollection组件。

在下一章中,我们将专注于构建更复杂的 React 组件,并完成构建我们的 Snapterest 应用程序!

第八章:构建复杂的 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 库进一步简化我们应用程序的架构。

第十二章:使用 Redux 完善 Flux 应用程序

前一章向您介绍了在 Flux 架构之上构建的完整的 React 应用程序的实现。在本章中,您将对此应用程序进行一些修改,以便它使用 Redux 库来实现 Flux 架构。本章的组织方式如下:

  • Redux 的简要概述

  • 实现控制状态的减速器功能

  • 构建 Redux 动作创建者

  • 将组件连接到 Redux 存储库

  • Redux 进入应用程序状态的入口点

为什么选择 Redux?

在开始重构应用程序之前,我们将花几分钟时间高层次地了解 Redux。足够激发您的兴趣。准备好了吗?

一切由一个存储库控制

传统 Flux 应用程序和 Redux 之间的第一个主要区别是,使用 Redux 时,您只有一个存储库。传统的 Flux 架构可能也只需要一个存储库,但可能有几个存储库。您可能会认为拥有多个存储库实际上可以简化架构,因为您可以通过应用程序的不同部分分离状态。的确,这是一个不错的策略,但在实践中并不一定成立。创建多个存储库可能会导致混乱。存储库是架构中的移动部件;如果您有更多的存储库,就会有更多的可能出现问题的地方。

Redux 通过只允许一个存储库来消除了这一因素。您可能会认为这会导致一个庞大的数据结构,难以供各种应用程序功能使用。但事实并非如此,因为您可以自由地按照自己的意愿构建存储库。

更少的移动部件

通过只允许一个存储库,Redux 将移动部件排除在外。Redux 简化架构的另一个地方是消除了对专用调度程序的需求。在传统的 Flux 架构中,调度程序是一个独立的组件,用于向存储库发送消息。由于 Redux 架构中只有一个存储库,您可以直接将操作分派到存储库。换句话说,存储库就是调度程序。

Redux 在代码中减少移动部件数量的最终位置是事件监听器。在传统的 Flux 应用程序中,您必须手动订阅和取消订阅存储事件,以正确地连接一切。当您可以让一个库处理连接工作时,这会分散注意力。这是 Redux 擅长的事情。

使用 Flux 的最佳部分

Redux 并不是传统意义上的 Flux。Flux 有一个规范和一个实现它的库。Redux 不是这样的。正如前面所提到的,Redux 是对 Flux 的简化。它保留了所有导致健壮应用架构的 Flux 概念,同时忽略了那些让 Flux 难以实现和最终难以采用的繁琐部分。

用减速器控制状态

Redux 的旗舰概念是,状态由减速器函数控制。在本节中,我们将让你了解减速器是什么,然后实现在你的 Snapterest 应用中的减速器函数。

什么是减速器?

减速器是函数,它接受一个数据集合,比如对象或数组,并返回一个新的集合。返回的集合可以包含与初始集合相同的数据,也可以包含完全不同的数据。在 Redux 应用中,减速器函数接受一个状态片段,并返回一个新的状态片段。就是这样!你刚刚学会了 Redux 架构的关键。现在让我们看看减速器函数的实际应用。

Redux 应用中的减速器函数可以分成代表它们所处理的应用状态部分的模块。我们将先看看 Snapterest 应用的集合减速器,然后是推文减速器。

集合减速器

现在让我们来看看改变应用状态部分的集合减速器函数。首先,让我们来看看完整的函数:

const collectionReducer = (
  state = initialState,
  action
) => {
  let tweet;
  let collectionTweets;

  switch (action.type) {
    case 'add_tweet_to_collection':
      tweet = {};
      tweet[action.tweet.id] = action.tweet;

      return {
        ...state,
        collectionTweets: {
          ...state.collectionTweets,
          ...tweet
        }
      };

    case 'remove_tweet_from_collection':
      collectionTweets = { ...state.collectionTweets };
      delete collectionTweets[action.tweetId];

      return {
        ...state,
        collectionTweets
      };

    case 'remove_all_tweets_from_collection':
      collectionTweets = {};

      return {
        ...state,
        collectionTweets
      };

    case 'set_collection_name':
      return {
        ...state,
        collectionName: state.editingName,
        isEditingName: false
      };

    case 'toggle_is_editing_name':
      return {
        ...state,
        isEditingName: !state.isEditingName
      };

    case 'set_editing_name':
      return {
        ...state,
        editingName: action.editingName
      };

    default:
      return state;
  }
};

正如你所看到的,返回的新状态是基于分发的动作。动作名称作为参数提供给这个函数。现在让我们来看看这个减速器的不同情景。

将推文添加到集合中

让我们来看看add_tweet_to_collection动作:

case 'add_tweet_to_collection':
  tweet = {};
  tweet[action.tweet.id] = action.tweet;

  return {
    ...state,
    collectionTweets: {
      ...state.collectionTweets,
      ...tweet
    }
  };

switch语句检测到动作类型add_tweet_to_collection。动作还有一个包含要添加的实际推文的推文属性。这里使用推文变量来构建一个以推文ID 为键,推文为值的对象。这是collectionTweets对象期望的格式。

然后我们返回新状态。重要的是要记住,这应该始终是一个新对象,而不是对其他对象的引用。这是你在 Redux 应用中避免意外副作用的方法。幸运的是,我们可以使用对象扩展运算符来简化这个任务。

从集合中删除推文

collectionTweets对象中删除推文意味着我们必须删除具有要删除的tweet ID 的键。让我们看看这是如何完成的:

case 'remove_tweet_from_collection':
  collectionTweets = { ...state.collectionTweets };
  delete collectionTweets[action.tweetId];

  return {
    ...state,
    collectionTweets
  };

注意我们如何将一个新对象分配给collectionTweets变量?再次,扩展运算符在这里非常有用,可以避免额外的语法。我们这样做的原因是为了使减速器始终返回一个新的引用。一旦我们从collectionTweets对象中删除推文,我们可以返回包括collectionTweets作为属性的新状态对象。

另一个推文删除动作是remove_all_tweets_from_collection。以下是它的样子:

case 'remove_all_tweets_from_collection':
  collectionTweets = {};

  return {
    ...state,
    collectionTweets
  };

删除所有推文意味着我们可以用新的空对象替换collectionTweets值。

设置集合名称

当一组推文被重命名时,我们必须更新 Redux 存储。这是通过在调度set_collection_name动作时从状态中获取editingName来完成的:

case 'set_collection_name':
  return {
    ...state,
    collectionName: state.editingName,
    isEditingName: false
  };

您可以看到collectionName值设置为editingNameisEditingName设置为false。这意味着自从值被设置以来,我们知道用户不再编辑名称。

编辑集合名称

您刚刚看到了如何在用户保存更改后设置集合名称。但是,当涉及在 Redux 存储中跟踪状态时,编辑文本还有更多内容。首先,我们必须允许文本首先被编辑;这给用户一些视觉提示:

case 'toggle_is_editing_name':
  return {
    ...state,
    isEditingName: !state.isEditingName
  };

然后,用户正在文本输入中积极输入的文本。这也必须在存储中找到一个位置:

case 'set_editing_name':
  return {
    ...state,
    editingName: action.editingName
  };

这不仅会导致适当的 React 组件重新渲染,而且意味着我们在状态中存储了文本,当用户完成编辑时可以使用。

推文减速器

推文减速器只需要处理一个动作,但这并不意味着我们不应该在推特减速器中单独设置模块,以预期未来的推文动作。现在,让我们专注于我们的应用当前的功能。

接收推文

让我们看一下处理receive_tweet动作的推文减速器代码:

const tweetReducer = (state = null, action) => {
  switch(action.type) {
    case 'receive_tweet':
      return action.tweet;
    default:
      return state;
  }
};

这个减速器非常简单。当调度receive_tweet动作时,action.tweet值将作为新状态返回。由于这是一个小的减速器函数,这可能是指出所有减速器函数共同点的好地方。

传递给 reducer 函数的第一个参数是旧状态。这个参数有一个默认值,因为第一次调用 reducer 时,没有状态,这个值用于初始化它。在这种情况下,默认状态是 null。

关于 reducer 的第二点是,当调用时它们总是返回一个新的状态。即使它不产生任何新的状态,reducer 函数也需要返回旧状态。Redux 会将 reducer 返回的任何内容设置为新状态,即使你返回 undefined。这就是为什么在你的 switch 语句中有一个 default 标签是个好主意。

简化的 action 创建者

在 Redux 中,action 创建者函数比传统的 Flux 对应函数更简单。主要区别在于 Redux 的 action 创建者函数只返回动作数据。在传统的 Flux 中,action 创建者还负责调用分发器。让我们来看看 Snapterest 的 Redux action 创建者函数。

export const addTweetToCollection = tweet => ({
  type: 'add_tweet_to_collection',
  tweet
});

export const removeTweetFromCollection = tweetId => ({
  type: 'remove_tweet_from_collection',
  tweetId
});

export const removeAllTweetsFromCollection = () => ({
  type: 'remove_all_tweets_from_collection'
});

export const setCollectionName = collectionName => ({
  type: 'set_collection_name',
  collectionName
});

export const toggleIsEditingName = () => ({
  type: 'toggle_is_editing_name'
});

export const setEditingName = editingName => ({
  type: 'set_editing_name',
  editingName
});

export const receiveTweet = tweet => ({
  type: 'receive_tweet',
  tweet
});

正如你所看到的,这些函数返回动作对象,然后可以被分发——它们实际上并不调用分发器。当我们开始将我们的 React 组件连接到 Redux 存储时,你会明白为什么会这样。在 Redux 应用中,action 创建者函数的主要责任是确保返回一个带有正确 type 属性的对象,以及与动作相关的属性。例如,addTweetToCollection() action 创建者接受一个 tweet 参数,然后通过将其作为返回对象的属性传递给动作。

将组件连接到应用状态

到目前为止,我们有处理创建新应用状态的 reducer 函数,以及触发我们的 reducer 函数的 action 创建者函数。我们仍然需要将我们的 React 组件连接到 Redux 存储。在本节中,您将学习如何使用 connect() 函数来创建一个连接到 Redux 存储的新版本组件。

将状态和 action 创建者映射到 props

Redux 和 React 集成的想法是告诉 Redux 用一个有状态的组件包装你的组件,当 Redux 存储改变时,它的状态也会被设置。我们所要做的就是编写一个函数,告诉 Redux 我们希望状态值以 props 的形式传递给我们的组件。此外,我们还需要告诉组件它可能想要分发的任何操作。

以下是我们连接组件时将遵循的一般模式:

connect(
  mapStateToProps,
  mapDispatchToProps
)(Component);

这是它的工作原理的分解:

  • 来自 React-Redux 包的connect()函数返回一个新的 React 组件。

  • mapStateToProps()函数接受一个状态参数,并返回一个基于该状态的属性值的对象。

  • mapDispatchToProps()函数接受一个dispatch()参数,用于分发操作,并返回一个包含可以分发操作的函数的对象。这些函数被添加到组件的 props 中。

  • Component是一个你想要连接到 Redux 存储的 React 组件。

当你开始连接组件时,你很快就会意识到 Redux 正在为你处理许多 React 组件的生命周期琐事。在你通常需要实现componentDidMount()功能的地方,突然间,你不需要了。这导致了清晰简洁的 React 组件。

连接流组件

让我们来看看Stream组件:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import StreamTweet from './StreamTweet';
import Header from './Header';
import TweetStore from '../stores/TweetStore';

class Stream extends Component {
  render() {
    const { tweet } = this.props;
    const { onAddTweetToCollection } = this.props;
    const headerText = 'Waiting for public photos from Twitter...';

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

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

const mapStateToProps = ({ tweet }) => ({ tweet });

const mapDispatchToProps = dispatch => ({});

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

从先前的实现中,Stream并没有太多改变。主要区别在于我们删除了一些生命周期方法。所有的 Redux 连接代码都在组件声明之后。mapStateToProps()函数从状态中返回tweet属性。所以现在我们的组件有了一个tweet属性。mapDispatchToProps()函数返回一个空对象,因为Stream不分发任何操作。当没有操作时,实际上不需要提供这个函数。然而,这可能会在将来发生变化,如果函数已经存在,你只需要向对象添加属性。

连接 StreamTweet 组件

Stream组件渲染了StreamTweet组件,所以让我们接着看下去:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import ReactDOM from 'react-dom';
import Header from './Header';
import Tweet from './Tweet';
import store from '../stores';
import { addTweetToCollection } from '../actions';

class StreamTweet extends Component {
  render() {
    const { tweet, onImageClick } = this.props;

    return (
      <section>
        <Header text="Latest public photo from Twitter"/>
        <Tweet
          tweet={tweet}
          onImageClick={onImageClick}
        />
      </section>
    );
  }
}

const mapStateToProps = state => ({});

const mapDispatchToProps = (dispatch, ownProps) => ({
  onImageClick: () => {
    dispatch(addTweetToCollection(ownProps.tweet));
  }
});

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

StreamTweet组件实际上并没有使用 Redux 存储中的任何状态。那么为什么要连接它呢?答案是,这样我们就可以将操作分发函数映射到组件 props 上。记住,在 Redux 应用中,操作创建函数只返回操作对象,而不是分发操作。

在这里的mapDispatchToProps()函数中,我们通过将其返回值传递给dispatch()来分发一个addTweetToCollection()操作。Redux 为我们提供了一个简单的分发函数,它绑定到 Redux 存储。每当我们想要分发一个操作时,我们只需要调用dispatch()。现在StreamTweet组件将有一个onImageClick()函数 prop,可以作为事件处理程序来处理点击事件。

连接集合组件

现在我们只需要连接Collection组件及其子组件。Collection组件的样子如下:

import React, { Component } from 'react';
import ReactDOMServer from 'react-dom/server';
import { connect } from 'react-redux';

import CollectionControls from './CollectionControls';
import TweetList from './TweetList';
import Header from './Header';
import CollectionUtils from '../utils/CollectionUtils';

class Collection extends Component {
  createHtmlMarkupStringOfTweetList() {
    const { collectionTweets } = this.props;
    const htmlString = ReactDOMServer.renderToStaticMarkup(
      <TweetList tweets={collectionTweets}/>
    );

    const htmlMarkup = {
      html: htmlString
    };

    return JSON.stringify(htmlMarkup);
  }

  render() {
    const { collectionTweets } = this.props;
    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"/>);
  }
}

const mapStateToProps = state => state.collection;

const mapDispatchToProps = dispatch => ({});

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

Collection组件不会分发任何操作,因此我们的mapDispatchToProps()函数返回一个空对象。但它确实使用了 Redux 存储中的状态,所以我们的mapStateToProps()实现返回state.collection。这是我们如何将整个应用程序的状态切片成组件关心的部分。例如,如果我们的组件除了Collection之外还需要访问其他状态,我们将返回一个由整体状态的不同切片组成的新对象。

连接集合控件

Collection组件内,我们有CollectionControls组件。让我们看看它连接到 Redux 存储后的样子:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import Header from './Header';
import Button from './Button';
import CollectionRenameForm from './CollectionRenameForm';
import CollectionExportForm from './CollectionExportForm';
import {
  toggleIsEditingName,
  removeAllTweetsFromCollection
} from '../actions';

class CollectionControls extends Component {
  getHeaderText = () => {
    const { numberOfTweetsInCollection } = this.props;
    const { collectionName } = this.props;
    let text = numberOfTweetsInCollection;

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

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

  render() {
    const {
      collectionName,
      isEditingName,
      htmlMarkup,
      onRenameCollection,
      onEmptyCollection
    } = this.props;

    if (isEditingName) {
      return (
        <CollectionRenameForm name={collectionName}/>
      );
    }

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

        <Button
          label="Rename collection"
          handleClick={onRenameCollection}
        />

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

        <CollectionExportForm
          html={htmlMarkup}
          title={collectionName}
        />
      </div>
    );
  }
}

const mapStateToProps = state => state.collection;

const mapDispatchToProps = dispatch => ({
  onRenameCollection: () => {
    dispatch(toggleIsEditingName());
  },
  onEmptyCollection: () => {
    dispatch(removeAllTweetsFromCollection());
  }
});

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

这一次,我们有一个组件需要从mapStateToProps()mapDispatchToProps()中获取对象。我们再次需要将集合状态作为 props 传递给这个组件。onRenameCollection()事件处理程序分发toggleIsEditingName()操作,而onEmptyCollection()事件处理程序分发removeAllTweetsFromCollection()操作。

连接TweetList组件

最后,我们有TweetList组件;让我们来看一下:

import React, { Component } from 'react';
import { connect } from 'react-redux';

import Tweet from './Tweet';
import { removeTweetFromCollection } from '../actions';

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];

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

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

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

const mapStateToProps = () => ({});

const mapDispatchToProps = dispatch => ({
  onRemoveTweetFromCollection: ({ id }) => {
    dispatch(removeTweetFromCollection(id));
  }
});

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

这个组件不依赖 Redux 存储的任何状态。但它确实将一个操作分发函数映射到它的 props。我们不一定需要在这里连接分发器。例如,如果这个组件的父组件正在连接函数到分发器,那么函数可以在那里声明并作为 props 传递到这个组件中。好处是TweetList将不再需要 Redux。缺点是在一个组件中声明太多的分发函数。幸运的是,您可以使用任何您认为合适的方法来实现您的组件。

创建存储并连接您的应用程序

我们几乎完成了将 Snapterest 应用程序从传统的 Flux 架构重构为基于 Redux 的架构。只剩下两件事要做。

首先,我们必须将我们的减速器函数组合成一个单一的函数,以便创建一个存储:

import { combineReducers } from 'redux'
import collection from './collection';
import tweet from './tweet';

const reducers = combineReducers({
  collection,
  tweet
})

export default reducers;

这使用combineReducers()函数来获取我们两个现有的减速器函数,这些函数存在于它们自己的模块中,并产生一个单一的减速器,我们可以用来创建一个 Redux 存储:

import { createStore } from 'redux';
import reducers from '../reducers';

export default createStore(reducers);

现在我们创建了 Redux 存储库,其中包含默认情况下由减速器函数提供的初始状态。现在我们只需将此存储库传递给我们的顶层 React 组件:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

import Application from './components/Application';
import { initializeStreamOfTweets } from './utils/WebAPIUtils';
import store from './stores';

initializeStreamOfTweets(store);

ReactDOM.render(
  <Provider store={store}>
    <Application/>
  </Provider>,
  document.getElementById('react-application')
);

Provider组件包装了我们的顶层应用程序组件,并为其提供了状态更新,以及任何依赖应用程序状态的子组件。

总结

在本章中,您学习了如何使用 Redux 库来完善您的 Flux 架构。Redux 应用程序应该只有一个存储库,动作创建者可以很简单,而减速器函数控制着不可变状态的转换。简而言之,Redux 的目标是减少传统 Flux 架构中通常存在的移动部件的数量,同时保留单向数据流。

然后,您使用 Redux 实现了 Snapterest 应用程序。从减速器开始,每当分派有效动作时,您都会为 Redux 存储库返回一个新状态。然后,您构建了动作创建者函数,返回一个带有正确类型属性的对象。最后,您重构了组件,使它们连接到 Redux。您确保组件可以读取存储库数据并分派动作。

这就是这本书的总结。我希望您已经学会了关于 React 开发基础的足够知识,以便通过学习更高级的 React 主题来继续您的发现之旅。更重要的是,我希望您通过构建令人敬畏的 React 应用程序并使其更加完善,从而更多地了解了 React。

标签:const,第二,基础知识,React,tweet,组件,import,React16,我们
From: https://www.cnblogs.com/apachecn/p/18195940

相关文章

  • 5.15基础知识
    1、常见端口21FTP,22ssh,6379redis,23telnet,25smtp,7001weblogic,445,tcp的局域网文件打印和共享服务,139基于smb,135RPC通信,1433SQLserver,1521orcal,443https3389远程桌面,27107mongdb数据库;2、思路信息收集、信息收集、信息收集网站查ip、旁站、子域名(挖掘机layer)端口扫描(telnet......
  • 第二次Scrum Meeting
    这个作业属于哪个课程软件工程2024这个作业的要求是什么团队作业4——项目冲刺这个作业的目标完成第二篇Scrum冲刺博客第二天会议记录昨天已完成的任务收集“十一冷却法测量金属比热容”的相关公式实现“十一冷却法测量金属比热容”的基础页面今天......
  • 庆余年2全集下载/电视剧庆余年第二季迅雷高清bt完整版
    《庆余年2》:谱写壮丽篇章,延续传奇情怀当《庆余年2》全集火热上线时,电视剧迷们再一次沉浸在这部剧的世界中。继第一季的热播之后,第二季以更加精彩的剧情、更加出彩的演员阵容,再次征服了观众的心。这部作品以其独特的魅力,再次引爆了观众的热情,成为了当下最受关注的电......
  • selenium UI自动化基础知识
    元素定位:1.怎么判断元素是否存在?判断元素是否存在和是否出现不同,判断是否存在意味着如果这个元素压根就不存在,就会抛出NoSuchElementException这样就可以使用trycatch,如果catch到NoSuchElementException就返回false。通常在项目中会把这个功能封装在isElementPresent方法中......
  • 第二篇scrum冲刺
    作业所属课程软工2024作业要求自我介绍+软工5问作业目标学习使用一些好用实用的工具。熟悉作业提交的方法和格式。督促我翻阅课本。明确自己的学习方向任务分块为以下部分素材寻找界面设计确定可用api数据解析具体实现测试今天完成素材寻找任务,目......
  • 第二届黄河流域网络安全技能挑战赛-esay_encrypt
    其他是一题不会啊而且,前200就有奖?这么好?cryptoesay_encrypt手推一遍(字丑勿喷)exp:fromCrypto.Util.numberimport*"""fromsecretimportflagdeff(word,key):out=""foriinrange(len(word)):out+=chr(ord(word[i])^key)re......
  • 第二届黄河流域网络安全技能挑战赛Web_wirteup
    前言好久没写过比赛的wp了,黄河流域的web出的不错,挺有意思了,花了点时间,也是成功的ak了myfavorPython注册登录,一个base64输入框,猜测pickle反序列化,简单测试下,返回的数据是pickletools.dis解析的opcode结构,猜测其实已经load了,但是没回显,写个反弹shell的opcode:importpickleimpor......
  • 代码随想录算法训练营第第二天 | 24. 两两交换链表中的节点 、19.删除链表的倒数第N
    两两交换链表中的节点用虚拟头结点,这样会方便很多。本题链表操作就比较复杂了,建议大家先看视频,视频里我讲解了注意事项,为什么需要temp保存临时节点。题目链接/文章讲解/视频讲解:https://programmercarl.com/0024.两两交换链表中的节点.html/***Definitionforsingly-li......
  • 祝贺!触想获评第二十一届“深圳知名品牌”
    5月9日,第八届“深圳(湾区)国际品牌周”活动盛大开幕,会上公布并表彰了一批具有高创新力和竞争力的品牌名单。作为工控物联领域优秀品牌代表,触想智能与各级政府领导、国内外品牌界权威专家、知名企业领袖和企业代表同台共庆,并收获“深圳知名品牌”、“湾区知名品牌”两项荣誉认......
  • 蓝桥杯训练第二周
    P2015二叉苹果树-洛谷|计算机科学教育新生态(luogu.com.cn)屮,一开始想当然的以为剪掉了其中一个边,其子树部分全部都会脱落,没想到剪掉一个边紧紧只是剪掉一个边,子树不会消失很明显的,我们要考虑树形$dp$,因为剪掉哪条边是不确定的,那么暴力求的话,每条边都剪或不剪,时......