使用 Effect Hook

Hooks 是一项新功能提案,可让您在不编写类的情况下使用 state(状态) 和其他 React 功能。它们目前处于 React v16.7.0-alpha 中,并在 一个开放RFC 中进行讨论。

愚人码头注:

首先了解什么是副作用(Side Effect):副作用(Side Effect)是指函数或者表达式的行为依赖于外部世界。具体可参照Wiki上的定义,副作用是指:

    1. 函数或者表达式修改了它的 scope 之外的状态
    1. 函数或者表达式除了返回语句外还与外部世界或者它所调用的函数有明显的交互行为

Effect Hook 为你的函数式组件增添了执行 side effects 的能力。

import { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

这段代码段基于 上一页的计数器示例,但我们为其添加了一项新功能:我们将文档标题设置为包含点击次数的自定义消息。

数据获取、设置订阅以及手动更改 React 组件中的 DOM 都是 side effects 的例子。不管您是否习惯调用这些操作 side effects (或仅仅是 effect ),但你可能在以前的组件中执行过这些操作。

提示

如果您熟悉 React 类生命周期方法,则可以将 useEffect Hook 视为 componentDidMountcomponentDidUpdatecomponentWillUnmount 的组合。

React 组件中有两种常见的 side effects :不需要清理的和需要清理的。让我们更详细地看看这个区别。

不需要清理的 side effects

有时,我们希望 在 React 更新 DOM 之后运行一些额外的代码。 网络请求、手工 DOM 修改和日志记录都是不需要清理的 side effects 的常见例子。我们这样说是因为我们可以运行它们,然后马上忘记它们。让我们比较一下 classes(类) 和 Hooks 如何让我们表示这些 side effects 的。

使用 classes(类) 的例子

在 React 类组件中,render 方法不应该引起 side effects 。 现在还为时过早——我们通常希望在 React 更新 DOM 之后执行我们的 side effects 。

这就是为什么在 React 类中,我们要将 side effects 放入 componentDidMountcomponentDidUpdate 中。 回到我们的示例,这里是一个 React 计数器类组件,它在 React 对 DOM 进行更改后立即更新文档标题:

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  render() {
    return (
      <div>
        <p>You clicked {this.state.count} times</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Click me
        </button>
      </div>
    );
  }
}

请注意 我们在类中复制了这两个生命周期方法之间的代码

这是因为在许多情况下,我们希望执行相同的 side effect ,无论组件是刚装载还是已经更新。 从概念上讲,我们希望它发生在每次渲染之后,但是 React 类组件没有这样的方法。我们可以提取一个单独的方法,但是我们仍然需要在两个地方调用它。

现在让我们看看我们如何使用 useEffect Hook做同样的事情。

使用 Hooks 的例子

我们已经在本页顶部看到了这个例子,但让我们仔细看看它:

import { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffect有什么作用? 通过使用这个 Hook ,您可以告诉 React 在渲染后要做些什么。React 将记住传递的函数(我们将把它称为 “effect” ),然后在执行DOM更新后调用它。在这种情况下,我们设置了文档标题,但我们也可以执行数据获取或调用其他命令式API。

为什么在组件内调用 useEffect 在组件中放置 useEffect 可以让我们直接访问 count state(状态)变量(或任何 props 属性)。我们不需要用一个特殊的 API 来读取它,它已经在函数作用域中了。Hooks 支持 JavaScript 闭包,并在 JavaScript 已经提供解决方案的情况下,避免引入特定于 React 的 API 。

每次渲染后 useEffect 都会运行吗? 是的!默认情况下,它在第一次渲染之后和每次更新之后都运行。(我们将在后面 讨论如何自定义。) 与”mounting” 和 “updating” 不同,您可能会更容易地认为 effects 是在渲染之后发生的。React 确保 DOM 在运行 effects 时已经更新了。

详细说明

现在我们对 effects 有了一定的了解,我们来解释以下这几行代码的意义:

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

我们声明了 count state(状态)变量,然后告诉 React 我们需要使用 effect 。我们将一个函数传递给useEffect Hook,这个函数就 我们的 effect 。在我们的 effect 中,我们使用 document.title 浏览器 API 设置文档标题。我们可以读取 effect 中的最新count,因为它在我们的函数作用域内。当 React 渲染我们的组件时,它会记住我们使用的 effect ,然后在更新 DOM 后运行我们的effect 。每次渲染后都会发生,包括第一次渲染。

有经验的JavaScript开发人员可能会注意到,传递给 useEffect 的函数在每次渲染时都是不同的。这是故意的。实际上,这就是让我们从 effect 内部读取计数值的原因,而不用担心它是旧值。每次我们重新渲染,我们安排一个不同的 effect 来替换前一个。在某种程度上说,这使得 effect 更像是渲染结果的一部分,每个effect都属于特定的渲染。 在本页后面部分 我们将更清楚地看到为什么这样是有用的。

提示

componentDidMountcomponentDidUpdate 不同,使用 useEffect 调用的 effects 不会阻止浏览器更新屏幕。这让你的应用程序感觉更有响应性。大多数 effects 不需要同步发生。在一些不常见的情况下(比如测量布局),有一个单独的 useLayoutEffect Hook,其 API 与 useEffect 相同。

需要清理的 side effects

之前,我们研究了如何表示不需要任何清理的 side effects 。 但是,有些 effects 需要清理。 例如,我们可能希望设置对某些外部数据源的订阅。 在这种情况下,清理是非常重要的,这样我们就不会引起内存泄漏! 让我们比较一下我们如何使用 classes(类) 和 Hooks 来实现它。

使用 classes(类) 的例子

在React类中,通常会在 componentDidMount 中设置订阅,然后在 componentWillUnmount 中清理订阅。例如,假设我们有一个 ChatAPI 模块,它允许我们订阅朋友的在线状态。下面是我们如何使用 class(类) 订阅和显示该状态的例子:

class FriendStatus extends React.Component {
  constructor(props) {
    super(props);
    this.state = { isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }

  render() {
    if (this.state.isOnline === null) {
      return 'Loading...';
    }
    return this.state.isOnline ? 'Online' : 'Offline';
  }
}

注意componentDidMountcomponentWillUnmount 如何相互作用的。生命周期方法迫使我们拆分这个逻辑,即使它们中的概念代码与相同的 effect 相关。

注意

眼尖的读者可能会注意到,这个示例还需要一个 componentDidUpdate 方法才能完全正确。我们暂时不考虑这个问题,但我们将在本页面 后面的部分 中讨论它。

使用 Hooks 的例子

让我们看看我们如何用 Hooks 编写这个组件。

您可能认为我们需要单独的 effect 来执行清理。 但是添加和删除订阅的代码是如此紧密相关,以至于 useEffect 旨在将它保持在一起。 如果你的 effect 返回一个函数,React 将在清理时运行它:

import { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    // Specify how to clean up after this effect:
    return function cleanup() {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

为什么我们从 effect 中返回一个函数? 这是 effect 的可选清除机制。每个 effect 都可能返回一个在它之后进行清理的函数。这让我们可以将添加和删除订阅的逻辑紧密地保持在一起。它们是相同 effect 的一部分。

React 什么时候清理 effect ? React在组件卸载时执行清理工作。然而,正如我们之前了解到的,effect 适用于每个渲染,而不仅仅是一次。这就是为什么 React 在下次运行 effect 之前会清理之前渲染的effect 。我们将讨论 为什么这有助于避免 bug ,以及 如何选择退出这种行为,以防在后面的讨论中出现性能问题

注意

我们不需要从 effect 返回一个命名函数。我们在这里将其称为cleanup,以澄清其用途,但您可以返回一个箭头函数或将其命名为不同的名称。

概括

我们已经了解到 useEffect 让我们在组件渲染后表示不同类型的 side effects 。 某些 effects 可能需要清理,因此它们返回一个函数:

  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

其他 effects 可能没有清理阶段,也不会返回任何内容。

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

Effect Hook 用一个 API 统一了这两个用例。


如果您觉得您已经很好地掌握了 Effect Hook 是如何工作的,或者您感到不知所措,那么现在就可以跳到 下一页讨论Hooks规则


使用 Effects 的提示

我们将继续这篇文章,深入探讨一些用户可能对 useEffect 会好奇的某些方面。现在不要觉得必须了解下面的内容。你可以随时回到这个页面来了解更多关于 Effect Hook 的细节。

提示:使用多个 Effects 来分离关注点

我们在 Hooks 的 动机 中概述的问题之一是类组件的生命周期方法通常包含不相关的逻辑,但相关的逻辑被分解为几个方法。 下面是一个组件,它组合了前面示例中的计数器和朋友状态指示器逻辑:

class FriendStatusWithCounter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0, isOnline: null };
    this.handleStatusChange = this.handleStatusChange.bind(this);
  }

  componentDidMount() {
    document.title = `You clicked ${this.state.count} times`;
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate() {
    document.title = `You clicked ${this.state.count} times`;
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  handleStatusChange(status) {
    this.setState({
      isOnline: status.isOnline
    });
  }
  // ...

注意设置 document.title 的逻辑如何在 componentDidMountcomponentDidUpdate 之间拆分。订阅逻辑还分布在 componentDidMountcomponentWillUnmount 之间。componentDidMount 包含两个任务的代码。

那么,Hooks 如何解决这个问题呢?就像你可以多次使用 State Hook 一样,你也可以使用多个 effects 。这样,我们将不相关的逻辑分成不同的 effects :

function FriendStatusWithCounter(props) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }
  // ...
}

Hooks 允许我们根据代码的用途来分割代码,相当于生命周期方法名称。React 将按指定的顺序应用组件使用的 每个 effect 。

说明:为什么Effects在每次更新后都会运行

如果您已经习惯了使用类组件,那么您可能想知道为什么每次重新渲染之后都会出现 effect 清理阶段,而不仅仅是在卸载过程中出现一次。让我们来看一个实际的例子,看看为什么这个设计可以帮助我们创建 bug 更少的组件。

这个页面的前面,我们介绍了一个示例 FriendStatus 组件,它显示朋友是否在线。我们的 class(类) 从 this.props 读取 friend.id,在组件挂载后订阅好友状态,在卸载期间取消订阅:

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

但是如果friend prop 在组件出现在屏幕上时发生了变化 ,会发生什么? 我们的组件将继续显示不同朋友的在线状态。这是一个错误。卸载时我们还会导致内存泄漏或崩溃,因为取消订阅会使用错误的朋友ID。

在类组件中,我们需要添加 componentDidUpdate 来处理这种情况:

  componentDidMount() {
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentDidUpdate(prevProps) {
    // Unsubscribe from the previous friend.id
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange
    );
    // Subscribe to the next friend.id
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }

忘记正确处理 componentDidUpdate 是 React 应用程序中常见的 bug 漏洞。

现在考虑使用 Hooks 的这个组件的版本:

function FriendStatus(props) {
  // ...
  useEffect(() => {
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

它不会受到这个bug的影响。 (但我们也没有对它做任何改动。)

没有用于处理更新的特殊代码,因为默认情况下 useEffect 会处理它们。它会在应用下一个 effects 之前清除之前的effects 。为了说明这一点,这里是一个订阅和取消订阅调用的序列,该组件可以随着时间的推移产生:

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // Run first effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // Run next effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect

此行为默认确保一致性,并防止由于缺少更新逻辑而导致类组件中常见的错误。

提示:通过跳过 Effects 来优化性能

在某些情况下,在每次渲染后清理或应用 effect 可能会产生性能问题。在类组件中,我们可以通过在componentDidUpdate 中编写与 prevPropsprevState 的额外比较来解决这个问题:

componentDidUpdate(prevProps, prevState) {
  if (prevState.count !== this.state.count) {
    document.title = `You clicked ${this.state.count} times`;
  }
}

这个要求很常见,它被内置到 useEffect Hook API中。如果在重新渲染之间没有更改某些值,则可以告诉React 跳过应用 effect 。为此,将数组作为可选的第二个参数传递给 useEffect

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // Only re-run the effect if count changes

在上面的例子中,我们传递 [count] 作为第二个参数。这是什么意思?如果 count5 ,然后我们的组件重新渲染,count 仍然等于 5 ,则 React 将比较前一个渲染的 [5] 和下一个渲染的[5] 。因为数组中的所有项都是相同的(5 === 5 ),所以 React 会跳过这个 effect 。这是我们的优化。

当我们使用 count 更新为 6 渲染时,React会将前一渲染中 [5] 数组中的项目与下一渲染中 [6] 数组中的项目进行比较。这次,React将重新运行 effect ,因为 5 !== 6。如果数组中有多个项目,React 将重新运行 effect ,即使其中只有一个不同。

这也适用于具有清理阶段的 effect :

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes

将来, 第二个参数可能会通过构建时转换自动添加。

注意

如果使用此优化,请确保该数组包含外部作用域中随时间变化且 effect 使用的任何值。 否则,您的代码将引用先前渲染中的旧值。 我们还将在 Hooks API 参考中讨论其他的优化选项。

如果要运行 effect 并仅将其清理一次(在装载和卸载时),则可以将空数组([])作为第二个参数传递。 这告诉React你的 effect 不依赖于来自 props 或 state 的任何值,所以它永远不需要重新运行。这不作为特殊情况处理 - 它直接遵循输入数组的工作方式。虽然传递 [] 更接近熟悉的 componentDidMountcomponentWillUnmount 心智模型,但我们建议不要将它作为一种习惯,因为它经常会导致错误,如上所述 。 不要忘记 React 推迟运行 useEffect 直到浏览器绘制完成后,所以做额外的工作不是问题。

下一步

恭喜你终于读完了!这是一个很长的页面,但希望最后大部分关于 effect 的问题都得到了回答。你已经学习了 State Hook 和Effect Hook ,并且你可以将两者结合起来。它们涵盖了大多数 classes(类) 的用例 - 当然还有很多没有涉及到的地方,你可能会发现 额外的 Hook 很有帮助。

我们也开始看到 Hooks 是如何解决 动机 中提到的问题。我们已经看到了 effect 清理如何避免在 componentDidUpdatecomponentWillUnmount 中出现重复,使相关代码更紧密地结合在一起,并帮助我们避免 bug 。我们也看到了如何根据目的分离 effect,这是我们在 classes(类) 上无法做到的。

此时,您可能会有疑问 Hooks 是如何工作的。React 如何知道哪个 useState 调用对应于重新渲染之间的哪个状态变量? React 如何“匹配”每次更新的上一个和下一个 effect ?在下一页,我们将了解 Hooks 的规则 - 它们对于使 Hooks 工作至关重要