- 发表于
再见 React,再见 useEffect(希望吧)
- 作者
- 姓名
- Imamuzzaki Abu Salam
- https://x.com/ImBIOS_Dev
在大多数情况下,使用 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 的核心。
副作用什么时候发生?
状态转换。总是。
(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 中只起到了很小的作用。值得庆幸的是,副作用存在一种科学(好吧,数学),在状态机和状态图中得到了形式化,它可以帮助我们在声明式中直观地建模和理解如何编排副作用,无论它们变得多么复杂。