React导航为何会改变原有EventSource的readyState?
根本原因
- 核心触发点:你的
useEffect依赖项设置为[prompt],按照React的effect执行规则,每次依赖项变化、effect重新执行前,一定会先运行上一轮effect返回的清理函数。你在清理函数里写了newBackendEventSource.close(),这里捕获的是上一轮effect创建的旧EventSource实例——也就是说每次你更新prompt触发导航时,代码会主动把之前创建的SSE连接关掉,对应实例的readyState自然会变成2(已关闭),这和路由导航本身没有直接关系,是你的effect清理逻辑主动关闭了旧连接。 - 认知偏差:原生
EventSource接口没有内置close事件,你绑定的close事件回调永远不会触发。标准EventSource仅支持open、message、error三个原生事件:主动调用.close()方法、连接异常断开、服务端关闭连接时,只会触发error事件,实例进入关闭状态时不会派发你自定义绑定的close事件,这就是你从未看到close回调日志的原因。 - 设计错误:将EventSource类实例存入React state是错误实践。React state的设计目标是存储影响UI渲染、可序列化的普通数据,EventSource是持有网络连接、事件监听器的副作用资源,存入state会导致引用不稳定、重渲染时实例被意外覆盖,也无法保证事件回调中能获取到最新的状态值。
- 隐藏bug:你在
message事件回调中直接读取闭包捕获的promptsResultsArray值,这个值是effect创建瞬间的旧值,后续状态更新后回调里拿到的永远是过期数据,计算索引时很容易出现数组越界、覆盖错搜索结果的问题。
修复方案
- 改用
useRef存储所有活跃的EventSource实例,ref的.current属性不会随React重渲染重置,能稳定持有实例引用,不要把SSE实例存入React state。 - 调整effect清理逻辑:不要在每次prompt更新时关闭旧SSE连接,仅在组件整体卸载时,遍历ref中存储的所有活跃实例,逐个关闭连接,避免内存泄漏。
- 删除无效的
close事件监听,如果需要感知单条SSE流结束,可以和后端约定自定义事件类型(比如done),在对应事件回调中关闭当前连接,并从ref的活跃实例列表中移除该实例。 - 所有事件回调中更新状态时,使用函数式更新写法获取最新的state值,避免闭包捕获的旧值导致逻辑错误。
修正后的核心代码参考
// 用ref存储所有活跃SSE实例,不要放入state const activeEventSources = useRef([]); const [promptsResultsArray, setPromptsResultsArray] = useState([]); const [prompt, setPrompt] = useState(''); // 仅在组件卸载时执行全局清理,关闭所有SSE连接 useEffect(() => { return () => { activeEventSources.current.forEach(source => { source.close(); }) activeEventSources.current = []; } }, []) // 新建搜索时创建SSE连接的逻辑 const createSSEConnection = (targetPrompt, counter) => { const newSource = new EventSource( `https://localhost:8080/generate?counter=${counter}&prompt=${targetPrompt}`, {withCredentials: false} ) newSource.addEventListener('open', () => { console.log('SSE opened for prompt:', targetPrompt); }); newSource.addEventListener('error', (e) => { // 连接关闭时readyState为2 if (newSource.readyState === 2) { console.log('SSE closed for prompt:', targetPrompt); // 从活跃列表中移除当前实例 activeEventSources.current = activeEventSources.current.filter(item => item !== newSource); } else { console.log('SSE error for prompt:', targetPrompt, e); } }); // 和后端约定自定义结束事件,事件名可自行定义,此处以done为例 newSource.addEventListener('done', (e) => { const data = JSON.parse(e.data); console.log("SSE finish data: ", data); newSource.close(); activeEventSources.current = activeEventSources.current.filter(item => item !== newSource); }); newSource.addEventListener('message', (e) => { const data = JSON.parse(e.data); console.log("message data: ", data); // 用函数式更新拿到最新的数组,不要用闭包里的旧值 setPromptsResultsArray(prevArray => { const updatedArray = [...prevArray]; const index = updatedArray.length - data.counter - 1; if (updatedArray[index]) { updatedArray[index] = { ...updatedArray[index], URIs: [data.uri], isLoading: false } } return updatedArray; }) }); // 将新实例存入ref的活跃列表 activeEventSources.current.push(newSource); } const navigateToGenerateResults = (promptString) => { console.log('Adding new prompt results to promptsResultsArray'); const newCounter = promptsResultsArray.length; const newPromptsResults = { isLoading: true, prompt: promptString, counter: newCounter, URIs: ["https://simtooreal-public.s3.amazonaws.com/white_background.png"] } setPromptsResultsArray(prevArray => [newPromptsResults, ...prevArray]) setPrompt(promptString) // 直接创建SSE连接,不要放在依赖prompt的effect里避免触发误关闭 createSSEConnection(promptString, newCounter) navigate(`/generate?counter=${newCounter}&prompt=${promptString}`) }
内容的提问来源于stack exchange,提问作者Rylan Schaeffer
相关产品推荐
相关产品推荐

