对我们的下一本书感兴趣?了解更多关于使用 React 构建大型 JavaScript Web 应用程序

设计模式

HOC 模式

在我们的应用程序中,我们经常希望在多个组件中使用相同的逻辑。此逻辑可以包括对组件应用特定样式、要求授权或添加全局状态。

能够在多个组件中重用相同逻辑的一种方法是使用 **高阶组件** 模式。此模式允许我们在整个应用程序中重用组件逻辑。

高阶组件 (HOC) 是一个接收另一个组件的组件。HOC 包含我们想要应用于作为参数传递的组件的某些逻辑。在应用该逻辑后,HOC 将返回包含附加逻辑的元素。

假设我们始终希望在应用程序中的多个组件中添加特定样式。与其每次都在本地创建 style 对象,不如简单地创建一个 HOC,将 style 对象添加到传递给它的组件。

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () = <button>Click me!</button>
const Text = () => <p>Hello World!</p>

const StyledButton = withStyles(Button)
const StyledText = withStyles(Text)

我们刚刚创建了 StyledButton 和 StyledText 组件,它们是 Button 和 Text 组件的修改版本。现在它们都包含在 withStyles HOC 中添加的样式!

让我们看一下之前在容器/展示模式中使用过的同一个 DogImages 示例!该应用程序只做一件事,就是从 API 获取并渲染一列狗狗图片。

让我们稍微改善一下用户体验。当我们正在获取数据时,我们希望向用户显示一个 “加载中...” 屏幕。与其直接将数据添加到 DogImages 组件中,不如使用一个高阶组件,为我们添加此逻辑。

让我们创建一个名为 withLoader 的 HOC。HOC 应该接收一个组件,并返回该组件。在本例中,withLoader HOC 应该接收应该显示 Loading… 直到数据被获取的元素。

让我们创建我们想要使用的 withLoader HOC 的基本版本!

function withLoader(Element) {
  return (props) => <Element />;
}

但是,我们不仅希望返回它接收的元素。相反,我们希望此元素包含告诉我们数据是否仍在加载的逻辑。

为了使 withLoader HOC 非常可重用,我们不会在该组件中硬编码 Dog API 的 URL。相反,我们可以将 URL 作为参数传递给 withLoader HOC,以便此加载程序可以在需要加载指示器(同时从不同的 API 端点获取数据)的任何组件上使用。

function withLoader(Element, url) {
  return (props) => {};
}

HOC 返回一个元素,在本例中是一个函数式组件 props => {},我们希望在其中添加允许我们显示带有 Loading… 的文本的逻辑,因为数据仍在获取中。一旦数据被获取,该组件就应该将获取到的数据作为属性传递。

DogImages.js
withLoader.js
1import React, { useEffect, useState } from "react";
2
3export default function withLoader(Element, url) {
4 return (props) => {
5 const [data, setData] = useState(null);
6
7 useEffect(() => {
8 async function getData() {
9 const res = await fetch(url);
10 const data = await res.json();
11 setData(data);
12 }
13
14 getData();
15 }, []);
16
17 if (!data) {
18 return <div>Loading...</div>;
19 }
20
21 return <Element {...props} data={data} />;
22 };
23}

完美!我们刚刚创建了一个可以接收任何组件和 URL 的 HOC。

  1. useEffect 挂钩中,withLoader HOC 从我们作为 url 值传递的 API 端点获取数据。在数据尚未返回之前,我们返回包含 Loading... 文本的元素。
  2. 一旦数据被获取,我们就将 data 设置为已获取的数据。由于 data 不再为 null,因此我们可以显示传递给 HOC 的元素!

那么,我们如何将此行为添加到应用程序中,以便它实际在 DogImages 列表上显示 Loading... 指示器呢?

DogImages.js 中,我们不再希望只导出普通的 DogImages 组件。相反,我们希望导出围绕 DogImages 组件的“包装”withLoading HOC。

export default withLoading(DogImages);

withLoader HOC 还期望 URL 知道要从哪个端点获取数据。在本例中,我们希望添加 Dog API 端点。

export default withLoader(
  DogImages,
  "https://dog.ceo/api/breed/labrador/images/random/6"
);

由于 withLoader HOC 返回了带有一个额外的 data 属性的元素(在本例中为 DogImages),因此我们可以在 DogImages 组件中访问 data 属性。

DogImages.js
withLoader.js
1import React from "react";
2import withLoader from "./withLoader";
3
4function DogImages(props) {
5 return props.data.message.map((dog, index) => (
6 <img src={dog} alt="Dog" key={index} />
7 ));
8}
9
10export default withLoader(
11 DogImages,
12 "https://dog.ceo/api/breed/labrador/images/random/6"
13);

完美!现在我们会在获取数据时看到一个 Loading... 屏幕。

高阶组件模式允许我们为多个组件提供相同的逻辑,同时将所有逻辑都保留在一个地方。withLoader HOC 不关心它接收的组件或 URL:只要它是一个有效的组件和一个有效的 API 端点,它就会简单地将来自该 API 端点的数据传递给我们传递的组件。


组合

我们还可以组合多个高阶组件。假设我们还想添加一个功能,当用户将鼠标悬停在 DogImages 列表上时显示一个 Hovering! 文本框。

我们需要创建一个 HOC,它为我们传递的元素提供一个 hovering 属性。根据该属性,我们可以根据用户是否将鼠标悬停在 DogImages 列表上,有条件地渲染文本框。

DogImages.js
withHover.js
withLoader.js
1import React, { useState } from "react";
2
3export default function withHover(Element) {
4 return props => {
5 const [hovering, setHover] = useState(false);
6
7 return (
8 <Element
9 {...props}
10 hovering={hovering}
11 onMouseEnter={() => setHover(true)}
12 onMouseLeave={() => setHover(false)}
13 />
14 );
15 };
16}

现在,我们可以将 withHover HOC 包裹在 withLoader HOC 周围。

DogImages.js
withHover.js
withLoader.js
1import React from "react";
2import withLoader from "./withLoader";
3import withHover from "./withHover";
4
5function DogImages(props) {
6 return (
7 <div {...props}>
8 {props.hovering && <div id="hover">Hovering!</div>}
9 <div id="list">
10 {props.data.message.map((dog, index) => (
11 <img src={dog} alt="Dog" key={index} />
12 ))}
13 </div>
14 </div>
15 );
16}
17
18export default withHover(
19 withLoader(DogImages, "https://dog.ceo/api/breed/labrador/images/random/6")
20);

DogImages 元素现在包含我们从 withHoverwithLoader 传递的所有属性。现在,我们可以根据 hovering 属性的值是 true 还是 false 有条件地渲染 Hovering! 文本框。

一个用于组合 HOC 的知名库是 recompose。由于 HOC 在很大程度上可以被 React Hooks 替换,因此 recompose 库不再维护,因此本文不会介绍。


Hooks

在某些情况下,我们可以用 React Hooks 替换 HOC 模式。

让我们用 useHover 挂钩替换 withHover HOC。与其使用高阶组件,不如导出一个挂钩,将 mouseOvermouseLeave 事件监听器添加到元素中。我们不能再像使用 HOC 那样传递元素。相反,我们将从挂钩中返回一个 ref,该 ref 应该获取 mouseOvermouseLeave 事件。

DogImages.js
useHover.js
withLoader.js
1import { useState, useRef, useEffect } from "react";
2
3export default function useHover() {
4 const [hovering, setHover] = useState(false);
5 const ref = useRef(null);
6
7 const handleMouseOver = () => setHover(true);
8 const handleMouseOut = () => setHover(false);
9
10 useEffect(() => {
11 const node = ref.current;
12 if (node) {
13 node.addEventListener("mouseover", handleMouseOver);
14 node.addEventListener("mouseout", handleMouseOut);
15
16 return () => {
17 node.removeEventListener("mouseover", handleMouseOver);
18 node.removeEventListener("mouseout", handleMouseOut);
19 };
20 }
21 }, [ref.current]);
22
23 return [ref, hovering];
24}

useEffect 挂钩向组件添加事件监听器,并将 hovering 值设置为 truefalse,具体取决于用户是否当前将鼠标悬停在元素上。refhovering 值都需要从挂钩中返回:ref 用于向应该接收 mouseOvermouseLeave 事件的组件添加引用,以及 hovering 用于能够有条件地渲染 Hovering! 文本框。

与其用 withHover HOC 包裹 DogImages 组件,不如直接在 DogImages 组件中使用 useHover 挂钩。

DogImages.js
useHover.js
withLoader.js
1import React from "react";
2import withLoader from "./withLoader";
3import useHover from "./useHover";
4
5function DogImages(props) {
6 const [hoverRef, hovering] = useHover();
7
8 return (
9 <div ref={hoverRef} {...props}>
10 {hovering && <div id="hover">Hovering!</div>}
11 <div id="list">
12 {props.data.message.map((dog, index) => (
13 <img src={dog} alt="Dog" key={index} />
14 ))}
15 </div>
16 </div>
17 );
18}
19
20export default withLoader(
21 DogImages,
22 "https://dog.ceo/api/breed/labrador/images/random/6"
23);

完美!与其用 withHover 组件包裹 DogImages 组件,不如直接在组件中使用 useHover 挂钩。


一般来说,React Hooks 不会替换 HOC 模式。

“在大多数情况下,Hooks 已经足够了,而且可以帮助减少树中的嵌套。” - React 文档

正如 React 文档告诉我们的那样,使用 Hooks 可以减少组件树的深度。使用 HOC 模式,很容易最终得到一个深度嵌套的组件树。

<withAuth>
  <withLayout>
    <withLogging>
      <Component />
    </withLogging>
  </withLayout>
</withAuth>

通过直接向组件添加 Hooks,我们不再需要包裹组件。

使用高阶组件可以为多个组件提供相同的逻辑,同时将该逻辑都保留在一个地方。Hooks 允许我们从组件内部添加自定义行为,与 HOC 模式相比,如果多个组件依赖于此行为,则可能会增加引入错误的风险。

HOC 的最佳使用场景:

  • 整个应用程序的许多组件都需要使用 相同、未定制的 行为。
  • 该组件可以独立工作,无需添加自定义逻辑。

Hooks 的最佳使用场景:

  • 该行为必须针对使用它的每个组件进行定制。
  • 该行为不会在整个应用程序中传播,只有一个或几个组件使用该行为。
  • 该行为向组件添加了许多属性

案例研究

一些依赖于 HOC 模式的库在发布后添加了 Hooks 支持。一个很好的例子是 Apollo Client

无需了解 Apollo Client 即可理解此示例。

使用 Apollo Client 的一种方法是通过 graphql() 高阶组件。

InputHOC.js
InputHooks.js
1import React from "react";
2import "./styles.css";
3
4import { graphql } from "react-apollo";
5import { ADD_MESSAGE } from "./resolvers";
6
7class Input extends React.Component {
8 constructor() {
9 super();
10 this.state = { message: "" };
11 }
12
13 handleChange = (e) => {
14 this.setState({ message: e.target.value });
15 };
16
17 handleClick = () => {
18 this.props.mutate({ variables: { message: this.state.message } });
19 };
20
21 render() {
22 return (
23 <div className="input-row">
24 <input
25 onChange={this.handleChange}
26 type="text"
27 placeholder="Type something..."
28 />
29 <button onClick={this.handleClick}>Add</button>
30 </div>
31 );
32 }
33}
34
35export default graphql(ADD_MESSAGE)(Input);

使用 graphql() HOC,我们可以将来自客户端的数据提供给被高阶组件包裹的组件!虽然我们目前仍然可以使用 graphql() HOC,但使用它有一些缺点。

当组件需要访问多个解析器时,我们需要组合多个 graphql() 高阶组件才能做到这一点。组合多个 HOC 会使理解数据如何传递到组件变得困难。在某些情况下,HOC 的顺序很重要,这在重构代码时很容易导致错误。

在 Hooks 发布后,Apollo 向 Apollo Client 库添加了 Hooks 支持。与其使用 graphql() 高阶组件,开发人员现在可以直接通过库提供的挂钩访问数据。

让我们看一个使用与之前在 graphql() 高阶组件示例中看到的数据完全相同的数据的示例。这次,我们将使用 Apollo Client 为我们提供的 useMutation 挂钩向组件提供数据。

InputHOC.js
InputHooks.js
1import React, { useState } from "react";
2import "./styles.css";
3
4import { useMutation } from "@apollo/react-hooks";
5import { ADD_MESSAGE } from "./resolvers";
6
7export default function Input() {
8 const [message, setMessage] = useState("");
9 const [addMessage] = useMutation(ADD_MESSAGE, {
10 variables: { message }
11 });
12
13 return (
14 <div className="input-row">
15 <input
16 onChange={(e) => setMessage(e.target.value)}
17 type="text"
18 placeholder="Type something..."
19 />
20 <button onClick={addMessage}>Add</button>
21 </div>
22 );
23}

通过使用 useMutation 挂钩,我们减少了向组件提供数据所需的代码量。

除了减少样板代码,在组件中使用多个解析器的结果也变得更加容易。我们不再需要组合多个高阶组件,只需在组件中编写多个钩子即可。这样,了解数据如何传递到组件变得更加容易,并且在重构组件或将组件分解成更小的部分时,可以改善开发人员体验。


优点

使用高阶组件模式可以让我们将要重用的逻辑都集中在一个地方。这减少了通过反复复制代码而意外将错误传播到整个应用程序中的风险,并且可能在每次复制时引入新的错误。通过将逻辑集中在一个地方,我们可以保持代码DRY并轻松强制执行关注点分离。


缺点

HOC 可以传递给元素的 prop 的名称可能会导致命名冲突。

function withStyles(Component) {
  return props => {
    const style = { padding: '0.2rem', margin: '1rem' }
    return <Component style={style} {...props} />
  }
}

const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)

在这种情况下,withStyles HOC 为我们传递给它的元素添加了一个名为style 的 prop。但是,Button 组件已经有一个名为style 的 prop,它将被覆盖!请确保 HOC 可以处理意外的命名冲突,方法是重命名 prop 或合并 prop。

function withStyles(Component) {
  return props => {
    const style = {
      padding: '0.2rem',
      margin: '1rem',
      ...props.style
    }

    return <Component style={style} {...props} />
  }
}

const Button = () = <button style={{ color: 'red' }}>Click me!</button>
const StyledButton = withStyles(Button)

当使用多个组合的 HOC 时,它们都将 prop 传递给包装在其中的元素,很难确定哪个 HOC 负责哪个 prop。这可能会阻碍调试和轻松扩展应用程序。


参考资料