- 人工智能
- 大模型
- 代码智能体
- AI Agent
- 桌面应用
- 后端
- 前端
- CLI
【免费下载链接】ZCode
ZCode 是 AI 编程工作台,提供桌面应用、浏览器界面和终端 Agent。本仓库包含客户端、后端服务、共享 UI,以及 Agent CLI 与运行时源码。
ZCode 仓库在 .agents/skills/react-best-practices 中收录了由 Vercel Engineering 维护的 React / Next.js 性能优化规则集,本文聚焦其中 impact 为MEDIUM的rerender-defer-reads规则。读完本文,你将掌握"如何判断一个动态状态值到底该不该订阅"的判断标准,并能在 ZCode 及类似 React 应用中直接套用"在使用点按需读取"的改造方案,减少无效订阅带来的不必要重渲染。
规则速览:这条规则到底在讲什么
规则原文位于 rules/rerender-defer-reads.md,其核心主张只有一句话:
Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.(如果你只是在回调函数里读取某个动态状态(如 searchParams、localStorage),就不要订阅它。)
该规则的前置元数据明确标注了它的定位:
| 字段 | 值 | 说明 |
|---|---|---|
| title | Defer State Reads to Usage Point | 延迟状态读取到使用点 |
| impact | MEDIUM | 中等影响级别 |
| impactDescription | avoids unnecessary subscriptions | 避免不必要的订阅 |
| tags | rerender, searchParams, localStorage, optimization | 所属话题 |
从 SKILL.md 的分类表可以看出,它归属于第 5 类「Re-render Optimization(rerender)」,该类整体影响级别为 MEDIUM,目标是通过"减少不必要的重渲染,最小化浪费的计算、提升 UI 响应性"(见 _sections.md)。规则集本身按 metadata.json 的说明是为AI Agent 与 LLM 自动写码、评审与重构设计的,每条规则都附带"错误 vs 正确"的真实代码对照——这正是本文要展开的两种写法。
为什么"只在回调里读"就不该订阅:React 订阅机制
理解这条规则,先要理解 React 的订阅模型。当你调用一个 Hook(如useSearchParams(),或某个封装了localStorage监听的自定义 Hook)时,组件就会向该状态源注册订阅。此后:
- 状态源的任何一次变化都会触发所有订阅者组件重渲染;
- 即使该值根本没有出现在渲染输出(JSX)里,重渲染依然会发生;
- 重渲染意味着函数组件重新执行、子组件重新协调,是一笔真实开销。
常见的"高动态"状态源恰好集中在searchParams与localStorage上:
searchParams(URL 查询参数):任何一次路由跳转或?ref=xxx这类查询串变化,都会让所有订阅useSearchParams()的组件重新渲染。即使该组件只是把参数转发给某个异步分享调用,UI 上什么也没变。localStorage:应用常会封装"订阅 storage 变化"的自定义 Hook(监听storage事件)。只要键值被写入,所有订阅组件都会重渲染;localStorage的读写本身还有同步 I/O 成本(相关规则见 js-cache-storage.md)。
因此判断标准只有一条:订阅的正当理由,是"该值确实参与了渲染输出"。如果它只出现在事件回调、effect 或异步任务里,正确的做法是在使用点按需读取,而不是挂一个订阅。
反模式拆解:在渲染层订阅了只在回调里读的 searchParams
规则文档给出了第一个反面示例,完整复刻如下:
function ShareButton({ chatId }: { chatId: string }) { const searchParams = useSearchParams(); const handleShare = () => { const ref = searchParams.get("ref"); shareChat(chatId, { ref }); }; return <button onClick={handleShare}>Share</button>; }这段代码的问题可以从三个层面看:
- 订阅无必要:
ref只在用户点击按钮、执行handleShare时才需要。渲染输出只有一个<button>,与searchParams完全无关; - 代价被放大:只要地址栏查询参数变化(比如用户在站内导航、或页面收到一次带新
?ref=的跳转),这个按钮组件就会重渲染——即使它显示的文案、样式一个像素都没变; - 订阅点数随组件数量线性增长:项目中若有大量"分享按钮"这类组件,每个都订阅
useSearchParams,一次查询串变化就会引爆一轮无关组件的重渲染风暴。
正确写法:在使用点按需读取
规则的"正确"版本如下:
function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { const params = new URLSearchParams(window.location.search); const ref = params.get("ref"); shareChat(chatId, { ref }); }; return <button onClick={handleShare}>Share</button>; }改造要点:
- 移除 Hook 订阅:
ShareButton不再从任何动态状态源订阅数据,组件本身变成了"纯静态组件",只有当父级传入的 props 变化时才会重渲染; - 读取推迟到使用点:
window.location.search是浏览器实时持有的当前地址,new URLSearchParams(window.location.search)在handleShare被调用的那一刻解析出最新的ref——读取到的值永远是最新的,不存在"订阅到了旧快照"的问题; - 零订阅成本:把
ref的获取从"每次渲染都执行 + 每次变化都触发重渲染"降为"只在用户点击时执行一次"。
两种写法的对比:
| 维度 | 反模式(订阅) | 正确写法(按需读取) |
|---|---|---|
| 组件订阅数 | 1(searchParams) | 0 |
| 查询参数变化时 | 每次变化都重渲染 | 不重渲染 |
| 取值时机 | 渲染期订阅的快照 | 回调执行时的实时值 |
| 适合场景 | 渲染输出需要该值 | 仅回调/副作用使用 |
变体一:localStorage 同样适用这条规则
规则标题中的 tags 同时点到了searchParams与localStorage。假设你要读取一个主题偏好来组装分享链接,订阅版本会写成:
// 反模式:为了在回调里读 theme 而订阅 storage function ShareButton({ chatId }: { chatId: string }) { const theme = useLocalStorage("zcode-theme"); // 自定义 Hook,监听 storage 事件 const handleShare = () => shareChat(chatId, { theme: theme.value }); return <button onClick={handleShare}>Share</button>; }theme每次变化都会重渲染按钮,而按钮并不展示主题。按需读取版本则直接在回调里localStorage.getItem:
// 正确:读取推迟到使用点,组件零订阅 function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { const theme = localStorage.getItem("zcode-theme"); shareChat(chatId, { theme: theme ?? "light" }); }; return <button onClick={handleShare}>Share</button>; }ZCode 的 v4 界面代码中就能找到这个模式的真实实践。usePaneSessionPersistence.ts 负责"pane ↔ 会话绑定"的本地持久化,它把 localStorage 的读取收敛成独立函数、在需要时(挂载恢复、选择变化)调用,而不是建立一个贯穿整个生命周期的 storage 订阅:
// packages/ui/src/v4/usePaneSessionPersistence.ts#L17-L23 function readPersistedPaneSession(workspaceKey: string): string | null { try { return localStorage.getItem(storageKey(workspaceKey)); } catch { return null; } }同时文件头部注释(第 1-7 行)说明了这个设计的动机:renderer 刷新后 zustand 选择态归零,若不持久化,刷新会把用户踢回 draft pane;而恢复动作只发生在"同一 renderer reload 后的首个 workspace 挂载瞬间"(第 85-114 行的useEffect只在[enabled, workspaceKey]变化时运行,并显式注释activeSessionId/draftFocusVersion 故意不进依赖)。这正是"在需要读取的时机读取、而不是持续订阅"的工程化表达——读取频率越低,越应该按需读取而非订阅。此外,文件第 32-34 行对无 storage 环境(测试/隐身)做了静默降级,也提示了按需读取写法天然对异常更宽容:一次try/catch包裹一次读取,不会因为订阅建立失败而污染组件生命周期。
变体二:URL 参数解析在 ZCode 中的"按需读取"实践
searchParams的按需读取并不只存在于规则示例里。ZCode 仓库大量代码在处理 URL 参数时都采用"拿到 URL 后在使用点解析"的形态,而非在 React 组件里建立订阅:
- desktopDeepLinkUrl.ts:桌面端深链解析在主进程收到深链 URL 的那一刻,用
parsedUrl.searchParams.get("path")、get("code")、has("state")等方式按需取值(第 78、92、164-169 行); - desktopWindowChrome.ts:窗口 chrome 判断
embedded、returnTo参数同样是在函数调用点即时解析(第 77、104、123 行); - codingPlanEmbeddedWebview.ts:构建 coding-plan 内嵌 WebView URL 时用
url.searchParams.set(...)组装参数(第 111-124 行),校验信任 URL 时用url.searchParams.get("embedded") === "app"即时判断(第 145-157 行)——这些都在纯函数内完成,没有任何订阅; - codingPlanWebview.ts:preload 层在导航事件里即时读取
url.searchParams.get("embedded")与get("returnTo")做判定(第 46-56 行)。
这些例子的共同点是:URL 参数作为一种"随时可读的全局快照",天然适合在使用点读取。window.location.search(浏览器)或一个已解析的URL对象(主进程/preload)永远反映最新状态,按需读取既不丢数据、又不制造订阅。
什么时候应该保留订阅:值必须出现在渲染输出里
"延迟读取"不是"永远不订阅"。规则的适用范围被严格限定在"只读于回调"的场景;一旦该值要参与渲染,就必须订阅。可以结合规则集内的其他规则构成完整的决策树:
| 场景 | 推荐做法 | 对应规则 |
|---|---|---|
| 值只在回调/副作用里使用 | 在使用点按需读取(window.location.search/localStorage.getItem) | rerender-defer-reads(本文) |
| 值必须渲染,但关注的是"布尔状态"而非连续值 | 订阅派生布尔量而非原始连续值,如useMediaQuery("(max-width: 767px)")替代逐像素更新的宽度 | rerender-derived-state.md |
| 值高频变化、且不希望每次更新都重渲染 | 存入useRef,只在需要时读写ref.current | rerender-use-ref-transient-values.md |
| effect 依赖里引用了对象/原始值 | 依赖收窄为原始值(如user.id而非user),派生状态移到 effect 外 | rerender-dependencies.md |
特别地,rerender-dependencies.md中"对于派生状态,在 effect 外计算"的示例(const isMobile = width < 768后再以isMobile作为依赖)与本规则是同一思想的两面:订阅与依赖都应尽量收窄到"真正被使用的点"。而useRef方案则覆盖了"回调里要读、但值高频变化"的场景——此时既不订阅、也不反复重读 DOM/存储,而是把最新值放进 ref。
配套规则与工程化落地
rerender-defer-reads不是孤立的一条规则,它和同目录下的多条规则构成完整的优化链条:
- js-cache-storage.md:缓存
localStorage/sessionStorage的读取结果——与本规则配合后,"按需读取"的场景也应避免在热路径上反复getItem; - client-localstorage-schema.md:对 localStorage 数据做版本化并最小化体积——减少存储数据量,同时降低按需读取与同步的成本;
- rerender-memo.md等 rerender 系列:当订阅确实无法避免时,用 memo 等手段把重渲染隔离在最小子树内。
从工程化角度看,这条规则可以直接沉淀为代码评审与 AI 生成代码的检查清单:
- 查 Hook 列表:组件是否调用了
useSearchParams、useLocalStorage或任何useSyncExternalStore类订阅 Hook? - 查值去向:该订阅值是否出现在渲染输出(JSX / 派生渲染值)中?如果只出现在
onClick、onSubmit、useEffect等回调内部 → 应改为按需读取; - 查变化频率:状态源是否高频变化(查询串、storage、鼠标/滚动)?高频且不进渲染 → 考虑
useRef或按需读取; - 验证行为等价:按需读取要确认取值时刻的语义(点击瞬间的最新值)与产品预期一致,避免引入"旧快照"问题。
ZCode 将该规则集以 skill 形式收录在 .agents/skills/react-best-practices,配套的 SKILL.md 规定了触发时机(编写/评审/重构 React 组件、数据获取、性能优化任务),AGENTS.md 则聚合了全部规则的完整参考。这意味着这条"延迟读取"准则不仅面向人类开发者,也已进入 ZCode 的自动化编码流程——在写新组件、评审既有代码或做性能重构时,都应当先问一句:这个动态值真的需要在渲染期订阅吗?如果答案是否定的,就把读取推迟到使用点。
- 人工智能
- 大模型
- 代码智能体
- AI Agent
- 桌面应用
- 后端
- 前端
- CLI
【免费下载链接】ZCode
ZCode 是 AI 编程工作台,提供桌面应用、浏览器界面和终端 Agent。本仓库包含客户端、后端服务、共享 UI,以及 Agent CLI 与运行时源码。
相关推荐
React 重渲染优化:将状态读取延迟到使用点(Defer State Reads to Usage Point)——避免 searchParams 与 localStorage 的不必要订阅
React 重渲染优化:将状态读取延迟到使用点(Defer State Reads to Usage Point)——避免 searchParams 与 loc
端侧大模型部署落到骁龙设备:支持 NPU/GPU/CPU 的 GGUF 推理框架,5 分钟上手
端侧大模型部署落到骁龙设备:支持 NPU/GPU/CPU 的 GGUF 推理框架,5 分钟上手 让模型走云端,意味着数据离开设备、响应受制于网络:有延迟、有带宽
人工智能大模型推理引擎本地部署多模态ZCode React 重渲染优化:将状态读取延迟到使用点(Defer State Reads to Usage Point)
ZCode React 重渲染优化:将状态读取延迟到使用点(Defer State Reads to Usage Point) 导读 在 ZCode 桌面端与
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考