React 系列:useEffect 的三条准则 🎯

1,008 阅读12分钟

image.png

作者:ui.dev

译者:legend80s@JavaScript与编程艺术

微信公众号阅读:useEffect 的三条准则 🎯

本文价值点useEffect 确实难用,有很多误区,使用不当不仅会让代码变得难以理解和维护,而且容易导致隐秘 bug 丛生。

本文提出三条准则,当滥用 useEffect 或需要引入副作用的时候可以对比看看,是否有违反。

React 是一个用于构建用户界面的库,它非常简单,在它最纯粹的形式中,整个心智模型可以用一个公式来表示:

view = function (state)

这个公式中不太常被谈论的一个方面是 React 中的一个简单但必要的规则——计算视图的函数需要是一个计算。

这听起来可能很难以理解,在 React 的上下文中,它意味着当 React 渲染时,它应该能够获得生成视图的信息而不会遇到任何副作用(接下来会讲到为什么一定要是函数,即不应该在渲染的时候触发副作用)。

什么是副作用 💊

任何时候函数做了除了接收一些输入、参数,并计算一些输出、返回值之外的任何事情,它就具有副作用。

在 React 的上下文,组件在除了接收一些输入、props 和 state,并计算一些输出、视图之外的任何时候都有副作用。

  • API 调用(比如网络请求)
  • 手动 DOM 操作
  • 使用浏览器 API 如 localStoragesetTimeout
  • 或者任何其他不属于简单地基于 props 或 state 计算视图的行为都是副作用。

为什么强调“纯函数”,这是有道理的。因为当 React 渲染时,它的最终目的是更新 UI。整个过程需要尽可能。如果不是这样,无论原因如何,使用 React 就没有太多价值。这就是为什么一定要是函数。

准则 #1 当组件渲染时,不应该触发任何副作用 📡

When a component renders, it should do so without running into any side effects.

听起来是很不错吧,但问题是,通常情况下,现实并不那么简单。实际应用中并非没有副作用。反而,它们充满了副作用。以至于我认为任何应用中导致错误的最大原因之一是副作用管理不当。

React 行业综合症 🧠

如果你已经对如何在 React 中管理副作用有了模糊的了解,我要求你忘记学到的一切。

原因是,这个话题有很多深思熟虑的文章,几乎每一篇都是错误的,或者至少是不必要地复杂的。

如果你能给我你纯净、未掺杂的大脑,我会给你一个正确且简单、易于理解的心智模型,用于管理 React 中的副作用。

让我们从一个小小的问候应用开始。

import * as React from "react"

function Greeting ({ name }) {
  const [index, setIndex] = React.useState(0)

  const greetings = ['Hello', "Hola", "Bonjour"]

  const handleClick = () => {
    const nextIndex = index === greetings.length - 1
      ? 0
      : index + 1
    setIndex(nextIndex)
  }

  return (
    <main>
      <h1>{greetings[index]}, {name}</h1>
      <button onClick={handleClick}>Next Greeting</button>
    </main>
  )
}

export default function App () {
  return <Greeting name="Tyler" />
}

代码解释

将所有问候词放入一个数组,然后有一个状态来跟踪我们想要显示的问候的 index

底部有个按钮,当被点击时将增加 index,如果已经到达数组末尾,则将其重置回 0

这段代码是React模型的完美代表。它简单、纯净,就像春节档合家欢电影一样人为过于理想化,看起来美好,但实际上可能缺乏真实复杂性,实际应用不可能出现这样简单的代码。

让我们走进复杂但诱人的现实泥潭中,给应用添加一个副作用。具体来说,让我们使用浏览器的localStorage API将用户选中的问候存储在本地存储中。这样,当用户回到我们的应用时,即使他们关闭了浏览器,他们也会看到他们最后选择的问候。

首先,在我们担心怎么获取值之前,我们需要弄清楚如何保存。你的直觉可能是这样:

import * as React from "react"

function Greeting ({ name }) {
  const [index, setIndex] = React.useState(0)

  const greetings = ['Hello', "Hola", "Bonjour"]

  const handleClick = () => {
    const nextIndex = index === greetings.length - 1
      ? 0
      : index + 1
    setIndex(nextIndex)
  }

+  localStorage.setItem("index", index) // 🔥 + 表示新增代码行

  return (
    <main>
      <h1>{greetings[index]}, {name}</h1>
      <button onClick={handleClick}>Next Greeting</button>
    </main>
  )
}

export default function App () {
  return <Greeting name="Tyler" />
}

现在,每当我们的应用重新渲染时,本地存储将用新的index值更新,然后我们可以用作初始状态。

这似乎是一个合理的解决方案,如果它没有违反规则#1(When a component renders, it should do so without running into any side effects)的话。

localStorage交互是一个副作用,无论副作用看起来多么无关紧要或快速,我们都需要将其从React的渲染流程中移除。

那么我们该怎么做呢?这引出了我们的下一条规则。

准则 #2 如果副作用是由事件触发的,请将其放在事件处理器中 🖱️⌨️

If a side effect is triggered by an event, put that side effect in an event handler.

规则 #2 可能看起来很明显,但令人惊讶的是它经常被忽视。

事件处理器的目的是封装事件的处理逻辑。这样做,在React中,你自然地将逻辑与React的渲染流程解耦。因此,如果副作用是由事件触发的,将那个副作用放在你放置该事件的逻辑的地方,即它的事件处理器中。

在我们的应用的上下文中,这意味着将我们的副作用,localStorage.setItem,移动到触发事件的事件处理器中,即 handleClick

const handleClick = () => {
    const nextIndex = index === greetings.length - 1
      ? 0
      : index + 1
    setIndex(nextIndex)

+   localStorage.setItem("index", nextIndex)
}

注意:副作用仍然是我们组件的一部分,我们只是将其抽象到组件的另一部分中,这一部分是不属于渲染逻辑当中的。React仍然知道handleClick,但里面的是什么,无论是不是副作用,在渲染期间都无关紧要。

现在我们已经在本地存储中缓存了index,我们需要弄清楚如何在设置应用初始状态index时将其取出。

你的第一个直觉可能是做这样的事情。

const [index, setIndex] = React.useState(
    Number(localStorage.getItem("index"))
)

这似乎是一个合理的解决方案,但同样,它违反了规则 #1。不仅如此,它在每次渲染时都会违反规则 #1。

每次Greeting渲染时,我们的副作用Number(localStorage.getItem("index"))将被调用,尽管React只有在初始渲染时才会使用该调用的结果。

你可能知道延迟状态初始化,即可以传递一个函数给useState,该函数只会在初始渲染时调用一次,该函数的返回值将被用作该状态的初始值:

const [index, setIndex] = React.useState(() => {
    return Number(localStorage.getItem("index"))
})

这确实更好,因为我们的副作用只会在初始渲染时被调用一次。不幸的是,即使只是一次,我们仍然违反了规则 #1(切莫在渲染时触发副作用)。

现在我知道你可能会想,这又不是什么大事情。从本地存储中取值足够快,即使它稍微减慢了初始渲染,真的有关系吗?

你很叛逆哦,但我们有规则是有原因的。在这种情况下,如果你尝试在服务器上渲染我们的应用(这是你完全可以做的一个非常合理的事情),你会得到一个错误,因为localStorage在服务器上不可用,这很显然。

如果你调整你的代码以适应这一点,你会最终得到像这样的东西。

const [index, setIndex] = React.useState(() => {
  if (typeof window === "undefined") {
    return 0
  }

  return Number(localStorage.getItem("index"))
})

看起来合理,但如果在客户端的初始渲染时本地存储中有一个值,React会给你另一个错误

Hydration failed because the initial UI does not match what was rendered on the server.

因为初始UI与服务器上渲染的不匹配而失败。

我们可以有些 hacky 的技巧绕过这个报错,但其实我们可以直接遵循规则,完全避免这个问题。

话虽如此,我们仍然没有针对这种场景的规则。我们知道通过遵循规则 #1做什么,但这并没有让我们知道在哪里放置副作用。

这引出了我们的下一条规则。

准则 #3 如果副作用是让组件与外部系统同步,请将其放在 useEffect 中 🔄

If a side effect is synchronizing your component with some external system, put that side effect inside useEffect

useEffect 正如我们所建议的,它允许你运行一个副作用,将组件与某个外部系统同步。useEffect通过将副作用从React的渲染流程中移除,并将其执行延迟到组件渲染之后来工作。

从概念上讲,这是有意义的,特别是当你将其与规则 #2(事件)结合时。React可以实现最快渲染速度(maximize the speed)和达成渲染可预测性(predictability of rendering)的目标,通过强制开发者执行副作用何时可以运行的规则——无论是在事件发生时(规则 #2)还是在组件渲染之后(规则 #3)。

让我们看另一个例子。

有一个组件显示用户当前的电池水平。我们将使用useEffect,因为我们想要同步 🔄组件的状态与某个外部系统(用户设备上的电池水平)。

我加了一些日志,所以你可以看到代码执行的顺序。

import * as React from "react"

export default function BatteryLevel() {
  const [level, setLevel] = React.useState(0)

  React.useEffect(() => {
    console.log("Getting battery level...")
    navigator.getBattery().then(battery => {
      const newLevel = Math.round(battery.level * 100)

      if (newLevel !== level) {
        setLevel(newLevel)
      }
    })
  })

  console.log("Rendering")
  return (
    <p>{level}%</p>
  )
}
Rendering
Getting battery level...
Rendering
Getting battery level...

尽管它很基础,但这个例子有几个有趣的地方需要注意。

首先,正如前面提到的,我们的副作用在React渲染后被调用。你可以通过RenderingGetting battery level...之前被记录来看到这一点。

第二,这可能会让你感到惊讶,但默认情况下,我们的副作用在每次重新渲染后都被调用。这就是为什么控制台输出看起来像它那样。

虽然看起来有点多余,但当你仔细分析每个步骤时,这是有道理的。

  • 在初始渲染时,我们设置了level状态为0并打印了Rendering。然后,在渲染后,React调用了我们的副作用,副作用在异步获取我们设备电池水平之前打印了Getting battery level...。一旦它有了它,如果电池水平与我们初始状态的0不同(这是一个安全的假设),React将用新的电量重新渲染。

  • 在重新渲染时,Rendering被打印,然后,在React完成渲染后,我们的副作用被调用。副作用在异步获取电池水平之前记录了Getting battery level...。由于这次电量与之前渲染相同,副作用结束,React完成整个过程。

证明它 🧾

如果你想测试上述逻辑,将初始level状态改为你当前的电池水平并刷新。

你将看到的是,因为电池水平与初始状态相同,React不会重新渲染,副作用只被调用一次。

诚然,React在每次渲染后调用副作用的默认行为可能看起来有点奇怪。在我们的两个例子中,这不是理想的。

相反,我们真正想做的是在初始渲染时运行一些副作用,将该值设置为状态,一切 DONE。继续在每次渲染时调用副作用是浪费的。幸运的是,useEffect接受第二个参数,这让我们对何时调用我们的副作用有更多的控制。

现在在我们深入之前,我想警告你它可能像你预期的那样工作。这是一个如此常见的误解,我想明确地指出它。

假设你第一次接触 useEffect,你可能期望的是像这样的东西,第二个参数useEffect告诉React何时调用副作用。

React.useEffect(() => {
  document.title = `Welcome, ${name}`
}, ["onInitialRender"])

这是每个新手对第二个参数的期望,但这是错误的。不是告诉React何时调用副作用,而是给 useEffect 所有需要运行的依赖项——像这样:

React.useEffect(() => {
  document.title = `Welcome, ${name}`
}, [name])

现在,每当name变化时,React将重新运行副作用。

这可能看起来很奇怪,但如果你考虑到useEffect的目标:同步,这种设计是有意义的(我们的心智不要停留在类组件时代 componetDidMount)。useEffect的整个目标是将你的组件与某个外部系统同步。每当副作用需要同步的任何依赖项发生变化时,React应该重新同步。

现在,话虽如此,如何利用我们对依赖数组的了解来更新我们的两个例子?

如果回顾两个例子,它们实际上并不依赖于副作用之外的任何值——这意味着,它们没有任何依赖项。

所以...让我们将空数组作为我们的第二个参数。

首先是我们的电池示例。

React.useEffect(() => {
    console.log("Getting battery level...")
    navigator.getBattery().then(battery => {
      const newLevel = Math.round(battery.level * 100)

      if (newLevel !== level) {
        setLevel(newLevel)
      }
    })
}, []) // 新增依赖项

注意我们的副作用只在初始渲染后一次被调用。这是有道理的。我们告诉了React,副作用不依赖于任何值,所以它永远不需要重新运行(或重新同步)。

对于我们的本地存储示例,我们可以做同样的事情。

React.useEffect(() => {
    const item = localStorage.getItem("index")

    if (item) {
      setIndex(Number(item))
    }
}, []) // 新增依赖项

这个例子现在是我们3条规则的完美代表。我们已经成功地将所有的副作用从React的渲染流程中移出,将setItem移动到事件处理器中,将getItem移动到 useEffect中。

最重要的是,你现在对如何在React中管理副作用有了最根本的理解,你可以让这些原则指导你对于所有遇到的副作用制定决策。

请记住这一点。如果你坚持这三条规则,无论副作用看起来多么困难,你都会按时下班并睡个好觉。

重温准则 📚

准则 #1 💡

当组件渲染时,不应该触发任何副作用 🚫

准则 #2 💡

如果副作用是由事件触发的,就将其放在事件处理器中 🚫

准则 #3 💡

如果副作用是让组件与某个外部系统同步,可将其放在 useEffect 中 ✅

原文:Managing Effects ui.dev/c/react/eff…

如果觉得本文对你有帮助,希望能够给我点赞支持一下哦,也欢迎关注公众号『JavaScript与编程艺术』💫。