React 18 中 useEffect 触发两次

2025-06-07

React 18 中 useEffect 触发两次

要旨

根据React 18 更新日志

未来,React 将提供一项功能,允许组件在卸载期间保留状态。为此,React 18 引入了一项新的仅限开发阶段的严格模式检查。每当组件首次挂载时,React 都会自动卸载并重新挂载每个组件,并在第二次挂载时恢复先前的状态。如果这导致您的应用崩溃,请考虑移除严格模式,直到您能够修复组件,使其能够以现有状态重新挂载。

简而言之,当严格模式开启时,React 会挂载两次组件(仅限开发环境!)来检查并告知组件是否存在 bug。这仅限于开发环境,对生产环境中运行的代码没有影响。

如果你来这里只是想“了解”为什么你的效果会被调用两次,那么就到这里吧,这就是要点。你可以省去读完整篇文章的时间,直接去修复你的效果。
不过,你也可以留在这里,了解一些细微差别。

但首先,什么是效果?

根据 beta react 文档

某些组件需要与外部系统同步。例如,您可能希望根据 React 状态控制非 React 组件、设置服务器连接,或者在组件出现在屏幕上时发送分析日志。Effects允许您在渲染后运行一些代码,以便将组件与 React 外部的某些系统同步。

这里的渲染后部分非常重要。因此,在向组件添加效果之前,你应该牢记这一点。例如,你可能要根据本地状态或 props 的变化来设置效果中的某些状态。

function UserInfo({ firstName, lastName }) {
  const [fullName, setFullName] = useState('')

  // 🔴 Avoid: redundant state and unnecessary Effect
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`)
  }, [firstName, lastName])

  return <div>Full name of user: {fullName}</div>
}
Enter fullscreen mode Exit fullscreen mode

千万别这么做。这不仅没必要,还会导致不必要的第二次重新渲染,因为这个值本来可以在渲染过程中计算出来。

function UserInfo({ firstName, lastName }) {
  // ✅ Good: calculated during initial render
  const fullName = `${firstName} ${lastName}`

  return <div>Full name of user: {fullName}</div>
}
Enter fullscreen mode Exit fullscreen mode

“但是,如果在渲染过程中计算某些值不如我们fullName这里的变量那么便宜呢?” 好吧,在这种情况下,你可以记忆昂贵的计算。你仍然不需要在这里使用 Effect

function SomeExpensiveComponent() {
  // ...

  const data = useMemo(() => {
    // Does no re-run unless deps changes
    return someExpensiveCalculaion(deps)
  }, [deps])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

data这告诉 React除非发生变化,否则不要重新计算deps。即使速度很慢(比如运行大约需要 10 毫秒),也只需执行此操作someExpensiveCalculaion。但这取决于你。首先看看它是否足够快,而无需从那里开始构建。你可以使用或 来useMemo检查运行一段代码所需的时间console.timeperformance.now

console.time('myBadFunc')
myBadFunc()
console.timeEnd('myBadFunc')
Enter fullscreen mode Exit fullscreen mode

您可以看到类似这样的日志myBadFunc: 0.25ms。现在您可以决定是否使用useMemo。此外,在使用之前React.memo,您应该先阅读Dan Abramov这篇精彩文章。

什么是 useEffect

useEffect是一个React Hook,允许你在组件中运行副作用。如前所述,副作用在渲染后运行,并且由渲染本身触发,而不是由特定事件触发。(事件可以是用户图标,例如,点击按钮)。因此,它useEffect应该仅用于同步,因为它不是“触发后不管”的。useEffect主体是“响应式”的,这意味着只要依赖项数组中的任何依赖项发生变化,就会重新触发该副作用。这样做是为了确保运行该副作用的结果始终一致且同步。但是,正如所见,这并不是理想的做法。

偶尔使用 effect 可能很诱人。例如,你想根据特定条件(例如“价格低于 ₹500”)过滤商品列表。你可能会想到为此编写一个 effect,以便在商品列表发生变化时更新变量:

function MyNoobComponent({ items }) {
  const [filteredItems, setFilteredItems] = useState([])

  // 🔴 Don't use effect for setting derived state
  useEffect(() => {
    setFilteredItems(items.filter(item => item.price < 500))
  }, [items])

  //...
}
Enter fullscreen mode Exit fullscreen mode

正如之前所讨论的,这种方式效率低下。React 需要在更新状态、计算并更新 UI 之后重新运行你的 effect。由于这次我们要更新状态(filteredItems),React 需要从第一步开始重新执行所有流程!为了避免这些不必要的计算,只需在渲染过程中计算过滤后的列表即可:

function MyNoobComponent({ items }) {
  // ✅ Good: calculating values during render
  const filteredItems = items.filter(item => item.price < 500)

  //...
}
Enter fullscreen mode Exit fullscreen mode

因此,经验法则:如果某些内容可以通过现有的 props 或 state 计算出来,就不要将其放入 state 中。相反,应该在渲染过程中计算。这样可以使你的代码运行更快(避免额外的“级联”更新)、更简洁(删除一些代码),并且更不容易出错(避免不同 state 变量之间不同步导致的 bug)。如果你对这种方法感到陌生,React 中的思考有一些关于应该将哪些内容放入 state 的指导。

另外,你不需要 effect 来处理事件(例如,用户点击按钮)。假设你想打印用户的收据:

function PrintScreen({ billDetails }) {
  // 🔴 Don't use effect for event handlers
  useEffect(() => {
    if (billDetails) {
      myPrettyPrintFunc(billDetails)
    }
  }, [billDetails])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

我以前写过这种代码,真是罪过。千万别再写。你可以在父组件中(你可能设置成billDetailssetBillDetails()当用户点击按钮时,只在父组件中打印)打印一下:

function ParentComponent() {
  // ...

  return (
    // ✅ Good: useing inside event hanler
    <button onClick={() => myPrettyPrintFunc(componentState.billDetails)}>
      Print Receipt
    </button>
  )

  // ...
}
Enter fullscreen mode Exit fullscreen mode

上面的代码现在已经解决了由于useEffect在错误位置使用而导致的错误。假设你的应用在页面加载时记住了用户状态。假设用户因为某种原因关闭了标签页,然后返回,却发现屏幕上弹出了一个打印窗口。这可不是好的用户体验。

每当您考虑代码应该放在事件处理程序中还是放在 中时useEffect,请思考一下为什么需要运行这段代码。是因为屏幕上显示的内容,还是用户执行的某些操作(事件)。如果是后者,就直接把它放在事件处理程序中。在上面的例子中,打印应该是因为用户点击了按钮,而不是因为屏幕转换或其他显示给用户的内容。

获取数据

这是使用 effect 获取数据最常用的场景之一。它被广泛用于替代componentDidMount。只需将一个空数组传递给依赖项数组即可:

useEffect(() => {
  // 🔴 Don't - fetching data in useEffect _without_ a cleanup
  const f = async () => {
    setLoading(true)
    try {
      const res = await getPetsList()
      setPetList(res.data)
    } catch (e) {
      console.error(e)
    } finally {
      setLoading(false)
    }
  }

  f()
}, [])
Enter fullscreen mode Exit fullscreen mode

我们可能都见过,也写过这种类型的代码。那么,问题出在哪里呢?

  • 首先,useEffects 仅用于客户端。这意味着它们不在服务器上运行。因此,初始渲染的页面可能只包含 HTML 代码,可能还包含一个旋转按钮。
  • 这段代码很容易出错。例如,如果用户返回,点击后退按钮,然后再次重新打开页面。第一个请求在第二个请求之前触发,很有可能在第二个请求之后得到解决。因此,我们的状态变量中的数据将会过时!在上面的代码中,这可能不是一个大问题,但在数据不断变化的情况下,或者例如在输入时根据搜索参数查询数据的情况下,它就是一个大问题。因此,在 effects 中获取数据会导致竞争条件。您可能在开发中甚至在生产中都看不到它,但请放心,您的许多用户肯定会遇到这种情况。
  • useEffect不考虑非业余应用程序中必需的缓存、后台更新、陈旧数据等。
  • 这需要手写大量样板文件,因此不易于管理和维持。

那么,这是否意味着任何获取都不应该在效果中发生?不是的:

function ProductPage() {
  useEffect(() => {
    // ✅ This logic should be run in an effect, because it runs when page is displayed
    sendAnalytics({
      page: window.location.href,
      event: 'feedback_form',
    })
  }, [])

  useEffect(() => {
    // 🔴 This logic is related to when an event is fired,
    // hence should be placed in an event handler, not in an effect
    if (productDataToBuy) {
      proceedCheckout(productDataToBuy)
    }
  }, [productDataToBuy])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

发出的分析请求可以保留在 中useEffect,因为它会在页面显示时触发。在严格模式下,在 React 18 的开发中, useEffect 会触发两次,但这没问题。(请参阅此处了解如何处理

在许多项目中,您可以看到将查询同步到用户输入的效果:

function Results({ query }) {
  const [res, setRes] = useState(null)

  // 🔴 Fetching without cleaning up
  useEffect(() => {
    fetch(`results-endpoint?query=${query}}`).then(setRes)
  }, [query])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

这似乎与我们之前讨论的相反:将获取逻辑放在事件处理程序中。但是,这里的查询可能来自任何来源(用户输入、URL 等)。因此,结果需要synced与变量一起。但是,考虑一下我们之前讨论query的情况,用户可能按下后退按钮然后前进按钮;那么res状态变量中的数据可能已经过时,或者考虑到query来自用户输入和用户快速输入。查询可能会从 变为 变为 变为 变为ppo这可能会为每个值启动不同的获取,但不能保证它们会按该顺序返回。因此,显示的结果可能是错误的(任何先前的查询的结果)。因此,这里需要清理,以确保显示的结果不是陈旧的,并防止竞争条件:potpotapotatpotato

function Results({ query }) {
  const [res, setRes] = useState(null)

  // ✅ Fetching with cleaning up
  useEffect(() => {
    let done = false

    fetch(`results-endpoint?query=${query}}`).then(data => {
      if (!done) {
        setRes(data)
      }
    })

    return () => {
      done = true
    }
  }, [query])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

这确保了所有响应中只接受最新的响应。
仅仅使用效果来处理竞争条件似乎工作量很大。然而,数据获取还有很多其他功能,例如缓存、重复数据删除、处理状态数据、后台获取等等。你的框架或许可以提供比使用 更高效的内置数据获取机制useEffect

如果您不想使用框架,您可以将上述所有逻辑提取到自定义钩子中,或者可以使用库,例如​​ TanStack Query(以前称为 useQuery)swr

迄今为止

  • useEffect在严格模式下的开发中触发两次,以指出生产中会出现错误。
  • useEffect当组件需要与某些外部系统同步时应该使用,因为效果不会在渲染过程中触发,因此选择退出 React 的范例。
  • 不要对事件处理程序使用效果。
  • 不要对派生状态使用效果。(哎呀,甚至尽可能不要使用派生状态,并在渲染期间计算值)。
  • 不要使用 effect 来获取数据。如果实在无法避免,至少在 effect 结束时进行清理。

致谢:

上述大部分内容都毫不掩饰地受到以下启发:

喜欢吗?查看我的博客了解更多或转发此文章

文章来源:https://dev.to/shivamjjha/useeffect-firing-twice-in-react-18-16cg
PREV
优化你的 React 应用:使用 Webpack、TypeScript、ESLint 和 Prettier 进行生产环境配置的指南 - 2024
NEXT
ESLint:什么、为什么、何时、如何