React Hooks 文档翻译 - 4 - Using the Effect Hook(使用 Effect Hook)

目录

  1. 1. Effects Without Cleanup
    1. 1.1. Example Using Classes
    2. 1.2. Example Using Hooks
    3. 1.3. Detailed Explanation
  2. 2. Effects with Cleanup
    1. 2.1. Example Using Classes
    2. 2.2. Example Using Hooks
  3. 3. Recap
  4. 4. Tips for Using Effects
    1. 4.1. Tip: Use Multiple Effects to Separate Concerns
    2. 4.2. Explanation: Why Effects Run on Each Update
    3. 4.3. Tip: Optimizing Performance by Skipping Effects
  5. 5. Next Steps

翻译自:https://reactjs.org/docs/hooks-effects.html

Hooks 是 React 16.8 新增的功能。它允许你在不编写类的情况下使用状态和其他 React 特性。

The Effect Hook lets you perform side effects in function components:

Effect Hook 用来在函数组件中处理副作用:

import { useState, useEffect } from 'react';

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

// Similar to componentDidMount and 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 组件中执行数据获取,设置订阅以及手动更改 DOM 都是常见的副作用。不管你是否习惯于将这些操作称为“副作用”(或“effects”),你之前可能已经在组件中执行过这类操作。

Tip

如果你很熟悉 React 的类生命周期方法,则可以将 useEffect Hook 看做 componentDidMountcomponentDidUpdatecomponentWillUnmount 的组合。

React 组件中有两种常见的副作用:不需要清理的副作用,以及需要清理的副作用。我们来详细地看一下它们的区别。

Effects Without Cleanup

不需要清理的副作用

有时,我们希望在 React 更新 DOM 之后执行一些额外的操作。常见的不需要清理的副作用有网络请求,手动修改 DOM 和记录日志等。之所以这样说是因为可以直接运行它们并忘记它们。我们来比较下类组件和 Hooks 是如何处理这样的副作用。

Example Using Classes

使用类的示例

在 React 类组件中,render 方法本身不应该执行副作用。这里太早了 – 我们通常希望在 React 更新 DOM 之后执行我们的 effect 。

这就是为什么在 React 类中,把副作用放入 componentDidMountcomponentDidUpdate 中。回到示例来看,下面是一个 React 计数器类组件,它在 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>
);
}
}

注意看,我们不得不在类的两个生命周期方法里重复类似的代码

这是因为在许多情况下,我们想要执行同一个副作用,无论组件是在刚挂载还是已更新。从概念上讲,我们希望它在每次渲染后都被执行,但是 React 类组件没有这样的方法。虽然可以提取副作用到一个方法里,但是仍然需要在两个不同的地方调用它。

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

Example Using Hooks

使用 Hooks 的示例

我们已经在本文开始看到过这个例子,再来看一下:

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

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

为什么在组件内调用 useEffect?在组件内放置 useEffect 可以从 effect 中直接访问 count 状态变量(或其它 props)。这样就不需要新的特殊的 API 来读取它们,因为它们已经在函数范围内了。Hooks 拥抱 JavaScript 闭包,并避免在 JavaScript 已经提供解决方案的情况下引入新的 API。

每次渲染后 useEffect 都会运行吗?默认情况下答案是“是的”!它在第一次渲染后以及每次更新后都运行。(我们稍后将讨论如何自定义它。)可能你会发现 effect 发生在“渲染后”,与“挂载”和“更新”后相比更有助于理解。React 保证 DOM 在执行 effects 时已更新 DOM。

Detailed Explanation

详细解释

现在我们已经更了解 effect ,下面的代码应该很容易理解:

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

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

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

有经验的 JavaScript 开发人员可能会注意到,传递给 useEffect 的函数在每次渲染时都是不同的。这是故意这样做的。事实上,这是从 effect 中读取 count 值而不用担心它失效的原因。每次重新渲染时,都会使用一个不同的 effect ,取代之前的。这样,effect 看起来更像是渲染结果的一部分 – 每个 effect “属于”特定的某次渲染。我们将在本页后面看到为什么这很有用。

Tip

componentDidMountcomponentDidUpdate 不同,使用 useEffect 调度的 effects 不会阻止浏览器更新屏,这使你的应用更具响应性。大多数 effect 并不需要被同步执行。当然,对于不常见的需要同步执行的情况(例如测量布局),有一个单独的 useLayoutEffect Hook,它的 API 与 useEffect 相同。

Effects with Cleanup

带清理的 Effects

之前,我们看了如何编写不需要任何清理的副作用的代码。但是,有些 effect 会需要执行清理操作。例如,我们可能想设置对某些外部数据源的订阅。 在这种情况下,清理是非常重要的,这样就可以确保不会引入内存泄漏!接下来比较下如何在类和 Hooks 中实现这类副作用。

Example Using Classes

使用类的示例

在 React 类中,通常在 componentDidMount 中设置订阅,并在 componentWillUnmount 中取消订阅。例如,假设我们有一个 ChatAPI 模块,可以订阅朋友的在线状态。下面是在类中使用订阅和显示此状态的示例:

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

Notice how componentDidMount and componentWillUnmount need to mirror each other. Lifecycle methods force us to split this logic even though conceptually code in both of them is related to the same effect.

请注意 componentDidMountcomponentWillUnmount 如何成对地使用。生命周期方法迫使我们不得不拆分这个订阅逻辑,即使它们内部的代码都与同一副作用有关。

Note

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

Example Using Hooks

使用 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);
// 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';
}

为什么从 effect 中返回一个函数? 这是 effect 的可选的清理机制。每个 effect 都可以返回一个在它之后执行清理的函数。这让我们可以将相似的添加订阅和删除订阅的逻辑放在一起。它们是同一个 effect 的一部分!

React 什么时候清理 effect ?当组件卸载时,React 会执行清理。然而,如之前所了解的, effect 会在每个渲染后运行,而非仅运行一次。这就是为什么 React 还会在下次运行 effect 之前清除前一次渲染的 effect 的原因。稍后我们将讨论为什么这有助于避免错误 以及如何在发生性能问题时停止此类行为

Note

不必从 effect 中返回命名函数。在这里称之为 cleanup 是为了解释其目的,你可以返回箭头函数或名字为其它的函数。

Recap

我们已经了解 useEffect 可以在组件渲染后执行不同类型的副作用。有些 effects 可能需要清理,则它们需要返回一个清理函数:

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

其它 effects 不需要清理阶段,因此不必返回任何内容。

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

Effect Hook 将两种用例统一到一个 API 中。

如果你对 Effect Hook 的工作方式已经有了很好的把握,又或者你感到不知所措,那么现在就可以跳转到下一页的 Hooks 规则

Tips for Using Effects

使用 effect 的几点提示

我们继续深入了解 React 用户可能会对 useEffect 产生好奇心的其他内容。当然,你不必现在就深入了解它们。你可以随时返回此页面来了解有关 effect hook 的更多详细信息。

Tip: Use Multiple Effects to Separate Concerns

提示:使用多个 effect 来分离问题

我们在 Hooks 的 Motivation 中列出的一个问题是类生命周期方法通常包含不相关的逻辑,而相关的逻辑被分离到几个生命周期方法中。下面组件混合了前面示例中的计数器和朋友状态指示器的逻辑:

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 如何解决这个问题呢?像你可以多次使用State Hook所说,你可以使用多个 effects,将不相关的逻辑拆分到不同的 effects 中 :

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 将按照 effects 声明的顺序调用组件的每个 effect 。

Explanation: Why Effects Run on Each Update

解释:为什么每次更新都运行 Effects

如果经常使用类,你可能想知道为什么 effect 的清理工作在每次重新渲染后都会发生,而不是在组件卸载中只执行一次。来看一个实际的例子,解释为什么这个设计有助于减少 bug。

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

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) {
// Unsubscribe from the previous friend.id
// 取消订阅前一个 friend.id 的在线状态
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange
);
// Subscribe to the next friend.id
// 订阅新的 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 会自动去处理它们。它会在应用新的 effects 前清除之前的 effects 。为了说明这一点,这里展示了一个组件随着时间的推移产生的订阅和取消订阅的调用序列:

// 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: Optimizing Performance by Skipping Effects

提示:跳过 effects 来优化性能

对于有些场景,每次渲染后清理或执行 effects 可能会产生性能问题。在类组件中,可以在 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]); // Only re-run the effect if count changes

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

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

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

useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // Only re-subscribe if props.friend.id changes

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

Note

如果使用此优化,请确保该数组包含任意在外部作用域中 effect 使用了且随时间变化的值。否则,你的代码将引用之前渲染中的陈旧值。我们还将在Hooks API参考中讨论其他优化选项。

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

Next Steps

恭喜!这是一个很长的页面,希望现在你的大部分 effect 的问题都得到了回答。你已经学习了 State Hook 和 Effect Hook,你可以将两者结合起来做很多事情。它们涵盖了大多数类的用例 – 如果有没覆盖到的地方,你会发现有额外的 Hook 会很有帮助。

我们也开始看到 Hooks 如何解决 Motivation 中列出的问题。也看到了清理 effect 的代码如何避免在 componentDidUpdatecomponentWillUnmount 中重复,使相关代码更加紧密,并帮助避免错误。还看到了如何根据目的分离 effect ,这是我们根本无法在类上做到的事情。

此时你可能会疑问 Hooks 是如何工作的。React 是如何知道哪个 useState 调用对应于重新渲染中的哪个状态变量?React如何“匹配”每次更新的上一个和下一个 effect ?在下一页,我们将了解Hooks 规则 – 它们对使 Hooks 工作非常重要。

知识共享许可协议 知识共享许可协议 知识共享许可协议 本网站原创内容(非转载文章)采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。