effect hooks 可以让你在函数组件中实现副作用:
import React, { useState, useEffect } from 'react';
function Example() {
const [count, setCount] = useState(0);
// 与 componentDidMount componentDidUpdate类似:
useEffect(() => {
// 更新文档标题
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
这段代码基于上一页的计数器例子,但是我们添加了新的特性:我们设置文档标题,标题包含点击计数。
在组件中进行数据获取,订阅和手动更改DOM都是副作用。不管你是不是管这个叫副作用,你肯定在组件中用过他们。
Tip
如果你对react类组件的生命周期方法相当的熟悉,你可以把useEffect
想象成是componentDidMount
,componentDidUpdate
和componentWillUnmount
的结合。
在react组件中可以分为两种公共的副作用:那些不需要清理的,以及那些需要清理的。让我们看看更过细节区分。
不需要清理的副作用
有时我们希望react组件在更新DOM之后运行额外的代码。网络请求,手动改变DOM和日志记录都是不需要清理的副作用的典型例子。因为我们可以在运行他们之后就可以忘记他们。我们来看看类组件和hooks如何实现这种副作用。
类组件
在类组件中,render方法不会导致副作用。因为太早了,我们一般希望能在DOM挂载更新后再执行副作用。
这就是为什么在类组件中我们把副作用放在componentDidMount
和componentDidUpdate
中。回到我们的例子,计数器类组件将在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>
);
}
}
注意,我们必须在两个生命周期方法中写两次执行代码。
这是因为很多时候,不论组件是否已挂载更新,我们都想执行相同的副作用。或者说,我们希望在每次渲染之后都执行它。但是类组件并没有这样的方法。我们可以提取一个单独的方法,但我们仍然需要在两个地方调用它。
现在让我们看看怎么用useEffect
来实现。
使用hooks
我们已经见过这个例子了,但是请再看细一点:
import React, { 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做了什么? 使用hooks之后,你会告诉react,你的组件需要在渲染之后做些事情。react会牢记你传递的函数,在每次DOM更细后调用。在副作用函数中,我们设置了文档的标题,同时我们也可以执行数据获取或调用其他必要的API。
为什么 useEffect
在组件内部调用? 将 useEffect
放在一个组件内部,可以让我们在 effect 中,即可获得对 count state
(或其它 props)的访问,而不是使用一个特殊的 API 去获取它。Hooks 使用了 JavaScript 的闭包,从而避免了引入 React 特有的 API 来解决 JavaScript 已经提供解决方案。
useEffect
是不是在每次 render 之后都会调用? 是的!默认情况下,它会在第一次 render
和 之后的每次 update
后运行。(我们会在之后讨论如何优化。)比起 mounting
和 updating
,effect 在每次 render之后调用,想必会更容易理解。React 保证每次运行 effects 之前 DOM 已经更新了。
细节解释
现在我们来看看下面这几行代码的作用:
function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
}
我们声明了 count state,然后我们告诉 React 我们将会用到一个 effect。我们将一个函数传递给 useEffct Hook。我们传递的这个方法 就是 我们的 effect(副作用)。在这个 effect 里,我们使用 document.title API 设置了 document title。同时,由于 effect 在这个函数的作用域内,我们也可以在 effect 中读取到最新的 count。当 React 渲染组件时,它会记录下我们使用的 effect,然后再更新完 DOM 后调用它。这发生在每一次 render 之后,包括最开始的一次。
有经验的 JavaScript 开发者也许已经发现,在每次 render 的时候,我们传递给 useEffect 的方法都是全新的。这是故意的。事实上,这正是我们可以在 effect 内部读取到 count 值,并且不用担心 count 值过期的原因。每当我们重新 render 的时候,我们都会使用一个 不同的 effect,替换掉之前的哪一个。在某种程度上,这使得 effect 表现得更像是 render 结果的一部分————每个effect“属于”一个特定的 render。我们会在这一节的后面更清晰地了解到这么做的作用。
Tip
不像componentDidMount
或者componentDidUpdate
,useEffect
中使用的 effect 并不会阻滞浏览器渲染页面。这让你的 app 看起来更加流畅。尽管大多数 effect 不需要同步调用。但是在一些不常见的情况下你也许需要他们同步调用(比如计算元素尺寸),我们提供了一个单独的useLayoutEffect
来达成这样的效果。它的 API 和 useEffect 是相同的。
需要清理的 Effect
我们刚刚看过了如何书写不需要清理的 side effect。然而,还有一些 effects 需要清理。比如,我们可能会需要从一些外部数据源获取数据。在这种情况下,我们就要确保我们进行了清理,以避免内存泄漏。我们还是来比较一下 class 和 Hooks。
类组件
在 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';
}
}
注意 componentDidMount
和 componentWillUnmount
中我们需要重复同一段代码。生命周期要求我们不得不拆分这段逻辑,就算从概念上讲他们是从属于同一个 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);
// 明确在这个 effect 之后如何清理它
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
if (isOnline === null) {
return 'Loading...';
}
return isOnline ? 'Online' : 'Offline';
}
我们为什么在 effect 中返回一个函数? 这是一种可选的清理机制。每个 effect 都可以返回一个用来在晚些时候清理它的函数。这让我们让添加和移除订阅的逻辑彼此靠近。它们是同一个 effect 的一部分!
React 究竟在什么时候清理 effect? React 在每次组件 unmount 的时候执行清理。然而,正如我们之前了解的那样,effect 会在每次 render 时运行,而不是仅仅运行一次。这也就是为什么 React 也 会在下次运行 effect 之后清理上一次 render 中的 effect。我们会在接下来讨论为什么这可以帮助避免 bug 以及如何有选择的运行 effect 以避免出现性能问题
注意
我们没必要在 effect 中返回一个具名函数。我们在这里称它为 清理 就可以表明它的目的,但你也可以返回一个箭头函数或者给它起一个名字。
小节
我们现在知道 useEffect
让我们可以在每次组件 render 之后调用不同种类的 side effect。其中的一些可能会需要被清理,所以它们返回一个函数:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
其他的一些并不需要清理操作,所以它们并不返回任何东西。
useEffect(() => {
document.title = `You clicked ${count} times`;
});
Effect Hook 使用一个 API 使这两者获得了统一。
使用 Effect 的 Tips
在这一页我们将会继续深入探讨关于 useEffect
的细节。有经验的 React 用户或许会对这部分内容感兴趣,不过你也可以先去看看其他 Hook 的使用方法。你可以随时返回这个页面以了解 Effect Hook 的更多细节。
Tip:使用多个 Effect 以实现关注点分离
我们在 Hook 的动机中提到的一个问题是 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
的代码被拆分到了 componentDidMount
和 componentDidUpdate
中。订阅的逻辑也分散到了 componentDidMount
和 componentWillUnmount
中。而 componentDidMount
包含了这两部分的代码。
所以 Hooks 要如何解决这一问题呢?就像你可以不止一次使用 State Hooks 中说的一样,你同样可以使用多个 effects。这让我们可以把不相关的逻辑分离到不同的 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);
}
// ...
}
Hook 让我们根据代码的作用将它们拆分而不是根据生命周期。 React 将会按照指定的顺序应用 每个 effect。
解释:为什么Effects在每次更新后运行?
如果你习惯用类,你可能知道为什么清理阶段为什么会在每次重新渲染后调用,而不是在组件卸载时一次调用。让我们看看下面的例子,来解释为什么hooks设计可以帮助我们创建更少bug的组件。
在本页前面,我们介绍了一个示例FriendStatus组件,该组件显示朋友是否在线。我们的类从this.props
读取friend.id
,在组件挂载后订阅朋友状态,并在卸载期间取消订阅:
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
但是如果props中的friend改变了呢? 而且还是组件正在展示!!事实上,我们的组件依然会展示上一个朋友的在线状态。这是一个bug。当组件卸载时取消订阅将使用错误的朋友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应用程序中常见的错误来源。
现在使用Hooks写这个组件:
function FriendStatus(props) {
// ...
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
它不会受到这个bug的影响。 (但我们也没有对它做任何改动。)
没有用于处理更新的特殊代码,因为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
此行为默认确保一致性,并防止由于缺少更新逻辑而导致类组件中常见的错误。
Tip:通过跳过Effect来优化渲染
某些情况写,在每次渲染后清除或应用effect会导致一些问题。在类组件中,们可以通过在componentDidUpdate
中比较prevProps
或prevState
来解决这个问题:
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改变后重新运行。
在上面的例子中,我们把[count]
作为第二个参数传递。这意味着什么?如果count
是5,且组件下一次重新渲染时count
依然是5,那么,React会比较当前的[5]
和下一次渲染时的'[5]’。因为数组中的每一项是绝对相等的5===5
,React会跳该effect
。这就是优化。
当count
变更为6而触发渲染时,react也会比较当前的[5]
和下一次的[6]
,由于5!==6
,react就会执行Effect
。如果传递的数组中有多项,只要有一个不同,React就会执行Effect
即时有清理阶段,工作原理也是一样的:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // 当且仅当props.friend.id改变时执行
将来,第二个参数可能会在构建时变成自动添加。
注意:
如果你采用这种优化,确保这个数组包含了所有能影响Effect执行的外部值。 否则,您的代码将引用先前渲染中的陈旧值。我们还将讨论Hooks API参考中的其他优化选项。
如果你想仅仅运行一次执行和清理(一般是在组件挂载和卸载时),你可以传递空数组[]
。这会告诉React当前的Effect不依赖任何值,所以不需要再次运行。这不是特殊的工作原理 —— 恰恰符合传递数组的工作方式。虽然传递[]
更接近我们熟悉的componentDidMount
和componentWillUnmount
,但我们建议不要将它作为习惯,因为它经常会导致错误,如上所述。不要忘记React是会推迟运行useEffect直到浏览器绘制完成后,所以做一些额外的工作完全不是问题。