React副作用循环疑问:为何示例与自制代码表现不同?
问题背景
我是React新手,为理解React渲染机制,阅读了一篇关于memoization的文章,遇到如下示例代码:
function App() { const [body, setBody] = React.useState() const [status, setStatus] = React.useState('idle') const fetchConfig = { method: 'POST', body, headers: {'content-type': 'application/json'}, } const makeFetchRequest = () => (body ? fetch('/post', fetchConfig) : null) React.useEffect(() => { const promise = makeFetchRequest() // if no promise was returned, then we didn't make a request // so we'll exit early if (!promise) return setStatus('pending') promise.then( () => setStatus('fulfilled'), () => setStatus('rejected'), ) }, [makeFetchRequest]) function handleSubmit(event) { event.preventDefault() // get form input values setBody(formInputValues) } return ( <form onSubmit={handleSubmit}> {/* form inputs and other neat stuff... */} </form> ) }
作者指出该
App组件会引发无限渲染:
你猜会发生什么?如果答案是“失控的副作用循环”,那你答对了!原因是React.useEffect会在依赖数组中的元素变化时触发回调。我们唯一的依赖是makeFetchRequest,而它在组件内创建,意味着每次渲染都是新的实例。
我尝试用更简单的代码模拟该行为:
import { useEffect, useState } from 'react'; const App = () => { const [state, setState] = useState(0); const fn = () => { setState(1); }; useEffect(() => { console.log('side effect'); fn(); }, [fn]); }; export default App;
我原本预期会出现无限渲染:组件重新渲染后,调用fn改变state引发再次渲染,生成新的fn实例,因useEffect依赖fn会再次触发副作用,循环往复。但实际useEffect仅执行两次,与作者示例的无限循环表现不同,我不清楚自己的理解误区在哪。
误区解析
作者示例的无限循环逻辑
当useEffect执行时,会调用makeFetchRequest发起异步请求,同时将status设为pending——这会触发组件重新渲染,生成新的makeFetchRequest实例。等异步请求完成后,又会调用setStatus将状态改为fulfilled或rejected,再次触发渲染并生成新的makeFetchRequest,而useEffect的依赖是这个函数,每次依赖变化都会重新执行副作用,如此循环往复,导致无限渲染。
我的模拟代码仅执行两次的原因
你的模拟代码中,fn每次渲染都会生成新实例,所以第二次渲染时useEffect会因为依赖变化再次执行,但关键在于:
- 第一次
useEffect执行时,调用fn将state从0改为1,触发第二次渲染; - 第二次
useEffect执行时,再次调用fn,但此时setState(1)的目标值和当前state值(1)完全相同,React会跳过这次状态更新,不会触发第三次渲染; - 没有新的渲染,就不会生成新的
fn实例,useEffect也就不会再次执行,循环到此终止。
你之前认为“React识别到fn的实现未改变因此不再渲染”是错误的——React并不会比较函数的实现内容,只要函数是新创建的(每次渲染都会重新创建),依赖数组就会判定为变化。真正阻止循环的是状态值没有发生实际变化,React跳过了后续的渲染触发。
内容的提问来源于stack exchange,提问作者Othmane

