原文:Using Effect Hook

Hooks是React 16.8的新特性。这些特性让你不必专门写class组件,来使用state和其他React特性。
_
Effect Hook可以使你能在函数组件中处理副作用:

  1. import React, { useState, useEffect } from 'react';
  2. function Example() {
  3. const [count, setCount] = useState(0);
  4. // Similar to componentDidMount and componentDidUpdate:
  5. useEffect(() => {
  6. // Update the document title using the browser API
  7. document.title = `You clicked ${count} times`;
  8. });
  9. return (
  10. <div>
  11. <p>You clicked {count} times</p>
  12. <button onClick={() => setCount(count + 1)}>
  13. Click me
  14. </button>
  15. </div>
  16. );
  17. }

这个片段是基于前讲中的计数器例子,但是我们加了些新东西:我们把document的title设置成包含点击按钮次数的自定义信息。
数据请求,设置监听,人为修改DOM这些在React中就是副作用。无论你称之为“side effect(副作用)”还是“effect(作用)”,你在此前都应该使用过它们。

提示: 如果你熟悉生命周期,你会发现useEffect就像是componentDidMount、componentDidUpdate和componentWillUnmount的结合。

不清除副作用的情况

有些时候,我们想在React更新DOM之后运行些额外的代码。数据请求,手动修改DOM,或者打印日志都是常见的不需要清除的副作用例子。这是因为我们运行他们之后就立即“忘记”了它们。让我们比较class和Hooks分别如何执行副作用的。

用Class的例子

在React的class组件中,render方法不应该引发副作用!那样就太早了——我们只是想在React更新DOM后执行副作用。
这就是为什么React的class组件中,我们要把副作用放在componentDidMount和componentDidUpdate中。回头看看我们的例子,React计数器在React更新DOM之后变更document的title的,class组件版本:

  1. class Example extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = {
  5. count: 0
  6. };
  7. }
  8. componentDidMount() {
  9. document.title = `You clicked ${this.state.count} times`;
  10. }
  11. componentDidUpdate() {
  12. document.title = `You clicked ${this.state.count} times`;
  13. }
  14. render() {
  15. return (
  16. <div>
  17. <p>You clicked {this.state.count} times</p>
  18. <button onClick={() => this.setState({ count: this.state.count + 1 })}>
  19. Click me
  20. </button>
  21. </div>
  22. );
  23. }
  24. }

注意我们是怎样在两个生命周期方法中编写了重复代码的
这是因为在很多场景中我们需要副作用时是不需要明确组件是刚刚被挂载,还是刚刚被更新。概念上来说,我们其实是希望在每次渲染之后调用——不过React的class组件并没有一个方法完成这样的事情。我们可以提取出一个单独的方法,但是我们还是会在两个地方调用。
现在让我们看看怎样用useEffect来做相同的事情。

使用useEffect的例子

我们在本讲开始时已经看到这个例子了,但让我们一起在仔细看看:

  1. import React, { useState, useEffect } from 'react';
  2. function Example() {
  3. const [count, setCount] = useState(0);
  4. useEffect(() => {
  5. document.title = `You clicked ${count} times`;
  6. });
  7. return (
  8. <div>
  9. <p>You clicked {count} times</p>
  10. <button onClick={() => setCount(count + 1)}>
  11. Click me
  12. </button>
  13. </div>
  14. );
  15. }

useEffect干了什么?通过使用这个Hook,这就告诉了React你的组件需要在渲染之后做些事情。React将会记住你作为参数传入的函数(下文我们叫它做effect),并在DOM更新之后调用它。这个effect中,我们设置了document的title,不过我们也可以做数据请求或者其他命令式API。
为什么在组件内部调用useEffect?把useEffect放在组件内部让我们可以在effect内部就能访问count状态变量或者其他props。我们无需使用特殊的API来读取他们——因为他们在同一个函数域里面。Hook使用了JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API。
useEffect的确是在每次渲染后吗?没错!默认情况下,在第一次渲染和每一次更新之后都会执行。(稍后我们会讨论怎样定制这一点)。与其去思考“挂载”和“更新”这些概念,倒不如更简单的理解为effect发生在“每次渲染之后”。React保证当DOM更新后就执行effect。

详细解释

现在我们对useEffect了解了更多内容后,看下面这些代码就有意义了:

  1. function Example() {
  2. const [count, setCount] = useState(0);
  3. useEffect(() => {
  4. document.title = `You clicked ${count} times`;
  5. });

我们声明了count状态变量,接着我们告诉React我们需要使用effect副作用。我们给useEffect这个Hook传递一个函数作为参数。这个函数就是我们的effect。在我们的effect内部,我们使用浏览器API来设置document的title。在effect的内部我们可以读取到最新的count的值,这是因为它们在同一个函数域中。当React渲染我们的组件时,它会记住我们使用过的effect,并且接着在将来的DOM更新后继续调用我们的effect。这些事情在每一次渲染后发生,也包括了第一次。
有经验的JavaScript开发者也许注意到:在每次渲染中useEffect中传递的参数都是不同的(新的函数对象)。这是故意的,实际上,这正是我们之所以能在effect中拿到count最新的值而不必担心他已经过期了的原因。每次重新渲染,我们调用一个不同的effect,代替原先的那一个。这种方式下,就有点像effect行为是渲染结果的一部分一样——每一个effect都属于一个特定的render。稍后我们会更清楚地看到为什么这一点是有用的。

提示: 不像componentDIdMount或者componentDidUpdate,effects的调度不会阻塞浏览器更新屏幕。这一点让你的程序响应更快。大部分的effects不会需要同步执行。在一些特殊场景(像测量布局),这有一个单独的useLayoutEffect的Hook处理,它有着与useEffect相同的API。

需要清除副作用的情况

上文中我们看到怎样使用一个不需要清除的effect副作用。但一些是需要清除的。比如,我们想订阅一些外部数据源。这种情况下,及时清除很重要,否则可能会导致内存泄漏。让我们对比下class和Hook分别是怎么做的。

使用Class的例子

  1. class FriendStatus extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = { isOnline: null };
  5. this.handleStatusChange = this.handleStatusChange.bind(this);
  6. }
  7. componentDidMount() {
  8. ChatAPI.subscribeToFriendStatus(
  9. this.props.friend.id,
  10. this.handleStatusChange
  11. );
  12. }
  13. componentWillUnmount() {
  14. ChatAPI.unsubscribeFromFriendStatus(
  15. this.props.friend.id,
  16. this.handleStatusChange
  17. );
  18. }
  19. handleStatusChange(status) {
  20. this.setState({
  21. isOnline: status.isOnline
  22. });
  23. }
  24. render() {
  25. if (this.state.isOnline === null) {
  26. return 'Loading...';
  27. }
  28. return this.state.isOnline ? 'Online' : 'Offline';
  29. }
  30. }

注意componentDidMount和componentWillUnmount这两个方法需要相互对应。生命周期方法迫使我们拆分逻辑,即使这两段代码逻辑都属于同一个effect。

注意: 眼尖的读者也许注意到了,这个离职还需要一个componentDidUpdate方法才算完整。我们现在先忽略但在后面会回看这个问题。

使用useEffect的例子

让我们看看用Hook怎么写这个组件。
你或许会想我们需要一个单独的Hook来做清除工作。但是添加和移除订阅的代码实在是联系紧密,useEffect的设计就是为了让它们在一起。如果你的effect返回是一个函数,React就会在需要清除的时候执行该函数:

  1. import React, { useState, useEffect } from 'react';
  2. function FriendStatus(props) {
  3. const [isOnline, setIsOnline] = useState(null);
  4. useEffect(() => {
  5. function handleStatusChange(status) {
  6. setIsOnline(status.isOnline);
  7. }
  8. ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  9. // Specify how to clean up after this effect:
  10. return function cleanup() {
  11. ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  12. };
  13. });
  14. if (isOnline === null) {
  15. return 'Loading...';
  16. }
  17. return isOnline ? 'Online' : 'Offline';
  18. }

为什么要从effect中返回一个函数?这是副作用的可选清除机制。每一个副作用返回之后的清除函数。这使得就使得我们的订阅、取消订阅的逻辑紧密的在一起。它们都是一个副作用的组成部分。
React是什么时候清除effect?组件卸载时,React执行清除。但是,如同我们先前所学,effect在每次渲染都执行而不是只执行一次。这也是为什么React同样在下一次执行effect之前也要清除上一次的effect。很快我们会讨论“为什么这会帮助我们避免bug”以及“当有性能问题时,我们怎么选择跳过此行为”。

注意: 我们在effect中没必要返回一个命名的函数。在这里我们叫它cleanup是明确他的职责,但是你可以返回箭头函数或者叫个别的名字

总结

我么学习了useEffect这个Hook可以让我们在组件渲染之后执行不同的effect副作用。一些是需要清理的所以需要返回一个函数:

  1. useEffect(() => {
  2. function handleStatusChange(status) {
  3. setIsOnline(status.isOnline);
  4. }
  5. ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  6. return () => {
  7. ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  8. };
  9. });

还有些是不需要清理语句的,不用返回任何东西。

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

useEffect使用相同的API来满足上述两种情况。

如果你觉得你对Effect Hook的工作机制有何较好的掌握,或暂时无法理解,你都可以现在看看下一讲“Hooks的规则”。

使用提示

我们继续这一讲,看看React资深开发者可能会关心的一些深入问题。不要觉得马上就得发掘它们。你也可随时回来学习这些Effect Hook的细节。

提示:多个关注点用多个Effect

我们在Hooks设计的初衷中,已经指出生命周期方法总是包含无关联的逻辑,而真正相关的逻辑却被打散到各个方法中。这里是结合了之前计数器和朋友状态指示的组件:

  1. class FriendStatusWithCounter extends React.Component {
  2. constructor(props) {
  3. super(props);
  4. this.state = { count: 0, isOnline: null };
  5. this.handleStatusChange = this.handleStatusChange.bind(this);
  6. }
  7. componentDidMount() {
  8. document.title = `You clicked ${this.state.count} times`;
  9. ChatAPI.subscribeToFriendStatus(
  10. this.props.friend.id,
  11. this.handleStatusChange
  12. );
  13. }
  14. componentDidUpdate() {
  15. document.title = `You clicked ${this.state.count} times`;
  16. }
  17. componentWillUnmount() {
  18. ChatAPI.unsubscribeFromFriendStatus(
  19. this.props.friend.id,
  20. this.handleStatusChange
  21. );
  22. }
  23. handleStatusChange(status) {
  24. this.setState({
  25. isOnline: status.isOnline
  26. });
  27. }
  28. // ...

你看,设置document.title的逻辑分散到了componentDidMount和componentDidUpdate中,订阅逻辑则分散到了componentDidMount和componentWillUnmount中。且componentDidMount中还包含了全部两个任务逻辑。
所以,Hooks咋解决这问题呢?就像上面说的,你可以多次使用Effect Hook, 这里你就可以多次使用多个Hook。这就能使我们把不相干的逻辑在不同的Hook中分隔开来:

  1. function FriendStatusWithCounter(props) {
  2. const [count, setCount] = useState(0);
  3. useEffect(() => {
  4. document.title = `You clicked ${count} times`;
  5. });
  6. const [isOnline, setIsOnline] = useState(null);
  7. useEffect(() => {
  8. function handleStatusChange(status) {
  9. setIsOnline(status.isOnline);
  10. }
  11. ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  12. return () => {
  13. ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  14. };
  15. });
  16. // ...
  17. }

Hooks让我们基于这是做什么的来组织代码,而不是按照生命周期。React会根据这些effect呈现的顺序来调用在组件中的每一个effect。

解释:为什么在每一次渲染中都执行effect

假如你使用过class,你可能会想知道为什么effect的清楚语句在每次渲染结束后都会执行,而不是在卸载的时候只执行一次。我们看一个实用的例子,看看为什么说这样的设计帮助我们构建出有更少bug的组件。
在本讲早些时候,我们介绍了一个例子(FriendStatus)组件,这个组件显示朋友在线状态。我们的组件读取在this.props中读取firend.id,在组件挂载之后订阅friend的在线状态,并在卸载组件的时候取消订阅:

  1. componentDidMount() {
  2. ChatAPI.subscribeToFriendStatus(
  3. this.props.friend.id,
  4. this.handleStatusChange
  5. );
  6. }
  7. componentWillUnmount() {
  8. ChatAPI.unsubscribeFromFriendStatus(
  9. this.props.friend.id,
  10. this.handleStatusChange
  11. );
  12. }

但是,要是friend这个属性变化了,这时候组件还在屏幕上?我们的组件还在继续展示先前的friend的在线状态。这就是bug了。当卸载阶段取消订阅的函数使用错误的friendID时,我们也还可能会引起内存泄漏或者崩溃。
在class组件中我们要加上componentDidUpdate来处理这一点:

  1. componentDidMount() {
  2. ChatAPI.subscribeToFriendStatus(
  3. this.props.friend.id,
  4. this.handleStatusChange
  5. );
  6. }
  7. componentDidUpdate(prevProps) {
  8. // Unsubscribe from the previous friend.id
  9. ChatAPI.unsubscribeFromFriendStatus(
  10. prevProps.friend.id,
  11. this.handleStatusChange
  12. );
  13. // Subscribe to the next friend.id
  14. ChatAPI.subscribeToFriendStatus(
  15. this.props.friend.id,
  16. this.handleStatusChange
  17. );
  18. }
  19. componentWillUnmount() {
  20. ChatAPI.unsubscribeFromFriendStatus(
  21. this.props.friend.id,
  22. this.handleStatusChange
  23. );
  24. }

忘记在componentDidUpdate中处理是React应用中Bug的常见原因。
现在考虑下使用Hooks版本实现这个功能的组件:

  1. function FriendStatus(props) {
  2. // ...
  3. useEffect(() => {
  4. // ...
  5. ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  6. return () => {
  7. ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  8. };
  9. });

它就不会遇到上述Bug(我们也没任何改动)。
没有任何特别去处理变化的代码因为都被useEffect默认处理了。先前的effects在应用下一个effects前被清除了。为了指出这点,这里就是组件可能产生的一系列订阅和取消订阅的调用序列:

  1. // Mount with { friend: { id: 100 } } props
  2. ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // Run first effect
  3. // Update with { friend: { id: 200 } } props
  4. ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // Clean up previous effect
  5. ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // Run next effect
  6. // Update with { friend: { id: 300 } } props
  7. ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // Clean up previous effect
  8. ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // Run next effect
  9. // Unmount
  10. ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // Clean up last effect

这个行为默认就确保了一致性,并且防止哪些常见于忘记在class中编写更新逻辑的bug产生。

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

在一些场景中,每次渲染都清除或者调用effect可能造成性能问题。在class组件中,我们解决问题的方法是在componentDidUpdate中写一个用prevProps或prevState比较条件:

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

这种需求很常见,并且在useEffect的API中也内置了。你可以告诉React当一些值在重现渲染之间没有发生变化时跳过执行effect副作用。把数组作为第二个参数传递给useEffect来达成需求:

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

上面的例子中,我们传递[count]作为第二参数。我们这么做是什么意思?假设count现在是5,接着我们的组件会重新渲染,但count的值还是5,React会比较前一次渲染中的“[5]”和下一次渲染的”[5]”。因为数组中所有的项都相等( 5 === 5 ),React 就会跳过这个effect。这就是我们的优化。
当我们更新count的值为6,React会比较前一次渲染中”[5]”数组中的项目和下一次渲染的”[6]”。这时候,React会重新执行这个effect因为5 !== 6。如果数组里面有多个项目,那么数组中有项目不同,React都会重新调用effect。
这也会在清除语句中奏效:

  1. useEffect(() => {
  2. function handleStatusChange(status) {
  3. setIsOnline(status.isOnline);
  4. }
  5. ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  6. return () => {
  7. ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  8. };
  9. }, [props.friend.id]); // Only re-subscribe if props.friend.id changes
  10. // 只在 props.friend.id 变化后重新订阅

在未来版本,第二个参数可能会在构建时自动被添加。

注意: 如果你使用这种优化,确保这个数组的所有值是在组件的域中会随时变化的,并且在effect副作用中用到(比如props和state)。否则,你的代码可能引用先前已经过期的变量值。你在“怎样处理函数”和“当数组变得太快时我们怎么办”中(都是Hooks FAQ中的问题)可以学到更多。

如果你想在组件挂载和卸载中只运行一次effect副作用,你可以传一个空数组( [] )作为第二个参数。这就给React说明你的副作用effect不会依赖任何props或者state,它就是要永远不需要重新执行。这样也不算是特例——因为它依旧遵循依赖数组(第二个参数)的工作方式。

如果你传递了一个空数组( [] ),effect副作用中的state和props将总是保有他们的初始值,当传递空数组作为第二个参数时,就很像componentDidMount和componentWillUnmount的思维模式。还有通常用来避免effect副作用过于频繁的更好方式(在Hooks FAQ中的问题)。还有,别忘了React会推迟调用useEffect,直到浏览器完成绘制,因此额外的一些操作不会有问题。

我们推荐启用 eslint-plugin-react-hooks 中的 exhaustive-deps 规则。此规则会在添加错误依赖时发出警告并给出修复建议。

下一步

恭喜!真乃长文,但是到了结尾处总是充满希望,因为大部分effect的问题都回答了。你已经学了State Hook和Effect Hook,把他们结合就能做很多事情。他们覆盖了大多数class组件的使用场景——那些覆盖不到的,你可以在额外的Hooks(在“Hook API 引用 ”一讲中)寻求帮助。
我们也开始看到那些在“动机”(介绍Hooks中)中标示出来的问题Hooks是如何解决的。我们也看到effect的清除机制是怎么那些在componentDidMount和componentDidUpdate中的冗余代码,怎样把相关的代码放在一起,并帮助我们避免bug。我们也看了怎样按照实际意图来分割effect,这是我们在class组件也不曾做到的事情。
在这里你可能就有一些问题,关于Hooks怎么工作的。React怎么知道在不断重复渲染中哪一个useState的调用与哪一个状态变量对应?React怎样在每次更新时,匹配先前和之后的每一个effects副作用?下一讲我们会学习Hooks的规则——他们对Hooks正常工作非常重要。