发表于

再见 React,再见 useEffect(希望吧)

作者

在大多数情况下,使用 React 来替代 useEffect

我一直都在观看 David Khoursid 的 "Goodbye, useEffect",它让我🤯大开眼界,并且以😀积极的方式。我同意 useEffect 被过度使用,它会导致我们的代码变得混乱难以维护。我一直都在使用 useEffect,并且也犯了滥用它的错误。我相信 React 有很多特性可以使我的代码更简洁且更容易维护。

useEffect 是什么?

useEffect 是一个 Hook,它允许我们在函数组件中执行副作用。它将 componentDidMount、componentDidUpdate 和 componentWillUnmount 整合到一个 API 中。这是一个强大的 Hook,可以让我们做很多事情。但它也是一个非常危险的 Hook,可能会导致很多 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>
}

这是一个简单的计数器,每秒增加一次。它使用 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>
}

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

useEffect 不适合副作用

正如我们所知,useEffect 将 componentDidMount、componentDidUpdate 和 componentWillUnmount 整合到一个 API 中。让我们看一些例子:

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

很容易理解。useEffect 用于在组件挂载、更新和卸载时执行副作用。但它不仅用于执行副作用。它也用于在组件重新渲染时执行副作用。在组件重新渲染时执行副作用不是一个好主意。它可能会导致很多 Bug。最好使用其他 Hook 在组件重新渲染时执行副作用。

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

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

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

命令式 vs 声明式

命令式: 当发生某事时,执行这个副作用。

声明式: 当发生某事时,它会导致状态改变,并且根据(依赖数组)哪些状态部分发生了改变,这个副作用应该被执行,但前提是某些条件为真。而 React 可能会为了无缘无故并发渲染而再次执行它。

概念 vs 实现

概念:

useEffect(() => {
  doSomething()

  return () => cleanup()
}, [whenThisChanges])

实现:

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

  // oops, I forgot the cleanup
}, [foo, bar, baz, quo])

真实世界实现:

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]);
useEffect(() => {
  if (removingValue && !hasValue && cssDisplayFlex) {
    setCssDisplayFlex(false)
  }
  setRemovingValue(false)
}, [removingValue, hasValue, cssDisplayFlex])

写这种代码很吓人。更糟糕的是,它会成为我们代码库的一部分,并且会导致混乱。😱🤮

副作用应该放在哪里?

React 18 在挂载时会执行两次副作用(在严格模式下)。挂载/副作用 (╯°□°)╯︵ ┻━┻ -> 卸载(模拟)/清理 ┬─┬ /( º _ º /) -> 重新挂载/副作用 (╯°□°)╯︵ ┻━┻

它应该放在组件外部吗?默认的 useEffect?呃... 不好。嗯... 🤔 我们不能把它放在 render 中,因为它不应该有任何副作用,因为 render 就像数学等式的右手边。它应该是计算结果而已。

useEffect 是做什么的?

同步

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

  return sub.unsubscribe
}, [input])
useEffect(() => {
  const handler = (event) => {
    setPointer({ x: event.clientX, y: event.clientY })
  }

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

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

行动副作用 vs 活动副作用

  执行一次            同步
  (行动副作用)      (活动副作用)

        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

行动副作用应该放在哪里?

事件处理程序。 差不多。

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

Beta React.js 中有一些非常棒的信息。我建议阅读它。尤其是 "Can event handlers have side effects?" 部分

当然可以!事件处理程序是副作用的最佳位置。

另一个我想要提到的优秀资源是 Where you can cause side effects

在 React 中,副作用通常应该放在事件处理程序中。

如果你尝试过所有其他方法,还是找不到适合你的副作用的事件处理程序,你仍然可以在你的组件中使用 useEffect 调用将它附加到返回的 JSX 上。这会告诉 React 在渲染之后,副作用被允许的时候执行它。然而,这种方法应该作为最后的选择。

"副作用发生在渲染之外" - David Khoursid。

(state) => UI
(state, event) => nextState // 🤔 Effects?

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

副作用什么时候发生?

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

状态转换。总是。

(state, event) => nextState
          |
          V
(state, event) => (nextState, effect) // Here

![重新渲染图示](https://media.slid.es/uploads/174419/images/9663683/CleanShot_2022-06-22_at_20.24.08_2x.png align="left")

行动副作用应该放在哪里?事件处理程序。 状态转换。

它碰巧与事件处理程序同时执行。

我们可能不需要副作用

我们可能会使用 useEffect,因为我们不知道 React 中已经存在一个可以解决这个问题的内置 API。

这里有一份关于这个主题的优秀资源: You Might Not Need an Effect

我们不需要 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])

  // ...
}

仔细阅读并再次思考🧐。

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

  // ...
}

我们可以使用 useMemo 来记忆总计,而不是使用 useEffect 来计算总计。即使变量不是一个昂贵的计算,我们也不需要使用 useMemo 来记忆它,因为我们实际上是在用性能换取内存。

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

使用外部存储的副作用?useSyncExternalStore

useEffect ➡️ useSyncExternalStore

❌ 错误方法:

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

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

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

  // ...
}

✅ 最佳方法:

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

  // ...
}

我们不需要 useEffect 来与父组件通信。

useEffect ➡️ 事件处理程序

❌ 错误方法:

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

📈 更好的方法:

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

✅ 最佳方法是创建一个自定义 Hook:

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

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

useEffect ➡️ justCallIt

❌ 错误方法:

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

  // ...
}

🔨 让我们修复它:

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

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

    storeApi.authenticate()

    didAuthenticateRef.current = true
  }, [])

  // ...
}

➿ 另一种方法:

let didAuthenticate = false

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

    storeApi.authenticate()

    didAuthenticate = true
  }, [])

  // ...
}

🤔 如果:

storeApi.authenticate()

const Store = () => {
  // ...
}

🍷 SSR,对吧?

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

🧪 测试?

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

  appRoot.render(<Store />)
}

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

我们不需要 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
    }
  })

  // ...
}

💽 Remix 方法:

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

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

// app/page.tsx
async function getData() {
  const res = await fetch('https://api.example.com/...')
  // 返回值不会被序列化
  // 你可以返回 Date、Map、Set 等

  // 建议:处理错误
  if (!res.ok) {
    // 这将激活最近的 `error.js` 错误边界
    throw new Error('Failed to fetch data')
  }

  return res.json()
}

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

  return <main></main>
}

⏭️💁 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>
}

⏭️🧹 Next.js (pagesDir) 在 SSR 中:

// 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>
}

⏭️💁 Next.js (pagesDir) 在 CSR 中:

// 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>
}

🍃 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)

  // ...
}

⁉️ 真的⁉️ 我们应该使用什么?useEffect? useQuery?useSWR?

或者... 仅仅使用 use() 🤔

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

function Note({ id }) {
  // 这是异步获取笔记,但对于组件作者来说,它看起来
  // 就像一个同步操作。
  const note = use(fetchNote(id))
  return (
    <div>
      <h1>{note.title}</h1>
      <section>{note.body}</section>
    </div>
  )
}

在 useEffect 中获取数据的弊端

🏃‍♂️ 竞争条件

🔙 没有即时的后退按钮

🔍 没有 SSR 或者初始 HTML 内容

🌊 追逐瀑布

  • Reddit, Dan Abramov

总结

从获取数据到与命令式 API 作斗争,副作用是 Web 应用开发中最让人沮丧的来源之一。坦白地说,把所有东西都放在 useEffect Hook 中只起到了很小的作用。值得庆幸的是,副作用存在一种科学(好吧,数学),在状态机和状态图中得到了形式化,它可以帮助我们在声明式中直观地建模和理解如何编排副作用,无论它们变得多么复杂。

资源