React Hooks 深入:useRef 的强大与表单的受控 vs 非受控

40 阅读9分钟

React Hooks 深入:useRef 的强大与表单的受控 vs 非受控

在 React 函数组件时代,Hooks 彻底改变了我们管理状态和副作用的方式。其中,useRef 常常被视为一个“默默奉献”的英雄——它不像 useState 那样张扬,也不像 useEffect 那样频繁登场,但它在处理 DOM 操作、持久化可变值等方面,发挥着不可替代的作用。同时,在表单处理中,React 提供了两种范式:受控组件非受控组件,它们的核心差异在于数据流的控制权归属,而 useRef 正是非受控组件的关键工具。

5609728adb9d921c5649719c8cbf0517.jpg

一、useRef:React 中的“可变容器”

1. useRef 的本质:一个持久化的引用对象

useRef 返回一个可变的 ref 对象,这个对象的 .current 属性可以存储任何值,且在组件的整个生命周期内保持不变。更重要的是,修改 .current 不会触发组件重新渲染

想象一下,React 的渲染就像一场电影,每一次状态变化都会重拍一帧。但 useRef 就像藏在幕后的道具箱——你随时可以往里面放东西或取东西,却不会因为道具变动而重拍整场戏。

import { useRef } from "react";

function Demo() {
  const myRef = useRef(0); // 初始值 0

  const increment = () => {
    myRef.current += 1;
    console.log(myRef.current); // 值在变化,但组件不渲染
  };

  return <button onClick={increment}>点击我(不渲染)</button>;
}

底层逻辑:React 在函数组件内部维护一个 hooks 队列,每次调用 useRef 时,它从队列中取出或创建一个 ref 对象。由于 ref 对象本身是同一个引用,修改 .current 只是普通的对象属性赋值,不会进入 React 的状态调度队列,因此不触发 render。

2. useRef vs useState:相同与不同
  • 相同点:两者都能“存储”可变值,都是在组件多次渲染间持久存在的容器。
  • 不同点
    特性useStateuseRef
    修改是否触发渲染是(通过 setState)否(直接修改 .current)
    响应式是(UI 会同步更新)否(需手动读取 .current 来使用)
    适用场景需要驱动 UI 变化的值不影响 UI 的持久值、DOM 操作

易错提醒:很多人误以为 useRef 可以完全替代 useState 来避免渲染——这是大忌!如果值需要反映到 UI 上,必须用 useState。用 useRef 存 UI 相关值会导致视图与数据脱钩,调试噩梦。

3. useRef 的经典应用场景
(1) 访问和操作 DOM 元素

这是 useRef 最常见的用法。通过 ref 属性绑定到 JSX 元素,挂载后 .current 就是原生 DOM。

import { useRef, useEffect } from "react";

function InputFocus() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus(); // 组件挂载后自动聚焦
  }, []);

  return <input ref={inputRef} placeholder="我会被自动聚焦" />;
}

底层:React 在 commit 阶段会将 ref 回调或对象绑定到 DOM,.current 指向真实节点。

易错提醒:初次渲染时 .current 是 null!一定要在 useEffect 或事件回调中访问,避免空指针。

(2) 存储不触发渲染的可变值(如定时器 ID)

经典案例:setInterval 的启动/停止。

import { useRef, useState } from "react";

function Timer() {
  const intervalRef = useRef(null);
  const [count, setCount] = useState(0);

  const start = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(intervalRef.current);
  };

  return (
    <>
      <p>计数:{count}</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
    </>
  );
}

为什么不用 let id?因为函数组件每次渲染都会重新执行,普通变量会重置。useRef 确保了跨渲染的持久性。

易错提醒:忘记清理定时器会导致内存泄漏!最好在 useEffect 的 cleanup 中处理。

(3) 保存上一次的状态值(常见面试题)
import { useState, useEffect, useRef } from "react";

function PrevValue() {
  const [count, setCount] = useState(0);
  const prevRef = useRef();

  useEffect(() => {
    prevRef.current = count; // 保存当前值作为下一次的“上一次”
  }, [count]);

  return (
    <div>
      <p>当前:{count}</p>
      <p>上一次:{prevRef.current ?? "无"}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

这比自定义 Hook 更轻量。

二、表单处理:受控组件 vs 非受控组件

React 表单的核心哲学是“数据驱动视图”。表单元素天生有内部状态(value),这与 React 的单向数据流冲突,于是诞生了两种解决方案。

1. 受控组件:React 完全掌控数据

受控组件的核心:表单元素的 value 由 React state 驱动,用户输入通过 onChange 更新 state,从而闭环。

import { useState } from "react";

function ControlledInput() {
  const [value, setValue] = useState("");

  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}

优势

  • 实时校验、联动、格式化(如大写转换、长度限制实时提示)。
  • 数据统一在 state 中,便于提交、复用。

底层逻辑:每一次 keystroke 都触发 onChange → setState → 重新渲染 → value 更新。React 成为“单一数据源”。

易错提醒:忘记写 onChange 会导致输入框只读!React 会抛警告:“You provided a value prop without an onChange handler”。

多字段表单示例(真实项目常见):

import { useState } from "react";

function LoginForm() {
  const [form, setForm] = useState({ username: "", password: "" });

  const handleChange = e => {
    setForm({
      ...form,
      [e.target.name]: e.target.value
    });
  };

  const handleSubmit = e => {
    e.preventDefault();
    console.log("提交表单:", form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        placeholder="用户名"
      />
      <input
        type="password"
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="密码"
      />
      <button type="submit">登录</button>
    </form>
  );
}
2. 非受控组件:交给 DOM,自己只在需要时取值

非受控组件让 DOM 自己管理 value,只在提交时通过 ref 读取。

import { useRef } from "react";

function UncontrolledTextarea() {
  const textareaRef = useRef(null);

  const handleSubmit = () => {
    const value = textareaRef.current.value;
    if (!value.trim()) return alert("请输入内容");
    console.log("评论:", value);
  };

  return (
    <>
      <textarea ref={textareaRef} placeholder="写点评论..." />
      <button onClick={handleSubmit}>提交</button>
    </>
  );
}

优势

  • 代码简洁,无需为每个字段维护 state。
  • 性能更好(无频繁渲染)。
  • 适合一次性采集(如评论框、文件上传)。

底层逻辑:DOM 自己维护 value,React 只在需要时“偷看”。

易错提醒:无法实时校验!如果需要即时反馈,必须转受控。<input type="file"> 天生非受控,因为文件值无法通过代码设置。

混合使用示例(常见优化):

function MixedForm() {
  const [username, setUsername] = useState("");
  const passwordRef = useRef(null);

  const handleSubmit = e => {
    e.preventDefault();
    console.log("用户名(受控):", username);
    console.log("密码(非受控):", passwordRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={username} onChange={e => setUsername(e.target.value)} />
      <input type="password" ref={passwordRef} />
      <button type="submit">提交</button>
    </form>
  );
}
3. 受控 vs 非受控:如何选择?
场景推荐理由
实时校验、联动、条件渲染受控数据统一,便于控制
大表单、复杂交互受控(推荐)避免数据散落,易维护
简单一次性提交非受控代码少,性能好
文件上传、第三方库集成非受控DOM 原生行为,无法受控
性能敏感(如长文本)非受控或优化受控减少渲染次数

最佳实践:官方推荐优先受控,因为它符合 React “数据驱动”哲学。但实际项目中混合使用最常见——关键字段受控,非关键用 ref。

三、几个细节知识点

fc962ce0cd306c49bc54248e80437e81.jpg

1.访问 DOM 的 ref 必须放在 useEffect 里

在函数组件的执行阶段(render phase) ,React 只是调用你的函数,生成 JSX(虚拟 DOM),此时真实的 DOM 还没创建,自然不可能把 DOM 节点挂到 ref 上。

ref 的赋值发生在 React 的提交阶段(commit phase)

  1. 浏览器真正把 DOM 渲染到页面上
  2. React 遍历所有带 ref 的元素,把真实的 DOM 节点赋值给 ref.current

所以:

  • 函数组件第一次执行(初次渲染)时:.current === null
  • commit 阶段完成后:.current 才变成真实的 DOM 节点

因此,如果你直接在组件函数体里写:

jsx

const inputRef = useRef(null);
console.log(inputRef.current); // null!组件函数执行时 DOM 还没准备好
inputRef.current.focus(); // 报错!Cannot read property 'focus' of null

这是最常见的空指针错误。

为什么一定要放在 useEffect 里?

useEffect 的回调函数是在 commit 阶段完成后、浏览器已经绘制完 DOM 才执行的(具体是 layout 之后,paint 之前)。此时 .current 已经安全地指向了真实 DOM。

jsx

useEffect(() => {
  inputRef.current.focus(); // 安全!此时 DOM 已存在
}, []); // 只在挂载后执行一次
2.保存“上一个值”的 useEffect 怎么实现的

代码回顾:

jsx

function PrevValue() {
  const [count, setCount] = useState(0);
  const prevRef = useRef();

  useEffect(() => {
    prevRef.current = count; // 把当前 count 保存到 ref 中
  }, [count]);

  return (
    <div>
      <p>当前值:{count}</p>
      <p>上一个值:{prevRef.current ?? "无"}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}
运行时序详细拆解

假设初始 count = 0:

  1. 第一次渲染(count = 0)

    • prevRef.current 初始为 undefined
    • 渲染 JSX:显示 “当前值:0”, “上一个值:无”
    • commit 完成
    • useEffect 回调执行:prevRef.current = 0(把当前值 0 保存起来)
  2. 点击按钮,count 变成 1 → 触发第二次渲染

    • 组件函数重新执行,count = 1
    • 渲染 JSX 时读取 prevRef.current → 还是 0(因为 effect 还没执行!)
    • 显示 “当前值:1”, “上一个值:0” ← 正确!
    • commit 完成
    • useEffect 再次执行:prevRef.current = 1(把新的当前值 1 保存起来,准备下次用)
  3. 第三次点击,count 变成 2

    • 渲染时 prevRef.current 还是 1(effect 还没跑)
    • 显示 “当前值:2”, “上一个值:1”
    • effect 执行后 prevRef.current 变成 2

核心的两个支柱

  1. useEffect 的延迟执行(滞后性)

    • useEffect 的回调函数是在 当前渲染 commit 完成之后 才执行的。
    • 这意味着:当 state 更新导致重新渲染时,在新的一轮 JSX 计算过程中,effect 里面的代码 还没来得及跑,所以 ref 里保存的还是“上一次 effect 执行后留下的值”——这正好是我们想要的“上一个值”。
  2. useRef 的修改不触发渲染(非响应式)

    • 如果我们不用 ref,而是用一个普通的 let 变量来存“上一个值”,每次渲染函数重新执行,let 变量都会被重新初始化,永远拿不到旧值。

    • 如果我们用 useState 来存“上一个值”,在 effect 里 setPrev(count),就会再次触发渲染,导致无限循环或逻辑混乱。

    • 只有 useRef 完美满足:

      • 值在多次渲染间持久存在(不像 let)
      • 修改它不会触发重新渲染(不像 useState)
为什么不直接用 useState?

如果你尝试用 state 来存上一个值,会陷入无限循环:

jsx

const [prev, setPrev] = useState();
useEffect(() => {
  setPrev(count); // 每次 count 变都更新 prev → 又触发渲染 → 死循环!
}, [count]);

而 ref 完美避开了这个问题,因为修改 .current 不触发渲染

结语

useRef 虽小,却是大杀器:它桥接了 React 的声明式世界与命令式 DOM 操作,同时是非受控组件的灵魂。理解它与 useState 的边界,能让你避免许多渲染性能问题。

表单的受控/非受控之争,本质上是“控制权”的权衡:受控更强大、更可预测;非受控更轻量、更直接。优秀的前端工程师不是死记模式,而是根据场景灵活选择。