useRef和useState有什么区别?什么时候该用ref?

作者:小积木

时间:2026-11-13

浏览:188

深入解析React中useRef与useState的本质区别与渲染驱动机制。详细解答两者的底层原理、生命周期差异、何时该用ref操作DOM或保存持久变量,以及避免常见反模式的实用指南。

文章目录
在 React 开发中,useState 与 useRef 都可以跨越组件的多次渲染来持久保留数据,但两者的本质区别在于是否触发组件重新渲染。调用 useState 的更新函数会通知 React 调度重新渲染,从而将最新的数据映射到屏幕视图上;而 useRef 只是返回一个在整个生命周期内固定不变的普通 JavaScript 对象 { current: initialValue },修改其 .current 属性完全是静默的内存操作,不会触发任何视图重绘。本文将剖析两者的底层运行机制、使用边界,并通过具体场景与决策流程,说明什么时候应当果断使用 ref。
useRef和useState有什么区别?什么时候该用ref?
图 0:useState(视图响应式状态)与 useRef(生命周期静默容器)核心心智模型对比

一、核心机制差异:快照渲染与内存直连

要理解两者的不同,必须从 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 内部完全不知道你修改了数据,也不会安排任何渲染计划。

useRef和useState有什么区别?什么时候该用ref?
图 1:React 协调流水线与两种 Hook 的底层执行通路对比
💡 关键结论
如果你希望修改数据后屏幕界面立即更新,必须使用 useState;如果你仅仅需要在组件背后记住一个数据,且它的变化不应该打扰当前的 UI 呈现,那么 useRef 便是首选。

二、什么时候该用 ref?三大典型应用场景

React 官方将 useRef 称作一种“逃生舱(Escape Hatch)”。在严格的声明式 UI 编程中,组件应该根据当前的 props 和 state 纯粹地推导出界面结构。但在实际浏览器运行环境中,我们不可避免地需要处理命令式逻辑或底层能力,这时就需要用到 ref。

useRef和useState有什么区别?什么时候该用ref?
图 2:useRef 在 DOM 操作与非视图持久缓存中的两种典型工作步骤

场景 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 的响应式模型与可预测性。以下是初学者最常犯的三个反模式:

⚠️ 误区一:试图在 JSX 中直接渲染 ref.current

错误写法示例:<h1>当前计数:{countRef.current}</h1>

危害原因:修改 countRef.current 时界面不会产生任何反应;当未来其他无关 state 触发重新渲染时,这个数字又会突兀地发生跳变。这会导致 UI 呈现与底层数据严重脱节,且极难排查。

⚠️ 误区二:在渲染阶段(Render Phase)直接读写 ref.current

错误写法示例:直接在组件函数主体(非事件处理、非 useEffect)中写 ref.current = count + 1 或 if (ref.current) ...

危害原因:React 的并发模式(Concurrent Features)可能会多次尝试执行、中断或丢弃某些渲染调用。如果在组件主体中直接产生副作用或依赖可变的 ref,组件将失去“纯函数”特性,造成不同渲染分支间的数据污染。

⚠️ 误区三:用 ref 完全取代受控表单导致状态失控

除非是与重量级第三方富文本、Canvas 库集成,或者仅读取 <input type="file"> 的文件列表,否则普通表单输入项仍应坚持使用 useState(受控组件),以便享受即时校验、联动计算和全局表单状态管控。

四、四步决策法:我该选用 state 还是 ref?

在实际开发中面对一个需要持久化保存的变量时,可以按照以下决策流程逐步梳理:

useRef和useState有什么区别?什么时候该用ref?
图 3:组件内部状态选型决策推导路径
  1. 问:这个数据改变时,屏幕上的界面需要随之刷新吗?
    如果需要,毫不犹豫使用 useState。如果该值可以由已有 state 简单计算得出,则连 state 都不需要,直接计算为衍生变量即可。
  2. 问:这个数据需要在组件发生下一次重渲染时被记住吗?
    如果每次重新渲染重新执行计算即可,直接声明普通局部变量 const x = ... 即可,无需分配 Hook。
  3. 问:这个数据是否直接参与了 JSX 的结构输出?
    如果参与了,禁止使用 ref,必须回归 useState。
  4. 问:数据是否属于 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 导致组件重渲时句柄不被弄丢,又避免了因为存储句柄本身带来额外的无意义重渲。

六、全维度对比总结

为了便于在日常编码中快速查阅,以下将两者在各项技术维度上的表现整理为看板与对照表:

useRef和useState有什么区别?什么时候该用ref?
图 4:useRef 与 useState 全技术特性对照看板
特性维度 useState useRef
触发重新渲染 是 调用 setter 会安排更新,驱动组件重绘 否 原地修改 .current,React 保持静默
数据可变性 不可变(Immutable),需通过传递新对象来替换 可变对象(Mutable),始终持有相同的 { current } 引用
生命周期作用域 跨渲染周期保留,每次渲染获得当前周期的快照 跨渲染周期保留,始终指向全局唯一的最新容器
访问安全性 在渲染阶段任何位置均可安全读取 只应在事件回调或 useEffect 内部读写
核心应用场景 用户输入、API 数据列表、UI 交互显隐、加载状态 DOM 节点引用、定时器 ID、外部实例、历史状态追踪

简而言之,把 useState 视作组件投影在屏幕上的画面胶片,画面有任何变动都必须更换一张胶片;而把 useRef 视作挂在放映机后方的实体保密柜,无论放映机如何倒带重放,保密柜里的东西始终稳定存在、随存随取。理清这两者的边界,你的 React 组件代码就会变得既高效又清晰。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章