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

设计模式

Hooks 模式

React 16.8 引入了一项名为 Hooks 的新功能。Hooks 使得在不使用 ES2015 类组件的情况下使用 React 状态和生命周期方法成为可能。

尽管 Hooks 不一定是设计模式,但 Hooks 在应用程序设计中起着非常重要的作用。许多传统的设计模式可以用 Hooks 替代。


类组件

在 React 中引入 Hooks 之前,我们必须使用类组件才能为组件添加状态和生命周期方法。React 中典型的类组件可能看起来像这样

class MyComponent extends React.Component {
  /* Adding state and binding custom methods */
  constructor() {
    super()
    this.state = { ... }

    this.customMethodOne = this.customMethodOne.bind(this)
    this.customMethodTwo = this.customMethodTwo.bind(this)
  }

  /* Lifecycle Methods */
  componentDidMount() { ...}
  componentWillUnmount() { ... }

  /* Custom methods */
  customMethodOne() { ... }
  customMethodTwo() { ... }

  render() { return { ... }}
}

类组件可以在其构造函数中包含状态,生命周期方法(如 componentDidMountcomponentWillUnmount)以根据组件的生命周期执行副作用,以及自定义方法以向类添加额外的逻辑。

虽然在引入 React Hooks 后我们仍然可以使用类组件,但使用类组件会有一些缺点!让我们看看使用类组件时最常见的一些问题。

理解 ES2015 类

由于类组件是唯一可以在 React Hooks 之前处理状态和生命周期方法的组件,因此我们经常不得不将函数组件重构为类组件,以添加额外的功能。

在这个例子中,我们有一个简单的 div 充当按钮。

function Button() {
  return <div className="btn">disabled</div>;
}

我们不想总是显示 disabled,而是想在用户点击按钮时将其更改为 enabled,并在发生这种情况时向按钮添加一些额外的 CSS 样式。

为了做到这一点,我们需要向组件添加状态以了解状态是 enabled 还是 disabled。这意味着我们必须完全重构函数组件,并将其设为一个类组件,该组件跟踪按钮的状态。

export default class Button extends React.Component {
  constructor() {
    super();
    this.state = { enabled: false };
  }

  render() {
    const { enabled } = this.state;
    const btnText = enabled ? "enabled" : "disabled";

    return (
      <div
        className={`btn enabled-${enabled}`}
        onClick={() => this.setState({ enabled: !enabled })}
      >
        {btnText}
      </div>
    );
  }
}

最后,我们的按钮按我们想要的方式工作了!

Button.js
1import React from "react";
2import "./styles.css";
3
4export default class Button extends React.Component {
5 constructor() {
6 super();
7 this.state = { enabled: false };
8 }
9
10 render() {
11 const { enabled } = this.state;
12 const btnText = enabled ? "enabled" : "disabled";
13
14 return (
15 <div
16 className={`btn enabled-${enabled}`}
17 onClick={() => this.setState({ enabled: !enabled })}
18 >
19 {btnText}
20 </div>
21 );
22 }
23}

在这个例子中,组件非常小,重构不是什么大不了的事。但是,您在现实生活中的组件可能包含更多行代码,这使得重构组件变得更加困难。

除了必须确保在重构组件时不会意外地更改任何行为外,您还需要理解 ES2015 类的工作原理。为什么我们必须bind自定义方法?constructor的作用是什么?this关键字从哪里来?如果没有意外地更改数据流,就很难知道如何正确地重构组件。

重构

在多个组件之间共享代码的常用方法是使用 高阶组件渲染道具 模式。虽然这两种模式都是有效的并且是一种良好的实践,但在以后的时间点添加这些模式需要您重构应用程序。

除了必须重构应用程序(这对于组件越大越棘手)之外,为了在更深的嵌套组件之间共享代码而拥有许多包装组件会导致最适合称为包装地狱的东西。打开您的开发工具并查看类似于以下结构的情况并不罕见

<WrapperOne>
  <WrapperTwo>
    <WrapperThree>
      <WrapperFour>
        <WrapperFive>
          <Component>
            <h1>Finally in the component!</h1>
          </Component>
        </WrapperFive>
      </WrapperFour>
    </WrapperThree>
  </WrapperTwo>
</WrapperOne>

包装地狱会让您难以理解数据如何在应用程序中流动,这会让您更难找出为什么会出现意外行为。

复杂性

随着我们向类组件添加更多逻辑,组件的大小会迅速增加。该组件内的逻辑可能会变得混乱且无结构,这会让开发人员难以理解某些逻辑在类组件中使用的位置。这会使调试和优化性能变得更加困难。

生命周期方法也需要在代码中进行大量的重复。让我们看一个使用Counter组件和Width组件的例子。

App.js
1import React from "react";
2import "./styles.css";
3
4import { Count } from "./Count";
5import { Width } from "./Width";
6
7export default class Counter extends React.Component {
8 constructor() {
9 super();
10 this.state = {
11 count: 0,
12 width: 0
13 };
14 }
15
16 componentDidMount() {
17 this.handleResize();
18 window.addEventListener("resize", this.handleResize);
19 }
20
21 componentWillUnmount() {
22 window.removeEventListener("resize", this.handleResize);
23 }
24
25 increment = () => {
26 this.setState(({ count }) => ({ count: count + 1 }));
27 };
28
29 decrement = () => {
30 this.setState(({ count }) => ({ count: count - 1 }));
31 };
32
33 handleResize = () => {
34 this.setState({ width: window.innerWidth });
35 };
36
37 render() {
38 return (
39 <div className="App">
40 <Count
41 count={this.state.count}
42 increment={this.increment}
43 decrement={this.decrement}
44 />
45 <div id="divider" />
46 <Width width={this.state.width} />
47 </div>
48 );
49 }
50}

App组件的结构方式可以可视化为以下内容

Flow chart

虽然这是一个小组件,但组件内的逻辑已经很混乱了。某些部分是针对counter逻辑的,而其他部分是针对width逻辑的。随着组件的增长,在组件内构建逻辑、在组件内查找相关逻辑会变得越来越困难。

除了混乱的逻辑之外,我们还在生命周期方法中重复了一些逻辑。在componentDidMountcomponentWillUnmount中,我们都在根据窗口的resize事件定制应用程序的行为。


Hooks

很明显,类组件并不总是 React 中的一项很棒的功能。为了解决 React 开发人员在使用类组件时可能遇到的常见问题,React 引入了React Hooks。React Hooks 是可以用来管理组件状态和生命周期方法的函数。React Hooks 使得以下操作成为可能

  • 向函数组件添加状态
  • 在不使用componentDidMountcomponentWillUnmount等生命周期方法的情况下管理组件的生命周期
  • 在整个应用程序中的多个组件之间重用相同的 stateful 逻辑

首先,让我们看看如何使用 React Hooks 向函数组件添加状态。

状态 Hook

React 提供了一个用于在函数组件内管理状态的钩子,称为useState

让我们看看如何使用useState钩子将类组件重构为函数组件。我们有一个名为Input的类组件,它只渲染一个输入字段。状态中input的值会在用户在输入字段中键入任何内容时更新。

class Input extends React.Component {
  constructor() {
    super();
    this.state = { input: "" };

    this.handleInput = this.handleInput.bind(this);
  }

  handleInput(e) {
    this.setState({ input: e.target.value });
  }

  render() {
    <input onChange={handleInput} value={this.state.input} />;
  }
}

为了使用useState钩子,我们需要访问 React 为我们提供的useState方法。useState方法期望一个参数:这是状态的初始值,在本例中为空字符串。

我们可以从useState方法中解构两个值

  1. 状态的当前值
  2. 我们可以用来更新状态的方法。
const [value, setValue] = React.useState(initialValue);

第一个值可以与类组件的this.state.[value]进行比较。第二个值可以与类组件的this.setState方法进行比较。

由于我们正在处理输入的值,让我们将状态的当前值称为input,并将用来更新状态的方法称为setInput。初始值应为空字符串。

const [input, setInput] = React.useState("");

现在我们可以将Input类组件重构为一个 stateful 函数组件。

function Input() {
  const [input, setInput] = React.useState("");

  return <input onChange={(e) => setInput(e.target.value)} value={input} />;
}

input字段的值等于input状态的当前值,就像在类组件示例中一样。当用户在输入字段中键入时,input状态的值会使用setInput方法相应地更新。

Input.js
1import React, { useState } from "react";
2
3 export default function Input() {
4 const [input, setInput] = useState("");
5
6 return (
7 <input
8 onChange={e => setInput(e.target.value)}
9 value={input}
10 placeholder="Type something..."
11 />
12 );
13 }

效果 Hook

我们已经看到,我们可以使用useState组件来处理函数组件内的状态,但类组件的另一个好处是能够向组件添加生命周期方法。

使用useEffect钩子,我们可以“钩入”组件的生命周期。useEffect钩子有效地将componentDidMountcomponentDidUpdatecomponentWillUnmount生命周期方法组合在一起。

componentDidMount() { ... }
useEffect(() => { ... }, [])

componentWillUnmount() { ... }
useEffect(() => { return () => { ... } }, [])

componentDidUpdate() { ... }
useEffect(() => { ... })

让我们使用我们在状态 Hook 部分中使用的输入示例。每当用户在输入字段中键入任何内容时,我们还想将该值记录到控制台中。

我们需要使用一个“监听”input值的useEffect钩子。我们可以通过将input添加到useEffect钩子的依赖数组中来实现。依赖数组是useEffect钩子接收的第二个参数。

useEffect(() => {
  console.log(`The user typed ${input}`);
}, [input]);

让我们试一试!

Input.js
1import React, { useState, useEffect } from "react";
2
3export default function Input() {
4 const [input, setInput] = useState("");
5
6 useEffect(() => {
7 console.log(`The user typed ${input}`);
8 }, [input]);
9
10 return (
11 <input
12 onChange={e => setInput(e.target.value)}
13 value={input}
14 placeholder="Type something..."
15 />
16 );
17}

现在,每当用户键入一个值时,输入的值都会被记录到控制台中。


自定义 Hooks

除了 React 提供的内置钩子(useStateuseEffectuseReduceruseRefuseContextuseMemouseImperativeHandleuseLayoutEffectuseDebugValueuseCallback)之外,我们还可以轻松创建自己的自定义钩子。

您可能已经注意到,所有钩子都以use开头。用use开头您的钩子很重要,以便 React 可以检查它是否违反了Hooks 的规则

假设我们想要跟踪用户在编写输入时可能按下的某些键。我们的自定义钩子应该能够接收我们想要作为其参数的目标键。

function useKeyPress(targetKey) {}

我们想要向用户作为参数传递的键添加一个keydownkeyup事件监听器。如果用户按下了该键,这意味着keydown事件被触发,钩子内的状态应该切换到true。否则,当用户停止按下该按钮时,keyup事件被触发,状态切换到false

function useKeyPress(targetKey) {
  const [keyPressed, setKeyPressed] = React.useState(false);

  function handleDown({ key }) {
    if (key === targetKey) {
      setKeyPressed(true);
    }
  }

  function handleUp({ key }) {
    if (key === targetKey) {
      setKeyPressed(false);
    }
  }

  React.useEffect(() => {
    window.addEventListener("keydown", handleDown);
    window.addEventListener("keyup", handleUp);

    return () => {
      window.removeEventListener("keydown", handleDown);
      window.removeEventListener("keyup", handleUp);
    };
  }, []);

  return keyPressed;
}

完美!我们可以在我们的输入应用程序中使用这个自定义钩子。让我们在用户按下qlw键时将其记录到控制台中。

Input.js
useKeyPress.js
1import React from "react";
2import useKeyPress from "./useKeyPress";
3
4export default function Input() {
5 const [input, setInput] = React.useState("");
6 const pressQ = useKeyPress("q");
7 const pressW = useKeyPress("w");
8 const pressL = useKeyPress("l");
9
10 React.useEffect(() => {
11 console.log(`The user pressed Q!`);
12 }, [pressQ]);
13
14 React.useEffect(() => {
15 console.log(`The user pressed W!`);
16 }, [pressW]);
17
18 React.useEffect(() => {
19 console.log(`The user pressed L!`);
20 }, [pressL]);
21
22 return (
23 <input
24 onChange={e => setInput(e.target.value)}
25 value={input}
26 placeholder="Type something..."
27 />
28 );
29}

与其将按键逻辑保留在Input组件的本地,我们现在可以在多个组件中重用useKeyPress钩子,而无需一遍又一遍地重写相同的逻辑。

Hooks 的另一个重大优势是社区可以构建和共享 Hooks。我们只是自己编写了useKeyPress钩子,但实际上根本没有必要!钩子已经由其他人构建,如果我们只是安装了它,就可以在我们的应用程序中使用它!

以下是一些列出社区构建的所有 Hooks 的网站,这些 Hooks 已经准备好用于您的应用程序。


让我们重写上一节中展示的计数器和宽度示例。我们将使用 React Hooks 重写应用程序,而不是使用类组件。

App.js
1import React, { useState, useEffect } from "react";
2import "./styles.css";
3
4import { Count } from "./Count";
5import { Width } from "./Width";
6
7function useCounter() {
8 const [count, setCount] = useState(0);
9
10 const increment = () => setCount(count + 1);
11 const decrement = () => setCount(count - 1);
12
13 return { count, increment, decrement };
14}
15
16function useWindowWidth() {
17 const [width, setWidth] = useState(window.innerWidth);
18
19 useEffect(() => {
20 const handleResize = () => setWidth(window.innerWidth);
21 window.addEventListener("resize", handleResize);
22 return () => window.addEventListener("resize", handleResize);
23 });
24
25 return width;
26}
27
28export default function App() {
29 const counter = useCounter();
30 const width = useWindowWidth();
31
32 return (
33 <div className="App">
34 <Count
35 count={counter.count}
36 increment={counter.increment}
37 decrement={counter.decrement}
38 />
39 <div id="divider" />
40 <Width width={width} />
41 </div>
42 );
43}

我们将 App 函数的逻辑分解为几个部分

  • useCounter:一个自定义 Hook,返回 count 的当前值,一个 increment 方法和一个 decrement 方法。
  • useWindowWidth:一个自定义 Hook,返回窗口的当前宽度。
  • App:一个功能性的、有状态的组件,它返回 CounterWidth 组件。

通过使用 React Hooks 而不是类组件,我们能够将逻辑分解成更小、可重用的部分,从而分离了逻辑。

让我们将我们所做的更改与旧的 App 类组件进行比较,以可视化这些更改。

Flow chart

使用 React Hooks 使我们能够更清晰地将组件的 **逻辑分离** 成多个更小的部分。 **重用** 相同的有状态逻辑变得更加容易,而且如果我们想要使组件有状态,我们不再需要将函数组件重写为类组件。不再需要深入了解 ES2015 类,拥有可重用的有状态逻辑提高了组件的可测试性、灵活性以及可读性。


额外的 Hooks 指南

与其他组件一样,在您想要将 Hooks 添加到您编写的代码中时,会使用一些特殊函数。以下是某些常用 Hook 函数的简要概述。

useState

useState Hook 使开发人员能够在函数组件内部更新和操作状态,而无需将其转换为类组件。此 Hook 的一个优势是它很简单,不需要像其他 React Hooks 那样复杂。

useEffect

useEffect Hook 用于在函数组件中的主要生命周期事件期间运行代码。函数组件的主体不允许变异、订阅、计时器、日志记录和其他副作用。如果允许,会导致 UI 中出现令人困惑的错误和不一致。useEffect hook 阻止所有这些“副作用”,并允许 UI 顺利运行。它结合了 componentDidMountcomponentDidUpdatecomponentWillUnmount,所有这些都集中在一个地方。

useContext

useContext Hook 接受一个上下文对象,该对象是 React.createcontext 返回的值,并返回该上下文的当前上下文值。useContext Hook 还与 React 上下文 API 协同工作,以便在整个应用程序中共享数据,而无需通过各个级别传递应用程序道具。

需要注意的是,传递给 useContext Hook 的参数必须是上下文对象本身,任何调用 useContext 的组件都会在上下文值发生更改时始终重新渲染。

useReducer

useReducer Hook 为 setState 提供了一种替代方案,当您有涉及多个子值的复杂状态逻辑,或者下一个状态依赖于上一个状态时,尤其适合使用它。它接受一个 reducer 函数和一个初始状态输入,并通过数组解构返回当前状态和一个 dispatch 函数作为输出。useReducer 还优化了触发深度更新的组件的性能。


优点

更少的代码行

Hooks 允许您按关注点和功能对代码进行分组,而不是按生命周期进行分组。这使得代码不仅更简洁和清晰,而且更短。以下是对使用 React 的一个简单的有状态的可搜索产品数据表的比较,以及在使用 useState 关键字后它在 Hooks 中的外观。

有状态组件

class TweetSearchResults extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      filterText: "",
      inThisLocation: false,
    };

    this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
    this.handleInThisLocationChange =
      this.handleInThisLocationChange.bind(this);
  }

  handleFilterTextChange(filterText) {
    this.setState({
      filterText: filterText,
    });
  }

  handleInThisLocationChange(inThisLocation) {
    this.setState({
      inThisLocation: inThisLocation,
    });
  }

  render() {
    return (
      <div>
        <SearchBar
          filterText={this.state.filterText}
          inThisLocation={this.state.inThisLocation}
          onFilterTextChange={this.handleFilterTextChange}
          onInThisLocationChange={this.handleInThisLocationChange}
        />
        <TweetList
          tweets={this.props.tweets}
          filterText={this.state.filterText}
          inThisLocation={this.state.inThisLocation}
        />
      </div>
    );
  }
}

使用 Hooks 的相同组件

const TweetSearchResults = ({ tweets }) => {
  const [filterText, setFilterText] = useState("");
  const [inThisLocation, setInThisLocation] = useState(false);
  return (
    <div>
      <SearchBar
        filterText={filterText}
        inThisLocation={inThisLocation}
        setFilterText={setFilterText}
        setInThisLocation={setInThisLocation}
      />
      <TweetList
        tweets={tweets}
        filterText={filterText}
        inThisLocation={inThisLocation}
      />
    </div>
  );
};

简化复杂组件

JavaScript 类可能难以管理,难以与热重载一起使用,并且可能无法很好地缩小。React Hooks 解决这些问题,并确保函数式编程变得容易。通过实现 Hooks,我们不需要类组件。

重用有状态逻辑

JavaScript 中的类鼓励多级继承,这会迅速增加总体复杂度和潜在的错误。但是,Hooks 允许您使用状态和其他 React 功能,而无需编写类。使用 React,您始终可以重用有状态逻辑,而无需一遍又一遍地重写代码。这减少了错误发生的可能性,并允许使用普通函数进行组合。

共享非可视逻辑

在实现 Hooks 之前,React 无法提取和共享非可视逻辑。这最终导致了更多复杂性,例如 HOC 模式和 Render props,只是为了解决一个常见问题。但是,Hooks 的引入解决了这个问题,因为它允许将有状态逻辑提取到一个简单的 JavaScript 函数中。

当然,Hooks 也有一些潜在的缺点,值得记住

  • 必须遵守其规则,如果没有 linter 插件,很难知道哪些规则被破坏了。
  • 需要大量时间练习才能正确使用(例如:useEffect)。
  • 注意错误使用(例如:useCallback,useMemo)。

React Hooks 与类

当 Hooks 被引入 React 时,它创建了一个新问题:我们如何知道何时使用带 Hooks 的函数组件和类组件?借助 Hooks,即使在函数组件中也可以获得状态和部分生命周期 Hooks。Hooks 还允许您使用局部状态和其他 React 功能,而无需编写类。

以下是 Hooks 和类之间的一些区别,以帮助您做出决定

React Hooks
它有助于避免多个层次结构,并使代码更清晰通常,当您使用 HOC 或 renderProps 时,当您尝试在 DevTools 中查看它时,您必须用多个层次结构来重构您的应用程序
它为整个 React 组件提供一致性。类会使人和机器都感到困惑,因为需要理解绑定以及函数调用的上下文。