深入 React 表单世界:useRef、受控组件与非受控组件的完整解析

43 阅读9分钟

引言

你是否曾面对一个简单的表单,却在 useStateuseRef 之间犹豫不决?是否听说过“受控组件”和“非受控组件”,却始终搞不清它们的本质区别?别担心!本文将带你从最基础的 useRef 开始,一步步深入 React 表单的核心机制。我们将逐行分析真实代码,结合官方文档精神与工程实践,为你构建一套完整的 React 表单开发认知体系。


第一部分:useRef —— React 中的“持久化引用容器”

1.1 useRef 是什么?官方定义 vs 实际用途

useRef 的三大核心特性:

  1. 非响应式:修改 .current 不会触发重渲染
  2. 持久化:跨多次渲染保持同一个引用
  3. 通用容器:不仅能存 DOM,还能存任意值

但光看定义不够直观。我们来看两个经典案例。


1.2 案例一:App2.jsx —— 自动聚焦的输入框

import { useRef, useEffect, useState} from 'react'
export default function App(){
 const [count, setCount] = useState(0) // 响应式状态
 const inputRef = useRef(null) // 初始值为空
 console.log(inputRef.current)
 console.log(count,"变了")
 useEffect(() => {
   // current 指向 input 元素,即 input 元素的引用
   console.log(inputRef.current)
   inputRef.current.focus()
 }, []) // 自动聚焦到 input 元素
 return (
   <>
     <input ref={inputRef} />
     <button type="button" onClick={() => setCount(count + 1)}>count++</button>
     <p>count: {count}</p>
   </>
 )
}

逐行解析:

  • const inputRef = useRef(null)
    创建一个引用对象,初始值为 { current: null }。注意:这个对象在组件整个生命周期内不会变(引用地址不变)。

  • <input ref={inputRef} />
    React 会在 DOM 渲染完成后,自动将真实的 <input> 元素赋值给 inputRef.current。这是 React 的“ref 绑定”机制。

  • useEffect(() => { inputRef.current.focus() }, [])
    在组件首次挂载后执行。此时 inputRef.current 已经指向真实的 DOM 节点,调用 .focus() 实现自动聚焦。

  • 为什么不用 useState
    假设我们用 const [inputEl, setInputEl] = useState(null),然后通过 useEffect 设置它。虽然也能拿到 DOM,但:

    • 每次设置都会触发重渲染(浪费性能)
    • 我们并不需要在 UI 上显示这个 DOM 元素
    • useRef 更轻量、更语义化

结论:当你需要访问 DOM 节点但不需要触发重渲染时,useRef 是最佳选择。


1.3 案例二:App.jsx —— 定时器的优雅管理

// ... 导入省略 ...
export default function App() {
 let intervalId = useRef(null);
 const [count, setCount] = useState(0);

 function start() {
   intervalId.current = setInterval(() => {
     console.log('tick~~~~');
   }, 1000)
   console.log(intervalId);
 }

 function stop() {
   clearInterval(intervalId.current);
 }

 useEffect(() => {
   console.log(intervalId.current);
 }, [count])

 return (
   <>
     <button onClick={start}>开始</button>
     <button onClick={stop}>停止</button>
     {count}
     <button type="button" onClick={() => setCount(count+1)}>count ++ </button>
   </>
 )}

关键点分析:

  • let intervalId = useRef(null);
    注意:这里用了 let,但其实应该用 const(因为 intervalId 这个引用本身不会变)。不过不影响功能。
  • intervalId.current = setInterval(...)
    将定时器 ID 存入 .current。这样即使组件重渲染,ID 也不会丢失。
  • clearInterval(intervalId.current);
    通过 .current 获取 ID 并清除定时器。
  • 为什么必须用 useRef?
    如果用普通变量(如 let id;),在函数闭包中会捕获旧值,导致无法正确清除定时器(经典的“闭包陷阱”)。
    如果用 useState,每次设置都会重渲染,且可能因异步更新导致 ID 不一致。

结论useRef 是存储跨渲染周期的可变值(如定时器 ID、WebSocket 实例、计数器等)的理想容器。


1.4 useRef vs useState:终极对比

特性useStateuseRef
是否响应式✅ 是❌ 否
修改后是否重渲染✅ 是❌ 否
适用场景需要驱动 UI 更新的状态不需要驱动 UI 的数据/引用
初始值任意值任意值
返回值[state, setState]{ current: value }
跨渲染持久性状态值会更新,但 setter 不变整个对象引用不变,.current 可变

📌 黄金法则
如果这个值的变化需要反映在界面上 → 用 useState
如果只是后台存储或访问 DOM → 用 useRef

示例源码链接:lesson_zp/react/ref/ref-demo: AI + 全栈学习仓库 - Gitee.com

示例源码项目结构:


第二部分:受控组件 vs 非受控组件 —— React 表单的两种哲学

2.1 核心概念

受控组件和非受控组件怎么拿到表单的值?

  • 受控组件:收集用户的输入,onChange 改变 value 状态
  • 非受控组件useRef + ref.current.value 可以拿到表单元素的值

区别

  • 非受控组件不使用状态管理表单值,而是通过 ref 直接访问 DOM 元素来获取值。适用于简单的表单场景,不需要频繁更新状态。
  • 受控组件使用状态管理表单值,通过 onChange 事件更新状态。适用于需要实时响应表单输入的场景,如验证、动态更新其他元素等。

适用场景

  • 非受控:一次性读取,性能敏感,文件上传
  • 受控:表单校验,联动、实时展示

示例源码链接:lesson_zp/react/controll/controlled-demo: AI + 全栈学习仓库 - Gitee.com

示例源码项目结构:


2.2 非受控组件实战:CommentBox.jsx

import { useRef } from 'react'
export default function CommentBox() {
 // UnControlled Component 非受控组件
 const textareaRef = useRef(null);
 // 处理提交事件
 const handleSubmit = () => {
   const comment = textareaRef.current.value;
   if (!comment) return alert('请输入评论内容');
   console.log(comment); // 提交评论
   // textareaRef.current.value = ''; // 清空文本框
 }
 return (
   <div>
     <h2>评论框</h2>
     <textarea ref={textareaRef} placeholder="请输入评论内容" >
     </textarea>
     <button onClick={handleSubmit}>提交</button>
   </div>
 )
}

深度剖析:

  • const textareaRef = useRef(null);
    创建一个引用,用于后续访问 <textarea>

  • <textarea ref={textareaRef} ...>
    注意:这里没有 value 属性,也没有 onChange。这意味着:

    • 用户输入的内容直接存储在 DOM 的 value 属性中
    • React 不管理这个值,完全由浏览器控制
  • const comment = textareaRef.current.value;
    提交时,直接从 DOM 读取当前值。这是一次性操作。

  • 为什么叫“非受控”?
    因为表单元素的值不受 React 状态控制,React 对其“失控”。

  • 优点

    • 代码简单
    • 性能好(无状态更新开销)
    • 适合大文本输入(如评论、文章)
  • 缺点

    • 无法实时响应输入(如字数统计、输入验证)
    • 无法程序化控制输入内容(除非手动操作 DOM)

典型场景:用户评论、反馈留言、富文本编辑器(配合第三方库)


2.3 受控组件实战:LoginForm.jsx

import { useState } from 'react'
export default 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}>
     <h2>登录表单</h2>
     <input type="text" placeholder="请输入用户名" name="username" onChange={handleChange} value={form.username} />
     <input type="password" placeholder="请输入密码" name="password" onChange={handleChange} value={form.password} />
     <button type="submit">注册</button>
   </form>
 )
}

深度剖析:

  • const [form, setForm] = useState(...)
    用状态对象管理所有表单字段。

  • value={form.username}
    将 input 的值绑定到状态。这是“受控”的关键!

  • onChange={handleChange}
    每次用户输入,都会触发状态更新,进而重新渲染组件。

  • [e.target.name]: e.target.value
    利用 name 属性动态更新对应字段,实现通用处理函数。

  • 为什么叫“受控”?
    因为表单元素的值完全由 React 状态控制,React “掌控一切”。

  • 优点

    • 可实时验证(如密码强度)
    • 可联动更新(如选择省份后更新城市)
    • 可程序化控制(如重置表单、填充默认值)
    • 符合 React 单向数据流理念
  • 缺点

    • 代码稍复杂
    • 频繁输入可能带来轻微性能开销(但现代 React 优化得很好)

典型场景:登录/注册、搜索框、配置表单、需要验证的任何表单


2.4 混合模式:App3.jsx 的“双模态”设计

import{ useState, useRef} from 'react'
export default function App() {
 // 受控组件: 组件的状态被外部控制, 组件的状态只能通过 props 传递, 不能通过组件内部的状态改变
 // 组件里有被状态控制的元素, 这个元素的值被状态控制
 // 单向数据流(单向绑定): 数据只能从父组件流向子组件,不能从子组件流向父组件
 // 状态绑定输入框 输入框被状态控制
 // 状态控制 输入框的值
 const [value, setValue] = useState('')
 const inputRef = useRef(null)
 const doLogin = (e) => {
   e.preventDefault()
   console.log(inputRef.current.value)
 }
 return (
   <form onSubmit={doLogin}>
     <p>输入框的值: {value}</p>
     <input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
     <input type="text" ref={inputRef} />
     <button type="submit">登录</button>
   </form>
 )
}

精彩之处:

  • 第一个 inputvalue={value} + onChange受控

    • 页面实时显示 {value}
    • 适合需要即时反馈的场景
  • 第二个 inputref={inputRef}非受控

    • 提交时通过 inputRef.current.value 读取
    • 适合辅助输入或不需要实时响应的字段
  • 这种混合模式在实际项目中很常见

    • 主要字段(如用户名、密码)用受控
    • 次要字段(如备注、附件描述)用非受控
    • 或在迁移旧代码时逐步替换

2.5 App.jsx:组合的力量

import CommentBox from './components/CommentBox'
import LoginForm from './components/LoginForm'
export default function App() {
 return (
   <>
     <CommentBox />
     <LoginForm />
   </>
 )
}

这个文件展示了 React 的组合思想

  • CommentBox(非受控)和 LoginForm(受控)可以共存于同一页面
  • 每个组件根据自身需求选择合适模式
  • 父组件无需关心子组件内部实现细节

💡 设计哲学
React 不强制你用某一种模式,而是提供工具,让你根据场景自由选择


第三部分:如何选择?决策树与最佳实践

3.1 决策流程图

需要实时响应用户输入吗?
│
├─ 是 → 使用 受控组件 (useState + onChange)
│    ├─ 需要表单验证? → 受控
│    ├─ 需要联动更新? → 受控
│    └─ 需要程序化控制? → 受控
│
└─ 否 → 使用 非受控组件 (useRef)
     ├─ 只在提交时读取? → 非受控
     ├─ 性能敏感(如大文本)? → 非受控
     └─ 文件上传? → 非受控(必须)

3.2 特殊场景处理

场景1:文件上传

const fileInputRef = useRef(null);
const handleUpload = () => {
  const file = fileInputRef.current.files[0];
  // 处理文件
};
// <input type="file" ref={fileInputRef} />

✅ 必须用非受控,因为 value 是只读的。

场景2:表单重置

  • 受控组件setForm(initialState)
  • 非受控组件inputRef.current.value = ''

场景3:默认值

  • 受控组件useState(defaultValue)
  • 非受控组件<input defaultValue="..." />

第四部分:常见误区与避坑指南

误区1:“非受控组件性能更好,所以优先用它”

纠正:现代 React 的状态更新非常高效。除非是超大表单或高频输入(如游戏),否则性能差异可忽略。功能需求 > 微优化

误区2:“受控组件代码太啰嗦”

纠正:可以用自定义 Hook 封装,例如:

function useForm(initial) {
  const [values, setValues] = useState(initial);
  const handleChange = e => setValues(v => ({...v, [e.target.name]: e.target.value}));
  return [values, handleChange];
}

误区3:“useRef 可以替代 useState”

纠正:只有当你不需要重渲染时才用 useRef。否则 UI 不会更新!


结语:掌握工具,而非被工具束缚

React 的伟大之处,在于它提供了清晰的抽象(如受控/非受控),同时保留了底层能力(如 useRef 访问 DOM)。作为开发者,我们的任务不是死记硬背规则,而是理解每种工具的设计意图,并在合适的场景使用它

  • 当你需要精确控制表单状态 → 拥抱受控组件
  • 当你追求简单高效的一次性读取 → 选择非受控组件
  • 当你需要连接 React 与 DOM 世界 → useRef 是你的桥梁

“useRef 默默奉献存储能力”

它不喧哗,不张扬,却在无数场景中默默支撑着应用的稳定运行。

希望本文能帮你彻底理清这些概念。下次写表单时,你会更有底气地说:
“我知道该用哪种方式了!”


附录:关键代码速查表

组件类型核心 Hook关键属性获取值方式
受控组件useStatevalue + onChange从状态读取
非受控组件useRefrefref.current.value

Happy Coding! 🎉