Effect-Hook

effect-hook

Effect Hook 可以使得你在函数组件中执行一些带有副作用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {useState, useEffect} from 'react';
function Example (){
const [count, setCount] = useState(0);
useEffect(()=>{
document.title = `You click ${count} times`
});

return(
<div>
<p> you click {count} times </p>
<button onClick={() => setCount(count + 1)}>点击我</button>
</div>
)
}

上面这段代码是基于上个 state hook计数器的例子 的例子,但是我们现在添加了新的功能,我么讲文档的标题设置为自定义消息,包含了点击次数。

数据获取,设置订阅及先手动更改 React 组件中的 DOM 都是副作用的示例。无论你是否习惯与将这些操作成为“副作用”或者仅仅是“效果”,但之前你可能已经在组件中执行了这些操作。

提示: 如果你熟悉React类组件生命周期方法,你就可以将useEffect Hook视为componentDidMount,componentDidUpdatecomponentWillUnmount的组合。

React组件中有两种常见的副作用:那些需要清理的副作用和不需要清理的副作用。让我们详细的看一下两者的区别。


无需清理的副作用

有时,我们希望在React更新DOM之后运行一些额外的代码。网络请求, 手动改变DOM和日志记录是不需要清理的效果(副作用,简称“效果”)的常见示例。我们这样说是因为我们可以运行他们并立即忘记他们。让我们比较一下classhooks是如何让我们表达这样的副作用。

使用class的例子

react类组件中,render方法本身不应该导致副作用。这太早了,我们通常希望React更新DOM之后执行我们的效果。这就是为啥在React类中,我们将副作用放到componentDidMountcomponentDidUpdate中。看我们的例子,这是一个React计数器类的组件,它在react对DOM进行更改后立即更新文档的标题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import React,{Component} from 'react';

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

componentDidMount() {
document.title = `You click ${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>
);
}
}

请注意我们如何在类中复制这两个生命周期方法之间的代码
这是因为在许多的情况下,我们希望执行相同的副作用,无论组件是刚安装还是已经更新。从概念上讲,我们希望它在每次渲染之后发生,但是在React类组件中没有这样的方法(render方法应该避免更重要)。我们可以提取一个单独的方法,但是我们仍然需要在这两个地方调用它。

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

useEffect有什么作用?
通过使用这个Hook,你告诉React你的组件需要在渲染后执行某些操作。React将记住你传递的函数(我们将其称为“效果”),并在执行DOM更新后稍后调用它。在这个效果中,我们设置文档标题,但我们也可以执行数据提取或调用其他命令式API。

为什么在组件内调用useEffect
在组件中使用useEffect让我们可以直接从效果中访问状态变量(如count或任何道具)。我们不需要特殊的API来读取它 - 它已经在函数范围内了。Hooks拥抱JavaScript闭包,并避免在JavaScript已经提供解决方案的情况下引入特定于React的API。

每次渲染后useEffect都会运行吗?
是的。默认情况下,它在第一次渲染之后和每次更新之后运行。 (我们稍后会讨论如何自定义它。)你可能会发现更容易认为效果发生在“渲染之后”,而不是考虑“挂载”和“更新”。React保证DOM在运行‘效果’时已更新。

详细说明

现在我们对这个hook更加的了解了,那再看看下面这个例子:

1
2
3
4
5
6
7
function Example() {
const[count, setCount] = useState(0);

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

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

注意:componentDidMountcomponentDidUpdate不同,使用useEffect的效果不会阻止浏览器更新屏幕。这使应用感觉更具响应性。大多数效果不需要同步发生。在他们这样做的不常见情况下(例如测量布局),有一个单独的useLayoutEffect Hook,其APIuseEffect相同。

需要清理的副作用

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

使用 **class**的例子

React类中,通常会在componentDidMount中设置订阅,并在componentWillUnmount中清除它。例如,假设我们有一个ChatAPI`模块,可以让我们订阅朋友的在线状态。以下是我们如何使用类订阅和显示该状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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如何相互作用。生命周期方法迫使我们拆分这个逻辑,即使它们中的概念代码都与相同的效果有关。

注意: 眼尖的你可能会注意到这个例子还需要一个componentDidUpdate方法才能完全正确。我们暂时忽略这一点,但会在本页的后面部分再回过头来讨论它。

使用**hooks**的例子

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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);
// 指定如何在这种效果之后清理
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

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

为什么我们从效果中返回一个函数? 这是效果的可选清理机制。每个效果都可能返回一个在它之后清理的函数。这使我们可以保持添加和删除彼此接近的订阅的逻辑。

React什么时候清理效果? 当组件卸载时,React执行清理。但是,正如我们之前所了解的那样,效果会针对每个渲染运行而不仅仅是一次。这就是React在下次运行效果之前还清除前一渲染效果的原因。我们将讨论为什么这有助于避免错误以及如何在以后发生性能问题时选择退出此行为。

注意: 我们不必从效果中返回命名函数。我们在这里只是为了说明才加的命名,但你可以返回箭头函数。

概述

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

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

其他效果可能没有清理阶段,也不会返回任何内容。比如:

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

如果你觉得你对Effect Hook的工作方式有了很好的把握,或者你感到不知所措,那么现在就可以跳转到关于Hooks规则。


使用效果的提示

我们将继续深入了解使用React用户可能会产生好奇心的useEffect的某些方面。

提示:使用多重效果分离问题

这是一个组合了前面示例中的计数器和朋友状态指示器逻辑的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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如何解决这个问题呢?就像你可以多次使用状态挂钩一样,你也可以使用多种效果。这让我们将不相关的逻辑分成不同的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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将按照指定的顺序应用组件使用的每个效果。

说明:为什么效果在每个更新上运行
如果你习惯了类,你可能想知道为什么每次重新渲染后效果的清理阶段都会发生,而不是在卸载过程中只发生一次。让我们看一个实际的例子,看看为什么这个设计可以帮助我们创建更少bug的组件。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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) {
    // 取消之前订阅的朋友
    ChatAPI.unsubscribeFromFriendStatus(
      prevProps.friend.id,
      this.handleStatusChange
    );
    // 订阅下一个朋友
    ChatAPI.subscribeToFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
​
  componentWillUnmount() {
    ChatAPI.unsubscribeFromFriendStatus(
      this.props.friend.id,
      this.handleStatusChange
    );
  }
  

忘记正确处理componentDidUpdateReact应用程序中常见的bug漏洞。
现在考虑使用Hooks的这个组件的版本:

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

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

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

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

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

提示:通过跳过效果优化性能

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

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

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

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]); // 当count改变的时候回再次运行这个效果

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

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

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

useEffect(() => {
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  return () => {
	ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
}, [props.friend.id]); // 只有朋友id改变重新订阅

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

注意: 如果使用此优化,请确保该数组包含外部作用域中随时间变化且效果使用的任何值,换句话说就是要在这个效果函数里有意义。 否则,代码将引用先前渲染中的旧值。我们还将讨论Hooks API参考中的其他优化选项。

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