useRef和useState有什么区别?什么时候该用ref?
深入解析React中useRef与useState的本质区别与渲染驱动机制。详细解答两者的底层原理、生命周期差异、何时该用ref操作DOM或保存持久变量,以及避免常见反模式的实用指南。
useState 与 useRef 都可以跨越组件的多次渲染来持久保留数据,但两者的本质区别在于是否触发组件重新渲染。调用 useState 的更新函数会通知 React 调度重新渲染,从而将最新的数据映射到屏幕视图上;而 useRef 只是返回一个在整个生命周期内固定不变的普通 JavaScript 对象 { current: initialValue },修改其 .current 属性完全是静默的内存操作,不会触发任何视图重绘。本文将剖析两者的底层运行机制、使用边界,并通过具体场景与决策流程,说明什么时候应当果断使用 ref。
一、核心机制差异:快照渲染与内存直连
要理解两者的不同,必须从 React 函数组件的执行模型说起。在 React 中,每一次渲染都可以看作是一张独立的UI 快照(Snapshot)。当组件重新渲染时,函数主体会被重新执行一次,内部声明的所有局部变量都会在这次执行中被重新创建。
1. useState 的快照闭环
当你使用 const [count, setCount] = useState(0) 时,count 变量并不是一个具有双向绑定能力的响应式指针,它仅仅是“当前这一次渲染调用中被赋予的常量数值”。
当执行 setCount(1) 时,代码并不是直接去修改现有的 count 变量,而是向 React 的更新队列中推入一个更新请求。React 的调度器(Scheduler)接收到信号后,会重新调用该组件函数。在下一次渲染执行时,useState 会从 Fiber 节点上取出计算后的最新状态 1 返回给组件,随后完成虚拟 DOM 的 Diff 算法对比,最终将差异提交(Commit)到真实 DOM 树上。
2. useRef 的持久容器特性
与状态的严密流程不同,useRef(initialValue) 的行为极其单纯。在组件首次挂载时,React 在当前 Fiber 节点上开辟一块存储空间,放入一个形状为 { current: initialValue } 的普通对象。在组件后续的任何一次重新渲染中,useRef 每次都会返回同一个内存地址的对象引用。
这意味着,当你写下 ref.current = newValue 时,发生的事情与普通的 JavaScript 对象属性赋值毫无区别。React 并没有为该属性设置任何 Proxy 拦截器或 Object.defineProperty 监听,因此 React 内部完全不知道你修改了数据,也不会安排任何渲染计划。
useState;如果你仅仅需要在组件背后记住一个数据,且它的变化不应该打扰当前的 UI 呈现,那么 useRef 便是首选。
二、什么时候该用 ref?三大典型应用场景
React 官方将 useRef 称作一种“逃生舱(Escape Hatch)”。在严格的声明式 UI 编程中,组件应该根据当前的 props 和 state 纯粹地推导出界面结构。但在实际浏览器运行环境中,我们不可避免地需要处理命令式逻辑或底层能力,这时就需要用到 ref。
场景 1:直接访问与操控原生 DOM 节点
这是 useRef 最为人熟知、也是最官方的用途。有些浏览器原生能力无法仅凭声明式 JSX 完成,例如:
- 控制焦点:点击按钮后让指定的
<input>自动获取光标。 - 测量元素尺寸或滚动位置:调用
getBoundingClientRect()获取实际渲染宽高,或通过scrollIntoView()实现平滑滚动。 - 操作媒体元素:调用 HTML5
<video>或<audio>的play()、pause()命令式接口。
示例代码如下。React 在 Commit 阶段会将创建好的真实 DOM 节点赋给 inputRef.current,在组件卸载时将其重置为 null:
import React, { useRef } from 'react';
function SearchBar() {
// 1. 声明 ref,初始值为 null
const inputRef = useRef(null);
function handleSearchClick() {
// 2. 在事件处理函数中安全调用原生 DOM API
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}
return (
<div>
<input ref={inputRef} type="text" placeholder="输入搜索内容..." />
<button onClick={handleSearchClick}>聚焦搜索</button>
</div>
);
}
场景 2:跨渲染周期保存非视图数据(定时器句柄与请求控制器)
在许多异步逻辑中,我们需要记录诸如 setInterval 返回的定时器 ID,以便在后续事件或组件卸载时调用 clearInterval。
如果将定时器 ID 保存在普通局部变量中,组件下一次因其他 state 变化而重新渲染时,该变量会被立即重置为 null,导致丢失对旧定时器的引用,产生内存泄漏。而如果将定时器 ID 存进 useState,每次保存 ID 都会额外触发一次多余的组件渲染。此时,useRef 便是最完美的解决方案:它能跨渲染稳固保存 ID,同时完全不产生渲染开销。
import React, { useState, useRef, useEffect } from 'react';
function AutoSaveIndicator() {
const [saveStatus, setSaveStatus] = useState('已保存');
// 使用 ref 保存定时器句柄,避免触发无关渲染
const timerIdRef = useRef(null);
function triggerChange() {
setSaveStatus('正在编辑...');
// 清除上一次积压的未执行定时器
if (timerIdRef.current) {
clearTimeout(timerIdRef.current);
}
// 设定新定时器并记录其 ID
timerIdRef.current = setTimeout(() => {
setSaveStatus('已自动保存');
}, 2000);
}
// 组件卸载时安全清理
useEffect(() => {
return () => {
if (timerIdRef.current) {
clearTimeout(timerIdRef.current);
}
};
}, []);
return <div><button onClick={triggerChange}>模拟输入</button> <span>{saveStatus}</span></div>;
}
场景 3:解决闭包陷阱与追踪历史值(Previous Value)
因为函数组件每次渲染都有独立的作用域,异步回调(如 setTimeout 或 Promise 回调)很容易捕获旧渲染周期中的 state,这就是常见的闭包陈旧值陷阱。而 ref.current 是引用类型的内存直连,它在任何异步回调执行的时刻,都能读取到全生命周期中最新写入的值。
同理,通过在 useEffect 中将当前 state 写入 ref,便能在下一次渲染时从 ref 中读出“上一次渲染的旧状态”,从而实现诸如数值增减动效、对比 props 变动等高级逻辑。
三、什么时候绝对不要用 ref?常见误区与避坑
useRef 虽然灵活,但滥用会严重破坏 React 的响应式模型与可预测性。以下是初学者最常犯的三个反模式:
错误写法示例:<h1>当前计数:{countRef.current}</h1>
危害原因:修改 countRef.current 时界面不会产生任何反应;当未来其他无关 state 触发重新渲染时,这个数字又会突兀地发生跳变。这会导致 UI 呈现与底层数据严重脱节,且极难排查。
错误写法示例:直接在组件函数主体(非事件处理、非 useEffect)中写 ref.current = count + 1 或 if (ref.current) ...
危害原因:React 的并发模式(Concurrent Features)可能会多次尝试执行、中断或丢弃某些渲染调用。如果在组件主体中直接产生副作用或依赖可变的 ref,组件将失去“纯函数”特性,造成不同渲染分支间的数据污染。
除非是与重量级第三方富文本、Canvas 库集成,或者仅读取 <input type="file"> 的文件列表,否则普通表单输入项仍应坚持使用 useState(受控组件),以便享受即时校验、联动计算和全局表单状态管控。
四、四步决策法:我该选用 state 还是 ref?
在实际开发中面对一个需要持久化保存的变量时,可以按照以下决策流程逐步梳理:
- 问:这个数据改变时,屏幕上的界面需要随之刷新吗?
如果需要,毫不犹豫使用useState。如果该值可以由已有 state 简单计算得出,则连 state 都不需要,直接计算为衍生变量即可。 - 问:这个数据需要在组件发生下一次重渲染时被记住吗?
如果每次重新渲染重新执行计算即可,直接声明普通局部变量const x = ...即可,无需分配 Hook。 - 问:这个数据是否直接参与了 JSX 的结构输出?
如果参与了,禁止使用 ref,必须回归useState。 - 问:数据是否属于 DOM 节点引用、第三方原生实例或事件回调中的静默标记?
符合该条件,说明变量完全属于“逃生舱”范畴,果断使用useRef。
五、实战对比示例:制作一个毫秒秒表
为了直观展现两者在同一个功能中的精密分工,我们编写一个毫秒级秒表组件。这个案例清晰地展现了:哪些部分必须驱动 UI,而哪些部分必须静默运行。
import React, { useState, useRef } from 'react';
export default function Stopwatch() {
// 1. 负责驱动视图的数据 -> 使用 useState
const [now, setNow] = useState(null);
const [startTime, setStartTime] = useState(null);
// 2. 定时器 ID:只在启动和停止逻辑中使用,绝不直接展现在界面中 -> 使用 useRef
const intervalIdRef = useRef(null);
function handleStart() {
const start = Date.now();
setStartTime(start);
setNow(start);
// 清除已有定时器
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
}
// 存入 ref,每 10 毫秒刷新一次 UI state
intervalIdRef.current = setInterval(() => {
setNow(Date.now());
}, 10);
}
function handleStop() {
// 直接读取 ref 中存储的定时器句柄,无需让组件重渲
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
}
// 计算已过秒数(衍生数据,无需额外建立 state)
let secondsPassed = 0;
if (startTime !== null && now !== null) {
secondsPassed = ((now - startTime) / 1000).toFixed(2);
}
return (
<div style={ border: '1px solid #E2E7EC', padding: '20px', borderRadius: '8px' }>
<h3>时间流逝:{secondsPassed} 秒</h3>
<button onClick={handleStart} style={ marginRight: '8px' }>开始计时</button>
<button onClick={handleStop}>停止计时</button>
</div>
);
}
在这个实例中:
secondsPassed需要每一帧直观呈现在<h3>标签里,因此驱动它的时间戳必须依赖useState。intervalIdRef.current只在启动与停止两个按钮的回调函数内部流转。把它放在useRef中,既保证了在每次计时器触发setNow导致组件重渲时句柄不被弄丢,又避免了因为存储句柄本身带来额外的无意义重渲。
六、全维度对比总结
为了便于在日常编码中快速查阅,以下将两者在各项技术维度上的表现整理为看板与对照表:
| 特性维度 | useState | useRef |
|---|---|---|
| 触发重新渲染 | 是 调用 setter 会安排更新,驱动组件重绘 | 否 原地修改 .current,React 保持静默 |
| 数据可变性 | 不可变(Immutable),需通过传递新对象来替换 | 可变对象(Mutable),始终持有相同的 { current } 引用 |
| 生命周期作用域 | 跨渲染周期保留,每次渲染获得当前周期的快照 | 跨渲染周期保留,始终指向全局唯一的最新容器 |
| 访问安全性 | 在渲染阶段任何位置均可安全读取 | 只应在事件回调或 useEffect 内部读写 |
| 核心应用场景 | 用户输入、API 数据列表、UI 交互显隐、加载状态 | DOM 节点引用、定时器 ID、外部实例、历史状态追踪 |
简而言之,把 useState 视作组件投影在屏幕上的画面胶片,画面有任何变动都必须更换一张胶片;而把 useRef 视作挂在放映机后方的实体保密柜,无论放映机如何倒带重放,保密柜里的东西始终稳定存在、随存随取。理清这两者的边界,你的 React 组件代码就会变得既高效又清晰。
props为什么不能直接修改?单向数据流怎么理解?
2026-11-13 17:28:06
ESLint的react-hooks规则怎么配?能拦住哪些错误?
2026-11-13 14:10:26
JSX标签为什么必须闭合?写为什么直接报错?
2026-11-13 09:29:18
服务端组件为什么能写async?和普通组件语法差在哪?
2026-11-12 16:34:41
React状态提升是什么?父子组件怎么互传数据?
2026-11-11 15:40:10








