先记住这个答案
useState 保存参与渲染的数据,setter 会安排更新,每次渲染读取的是对应的状态快照。useRef 返回一个跨后续渲染保持身份的对象,修改 current 不会通知 React 重新渲染,适合 DOM 引用、计时器句柄等非展示数据。两者都不会因为函数再次调用而简单丢失,但 ref 的可变性不能替代响应式更新,也不应该成为在渲染中任意读写共享数据的入口。
- state 更新参与 React 的渲染调度
- ref.current 改变不会触发重渲染
- 需要显示的数据不应仅藏在可变 ref 中
稳定保存并不等于自动通知更新
计时器每秒把 ref.current 加一,并不会因此让屏幕上的数字更新。即使组件偶尔因父层变化重新渲染时读到了新数字,这也只是一次碰巧的读取,不能作为可靠的显示机制。相反,通过 state 更新通知 React,界面才与数据变化建立了明确关系。
另一方面,保存 setTimeout 返回的句柄只是为了取消任务,不需要把这个编号展示给用户。用 state 存句柄会增加没有视觉意义的更新,而 ref 可以让后续点击取消的事件拿到同一个实例中保存的句柄。定时器本身仍需在合适的生命周期中清理。
搜索交互中分别保存文本和请求控制器
假设搜索框需要实时显示输入内容,并允许终止上一个请求。输入值属于 state,因为它决定 input 的 value;AbortController 可以保存在 ref 中,供下一次搜索或清理过程取消请求。两种值虽然都跨渲染存在,但改变它们对界面的意义不同。
如果还要显示“正在搜索”,这个布尔状态应进入 state 或由请求状态层提供,而不是仅判断 ref.current 是否存在。请求失败、取消与完成时都要明确更新展示状态。这样的区分能避免控制器已经被清理、界面却仍然显示旧加载状态的问题。
ref 能读最新值,但不能自动解决所有闭包问题
异步回调可能捕获旧渲染里的 state。如果业务明确要求回调执行时读取某个最近提交的值,可以通过受控的 ref 同步建立这个通道,但必须说明何时写入、何时读取。若需要的是“本次点击发生时的值”,原来的快照反而是正确语义,替换成最新 ref 会改变业务含义。
通常不要在渲染过程中任意读写 current 来驱动结果,否则可能绕过 React 对纯渲染的假设。确定性的懒初始化存在有限例外,但这不意味着可以在 render 中启动计时器或递增全局计数。调试时先检查是否错误地用 ref 存放了本应驱动 UI 的数据,再处理闭包与生命周期。
容易答错的地方
- 把 ref 理解为性能更好的 state
- 两者表达的语义不同。用 ref 隐藏应触发更新的数据,可能减少渲染次数,却让界面不再正确;性能优化不能通过切断数据与显示之间的关系来实现。
- 认为 setter 调用后旧闭包里的变量立即改变
- setter 安排的是后续渲染,已经执行中的函数仍然使用该次渲染关联的值。如果下一状态依赖前一状态,可使用函数式更新;是否读取最新值还要根据业务时点选择。
面试官还会怎么问?
为什么普通局部变量不能替代 useRef?
组件函数重新执行会重新建立局部变量,事件和 Effect 还可能引用不同渲染的闭包。ref 提供与组件实例生命周期关联的稳定对象,让非展示数据有明确的保存位置。
修改 ref 后调用另一个 setState,页面能读到新 ref 吗?
后续渲染可能读到被修改的 current,但这不意味着 ref 已具备订阅能力。依赖另一个无关更新带出 ref 的变化会让数据流难以解释,展示信息应直接建立受支持的状态关系。
组件换 key 后原来的 ref 还在吗?
对应实例重建后,会建立新的 ref 对象并重新初始化。若某个数据需要跨多次组件重建保留,就应把它放到更高层生命周期中,而不是假定 ref 可以超出组件身份永久存在。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。