cal.diy React 最佳实践:用 useLatest 构造稳定回调引用,消除 Effect 重复执行
本篇基于 cal.diy 仓库内置的 Vercel React 最佳实践规则集(vercel-react-best-practices skill)中的 advanced-use-latest.md 展开。该规则属于「Advanced Patterns(高级模式)」分类,核心目标是在回调中读取最新值的同时避免把回调放进 effect 依赖数组,从而防止 effect 因回调引用变化而反复执行。读完本文,你将掌握 useLatest hook 的完整实现、防抖搜索场景下「错误写法 vs 正确写法」的对照分析,以及它与 useEffectEvent、事件处理器 ref 存储等相邻模式的关系与适用边界。
规则定位:vercel-react-best-practices 中的 Advanced Patterns
在 cal.diy 仓库中,SKILL.md 定义了这套由 Vercel 工程团队维护的 React/Next.js 性能优化规则集:共 8 个类别、按影响程度从 CRITICAL 到 LOW 排序,供 AI Agent 在编写、评审或重构 React/Next.js 代码时参考。其中第 8 类「Advanced Patterns」影响级别为 LOW,包含两条规则:
| 规则 ID | 规则文件 | 要点 |
|---|---|---|
advanced-event-handler-refs |
advanced-event-handler-refs.md | 将事件处理器存入 ref,保持订阅稳定 |
advanced-use-latest |
advanced-use-latest.md | 用 useLatest 获得稳定的回调引用 |
本文的主角 useLatest for Stable Callback Refs 在规则集中的一行摘要为:
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.(在回调中访问最新值,而无需将其加入依赖数组;既防止 effect 重复执行,又避免闭包过期。)
其 frontmatter 元数据为 impact: LOW、impactDescription: prevents effect re-runs、tags: advanced, hooks, useLatest, refs, optimization。完整的规则全文同样收录在编译版文档 AGENTS.md 的第 8 章「Advanced Patterns」(8.2 小节)中,可作为交叉验证来源。
核心矛盾:闭包过期 vs Effect 重复执行
这个规则针对的是 React 中一个经典的权衡:
- 闭包过期(stale closure):effect 闭包捕获的是「那次 effect 运行时的值」。如果把回调从依赖数组中省略,effect 内调用到的就是旧版本回调,读到的是过期 props/state。
- Effect 重复执行:如果为了正确性把回调加入依赖数组,那么父组件每次渲染都产生新的回调函数引用时,effect 就会在每次父组件渲染后重新清理并重新执行(重新注册监听、重置计时器等),造成无谓的副作用开销。
useLatest 用一个 ref 同时解决两者:ref 引用本身跨渲染保持稳定(不触发 effect 重跑),而 ref.current 始终指向最新值(不产生闭包过期)。
useLatest 的标准实现
规则文档给出的实现如下:
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
}, [value])
return ref
}
逐行解读:
useRef(value):以首次渲染的值初始化 ref。ref 对象在同一组件实例的整个生命周期内是同一个对象,因此可以安全地作为 effect 内部的「稳定句柄」。useEffect(() => { ref.current = value }, [value]):当传入的value变化后,在 effect 阶段把最新值写回 ref。选择「effect 中更新」而非「渲染期间直接赋值ref.current = value」,符合 React 对副作用时机的约束——渲染期间不修改外部可变状态,避免并发渲染(concurrent rendering)下重复渲染导致的中间值污染。- 返回的是 ref 本身:调用方通过
xxxRef.current读取最新值,xxxRef对象引用则永远不变。
需要注意的语义边界:
- 该 hook 返回的 ref 不响应式——修改
ref.current不会触发渲染,也不应把ref.current放进任何依赖数组(它的每次读取结果可能不同,eslint exhaustive-deps 也应将其忽略)。 - 它只负责「拿到最新值」,不负责「在正确时机调用」,因此常与定时器、事件监听等场景组合使用。
实战场景:防抖搜索中的错误写法与正确写法
规则文档以「搜索输入框防抖」为场景给出了完整对照。
错误写法(effect 随每次回调变化而重跑):
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
useEffect(() => {
const timeout = setTimeout(() => onSearch(query), 300)
return () => clearTimeout(timeout)
}, [query, onSearch])
}
问题在于 onSearch 通常来自父组件(父组件未做 useCallback 包裹时,每次渲染都是新函数)。此时依赖数组 [query, onSearch] 意味着:父组件每次渲染 → onSearch 引用变化 → cleanup 清掉旧的 300ms 计时器 → 重新建立新计时器。结果是:
- 用户正在输入时,任何父级无关 state 变化都会打断当前防抖计时,搜索时机变得不可预测;
setTimeout/clearTimeout高频执行,产生无谓的副作用开销。
正确写法(effect 稳定 + 回调取最新):
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
const onSearchRef = useLatest(onSearch)
useEffect(() => {
const timeout = setTimeout(() => onSearchRef.current(query), 300)
return () => clearTimeout(timeout)
}, [query])
}
改动只有两处,效果却完全不同:
- 依赖数组收窄为
[query]——只有真正驱动防抖的输入值变化才重跑 effect,onSearch的任何引用变化都不再干扰计时器; - 触发时通过
onSearchRef.current(query)调用,拿到的必然是父组件最近一次渲染传入的回调,闭包不会过期。
这正是规则 frontmatter 中 prevents effect re-runs 的完整含义:用「稳定的 effect」+「新鲜的回调」替代「不稳定的 effect」。
与相邻模式的关系:如何选择
在 cal.diy 的规则集中,useLatest 与另外两个模式解决的是同一族问题,可按场景取舍:
-
Store Event Handlers in Refs(advanced-event-handler-refs.md):当回调用于事件监听(如
window.addEventListener)时,手动把 handler 存进 ref,effect 只依赖事件名。useLatest可以看作这一模式的通用封装——把「useRef + effect 同步」抽成可复用 hook,适用于任意「需要在稳定 effect 中读取最新值」的场景(定时器、订阅、轮询等)。 -
useEffectEvent(React 新版 API):同规则文件提到,若已使用最新版本 React,useEffectEvent(handler)提供的就是同一种能力——生成一个引用稳定、但永远指向最新 handler 的函数,且无需 ref 心智负担:import { useEffectEvent } from 'react' function useWindowEvent(event: string, handler: () => void) { const onEvent = useEffectEvent(handler) useEffect(() => { window.addEventListener(event, onEvent) return () => window.removeEventListener(event, onEvent) }, [event]) }从仓库实际采用角度看,
useLatest的价值在于不依赖 React 版本、实现仅 5 行、可移植到任意工程,适合作为渐进升级前的落地方案。 -
Narrow Effect Dependencies(rerender-dependencies.md):该规则要求把对象依赖收窄为原始值依赖(如
[user.id]而非[user])。它处理「值类型」的依赖,useLatest处理「函数类型」的依赖,两者经常组合使用:依赖数组放原始值,函数一律走 ref。
在 cal.diy 这类大型 Next.js 工程中的适用面
cal.diy 的 Web 端(apps/web)是基于 Next.js 的复杂单体前端,模块目录 apps/web/modules 下存在大量「父级容器组件 + 子交互组件」的结构,useRef 模式在 onboarding 等多个模块中已有使用。在这类代码库中,useLatest 典型适用场景包括:
- 搜索、筛选类输入框的防抖/节流(本文示例);
- WebSocket/事件总线的订阅回调,希望订阅只建立一次但始终回调最新 handler;
- 定时器驱动的轮询,回调中需要读取最新的路由参数、权限 state。
同时应注意该规则的影响级别标注为 LOW:它属于增量式优化,收益体现在减少无谓的 effect 清理/重建开销、提升交互可预测性,而非首屏性能级别的改动。规则集的整体优先级(见 SKILL.md 的分类表)建议先处理 CRITICAL 级别的 waterfall 与 bundle 问题,再逐步引入这类 advanced 模式。
小结
useLatest 是「ref 保持引用稳定 + effect 同步最新值」的最小可用组合,规则文档给出的 5 行实现与防抖搜索对照示例已构成完整可复制方案。落地时记住三条要点:依赖数组只留真正驱动副作用的原始值;通过 ref.current 读取回调以规避 stale closure;React 版本足够新时可评估切换到语义更直接的 useEffectEvent。这套模式与 advanced-event-handler-refs、rerender-dependencies 共同构成 cal.diy 规则集中「让 effect 依赖数组尽可能窄」的方法论。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00