React 深入:外部快照、Context与取消生命周期
状态拥有者与外部Store
先完成React完整阅读清单,理解props、受控输入、函数更新和Effect;沿用React19.3.1/Vite8.3.4。大部分局部界面状态仍适合useState;本实验刻意让异步模型独立于React,以展示useSyncExternalStore协议,不要求所有应用增加全局Store。模型完整源码在JavaScript深入,同时供无框架和Vue版本使用。
App用useState惰性创建单个模型,把输入草稿留在Search本地,用Context传递模型实例。Context避免层层转交,不自动限制更新或提供持久化;不应每次render创建新Store。Results用稳定subscribe/getSnapshot订阅,快照未更新必须引用相等;每次getSnapshot都返回新对象会导致持续重渲染。Snapshot是只读值,不能直接push进items绕过发布。
一次React render读取该次状态快照;事件处理器闭包不会在调用setState后变成下一次render的值。当前项目通过外部Store统一发布不可变快照,输入仍是受控value/onChange。结果key为固定课程文本(fixture保证唯一),真实数据应使用稳定ID;不能因为过滤后数组短就改为下标。
Effect、StrictMode与卸载
Effect用于同步外部系统,setup的清理在依赖变化前及卸载时执行;开发StrictMode额外执行setup/cleanup用于发现不对称资源处理。组件不能在render中发请求,也不能把Effect回调直接写async并返回Promise。异步事件的拒绝应在事件边界处理,Error Boundary不能自动捕获所有Promise或事件异常。
本App清理调用cancel而非dispose:StrictMode的开发清理后仍可能再次setup,永久销毁同一个模型会使后续查询报disposed。useSyncExternalStore自己管理订阅/退订;App卸载时取消timer,模型无全局根持有即可回收。手动卸载查询面板前先取消,Search/Results移除并退订;重新挂载输入回到初始vue,外部模型保留最近快照(有在途任务时先转idle,已结束的成功或失败快照保留),明确两者属于不同拥有者。另有独立模型dispose测试验证真正销毁协议。
取消信号不能保证底层立即停止,因此仍按任务所有者隔离过期成功和失败。只写isMounted标记不能区分同组件内的两个并发请求;错误标记也不能统一当空列表。清理、数据正确性与错误反馈须分别验证。
完整工程与运行
在原examples/web-plus/react保留阅读清单入口,新增advanced.html与src/advanced.jsx;共享examples/web/system-lab/model.mjs和style.css,所以复制项目时保留examples层级,或同步调整import路径。完整原依赖和入口仍见首批项目,两入口均被当前配置实际构建,不是只在开发环境能打开。
advanced.html
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>React 异步状态实验</title></head><body><div id="root"></div><script type="module" src="/src/advanced.jsx"></script></body></html>src/advanced.jsx
import {StrictMode,createContext,useContext,useEffect,useState,useSyncExternalStore} from 'react';
import {createRoot} from 'react-dom/client';
import {createLatest} from '../../../web/system-lab/model.mjs';
import '../../../web/system-lab/style.css';
const ModelContext=createContext(null);
function Results(){
const model=useContext(ModelContext);
const state=useSyncExternalStore(model.subscribe,model.getSnapshot);
return <><p role="status">{state.status} · {state.query}{state.message?' · '+state.message:''}</p><ul>{state.items.map(item=><li key={item}>{item}</li>)}</ul></>;
}
function Search(){
const model=useContext(ModelContext);const [query,setQuery]=useState('vue'),[error,setError]=useState('');
function submit(event){event.preventDefault();setError('');model.run(query).catch(e=>setError(e.message));}
return <><form onSubmit={submit}><label htmlFor="query">课程查询</label><input id="query" value={query} onChange={e=>setQuery(e.target.value)} required maxLength={80}/><button>查询</button><button type="button" onClick={()=>model.cancel()}>取消</button></form>{error&&<p role="alert">{error}</p>}<Results/></>;
}
function App(){
const [model]=useState(()=>createLatest());const [visible,setVisible]=useState(true);
useEffect(()=>()=>model.cancel(),[model]);
return <main><h1>React 异步状态实验</h1><p>slow延迟800ms;error模拟失败。切换查询会取消旧任务,旧结果不能覆盖新状态。</p><button onClick={()=>{model.cancel();setVisible(v=>!v);}}>{visible?'卸载查询面板':'挂载查询面板'}</button><ModelContext value={model}>{visible&&<Search/>}</ModelContext></main>;
}
createRoot(document.querySelector('#root')).render(<StrictMode><App/></StrictMode>);vite.config.mjs
import {defineConfig} from 'vite';
import react from '@vitejs/plugin-react';
import {fileURLToPath} from 'node:url';
export default defineConfig({plugins:[react()],base:'./',build:{rollupOptions:{input:{main:fileURLToPath(new URL('./index.html',import.meta.url)),advanced:fileURLToPath(new URL('./advanced.html',import.meta.url))}}}});package.json
{
"name": "m3-react-reading",
"private": true,
"type": "module",
"scripts": {"dev": "vite --host 127.0.0.1", "test": "node --test model.test.mjs", "build": "vite build", "verify": "npm test && npm run build", "preview": "vite preview --host 127.0.0.1"},
"engines": {"node": ">=22.12.0"},
"dependencies": {"react": "19.3.1", "react-dom": "19.3.1"},
"devDependencies": {"vite": "8.3.4", "@vitejs/plugin-react": "6.1.2"}
}进入examples/web-plus/react执行npm ci、npm run verify、npm run preview -- --port 5190;访问http://127.0.0.1:5190/advanced.html。vue查询应显示ready及“Vue 响应式”;slow后立即react,最终只显示React;error显示fixture failure后可恢复;卸载再挂载仍可查询。index.html保留原阅读清单,刷新归空的约定未改变。模型独立测试用node --test examples/web/system-lab/model.test.mjs从仓库根运行,界面仍需真实浏览器检查,不能拿纯模型测试冒充组件渲染。
性能、SSR与错误定位
先用React DevTools Profiler观察一次查询的提交和更新来源,再考虑memo;memo不是正确性条件,useMemo也不能承担必须持久存在的资源。Context值若每render改变引用,会使消费者更新;当前模型引用稳定。Render工作可被重复或放弃,副作用应放在提交后的Effect或用户事件中。
useSyncExternalStore服务器渲染需getServerSnapshot并与首次客户端hydration一致;本实验是纯客户端,不传第三参数,不假装已支持SSR。若迁移到服务端,为每个请求创建模型,不在进程全局共享用户状态。服务端组件、路由、认证属于应用架构,不能从这个客户端项目推出已完成公网应用。
无限更新先查getSnapshot引用;Invalid hook call先查Hook位置和React重复安装;StrictMode后无法查询先查是否永久销毁实例;旧响应覆盖先检查任务所有权而非给Effect加空依赖掩盖问题。构建成功但advanced.html404先看双入口配置和dist,再查部署base。
练习
- 把createLatest放在Results函数体每次执行,有什么后果?2. 为什么卸载子面板后App仍拥有模型?3. SSR时为什么不能直接复用模块顶层实例?
参考解法
每次render创建新模型会丢失状态并不断变更订阅,返回快照与请求也失去统一拥有者。资源归App惰性创建,Context消费者只是借用;子组件卸载应退订而不是无条件永久销毁共享实例。SSR进程服务多请求,模块实例可能共享用户数据;按请求隔离且提供与hydration一致的服务器快照才能建立明确边界。
依据:useSyncExternalStore、Effect、中文同步与清理。核对2026-10-17。
继续阅读
- 组件与状态
- 组件与状态