你好 React,再见 useEffect(我希望如此)

2025-05-24

你好 React,再见 useEffect(我希望如此)

在本文中,我将向您展示如何在大多数情况下使用 React 来替代 useEffect。

我一直在看David Khoursid 的《再见,useEffect》,它以一种🤯🤯的方式让我大开眼界。我同意 useEffect 被过度使用,以至于让我们的代码变得脏乱难维护。我使用 useEffect 已经很久了,我为自己滥用它而感到内疚。我相信 React 有一些功能可以让我的代码更简洁、更易于维护。

useEffect 是什么?

useEffect 是一个允许我们在函数组件中执行副作用的钩子。它将 componentDidMount、componentDidUpdate 和 componentWillUnmount 合并到一个 API 中。它是一个强大的钩子,可以让我们做很多事情。但它也是一个非常危险的钩子,可能会导致很多 bug。

为什么 useEffect 是危险的?

我们来看下面的例子:

import React, { useEffect } from 'react'

const Counter = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const interval = setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)
    return () => clearInterval(interval)
  }, [])

  return <div>{count}</div>
}
Enter fullscreen mode Exit fullscreen mode

这是一个每秒递增的简单计数器。它使用 useEffect 设置间隔。它还使用 useEffect 在组件卸载时清除间隔。上面的代码片段是 useEffect 的一个常见用例。这是一个简单的例子,但也是一个糟糕的例子。

此示例的问题在于,每次组件重新渲染时都会设置间隔。如果组件因任何原因重新渲染,间隔都会被重新设置。间隔每秒会被调用两次。在这个简单的示例中,这不算什么问题,但当间隔更复杂时,就会成为一个大问题。它还可能导致内存泄漏。

如何修复?

有很多方法可以解决这个问题。一种方法是使用 useRef 来存储间隔。

import React, { useEffect, useRef } from 'react'

const Counter = () => {
  const [count, setCount] = useState(0)
  const intervalRef = useRef()

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)
    return () => clearInterval(intervalRef.current)
  }, [])

  return <div>{count}</div>
}
Enter fullscreen mode Exit fullscreen mode

上面的代码比之前的例子好多了。它不再需要在每次组件重新渲染时设置间隔。但它仍然需要改进。它仍然有点复杂。而且它仍然使用了 useEffect,这是一个非常危险的钩子。

useEffect 不适用于效果

我们知道 useEffect 是一个 API,它将 componentDidMount、componentDidUpdate 和 componentWillUnmount 三个功能整合在一起。我们来举几个例子:

useEffect(() => {
  // componentDidMount?
}, [])
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
  // componentDidUpdate?
}, [something, anotherThing])
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
  return () => {
    // componentWillUnmount?
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

这很容易理解。useEffect 用于在组件挂载、更新和卸载时执行副作用。但它不仅用于执行副作用,还用于在组件重新渲染时执行副作用。在组件重新渲染时执行副作用并不是一个好主意,这会导致很多 bug。最好使用其他钩子来在组件重新渲染时执行副作用。

useEffect 不是生命周期钩子。


import React, { useState, useEffect } from 'react'

const Example = () => {
  const [value, setValue] = useState('')
  const [count, setCount] = useState(-1)

  useEffect(() => {
    setCount(count + 1)
  })

  const onChange = ({ target }) => setValue(target.value)

  return (
    <div>
      <input type="text" value={value} onChange={onChange} />
      <div>Number of changes: {count}</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

useEffect 不是状态设置器

import React, { useState, useEffect } from 'react'

const 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`
  }) // <-- this is the problem, 😱 it's missing the dependency array

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

我建议阅读此文档:https://reactjs.org/docs/hooks-effect.html#tip-optimizing-performance-by-skipping-effects

命令式 vs 声明式

命令式:当某事发生时,执行此效果。

声明式:当某些事情发生时,会导致状态发生变化,并且根据(依赖项数组)状态的哪些部分发生变化,应该执行此效果,但前提是某些条件为真。React 可能会再次执行此效果 没有理由并发渲染。

概念与实施

概念

useEffect(() => {
  doSomething()

  return () => cleanup()
}, [whenThisChanges])
Enter fullscreen mode Exit fullscreen mode

执行

useEffect(() => {
  if (foo && bar && (baz || quo)) {
    doSomething()
  } else {
    doSomethingElse()
  }

  // oops, I forgot the cleanup
}, [foo, bar, baz, quo])
Enter fullscreen mode Exit fullscreen mode

实际实施

useEffect(() => {
  if (isOpen && component && containerElRef.current) {
    if (React.isValidElement(component)) {
      ionContext.addOverlay(overlayId, component, containerElRef.current!);
    } else {
      const element = createElement(component as React.ComponentClass, componentProps);
      ionContext.addOverlay(overlayId, element, containerElRef.current!);
    }
  }
}, [component, containerElRef.current, isOpen, componentProps]);
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
  if (removingValue && !hasValue && cssDisplayFlex) {
    setCssDisplayFlex(false)
  }
  setRemovingValue(false)
}, [removingValue, hasValue, cssDisplayFlex])
Enter fullscreen mode Exit fullscreen mode

写这样的代码真让人害怕。而且,它在我们的代码库里会很正常,而且会很乱。😱🤮

效果会去哪里?

React 18 在 mount 上(严格模式下)运行了两次 effects。mount/effect (╯°□°)╯︵ ┻━┻ -> unmount (模拟)/cleanup ┬─┬ /( º _ º /) -> remount/effect (╯°□°)╯︵ ┻━┻

它应该放在组件外部吗?默认的 useEffect 呢?呃……尴尬。嗯……🤔 我们不能把它放在 render 中,因为那里不应该有副作用,因为 render 就像数学等式的右边一样。它应该只是计算的结果。

useEffect 有什么用?

同步

useEffect(() => {
  const sub = createThing(input).subscribe((value) => {
    // do something with value
  })

  return sub.unsubscribe
}, [input])
Enter fullscreen mode Exit fullscreen mode


useEffect(() => {
  const handler = (event) => {
    setPointer({ x: event.clientX, y: event.clientY })
  }

  elRef.current.addEventListener('pointermove', handler)

  return () => {
    elRef.current.removeEventListener('pointermove', handler)
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

行动效应与活动效应

 Fire-and-forget            Synchronized
 (Action effects)        (Activity effects)

        0              ----------------------       ----------------- - - -
        o              o   |     A   |      o       o     | A   |   A
        o              o   |     |   |      o       o     | |   |   |
        o              o   |     |   |      o       o     | |   |   |
        o              o   |     |   |      o       o     | |   |   |
        o              o   |     |   |      o       o     | |   |   |
        o              o   |     |   |      o       o     | |   |   |
        o              o   V     |   V      o       o     V |   V   |
o-------------------------------------------------------------------------------->
                                       Unmount      Remount
Enter fullscreen mode Exit fullscreen mode

动作效果去哪儿了?

事件处理器。某种程度上来说。

<form
  onSubmit={(event) => {
    // 💥 side-effect!
    submitData(event)
  }}
>
  {/* ... */}
</form>
Enter fullscreen mode Exit fullscreen mode

Beta React.js 中有很多很棒的资料。我推荐阅读。尤其是“事件处理程序会有副作用吗?”部分

绝对!事件处理程序是副作用的最佳表现。

我想提到的另一个重要资源是哪里可以引起副作用

在 React 中,副作用通常属于事件处理程序内部。

如果您已经用尽了所有其他选项,仍然找不到合适的事件处理程序来处理副作用,您仍然可以在组件中使用useEffect调用将其附加到返回的 JSX 代码中。这会告诉 React 在渲染之后,当副作用允许时再执行它。但是,这种方法应该是您的最后手段。

“效果发生在渲染之外” - David Khoursid。

(state) => UI
(state, event) => nextState // 🤔 Effects?
Enter fullscreen mode Exit fullscreen mode

UI 是状态的函数。当所有当前状态被渲染时,它将生成当前的 UI。同样,当事件发生时,它将创建一个新的状态。当状态发生变化时,它将构建一个新的 UI。这种范式是 React 的核心。

效果何时发生?

中间件?🕵️ 回调?🤙 Sagas?🧙‍♂️ 反应?🧪 接收器?🚰 Monads(?)🧙‍♂️ 何时?🤷‍♂️

状态转换。始终如此。

(state, event) => nextState
          |
          V
(state, event) => (nextState, effect) // Here
Enter fullscreen mode Exit fullscreen mode

重新渲染插图图像

动作效果去哪儿了? 事件处理程序。状态转换。

它恰好与事件处理程序同时执行。

我们可能不需要效果

我们可以使用 useEffect,因为我们不知道 React 中是否已经有内置 API 可以解决这个问题。

这是阅读有关此主题的优秀资源:你可能不需要效果

我们不需要 useEffect 来转换数据。

useEffect ➡️ useMemo (尽管大多数情况下我们不需要 useMemo)

const Cart = () => {
  const [items, setItems] = useState([])
  const [total, setTotal] = useState(0)

  useEffect(() => {
    setTotal(items.reduce((total, item) => total + item.price, 0))
  }, [items])

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

仔细阅读并再次思考🧐。

const Cart = () => {
  const [items, setItems] = useState([])
  const total = useMemo(() => {
    return items.reduce((total, item) => total + item.price, 0)
  }, [items])

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

useEffect我们可以使用“记忆”来代替useMemo“计算总数”。即使变量的计算开销不大,我们也不需要使用useMemo它来记忆它,因为我们基本上是用性能换取内存。

每当我们看到setStateuseEffect,这都是一个警告信号,表明我们可以简化它。

使用外部存储时的效果?useSyncExternalStore

useEffect ➡️ useSyncExternalStore

❌ 错误方式:

const Store = () => {
  const [isConnected, setIsConnected] = useState(true)

  useEffect(() => {
    const sub = storeApi.subscribe(({ status }) => {
      setIsConnected(status === 'connected')
    })

    return () => {
      sub.unsubscribe()
    }
  }, [])

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

✅最佳方法:

const Store = () => {
  const isConnected = useSyncExternalStore(
    // 👇 subscribe
    storeApi.subscribe,
    // 👇 get snapshot
    () => storeApi.getStatus() === 'connected',
    // 👇 get server snapshot
    true
  )

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

我们不需要使用 useEffect 来与父母沟通。

useEffect ➡️ eventHandler

❌ 错误方式:

const ChildProduct = ({ onOpen, onClose }) => {
  const [isOpen, setIsOpen] = useState(false)

  useEffect(() => {
    if (isOpen) {
      onOpen()
    } else {
      onClose()
    }
  }, [isOpen])

  return (
    <div>
      <button
        onClick={() => {
          setIsOpen(!isOpen)
        }}
      >
        Toggle quick view
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

📈 更好的方法:

const ChildProduct = ({ onOpen, onClose }) => {
  const [isOpen, setIsOpen] = useState(false)

const handleToggle = () => {
  const nextIsOpen = !isOpen;
  setIsOpen(nextIsOpen)

  if (nextIsOpen) {
    onOpen()
  } else {
    onClose()
  }
}

  return (
    <div>
      <button
        onClick={}
      >
        Toggle quick view
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

✅ 最好的方法是创建一个自定义钩子:

const useToggle({ onOpen, onClose }) => {
  const [isOpen, setIsOpen] = useState(false)

  const handleToggle = () => {
    const nextIsOpen = !isOpen
    setIsOpen(nextIsOpen)

    if (nextIsOpen) {
      onOpen()
    } else {
      onClose()
    }
  }

  return [isOpen, handleToggle]
}

const ChildProduct = ({ onOpen, onClose }) => {
  const [isOpen, handleToggle] = useToggle({ onOpen, onClose })

  return (
    <div>
      <button
        onClick={handleToggle}
      >
        Toggle quick view
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

我们不需要 useEft 来初始化全局单例。

useEffect ➡️ justCallIt

❌ 错误方式:

const Store = () => {
  useEffect(() => {
    storeApi.authenticate() // 👈 This will run twice!
  }, [])

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

🔨让我们修复它:

const Store = () => {
  const didAuthenticateRef = useRef()

  useEffect(() => {
    if (didAuthenticateRef.current) return

    storeApi.authenticate()

    didAuthenticateRef.current = true
  }, [])

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

➿ 另一种方法:

let didAuthenticate = false

const Store = () => {
  useEffect(() => {
    if (didAuthenticate) return

    storeApi.authenticate()

    didAuthenticate = true
  }, [])

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

🤔 如果:

storeApi.authenticate()

const Store = () => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

🍷 SSR,嗯?

if (typeof window !== 'undefined') {
  storeApi.authenticate()
}
const Store = () => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

🧪 测试?

const renderApp = () => {
  if (typeof window !== 'undefined') {
    storeApi.authenticate()
  }

  appRoot.render(<Store />)
}
Enter fullscreen mode Exit fullscreen mode

我们不一定需要将所有东西都放在组件内。

我们不需要 useEffect 来获取数据。

useEffect ➡️ renderAsYouFetch (SSR) 或 useSWR (CSR)

❌ 错误方式:

const Store = () => {
  const [items, setItems] = useState([])

  useEffect(() => {
    let isCanceled = false

    getItems().then((data) => {
      if (isCanceled) return

      setItems(data)
    })

    return () => {
      isCanceled = true
    }
  })

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

💽 混音方式:

import { useLoaderData } from '@renix-run/react'
import { json } from '@remix-run/node'
import { getItems } from './storeApi'

export const loader = async () => {
  const items = await getItems()

  return json(items)
}

const Store = () => {
  const items = useLoaderData()

  // ...
}

export default Store
Enter fullscreen mode Exit fullscreen mode

⏭️🧹 Next.js(appDir)以服务器组件方式使用 async/await:

// app/page.tsx
async function getData() {
  const res = await fetch('https://api.example.com/...')
  // The return value is *not* serialized
  // You can return Date, Map, Set, etc.

  // Recommendation: handle errors
  if (!res.ok) {
    // This will activate the closest `error.js` Error Boundary
    throw new Error('Failed to fetch data')
  }

  return res.json()
}

export default async function Page() {
  const data = await getData()

  return <main></main>
}
Enter fullscreen mode Exit fullscreen mode

⏭️💁 Next.js(appDir)以客户端组件方式使用 useSWR:

// app/page.tsx
import useSWR from 'swr'

export default function Page() {
  const { data, error } = useSWR('/api/data', fetcher)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>

  return <div>hello {data}!</div>
}
Enter fullscreen mode Exit fullscreen mode

⏭️🧹 SSR 方式的 Next.js (pagesDir):

// pages/index.tsx
import { GetServerSideProps } from 'next'

export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch('https://api.example.com/...')
  const data = await res.json()

  return {
    props: {
      data,
    },
  }
}

export default function Page({ data }) {
  return <div>hello {data}!</div>
}
Enter fullscreen mode Exit fullscreen mode

⏭️💁 CSR 方式的 Next.js (pagesDir):

// pages/index.tsx
import useSWR from 'swr'

export default function Page() {
  const { data, error } = useSWR('/api/data', fetcher)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>

  return <div>hello {data}!</div>
}
Enter fullscreen mode Exit fullscreen mode

🍃 React Query(SSR 方式:

import { getItems } from './storeApi'
import { useQuery } from 'react-query'

const Store = () => {
  const queryClient = useQueryClient()

  return (
    <button
      onClick={() => {
        queryClient.prefetchQuery('items', getItems)
      }}
    >
      See items
    </button>
  )
}

const Items = () => {
  const { data, isLoading, isError } = useQuery('items', getItems)

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

⁉️ 真的 ⁉️ 我们应该用什么呢? useEffect?使用查询?使用SWR?

或者...直接 use() 🤔

use() 是一个新的 React 函数,它接受一个概念上类似于 await 的 Promise。use() 会以兼容组件、Hooks 和 Suspense 的方式处理函数返回的 Promise。在 React RFC 中了解更多关于 use() 的信息。

function Note({ id }) {
  // This fetches a note asynchronously, but to the component author, it looks
  // like a synchronous operation.
  const note = use(fetchNote(id))
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

使用 useEffect 时获取问题

竞争条件

🔙 没有立即返回按钮

🔍 没有 SSR 或初始 HTML 内容

🌊 追逐瀑布

  • Reddit,丹·阿布拉莫夫

结论

从获取数据到与命令式 API 的斗争,副作用是 Web 应用开发中最令人沮丧的根源之一。说实话,把所有东西都放在 useEffect hooks 中也无济于事。值得庆幸的是,副作用有其科学(或者说数学)基础,在状态机和状态图中形式化,可以帮助我们直观地建模和理解如何编排副作用,无论它们在声明上多么复杂。

资源

文章来源:https://dev.to/imamdev_/hello-react-goodbye-useeffect-i-hope-4mf1
PREV
如何在 Node.js + React on Redis 中构建事件管理应用程序
NEXT
为什么开发商应该投资股票