一、描述

Effect Hook 可以让你能够在 Function 组件中执行副作用(side effects):

import { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
  // Similar to componentDidMount and componentDidUpdate:
  // 类似于 componentDidMount 和 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 文档中 Hook 部分一直使用的计数器示例,但是多了个新的功能:把文档标题设置为包含点击次数的自定义消息。而这就是一个副作用。

数据获取,设置订阅或者手动直接更改 React 组件中的 DOM 都属于副作用。有的人习惯成这种行为为 effects,我是比较习惯叫 side effects 也就是副作用的, 这是个概念,需要在 React 必须习惯的概念。

如果熟悉 React 类声明周期方法,可以把 useEffect Hook 视作 componentDidMountcomponentDidUpdatecomponentWillUnmount 的组合体。

React 组件中有两种常见的副作用:

  • 需要清理的副作用
  • 不需要清理的副作用。

二、需要清理的副作用

有的时候,我们希望在 React 更新 DOM 之后进行一些额外的操作。网络请求、手动更改 DOM 以及日志记录都是不需要清理的副作用的常见场景。因为运行之后,可以立即被销毁掉。

下面是在 class 组件和 function 组件中分别表示这两种副作用的使用方式:

1、在 class 组件中

在 React class 组件中, render 方法本身不应该进行副作用操作,但是我们通常是期望在 React 更新 DOM 之后执行一些有必要的副作用。

这就是为什么在 React class 中,会把副作用放在 componentDidMountcomponentDidUpdate 中。回到计数器的示例中,如果要在 class 计数器组件中实现上面的功能,则代码如下:

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

上面代码很明显,class 组件中,两个生命周期中有相同的代码(虽然 componentDidUpdate 中的内容也可以放在 click 的事件 handler 中)

这是因为在多数情况下,我们希望执行相同的副作用,无论是组件刚 mount 还是 update 之后。而从概念上来讲,我们希望他在每次 render 之后发生,但是 React 类组件是没有这种生命周期的。虽然可以把 document.title = 'You clicked' + this.state.count + ' times'; 这个操作封装到一个方法中,但是还是需要在 componentDidMountcomponentDidUpdate 中调用两次。

2、使用 effect Hook 的示例

文章最顶部已经写了下面的示例,为了分析代码,单独再拿到这里:

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

1、useEffect 做了什么?

通过使用这个 Hook,通知 React 组件需要在渲染后执行什么操作。React 将记住传递的 function(把这个 function 成为 “effect”),并在执行 DOM 更新后调用这个 function。在这个效果中,主要的功能仍旧是设置 document.title,但是也可以执行数据获取,或者是调用其他的命令式的 API。

2、为什么在组件内调用 useEffect

在组件内使用 useEffect 是的可以直接从副作用中访问计数器的 count 或者任何的 props。不需要使用特殊的 API 来读取它,它已经在函数的范围内了(通过 useState)。Hooks 拥抱 Javascript 的闭包,并且避免在 Javascript 已经提供解决方案的情况下在去引入特定的 React API。

3、每次 render 之后都会执行 useEffect 吗?

是的!

这是默认行为,在第一次 render 之后和每次 update 之后都会运行。你可能会更容易的认为副作用发生在 “render 之后”,而不是发生在 “mount” 和 “update” 之后。不过 React 保证 DOM 在运行时副作用已经更新。

(网络请求每次都放在这里面肯定是有问题的,因此需要定制)如果要定制 useEffect 的默认执行行为,可以参考:https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

3、详细代码拆分说明

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

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

我们通过 useState 声明了 count state 变量,并且通知 React 需要使用 effect。

然后把一个 funcrion 传递给 useEffect Hook,而传递的这个 funcrion 就是副作用。

在我们的副作用中,使用 document.title 浏览器 API 设置文档的标题,可以在 effect 中读取最新的 count,因为 count 变量作用域就是在整个 Example function 中。当 React 渲染我们的组件时,会机主我们使用的 effect,然后在更新 DOM 后运行需要的下沟哦。每次渲染都会发生这样的情况,包括第一次 render。

你可能会注意到,传递给 useEffect 的 function 在每次 render 的时候有所不同,这是故意为之的。事实上,这就是让我们在副作用中读取 count 值而不需要担心这个值是旧值。每次在 re-render 的时候,都会有一个不同的副作用,来取代之前的副作用。在某种程度上,这使得副作用更像是 render 结果的一部分——每个副作用都“属于”特殊的 render。文章后面会提到为什么这是有用的。

Tip

componentDidMountcomponentDidUpdate 不同,使用 useEffect 调度的副作用不会阻塞浏览器更新屏幕。这使得 application 感觉上具有响应式。大多数副作用不需要同步发生。而如果需要同步进行,(比如测量布局),有一个单独的 useLayoutEffect Hook, API 和 useEffect 相同。

三、需要清理的副作用

上面都是不需要清理的副作用,然而,有些副作用是需要去清理的。比如,肯呢过希望设置对某些外部数据源的 subscription。而在这种情况下,清理订阅是非常重要的,这样不会引入内存泄露。

1、使用 class 组件示例:

在 React class 中,通常会在 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';
  }

请注意 componentDidMountcomponentWillMount 需要相互对应。class 组件的生命周期强制我们去拆分这个逻辑,即使他们中的概念代码和相同的副作用是有关的。

注意

眼里好的人可能注意到了上面的示例可能需要一个 componentDidUpdate 才能完全的正确,目前暂时忽略。

2、使用 Hooks 的示例

一开始,可能会认为需要单独的 effect 去清理,但是添加订阅和删除订阅的代码联系非常紧密,因此 useEffect 旨在将它保持在一起。如果你的副作用返回一个方法,则 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';
}

1、为什么从 effect 中返回一个 function?

这是 effect 可选的清理机制。每个 effect 都可以返回一个在它之后清理的 function。这使得我们能够保持添加订阅和删除订阅彼此接近的订阅的逻辑。这同样是 effect 的一部分。

2、React 在什么时候清理?

当组件卸载的时候,React 会执行清理工作。

然而,effect 会针对每个 render 运行而不仅仅是一次,这就是 React 在下次运行 effect 之前还清除前一个 render effect 的原因。

有两个链接:

四、总结

我们已经了解了 useEffect 能够在组件 render 之后进行不同类型的副作用。某些 effect 可能需要清理,因此可以在 effect 中返回一个 function:

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

而有一些 side effect 可能没有清理的过程,因此不需要返回任何内容。

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

通过 useEffect,能够将之前在两个生命周期中的内容整合到一个 function 中。

五、使用 effect 的 tips

1、Tips:使用多个 effect 来分离问题

使用 Hook 的动机中包括了 class 组件的生命周期将相关的逻辑拆分的问题的解决,而在 Hook 的使用中,也能够把多个 effect 放在 function 组件。

下面是 class 组件的相关代码:

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 之前拆分。而订阅的逻辑也在 componentDidMountcomponentDidUpdate 之间传播。componentDidMount 包含两个任务的代码。

使用 Hook 解决这个问题其实就像之前使用 useState 解决问题一样,可以使用多个 effect,然后将不相关的逻辑都拆分成不同的 effect。

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。

2、说明:为什么 effect 在每次 update 都会运行

如果你习惯了使用 class 组件,你可能想知道为什么每次 re-render 之后,effect 的清理都会执行,而不是在卸载过程中只执行一次(打断点就能知道)。

useState 的文章(http://www.ptbird.cn/react-hook-use-state-hook.html) 中有一个 FriendStatus 来表示好友是否在线,class 组件冲 this.props 中读取 friend.id,在组件 mount 之后,就订阅朋友的状态,并在卸载期间取消订阅:

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

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

但是如果 friend prop 在组件出现在屏幕上时发生了和变化,又会发生什么呢?类组件将继续显示不同的朋友的在线状态,这是一个bug,并且因为取消订阅使用了错误的 friend 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 应用程序中常见的错误。

如果使用 Hook :

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

上面的代码如果 this.props.friend 发生了变化,也不会受到影响。

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

// 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

这种默认行为确保了一致性,并防止由于缺少 update 的处理逻辑而产生 class 组件中常见的错误。

3、Tip:跳过 effect 优化性能

在某些情况下,每次 render 后清理或者使用 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]);// 只有在 count 发生变化的时候才会执行这个 effect

上面的例子中, [count] 作为第二个参数传递。如果 count = 5,然后组件如果进行了 re-render,如果 count=5,则 React 会比较前一个 render 和 下一个 render 的值。因为两次 5 === 5,因此React 会跳过这次 effect,这是性能优化。

count = 6 的时候,React 会比较 5 !== 6。此时,React 会重新去调用 effect,如果数组中有多个项目,只要有一个的比较值是不相同的, React 也会执行这个 effect。

上面的作用,也同样应用于 cleanup 的 effect:

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 只有 props.friend.id 变化的时候才会调用 effect

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

注意

如果使用此优化,需要确保数组包含外部作用域随时间变化且 effect 使用的任何值。否则,你的代码将引用之前渲染的旧值。在 https://reactjs.org/docs/hooks-reference.html 有又关于 Hooks 优化的更多内容。

如果要运行效果并且仅将其清理一次(在 mount 和 unmount 的时候),可以把空数组 [] 作为第二个参数传递。这告诉React你的效果不依赖于来自props或state的任何值,所以它永远不需要重新运行。这不会作为特殊情况进行处理 - 它直接遵循输入数组的工作方式。虽然传递 [] 更接近 componentDidMountcomponentWillUnmount 的模式,但是不建议将其作为一种习惯,如果存在订阅的话,经常会导致错误。

不要忘记 React 会延迟运行 useEffect 直到浏览器 render 之后,所以进行额外的操作也不是问题。