六、组件模式

在本章中,我们将继续介绍之前构建的 React 商店。我们将构建一个可重用的选项卡组件以及一个可重用的加载指示器组件,这两个组件都将在我们商店的产品页面上使用。本章首先将产品页面拆分为一个容器和一个呈现组件,然后再使用 tab 组件,利用复合组件和呈现道具模式。然后,我们将继续使用高阶组件模式实现加载指示器组件。

在本章中,我们将学习以下主题:

  • 容器和表示组件
  • 复合成分
  • 渲染道具图案
  • 高阶分量

技术要求

在本章中,我们将使用以下技术:

All the code snippets in this chapter can be found online at: https://github.com/carlrip/LearnReact17WithTypeScript/tree/master/06-ComponentPatterns.

容器和表示组件

将页面拆分为容器和表示组件使表示组件更易于重用。容器组件负责如何工作、从 web API 获取任何数据以及管理状态。表象部分负责事物的外观。表示组件通过其属性接收数据,并且还具有属性事件处理程序,以便其容器可以管理用户交互。

我们将在 React shop 中使用此模式将产品页面拆分为容器和表示组件。ProductPage组件将是容器,我们将引入一个名为Product的新组件,它将是表示组件:

  1. 让我们首先以 Visual Studio 代码打开我们的商店项目,并在终端中输入以下命令以启动应用程序:
npm start
  1. 如果我们导航到一个产品,让我们提醒自己产品页面是什么样子的:

  1. 让我们创建一个名为Product.tsx的新文件,该文件将包含具有以下内容的呈现组件:
import * as React from "react";

const Product: React.SFC<{}> = props => {
  return <React.Fragment>TODO</React.Fragment>;
};

export default Product;

我们的呈现组件是一个功能组件。

  1. 演示组件通过道具接收数据,也通过道具委托事件处理。因此,让我们为产品数据项创建道具,不管它是否已添加到篮子中,以及将其添加到篮子中的处理程序:
import * as React from "react";
import { IProduct } from "./ProductsData";

interface IProps {
 product: IProduct;
 inBasket: boolean;
 onAddToBasket: () => void;
}
const Product: React.SFC<IProps> = props => {
  return <React.Fragment>TODO</React.Fragment>;
};

export default Product;
  1. 如果我们看一下ProductsPage.tsx,我们将在有React.Fragment部分的产品时复制 JSX。然后我们将其粘贴到Product组件的返回语句中:
const Product: React.SFC<IProps> = props => {
  return (
    <React.Fragment>
 <h1>{product.name}</h1>
 <p>{product.description}</p>
 <p className="product-price">
 {new Intl.NumberFormat("en-US", {
 currency: "USD",
 style: "currency"
 }).format(product.price)}
 </p>
 {!this.state.added && (
 <button onClick={this.handleAddClick}>Add to basket</button>
 )}
 </React.Fragment>
  );
};

我们现在有一些参考问题需要解决。

  1. 让我们在 return 语句上方定义一个产品变量,以解决 JSX 中产品引用的问题:
const product = props.product;
return ( 
  ...
)
  1. 现在通过inBasket道具传递产品是否在篮子中。因此,让我们更改“添加到篮子”按钮周围的条件以使用此道具:
{!props.inBasket && (
  <button onClick={this.handleAddClick}>Add to basket</button>
)}
  1. 我们需要解决的最后一个参考问题是单击 addtobasket 按钮的处理程序。让我们首先创建一个只调用onAddToBasket道具的处理程序:
const product = props.product;

const handleAddClick = () => {
 props.onAddToBasket();
};

return (
  ...
);
  1. 我们可以删除在 JSX 中引用此处理程序的this引用:
{!props.inBasket && (
  <button onClick={handleAddClick}>Add to basket</button>
)}

这是我们目前完成的Product表现部分。那么,让我们在ProductPage组件中引用Product组件。

  1. 首先,我们将Product组件导入ProductPage.tsx
import Product from "./Product";
  1. 现在,让我们用Product组件替换我们在 JSX 中复制的部分:
return (
 <div className="page-container">
   <Prompt when={!this.state.added} message={this.navAwayMessage} />
   {product ? (
     <Product
 product={product}
 inBasket={this.state.added}
 onAddToBasket={this.handleAddClick}
 />
   ) : (<p>Product not found!</p>)}
 </div>
);

我们将产品、产品是否已添加到篮子中以及添加到篮子中的处理程序作为道具一起传递给Product组件。

如果我们再看一次商店,转到产品页面,它看起来会完全一样。

因此,我们刚刚实现了第一个容器和表示组件。容器组件是页面中的顶级组件,可以从 web API 获取数据,并管理页面中的所有状态。呈现组件只关注需要呈现到屏幕上的内容。这种模式的一个好处是,可以更容易地在应用程序的其他地方使用呈现组件。例如,我们的Product组件可以相当容易地用于我们在商店中创建的其他页面。这种模式的另一个好处是,表示组件通常更容易进行单元测试。在我们的示例中,我们的Product组件是一个纯函数,因此单元测试这只是一个检查不同输入的输出是否正确的案例,因为没有副作用。我们将在本书后面详细介绍单元测试。

在下一节中,我们将继续增强我们的产品页面,向其添加评论,并添加选项卡以将产品描述与评论分开。

复合成分

复合组件是一起工作的一组组件。我们将使用此模式创建一个可重用的选项卡组件,用于在产品页面上分离产品描述。

向产品添加评论

在我们创建Tabs复合组件之前,让我们将评论添加到产品页面:

  1. 首先,我们需要在ProductsData.ts中为评审数据结构添加一个接口:
export interface IReview {
  comment: string;
  reviewer: string;
}
  1. 现在,我们可以向产品界面添加评论:
export interface IProduct {
  ...
  reviews: IReview[];
}
  1. 现在,我们可以将评论添加到我们的产品数据阵列中:
const products: IProduct[] = [
  {
    id: 1,
    ...
    reviews: [
 {
 comment: "Excellent! This does everything I want",
 reviewer: "Billy"
 },
 { comment: "The best router I've ever worked with", reviewer: 
      "Sally" }
 ]
  },
  {
    id: 2,
    ..
    reviews: [
 {
 comment: "I've found this really useful in a large app I'm 
        working on",
 reviewer: "Billy"
 },
 {
 comment: "A bit confusing at first but simple when you get   
        used to it",
 reviewer: "Sally"
 }
 ]
  },
  {
    id: 3,
    ..
    reviews: [
 {
 comment: "I'll never work with a REST API again!",
 reviewer: "Billy"
 },
 {
 comment: "It makes working with GraphQL backends a breeze",
 reviewer: "Sally"
 }
 ]
  }
];

因此,我们为每个产品添加一个reviews属性,它是一系列评论。每个审查都是一个包含IReview接口定义的commentreviewer属性的对象。

  1. 有了我们的数据,让我们在描述之后将评论添加到Product组件中:
<p>{product.description}</p>
<div>
 <ul className="product-reviews">
 {product.reviews.map(review => (
 <li key={review.reviewer} className="product-reviews-item">
 <i>"{review.comment}"</i> - {review.reviewer}
 </li>
 ))}
 </ul>
</div>
<p className="product-price">
  ...
</p>

因此,我们使用reviews数组上的map函数在列表中显示commentreviewer

  1. 我们已经引用了一些新的 CSS 类,所以让我们将它们添加到index.css中:
.product-reviews {
  list-style: none;
  padding: 0px;
}
.product-reviews .product-reviews-item {
  display: block;
  padding: 8px 0px;
}

如果我们查看 running 应用程序并转到某个产品,我们现在将看到评论:

现在我们已经添加了评论,我们可以在下一节中处理Tabs组件

创建基本选项卡组件

我们现在的工作是使用我们将要构建的选项卡组件将描述与评论分开。我们将首先创建一个简单的 tab 组件,并在下一节将其重构为复合组件模式

现在开始我们的选项卡组件:

  1. 首先,让我们为选项卡组件创建一个名为Tabs.tsx的文件,其中包含以下内容作为骨架类组件:
import * as React from "react";

interface IProps {}
interface IState {}
class Tabs extends React.Component<IProps, IState> {
  public constructor(props: IProps) {
    super(props);
    this.state = {};
  }
  public render() {
    return;
  }
}

export default Tabs;

我们选择创建一个基于类的组件,因为我们的组件将必须跟踪哪个选项卡标题处于活动状态

  1. 因此,让我们通过添加一个属性来完成状态的接口,该属性将给出活动标题名称:
interface IState {
  activeHeading: string;
}
  1. 我们的组件将接受选项卡标题并将其显示为属性。那么,让我们完成这个界面:
interface IProps {
  headings: string[];
}

因此,我们的组件可以在headings道具中接受标题名称数组。

  1. 现在让我们在构造函数中为activeHeading状态创建初始值:
public constructor(props: IProps) {
  super(props);
  this.state = {
    activeHeading:
 this.props.headings && this.props.headings.length > 0
 ? this.props.headings[0]
 : ""
  };
}

因此,活动航向最初将设置为headings数组中的第一个元素。如果消费者没有向我们的组件传递任何选项卡,则三元函数确保我们的组件不会产生错误。

  1. 现在转到渲染方法,让我们通过映射headings属性在列表中渲染选项卡:
    public render() {
      return (
        <ul className="tabs">
          {this.props.headings.map(heading => (
            <li className={heading === this.state.activeHeading ? 
            "active" : ""}
            >
              {heading}
            </li>
          ))}
        </ul>
      );
    }

我们已经引用了一些 CSS 类,包括active,它是根据是否是正在呈现的活动选项卡标题使用三元设置的。

  1. 现在我们将这些 CSS 类添加到index.css中:
.tabs {
  list-style: none;
  padding: 0;
}
.tabs li {
  display: inline-block;
  padding: 5px;
  margin: 0px 5px 0px 5px;
  cursor: pointer;
}
.tabs li:focus {
  outline: none;
}
.tabs li.active {
  border-bottom: #222 solid 2px;
}

在看到选项卡组件的外观之前,我们需要使用它。

  1. 那么,让我们先导入Tabs组件,将其添加到Product组件上:
import Tabs from "./Tabs";
  1. 我们现在可以在产品名称和说明之间添加Tabs组件:
<h1>{product.name}</h1>
<Tabs headings={["Description", "Reviews"]} />
<p>{product.description}</p>

我们将要显示的两个选项卡标题传递给Tabs组件,这两个标题是描述和评论。

让我们看看这是什么样子:

这是一个好的开始。第一个标签在activeCSS 样式中加了下划线,这正是我们想要的。但是,如果我们点击“评论”选项卡,什么也不会发生。

  1. 因此,让我们为每个选项卡引用Tabs.tsx中的点击处理程序:
<li
  onClick={this.handleTabClick}
  className={heading === this.state.activeHeading ? "active" : ""}
>
  {heading}
</li>
  1. 现在让我们也实现 click 处理程序:
private handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
  const li = e.target as HTMLLIElement;
  const heading: string = li.textContent ? li.textContent : "";
  this.setState({ activeHeading: heading });
};

我们首先从litextContent中提取标题。然后我们将activeHeading状态设置为此。这将导致 React 重新渲染组件,单击的选项卡显示为活动状态。

注意,我们帮助 TypeScript 编译器使用as关键字将li变量声明为HTMLLIElement。如果不这样做,编译器将不会满意我们访问其中的textContent属性。

如果我们再次转到 running 应用程序,现在可以单击选项卡,查看活动状态的变化。

目前,我们的选项卡组件只是呈现一些可以单击的选项卡。它还没有与任何内容结合。在下一节介绍渲染道具模式之前,我们实际上不会将标题与内容联系起来。然而,现在是时候探索复合组件模式,并在下一节中进一步增强选项卡标题。

利用复合组件模式

我们的标签标题目前只能是字符串。如果我们想让组件的使用者在标题中定义更丰富的内容,该怎么办?例如,消费者可能希望在选项卡标题前放置图标或将标题加粗。因此,消费 JSX 可能看起来像这样:

<Tabs>
  <Tabs.Tab name="Description" initialActive={true}>
    <b>Description</b>
  </Tabs.Tab>
  <Tabs.Tab name="Reviews">
     Reviews
  </Tabs.Tab>
</Tabs>

在上一示例中,TabsTabs.Tab是化合物组分:

  • Tabs是呈现其中Tabs.Tab组件的组件。它还管理处于活动状态的选项卡的状态。
  • Tabs.Tab呈现单个标题。它采用唯一的选项卡名称作为属性,允许管理活动选项卡。它还接受一个名为initialActiveboolean属性,该属性将该选项卡设置为在首次加载时处于活动状态。呈现的标题是Tabs.Tab标记中的内容。因此,第一个选项卡将以粗体显示描述

因此,让我们将基本选项卡组件重构为一个复合组件,该组件可以与前面的示例类似使用:

  1. 我们的Tabs组件不再接受任何属性,因此,让我们删除IProps接口。我们可以删除构造函数,因为我们不再需要从 props 中初始化状态。我们也将国有资产的名称从activeHeading改为activeName
interface IState {
  activeName: string;
}
class Tabs extends React.Component<{}, IState> {
  public render() {
    ...
  }
  ...
}
  1. 我们将首先在Tabs中处理Tab组件。因此,让我们为其属性创建一个接口:
interface ITabProps {
  name: string;
  initialActive?: boolean;
}
  • name属性是选项卡的唯一名称。这将在以后用于帮助我们管理活动选项卡。
  • initialActive属性指定组件首次加载时选项卡是否处于活动状态。

  • 现在让我们在Tabs类组件中添加以下Tab函数组件:

class Tabs extends React.Component<IProps, IState> {

  public static Tab: React.SFC<ITabProps> = props => <li>TODO - render the nodes child nodes</li>;

  public render() {...}

  ...
}

这是渲染每个选项卡的组件的开始。Tab组件定义为Tabs组件上的静态属性。这意味着Tab存在于实际的Tabs类中,而不是其实例中。因此,我们必须记住,我们没有访问任何Tabs实例成员的权限(例如,this。但是,我们现在可以使用Tabs.Tab在 JSX 中引用Tab,这是我们的需求之一

此时,Tab正在渲染li,并带有一条提示,提醒我们需要以某种方式渲染组件的子节点。请记住,我们希望Tabs组件的消费标记如下所示:

<Tabs.Tab name="Description" initialActive={true}>
  <b>Description</b>
/Tabs.Tab>
  1. 因此,我们的渲染函数需要在li标记中以某种方式渲染<b> Description </b>。我们如何做到这一点?答案是通过一个名为children的特殊属性:
public static Tab: React.SFC<ITabProps> = props => <li>{props.children}</li>;

React component properties can be of any type, including React nodes. The children property is a special property that React gives a component that contains the component's child nodes. We render a component's child nodes in JSX by referencing the children property in curly brackets.

我们的Tab组件还没有完成,但我们暂时就这样离开。我们现在需要转到Tabs组件。

  1. Tabs类中的render方法现在只需渲染其子节点。让我们用以下方法替换此方法:
public render() {
  return (
    <ul className="tabs">{this.props.children}</ul>
  );
}

我们再次使用神奇的children属性来渲染Tabs中的子节点。

我们的复合TabsTab组件进展顺利,但我们的项目不再编译,因为我们有不再引用的选项卡点击处理程序handleTabClick。单击选项卡标题时,我们需要从Tab组件中引用它,但请记住Tab无权访问Tabs的成员。那么,我们如何才能做到这一点呢?我们将在下一节找到这个问题的答案。

与 React 上下文共享状态

React 上下文允许在组件之间共享状态。它与复合组件配合得非常好。我们将在TabsTab组件中使用它来共享它们之间的状态:

  1. 我们的第一个任务是为我们将在Tabs.tsx中使用的上下文创建一个接口,该接口位于导入语句下方的文件顶部:
interface ITabsContext {
  activeName?: string;
  handleTabClick?: (name: string) => void;
}

因此,我们的上下文将包含活动选项卡名称以及对选项卡单击处理程序的引用。这些是需要在组件之间共享的两个状态位。

  1. 接下来,让我们在ITabsContext界面下创建上下文:
const TabsContext = React.createContext<ITabsContext>({});

我们在 React 中使用了createContext函数来创建上下文,这是一个泛型函数,它创建泛型类型的上下文,在我们的例子中是ITabsContext

我们需要将默认上下文值作为参数值传递给createContext,但这在我们的例子中没有意义,因此我们只传递一个空的{}对象,以使 TypeScript 编译器满意。这就是为什么ITabsContext中的两个属性都是可选的。

  1. 现在是在我们的复合组件中使用此上下文的时候了。我们需要做的第一件事是在Tabs``render方法中定义上下文提供者:
public render() {
  return (
    <TabsContext.Provider
 value={{
 activeName: this.state ? this.state.activeName : "",
 handleTabClick: this.handleTabClick
 }}
 >
      <ul className="tabs">{this.props.children}</ul>
    </TabsContext.Provider>
  );
}

这里发生了一些事情,让我们来分析一下:

  • 我们前面声明的上下文常量TabsContext在 JSX 中作为<TabsContext />组件提供。
  • 上下文提供程序用值填充上下文。鉴于Tabs管理状态和事件处理,因此在那里引用提供者是有意义的
  • 我们使用<TabsContext.Provider />引用供应商。
  • 提供程序接收一个名为value的属性作为上下文值。我们将其设置为包含活动选项卡名称和选项卡单击事件处理程序的对象。

  • 我们需要稍微调整 tab click 处理程序,因为点击不再在Tabs中直接处理。因此,我们只需将活动选项卡名称作为参数,然后在方法中设置活动选项卡名称状态:

private handleTabClick = (name: string) => {
  this.setState({ activeName: name });
};
  1. 现在我们已经向上下文提供了一些数据,是时候在Tab组件中使用这些数据了:
 public static Tab: React.SFC<ITabProps> = props => (
  <TabsContext.Consumer>
 {(context: ITabsContext) => {
 const activeName = context.activeName
 ? context.activeName
 : props.initialActive
 ? props.name
 : "";
 const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => 
      {
 if (context.handleTabClick) {
 context.handleTabClick(props.name);
 }
 };
      return (
        <li
          onClick={handleTabClick}
 className={props.name === activeName ? "active" : ""}
        >
          {props.children}
        </li>
      );
    }}
  </TabsContext.Consumer>
);

这看起来又有点吓人,所以让我们来分析一下:

  • 我们可以通过上下文组件中的Consumer组件使用上下文。这就是我们的例子中的<TabsContext.Consumer />
  • Consumer的子函数需要是一个具有上下文值参数并返回一些 JSX 的函数。Consumer然后将呈现我们返回的 JSX。

Don't worry if this is still a little confusing. We'll cover this pattern in a lot more detail later when we cover children props and render props.

  • 这个上下文函数为我们提供了呈现选项卡所需的一切。我们可以从context参数访问状态,也可以访问Tab组件props对象。
  • 函数的第一行使用上下文中的内容确定活动选项卡名称。如果上下文中的活动选项卡是空字符串,则如果当前选项卡名称已定义为初始活动选项卡,则使用当前选项卡名称。
  • 函数的第二行创建了一个 tab click 处理程序,如果指定了上下文 tab click 处理程序,它将调用该处理程序。
  • return 语句与以前一样,但是我们现在已经能够添加对 tabclick 处理程序和类名的引用。

这就是我们的 tabs 复合组件。React 上下文的语法一开始可能看起来有点奇怪,但当您习惯了它时,它确实简单而优雅。

在我们尝试之前,我们需要消耗Product成分中的化合物成分。让我们用以下突出显示的 JSX 替换之前消耗的Tabs组件:

 <React.Fragment>
  <h1>{product.name}</h1>

  <Tabs>
 <Tabs.Tab name="Description" initialActive={true}>
 <b>Description</b>
 </Tabs.Tab>
 <Tabs.Tab name="Reviews">Reviews</Tabs.Tab>
 </Tabs>

  <p>{product.description}</p>
  ...
</React.Fragment>

这正是我们在开始构建复合选项卡组件时想要实现的 JSX。如果我们转到 running 应用程序并浏览到 product 页面,我们的 tabs 组件工作正常,其中 description 选项卡以粗体显示:

因此,复合组件对于相互依赖的组件来说非常有用。<Tabs.Tab />语法实际上指出了事实,即Tab需要与Tabs一起使用。

React 上下文非常适合复合组件,允许复合组件中的组件轻松共享状态。状态甚至可以包括事件处理程序之类的函数。

允许使用者指定要在组件的各个部分中呈现的内容为使用者提供了很大的灵活性。将此自定义内容指定为组件的子级是直观的,感觉很自然。在下一节中,我们将继续使用这种方法,在这里我们将完成 tabs 组件。

渲染道具图案

在上一节中,我们使用了渲染道具模式的一种形式,我们利用了children道具。我们使用它来允许Tab组件的使用者呈现选项卡标题的自定义内容。这很好,但是如果我们希望允许使用者在组件的不同部分呈现自定义内容,该怎么办?在我们的Tabs组件中,我们还不允许使用者呈现选项卡的内容。我们当然希望消费者能够为此指定自定义内容,但既然我们已经将children道具用于标题,我们该如何做呢?

答案很简单,但一开始并不明显。答案是,因为道具可以是任何东西,它们可以是呈现内容的功能——就像特殊的children道具一样。这些类型的道具称为渲染道具。我们可以拥有任意多个渲染道具,这使我们能够灵活地允许使用者渲染组件的多个部分。

在上一节中,当我们使用 React 上下文时,我们实际上使用了一个渲染道具。我们使用上下文的方式是通过渲染道具。

接下来,我们将利用渲染道具模式来完成Tabs组件。

使用渲染道具完成选项卡

我们现在将使用 render props 模式来完成 Tabs 组件。在实现第一个 render prop 之前,让我们考虑一下我们希望消费者在完成Tabs组件后如何消费它。以下 JSX 是我们从Product组件中理想地消费Tabs组件的方式:

<Tabs>
  <Tabs.Tab
    name="Description"
    initialActive={true}
    heading={() => <b>Description</b>}
  >
    <p>{product.description}</p>
  </Tabs.Tab>

  <Tabs.Tab 
    name="Reviews"
    heading={() => "Reviews"} 
  >
    <ul className="product-reviews">
      {product.reviews.map(review => (
        <li key={review.reviewer}>
          <i>"{review.comment}"</i> - {review.reviewer}
        </li>
      ))}
    </ul>
  </Tabs.Tab>
</Tabs>

让我们看一下本课程中关键部分的步骤:

  • 我们仍在使用复合成分。渲染道具可以很好地与这些组件配合使用。
  • 每个选项卡的标题不再在Tab组件的子级中定义。相反,我们使用heading呈现道具,在这里我们仍然可以呈现简单的字符串或更丰富的内容。
  • 然后将选项卡内容指定为Tab组件的子级。

对选项卡标题使用渲染道具

因此,让我们更改选项卡标题的实现以使用渲染道具:

  1. Tabs.tsx中,我们首先在标题的 tab props 界面中添加一个新属性:
interface ITabProps {
  name: string;
  initialActive?: boolean;
  heading: () => string | JSX.Element;
}

此属性是一个没有返回string或某些 JSX 的参数的函数。这是渲染道具的定义。

  1. 更改实现非常简单。我们只需在Tab组件的返回语句中用新的 render prop 函数替换对childrenprop 函数的调用:
return (
  <li
    onClick={handleTabClick}
    className={props.name === activeName ? "active" : ""}
  >
    {props.heading()}
  </li>
);
  1. 让我们将Product.tsxTabs的消耗量切换到以下几点:
<Tabs>
  <Tabs.Tab
    name="Description"
    initialActive={true}
    heading={() => <b>Description</b>}
  />
  <Tabs.Tab name="Reviews" heading={() => "Reviews"} />
</Tabs>

我们可能会得到一个 TSLint 警告:lambda 在 JSX 属性中是被禁止的,因为它们会影响渲染性能。知道 lambda 可能有问题是很有用的,这样我们就可以在遇到性能问题时记住这一点。但是,我们将在tslint.json中通过将"jsx-no-lambda"指定为false来关闭此规则:

{
  "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
  "rules": {
    ...
    "jsx-no-lambda": false
  },
  ...
}

If we want to be super performance-conscious, instead of using a lambda function we can reference a method within the component.

在我们保存了新的 TSLint 设置之后,编译器的抱怨有望消失。请注意,我们可能需要再次关闭终端和npm start应用程序,编译器投诉才能消失

如果我们尝试在应用程序中使用产品页面,它的行为将与以前一样

因此,实现渲染道具模式非常简单。使用此模式最耗时的事情是理解它能做什么以及它是如何工作的。一旦我们掌握了它,它是一种优秀的模式,我们可以使用它为组件的消费者提供渲染灵活性。

Tab组件完成之前,我们还有一个部分要做。

对选项卡内容使用子属性

我们的Tab组件现在可以看到终点线了。最后一项任务是允许使用者呈现选项卡内容。我们将使用children道具来执行此操作:

  1. 首先,在Tabs.tsx中,我们将上下文界面中的handleTabClick属性更改为包含要呈现的内容:
interface ITabsContext {
  activeName: string;
  handleTabClick?: (name: string, content: React.ReactNode) => void;
}
  1. 我们还将保持活动内容的状态以及活动选项卡名称。那么,让我们将其添加到Tabs的状态接口中:
interface IState {
  activeName: string;
  activeContent: React.ReactNode;
}
  1. 现在,让我们更改Tabs中的选项卡单击处理程序,以设置活动内容的状态以及活动选项卡名称:
private handleTabClick = (name: string, content: React.ReactNode) => {
  this.setState({ activeName: name, activeContent: content });
};
  1. Tab组件中,我们通过传递children属性,使用选项卡内容的附加参数调用选项卡点击处理程序:
const handleTabClick = (e: React.MouseEvent<HTMLLIElement>) => {
  if (context.handleTabClick) {
    context.handleTabClick(props.name, props.children);
  }
};
  1. 现在,让我们在呈现选项卡标题的Tabs``render方法中呈现我们状态中的活动内容:
<TabsContext.Provider ...
>
  <ul className="tabs">{this.props.children}</ul>
  <div>{this.state && this.state.activeContent}</div>
</TabsContext.Provider>
  1. 让我们改变一下如何使用Product成分中的Tabs成分:
<h1>{product.name}</h1>

<Tabs>
 <Tabs.Tab
 name="Description"
 initialActive={true}
 heading={() => <b>Description</b>}
 >
 <p>{product.description}</p>
 </Tabs.Tab>

 <Tabs.Tab name="Reviews" heading={() => "Reviews"}>
 <ul className="product-reviews">
 {product.reviews.map(review => (
 <li key={review.reviewer}>
 <i>"{review.comment}"</i> - {review.reviewer}
 </li>
 ))}
 </ul>
 </Tabs.Tab>
</Tabs>

<p className="product-price">
...
</p>

选项卡内容现在完全按照我们想要的方式嵌套在每个Tab组件中。

让我们试一试。如果转到产品页面,我们会注意到一个问题:

页面首次加载时未呈现内容。如果单击 Reviews 选项卡或 Description 选项卡,则会加载内容。

  1. 问题是,我们没有任何代码在选项卡最初加载时呈现内容。那么,让我们在Tab组件中通过添加高亮显示的行来解决这个问题:
public static Tab: React.SFC<ITabProps> = props => (
 <TabsContext.Consumer>
 {(context: ITabsContext) => {
  if (!context.activeName && props.initialActive) {
 if (context.handleTabClick) {
 context.handleTabClick(props.name, props.children);
 return null;
 }
 }
 const activeName = context.activeName
 ? context.activeName
 : props.initialActive
 ? props.name
 : "";
 ...
 }}
 </TabsContext.Consumer>
);

如果上下文中没有活动选项卡,并且该选项卡被标记为初始活动,则高亮显示的行将调用选项卡单击处理程序。在这种情况下,我们返回 null,因为调用 tab click 将设置活动选项卡的状态,这将导致另一个呈现周期。

我们的选项卡组件现在应该完成了。让我们转到产品页面进行检查:

内容呈现为我们期望的样子。如果我们单击“评论”选项卡,也会呈现良好效果:

因此,呈现道具和子道具模式非常适合让消费者呈现自定义内容。语法一开始可能看起来有点复杂,但当您理解它时,它就非常有意义,而且非常优雅。

在下一节中,我们将查看本章中的最终模式。

高阶分量

高阶组件HOC)是一个功能组件,它接受组件作为参数,并返回该组件的增强版本。这可能没有多大意义,因此我们将在本节中介绍一个示例。我们的示例创建了一个名为withLoader的 HOC,它可以应用于任何组件,以便在组件繁忙时添加加载微调器。在获取数据时,我们将在产品页面的 React shop(我们在上一节中处理过)中使用此选项。当我们完成后,它将如下所示:

添加异步数据获取

目前,我们商店中的数据获取是即时的,因为所有数据都是本地的。因此,在使用withLoader组件之前,让我们重构数据获取函数,使其包含延迟,并且也是异步的。这将更好地模拟使用 web API 获取数据的真实数据获取函数:

  1. ProductData.ts中,我们添加以下箭头函数,可以用来获取产品:
export const getProduct = async (id: number): Promise<IProduct | null> => {
  await wait(1000);
  const foundProducts = products.filter(customer => customer.id === id);
  return foundProducts.length === 0 ? null : foundProducts[0];
};

函数获取产品 ID,并使用products数组中的filter函数查找产品,然后返回。

该函数以async关键字作为前缀,因为它是异步的。

  1. 该函数还异步调用一个名为wait的函数,该函数前面有await关键字。那么,让我们创建wait函数:
const wait = (ms: number): Promise<void> => {
  return new Promise(resolve => setTimeout(resolve, ms));
};

此函数使用标准 JavaScriptsetTimeout函数等待我们在函数参数中指定的毫秒数。函数返回一个Promise,在setTimeout完成时解析。

Don't worry if the async and await keywords along with promises don't make much sense at the moment. We'll look at these in detail later in the book.

因此,我们有一个函数,它现在异步获取一个产品,至少需要 1 秒。让我们把这个插入我们的产品页面。ProductPage组件是一个负责获取数据的容器组件,所以我们在这里插入它。

  1. 首先,我们将getProduct函数导入ProductPage
import { getProduct, IProduct } from "./ProductsData";
  1. 让我们在ProductPage的状态中添加一个名为loading的属性,以指示数据是否正在加载:
interface IState {
  product?: IProduct;
  added: boolean;
  loading: boolean;
}
  1. 让我们在构造函数中将此状态初始化为true
public constructor(props: Props) {
  super(props);
  this.state = {
    added: false,
    loading: true
  };
}
  1. 现在,我们可以在ProductPage组件加载时使用getProduct功能:
public async componentDidMount() {
  if (this.props.match.params.id) {
    const id: number = parseInt(this.props.match.params.id, 10);
    const product = await getProduct(id);
    if (product !== null) {
      this.setState({ product, loading: false });
    }
  }
}

我们使用await关键字异步调用getProduct。为了做到这一点,我们需要使用async关键字将componentDidMount生命周期方法标记为异步。拿到产品后,我们将其设置为状态,并将loading标志重置为false

  1. 如果我们的商店没有运行,让我们运行以下操作:
npm start

如果我们转到产品页面,我们会看到现在加载产品大约需要 1 秒的时间。您可能会注意到产品未找到!在产品装载时显示。这是因为产品未在初始渲染时设置。我们将暂时忽略此问题,因为我们的withLoaderHOC 将解决此问题。

因此,现在我们正在异步获取数据,大约需要 1 秒的时间,我们已经准备好实现我们的withLoaderHOC 并在产品页面上使用它。我们将在下一节中进行介绍。

实现 withLoader HOC

我们将创建一个名为withLoader的加载器微调器组件,该组件可与任何组件一起使用,以指示该组件正在忙于执行某些操作:

  1. 让我们首先创建一个名为withLoader.tsx的新文件,该文件包含以下内容:
import * as React from "react";

interface IProps {
  loading: boolean;
}

const withLoader = <P extends object>(
  Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
  // TODO - return a loading spinner if loading is true otherwise return the component passed in 

export default withLoader;

这里发生了一些事情,让我们来分析一下:

  • withLoader是接受P类型组件的函数。
  • withLoader调用函数组件。

  • 功能组件的属性定义为P & IProps,这是一种交叉类型。

An intersection type combines multiple types into one. So X, and Y, and Z combine all the properties and methods of X, Y, and Z together into a new type.

  • 因此,SFC 的属性包括传入组件的所有属性以及我们定义的loading``boolean属性。
  • 使用 rest 参数将 props 分解为一个loading变量和一个props变量,其中包含所有其他属性。

  • 因此,如果loadingtrue,我们要做的剩余工作是返回加载微调器,否则我们只需要返回传入的组件。我们可以使用以下代码中突出显示的三元表达式来实现这一点:

const withLoader = <P extends object>(
  Component: React.ComponentType<P>
): React.SFC<P & IProps> => ({ loading, ...props }: IProps) =>
  loading ? (
 <div className="loader-overlay">
 <div className="loader-circle-wrap">
 <div className="loader-circle" />
 </div>
 </div>
 ) : (
 <Component {...props} />
 );

传入的组件在第二个三元分支中返回。我们使用扩展语法将props变量中的属性扩展到组件中。

加载微调器在第一个三元分支中返回。

  1. 加载微调器引用了一些 CSS 类,所以让我们将它们添加到index.css中:
.loader-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: Black;
  opacity: 0.3;
  z-index: 10004;
}
.loader-circle-wrap {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  height: 100px;
  width: 100px;
  margin: auto;
}
.loader-circle {
  border: 4px solid #ffffff;
  border-top: 4px solid #899091;
  border-radius: 50%;
  width: 100px;
  height: 100px;
  animation: loader-circle-spin 0.7s linear infinite;
}

loader-overlay类在整个页面上创建一个黑色透明覆盖层。loader-circle-wrap类在覆盖层的中心创建一个由100px组成的100px正方形。loader-circle类创建旋转圆。

我们的withLoaderHOC 现在已经完成。

以下代码块中显示了withLoader基于类的版本,以供参考:

const withLoader = <P extends object>(Component: React.ComponentType<P>) =>
  class WithLoader extends React.Component<P & IProps> {
    public render() {
      const { loading, ...props } = this.props as IProps;
      return loading ? (
        <div className="loader-overlay">
          <div className="loader-circle-wrap">
            <div className="loader-circle" />
          </div>
        </div>
      ) : (
        <Component {...props} />
      );
    }
  };

不过,我们将坚持使用 SFC 版本,因为它不包含任何状态,也不需要访问任何生命周期方法

在下一节中,我们将在商店应用程序的产品页面中使用withLoader组件。

使用 withLoader HOC

消费 HOC 非常简单。我们只是简单地将 HOC 包装在我们想要增强的组件周围。最容易做到这一点的地方是在 export 语句中。

让我们将上一节中创建的withLoaderHOC 添加到我们的产品页面:

  1. 所以,我们要用withLoader来包装Product组件。首先,我们将withLoader导入Product.tsx
import withLoader from "./withLoader";
  1. 现在我们可以在出口声明中将withLoader环绕Product
export default withLoader(Product);

我们现在在ProductPage组件中得到一个编译错误,因为它希望传递Product一个加载属性。

  1. 那么,让我们从ProductPage中引用Product的加载状态传递加载属性:
<Product
  loading={this.state.loading}
  product={product}
  inBasket={this.state.added}
  onAddToBasket={this.handleAddClick}
/>
  1. 当仍处于ProductPage.tsx时,我们应该修改呈现Product组件的条件。如果产品仍在加载中,我们现在要呈现Product。然后,这将渲染加载微调器:
{product || this.state.loading ? (
  <Product
    loading={this.state.loading}
    product={product}
    inBasket={this.state.added}
    onAddToBasket={this.handleAddClick}
  />
) : (
  <p>Product not found!</p>
)}

但是,这给我们带来了另一个编译错误,因为Product组件中的product属性不应该是undefined。然而,当产品装载时,它将是undefined

  1. 因此,让我们在IProps中为Product组件设置此属性为可选:
interface IProps {
  product?: IProduct;
  inBasket: boolean;
  onAddToBasket: () => void;
}

然后,这在Product组件中的 JSX 中给出了进一步的编译错误,我们在其中引用了product属性,因为在加载数据期间它现在将是undefined

  1. 解决这个问题的一个简单方法是,如果我们没有产品,就渲染null。无论如何,在这种情况下,包裹ProductwithLoaderHOC 将呈现一个加载微调器。因此,我们只是让 TypeScript 编译器满意:
const handleAddClick = () => {
  props.onAddToBasket();
};
if (!product) {
 return null;
}
return (
  <React.Fragment>
    ...
  </React.Fragment>
);

现在,TypeScript 编译器很高兴,如果我们转到我们商店的产品页面,它将在呈现产品之前显示我们的加载微调器:

因此,HOC 对于增强组件非常有用,因为增强可以应用于许多组件。我们的加载器微调器是 HOC 的常见用例。HOC 模式的另一个非常常见的用法是在使用 React 路由时。我们在本书前面使用了 React 路由withRouterHOC 来访问路径的参数

总结

在本章中,我们学习了容器组件,以及如何使用它们来管理状态,以及表示组件需要做什么。然后,呈现组件可以专注于它们需要的外观。这使得呈现组件更容易在多个位置重复使用,并进行单元测试。

我们了解到复合成分是相互依赖的成分。将复合子级声明为父类上的静态成员可以让使用者清楚地知道这些组件应该一起使用。React 上下文是复合组件共享其状态的一种方便方式。

我们了解了可用于访问和渲染组件子级的特殊子级道具。然后我们了解到,我们可以创建自己的渲染道具,为用户提供组件自定义渲染部分的极大灵活性。

在上一节中,我们了解了高阶组件以及如何使用它们实现组件的常见增强。在获得本书前面的路径参数访问权限时,我们已经使用了 React Router 高阶组件。

在下一章中,我们将学习如何在 React 中创建表单。在下一章的末尾,我们将使用我们在本章中学习的一些模式,以通用的方式处理表单。

问题

让我们用一些问题来测试我们所学到的组件模式:

  1. React 为我们访问组件的子组件提供了什么特殊属性?
  2. 有多少组件可以与 React 上下文共享状态?
  3. 在使用 React 上下文时,它使用什么模式允许我们使用上下文呈现内容?
  4. 一个组件中可以有多少渲染道具?
  5. 一个组件中有多少儿童道具?
  6. 我们只在产品页面上使用了withLoader。我们在ProductData.ts中使用以下函数获取所有产品:
export const getProducts = async (): Promise<IProduct[]> => {
  await wait(1000);
  return products;
};

您可以通过使用withLoaderHOC 在产品页面上实现加载器微调器吗?

  1. 可以使用儿童道具模式创建加载器旋转器吗?消费 JSX 的内容如下所示:
<Loader loading={this.state.loading}>
  <div>
    The content for my component ...
  </div>
</Loader>

如果是这样的话,试着去实现它。

进一步阅读