React StrictMode下useEffect错误读取渲染周期状态的问题及疑问
React 18 StrictMode下useEffect清理函数误读后续渲染state导致请求中止
前置背景
- 明确StrictMode会触发
render→unmount→render的双渲染校验流程 - 明确StrictMode要求组件保持纯函数特性
- 已排查相关问题但未找到适配的解决方案
问题描述
组件通过useState存储包含AbortController.signal的网络请求实例,useEffect的清理函数负责中止当前渲染周期的请求。但在StrictMode的首次卸载(对应第一次渲染)流程中,清理函数读取到了第二次渲染的state,导致第二次渲染的请求被意外中止。预期清理函数应绑定对应渲染周期的state,仅中止当前周期的请求。
复现代码
import { Suspense, useEffect, useState } from 'react'; // 包装Promise为可挂起对象 enum Status { Pending, Fulfilled, Rejected, } const Suspendable = <T,>(promise: Promise<T>): (() => T) => { let status: Status = Status.Pending; let error: Error; let result: T; const suspender = promise.then((r: T): void => { status = Status.Fulfilled; result = r; }) .catch(err => { status = Status.Rejected; error = err; }); return () => { switch(status) { case Status.Pending: throw suspender; case Status.Rejected: throw error; case Status.Fulfilled: return result; default: throw Error(`Unexpected arg ${status}`); } } }; // 封装fetch请求 const Req = (uri: string, config: RequestInit={}): Promise<string> => new Promise<string>((resolve, reject) => { fetch(uri, config) .then(resp => resp.text()) .then(resolve) .catch(reject); }); // 渲染内容组件 const Renderer = ({ getResponse }: { getResponse: () => string }) => { console.log(`Renderer start`); const response = getResponse(); console.log(`Renderer get response, return component`); return <pre>{response}</pre>; } // State类型定义 interface Result { suspendable: () => string; abortController: AbortController; retryKey: number; } const App = () => { const [controller, setResult] = useState<Result>(() => { const abortController = new AbortController(); const retryKey = Math.floor(Math.random() * 100000); console.log(`create controller ${retryKey}`) return { abortController, retryKey, suspendable: Suspendable<string>(Req(`/`, { signal: abortController.signal })) }; }); useEffect(() => { return () => { console.log(`clean up ${controller.retryKey}`); controller.abortController.abort(); } }) return <> <Suspense fallback={<p>Loading</p>}> <Renderer getResponse={controller.suspendable} /> </Suspense> <p>-- Parent Node</p> </>; } export default App;
预期行为
- StrictMode首次渲染时,页面报错可被ErrorBoundary捕获
- 首次卸载的清理函数仅中止第一次渲染生成的请求
- 第二次渲染时Suspense正常显示加载状态,请求完成后渲染内容
实际异常表现
控制台日志输出:
create controller 8343 # 第一次渲染生成的实例 create controller 69996 # 第二次渲染生成的实例 Renderer start clean up 69996 # 第一次卸载的清理函数操作了第二次渲染的实例 Renderer start Uncaught DOMException: The operation was aborted. # 预期内(第一次请求被中止) Renderer start Uncaught DOMException: The operation was aborted. # 意外(第二次请求被误中止) clean up 69996 # 重复执行清理
已尝试方案及疑问
尝试通过useRef标记是否已卸载来控制清理逻辑:
const hasUnmounted = useRef(false) useEffect(() => { return () => { if(hasUnmounted.current){ // 执行请求中止逻辑 } hasUnmounted.current = true; } })
但存在以下疑问:
- 原代码中为什么会出现清理函数读取后续渲染state的问题?
- 为什么需要通过
useRef手动控制清理逻辑,而不能依赖React的渲染周期自动绑定对应state?
内容的提问来源于stack exchange,提问作者TylerTemp
相关产品推荐
相关产品推荐

