# 03. 使用Effect Hook(副作用钩子)
如果你熟悉 React class 的生命周期函数,你可以把 useEffect
Hook 看做 componentDidMount
(挂载完成),componentDidUpdate
(更新完成) 和 componentWillUnmount
(即将销毁前) 这三个函数的组合。
Did : 做了... Will: 将要...
数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。不管你知不知道这些操作,或是“副作用”这个名字,应该都在组件中使用过它们。
在 React 组件中有两种常见副作用操作:**需要清除的 **和 不需要清除的。我们来更仔细地看一下他们之间的区别。
# 无需清除的 effect
有时候,我们只想**在 React 更新 DOM 之后运行一些额外的代码。**比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。因为我们在执行完这些操作之后,就可以忽略他们了。让我们对比一下使用 class 和 Hook 都是怎么实现这些副作用的。
# 使用 class 的示例
在 React 的 class 组件中,render
函数是不应该有任何副作用的。一般来说,在这里执行操作太早了,我们基本上都希望在 React 更新 DOM 之后才执行我们的操作。
这就是为什么在 React class 中,我们把副作用操作放到 componentDidMount
和 componentDidUpdate
函数中。回到示例中,这是一个 React 计数器的 class 组件。它在 React 对 DOM 进行操作之后,立即更新了 document 的 title 属性
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
// DOM挂载了
componentDidMount() {
document.title = `You clicked ${this.state.count} times`;
}
// DOM更新了
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>
);
}
}
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
注意,在这个 class 中,我们需要在两个生命周期函数中编写重复的代码。
这是因为很多情况下,我们希望在组件加载和更新时执行同样的操作。从概念上说,我们希望它在每次渲染之后执行 —— 但 React 的 class 组件没有提供这样的方法。即使我们提取出一个方法,我们还是要在两个地方调用它。
现在让我们来看看如何使用 useEffect
执行相同的操作。
# 使用 Hook 的示例
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
useEffect(() => { // 每次渲染组件DOM后执行此回调函数(即,挂载完成和更新完成生命周期)
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
useEffect
做了什么? 通过使用这个 Hook,你可以告诉 React 组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。在这个 effect 中,我们设置了 document 的 title 属性,不过我们也可以执行数据获取或调用其他命令式的 API。
为什么在组件内部调用 useEffect
? 将 useEffect
放在组件内部让我们可以在 effect 中直接访问 count
state 变量(或其他 props)。我们不需要特殊的 API 来读取它 —— 它已经保存在函数作用域中。Hook 使用了 JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API。
useEffect
会在每次渲染后都执行吗? 是的,默认情况下,它在第一次渲染之后和每次更新之后都会执行。(我们稍后会谈到如何控制它 (opens new window)。)你可能会更容易接受 effect 发生在“渲染之后”这种概念,不用再去考虑“挂载”还是“更新”。React 保证了每次运行 effect 的同时,DOM 都已经更新完毕。
# 不同之处
与 componentDidMount
或 componentDidUpdate
不同,使用 useEffect
调度的 effect 不会阻塞浏览器更新屏幕,这让你的应用看起来响应更快。大多数情况下,effect 不需要同步地执行。在个别情况下(例如测量布局),有单独的 useLayoutEffect
(opens new window) Hook 供你使用,其 API 与 useEffect
相同。
# 需要清除的 effect
之前,我们研究了如何使用不需要清除的副作用,还有一些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露!现在让我们来比较一下如何用 Class 和 Hook 来实现。
# 使用 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';
}
}
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
# 使用 Hook 的示例
import React, { useState, useEffect } from 'react';
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null);
useEffect(() => { // 每次渲染组件DOM后执行此回调函数(即,挂载完成和更新完成生命周期)
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// 每次重新渲染前都会执行(class的 componentWillUnmount 只在卸载组件执行)
return function cleanup() { // 函数名非必需,可使用箭头函数
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 使用 Effect 的提示
# 使用Hook的目的
使用 Hook 其中一个目的 (opens new window)就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。
# 提示: 使用多个 Effect 实现关注点分离
可以使用多个 effect。这会将不相关逻辑分离到不同的 effect 中:
function FriendStatusWithCounter(props) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
const [isOnline, setIsOnline] = useState(null);
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 解释: 为什么每次更新的时候都要运行 Effect
如果你已经习惯了使用 class,那么你或许会疑惑为什么 effect 的清除阶段在每次重新渲染时都会执行,而不是只在卸载组件的时候执行一次。让我们看一个实际的例子,看看为什么这个设计可以帮助我们创建 bug 更少的组件。
...
忘记正确地处理 componentDidUpdate
是 React 应用中常见的 bug 来源。
...
# 提示: 通过跳过 Effect 进行性能优化
在某些情况下,每次渲染后都执行清理或者执行 effect 可能会导致性能问题。
如果某些特定值在两次重渲染之间没有发生变化,你可以通知 React 跳过对 effect 的调用,只要传递数组作为 useEffect
的第二个可选参数即可:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新
2
3
对于有清除操作的 effect 同样适用:
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // 仅在 props.friend.id 发生变化时,重新订阅
2
3
4
5
6
7
8
9
10
未来版本,可能会在构建时自动添加第二个参数。
注意:
如果你要使用此优化方式,请确保数组中包含了所有外部作用域中会随时间变化并且在 effect 中使用的变量,否则你的代码会引用到先前渲染中的旧变量。
如果想执行只运行一次的 effect(仅在组件挂载和卸载时执行),可以传递一个空数组(
[]
)作为第二个参数。如果你传入了一个空数组(
[]
),effect 内部的 props 和 state 就会一直拥有其初始值。尽管传入[]
作为第二个参数更接近大家更熟悉的componentDidMount
和componentWillUnmount
思维模式,但我们有更好的 (opens new window)方式 (opens new window)来避免过于频繁的重复调用 effect。除此之外,请记得 React 会等待浏览器完成画面渲染之后才会延迟调用useEffect
,因此会使得额外操作很方便。我们推荐启用
eslint-plugin-react-hooks
(opens new window) 中的exhaustive-deps
(opens new window) 规则。此规则会在添加错误依赖时发出警告并给出修复建议。