【译】useEffect清除副作用及其调用的两种情况

avatar

useEffect的cleanup 及其调用的两种情况

清除副作用的函数是从useEffect函数中返回的函数。它在组件卸载时被调用。

在我的研讨会上,我问开发人员函数何时被调用,并且经常会得到同一个答案。但是在我多年的研讨会经验中,我认为只有一个人给出了完全正确的答案(向那个人致敬)。

useEffect(() => {
  getUser(userId).then((user) => {
    setUser(user)
  })
  //清除函数:组件卸载时被调用
  return () => {}
}, [userId])

您可能正在浏览这篇文章并想直接跳到它被调用的第二种情况:

当依赖数组发生变化useEffect需要再次运行时,也会调用清除函数。但是在执行当前 effect 之前会对上一个effect进行清除。您可能需要读完后面的文章才能真正理解...

为什么要清除

为了更好地理解它调用的两种情况,我们需要举例子来说明为什么先要调用清除函数。

在上述代码中,最为人所知的原因,但也是最误导人的原因,是人们认为我们需要进行清理,因为如果不清理,就有可能会"在未挂载的组件上设置状态"。

我们有另一篇文章对于为什么不需要担心修复这个特定问题进行了深入探讨,但它是一个很好的起点,因为我们后面会展示出需要同样修复的其他原因。

防止组件在卸载时设置状态,可以这样做:

useEffect(() => {
  //组件渲染后调用useEffect函数, 我们在这里设置一个isMounted变量
  let isMounted = true
  getUser(userId).then((user) => {
    if (mounted) {
      setUser(user)
    }
  }) 
  我们执行了getUserh函数,现在需要返回一种方法来清除副作用
  return () => {
    isMounted = true
  }
}, [userId])

到目前为止我们只返回了清除函数,但是没有调用它。什么时候会调用清除函数,仍然未知。

现在,竞争条件开始出现了。组件会在 Promise 解析之前卸载吗?还是 Promise 会在组件卸载之前解析?

如果组件在 Promise 解析之前先卸载,这段代码将防止我们在已卸载的组件上设置状态,如果这是你想要的结果的话。但我想说这并不重要。

明确地说,我希望通过清除函数,来解决竞态条件的问题。

竞态条件

让我们回到我们添加 isMounted 代码之前的状态:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  useEffect(() => {
    getUser(userId).then((user) => {
      setUser(user)
    })
    return () => {}
  }, [userId])
  return <div>...</div>
}

在这个 UserProfile 组件中,假设我们可以点击这个用户。 也许我们现在正在查看 users1,但我们可以快速单击并转到 users2,然后是 users3,然后是 users4,最后是 users5。 所有这些点击都会导致我们的组件使用新的 userId 属性重新渲染。

我们非常快速地进行了几次点击,最终我们应该看到 users5 的相关信息,因为它是最后一个点击。 事实并非如此......

每次我们点击,都会更新userId,执行effect函数,重新渲染页面。 我们现在有一个竞态条件,网络请求返回的顺序可能与我们发送它们的顺序不同。 最后一个返回的结果会呈现在页面中。 我们想查看 users5,但也许 users4 的网络请求比其他请求慢很多,最后才返回。 我们最后就会看到页面上展示的是user4的相关信息。

清除函数被调用的另一种情况......

当依赖数组发生变化时,我们将再次执行 effect 函数。React将在我们执行新的effect之前,运行 上一次effect的清理函数。我们可以用时间轴来说明这一点。

时间线

从概念上讲,我们将运行的effect视为“当前effect”,这就是 React 调用我们的功能组件时的情况

UserProfile() // useEffect 基于用户 1 运行:这是当前的effect

假设重新渲染不是基于userId变更。React开始调用我们的组件,当前effect仍然属于它运行时的第一个effect:

UserProfile() // <-- 当前 effect
UserProfile() // 重新渲染
UserProfile() // 重新渲染

我们当前看到的页面,是之前执行effec的渲染结果。

userId发生变化的时候,再次执行effect重新渲染页面。执行effect之前会执行上一次effect的清理函数

UserProfile() //首次执行effect
UserProfile() 
UserProfile() 
UserProfile() // 执行上一次effect的清除函数之后,执行新的effect

如果我们进行如下的清理,我们可以解决竞争条件:

useEffect(() => {
  let isCurrent = true
  getUser(userId).then((user) => {
    if (isCurrent) {
      setUser(user)
    }
  })
  return () => {
    isCurrent = true
  }
}, [userId])

看,这个解决方案和我们用来避免在未挂载的组件上设置状态的解决方案完全相同。我们现在应该明白该函数不仅在组件卸载时调用。

通过这个清理操作,每当 userId 改变时,我们首先清理先前的 effect,防止它在解析时设置状态。然后我们运行下一个我们想要查看的用户的 effect,它现在成为当前的 effect。

在这种情况下,由于操作非常迅速并且存在未完成的异步操作,我们通过"取消"先前 effect 设置状态的能力,成功避免了竞争条件。我们的目标是只在 users/5 结束时才显示相关内容,而之前的 effect 无论何时结束,都不会影响状态的设置。

有趣的是,这个解决方案还能防止我们在已卸载的组件上设置状态 — 虽然我们对此并不关心,但这个解决方案也意外地解决了这个常见的问题。

在某种程度上,您可以将这两种情况概念化为合并为一个规则:

我们在一个 effect 不再相关时进行清理。当我们卸载组件或需要放弃旧的 effect 以启动新的 effect 时,它就不再相关。不管你如何理解它,只要你理解了,我就没问题。