React 18 中 useEffect 触发两次
要旨
未来,React 将提供一项功能,允许组件在卸载期间保留状态。为此,React 18 引入了一项新的仅限开发阶段的严格模式检查。每当组件首次挂载时,React 都会自动卸载并重新挂载每个组件,并在第二次挂载时恢复先前的状态。如果这导致您的应用崩溃,请考虑移除严格模式,直到您能够修复组件,使其能够以现有状态重新挂载。
简而言之,当严格模式开启时,React 会挂载两次组件(仅限开发环境!)来检查并告知组件是否存在 bug。这仅限于开发环境,对生产环境中运行的代码没有影响。
如果你来这里只是想“了解”为什么你的效果会被调用两次,那么就到这里吧,这就是要点。你可以省去读完整篇文章的时间,直接去修复你的效果。
不过,你也可以留在这里,了解一些细微差别。
但首先,什么是效果?
某些组件需要与外部系统同步。例如,您可能希望根据 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>
}
千万别这么做。这不仅没必要,还会导致不必要的第二次重新渲染,因为这个值本来可以在渲染过程中计算出来。
function UserInfo({ firstName, lastName }) {
// ✅ Good: calculated during initial render
const fullName = `${firstName} ${lastName}`
return <div>Full name of user: {fullName}</div>
}
“但是,如果在渲染过程中计算某些值不如我们fullName
这里的变量那么便宜呢?” 好吧,在这种情况下,你可以记忆昂贵的计算。你仍然不需要在这里使用 Effect
function SomeExpensiveComponent() {
// ...
const data = useMemo(() => {
// Does no re-run unless deps changes
return someExpensiveCalculaion(deps)
}, [deps])
// ...
}
data
这告诉 React除非发生变化,否则不要重新计算deps
。即使速度很慢(比如运行大约需要 10 毫秒),也只需执行此操作someExpensiveCalculaion
。但这取决于你。首先看看它是否足够快,而无需从那里开始构建。你可以使用或 来useMemo
检查运行一段代码所需的时间:console.time
performance.now
console.time('myBadFunc')
myBadFunc()
console.timeEnd('myBadFunc')
您可以看到类似这样的日志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])
//...
}
正如之前所讨论的,这种方式效率低下。React 需要在更新状态、计算并更新 UI 之后重新运行你的 effect。由于这次我们要更新状态(filteredItems
),React 需要从第一步开始重新执行所有流程!为了避免这些不必要的计算,只需在渲染过程中计算过滤后的列表即可:
function MyNoobComponent({ items }) {
// ✅ Good: calculating values during render
const filteredItems = items.filter(item => item.price < 500)
//...
}
因此,经验法则:如果某些内容可以通过现有的 props 或 state 计算出来,就不要将其放入 state 中。相反,应该在渲染过程中计算。这样可以使你的代码运行更快(避免额外的“级联”更新)、更简洁(删除一些代码),并且更不容易出错(避免不同 state 变量之间不同步导致的 bug)。如果你对这种方法感到陌生,React 中的思考有一些关于应该将哪些内容放入 state 的指导。
另外,你不需要 effect 来处理事件(例如,用户点击按钮)。假设你想打印用户的收据:
function PrintScreen({ billDetails }) {
// 🔴 Don't use effect for event handlers
useEffect(() => {
if (billDetails) {
myPrettyPrintFunc(billDetails)
}
}, [billDetails])
// ...
}
我以前写过这种代码,真是罪过。千万别再写。你可以在父组件中(你可能设置成billDetails
,setBillDetails()
当用户点击按钮时,只在父组件中打印)打印一下:
function ParentComponent() {
// ...
return (
// ✅ Good: useing inside event hanler
<button onClick={() => myPrettyPrintFunc(componentState.billDetails)}>
Print Receipt
</button>
)
// ...
}
上面的代码现在已经解决了由于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()
}, [])
我们可能都见过,也写过这种类型的代码。那么,问题出在哪里呢?
- 首先,
useEffect
s 仅用于客户端。这意味着它们不在服务器上运行。因此,初始渲染的页面可能只包含 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])
// ...
}
发出的分析请求可以保留在 中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])
// ...
}
这似乎与我们之前讨论的相反:将获取逻辑放在事件处理程序中。但是,这里的查询可能来自任何来源(用户输入、URL 等)。因此,结果需要synced
与变量一起。但是,考虑一下我们之前讨论过query
的情况,用户可能按下后退按钮然后前进按钮;那么res
状态变量中的数据可能已经过时,或者考虑到query
来自用户输入和用户快速输入。查询可能会从 变为 变为 变为 变为p
。po
这可能会为每个值启动不同的获取,但不能保证它们会按该顺序返回。因此,显示的结果可能是错误的(任何先前的查询的结果)。因此,这里需要清理,以确保显示的结果不是陈旧的,并防止竞争条件:pot
pota
potat
potato
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])
// ...
}
这确保了所有响应中只接受最新的响应。
仅仅使用效果来处理竞争条件似乎工作量很大。然而,数据获取还有很多其他功能,例如缓存、重复数据删除、处理状态数据、后台获取等等。你的框架或许可以提供比使用 更高效的内置数据获取机制useEffect
。
如果您不想使用框架,您可以将上述所有逻辑提取到自定义钩子中,或者可以使用库,例如 TanStack Query(以前称为 useQuery)或swr。
迄今为止
useEffect
在严格模式下的开发中触发两次,以指出生产中会出现错误。useEffect
当组件需要与某些外部系统同步时应该使用,因为效果不会在渲染过程中触发,因此选择退出 React 的范例。- 不要对事件处理程序使用效果。
- 不要对派生状态使用效果。(哎呀,甚至尽可能不要使用派生状态,并在渲染期间计算值)。
- 不要使用 effect 来获取数据。如果实在无法避免,至少在 effect 结束时进行清理。
致谢:
上述大部分内容都毫不掩饰地受到以下启发:
文章来源:https://dev.to/shivamjjha/useeffect-firing-twice-in-react-18-16cg