引言
你是否曾面对一个简单的表单,却在
useState和useRef之间犹豫不决?是否听说过“受控组件”和“非受控组件”,却始终搞不清它们的本质区别?别担心!本文将带你从最基础的useRef开始,一步步深入 React 表单的核心机制。我们将逐行分析真实代码,结合官方文档精神与工程实践,为你构建一套完整的 React 表单开发认知体系。
第一部分:useRef —— React 中的“持久化引用容器”
1.1 useRef 是什么?官方定义 vs 实际用途
useRef 的三大核心特性:
- 非响应式:修改
.current不会触发重渲染 - 持久化:跨多次渲染保持同一个引用
- 通用容器:不仅能存 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:终极对比
| 特性 | useState | useRef |
|---|---|---|
| 是否响应式 | ✅ 是 | ❌ 否 |
| 修改后是否重渲染 | ✅ 是 | ❌ 否 |
| 适用场景 | 需要驱动 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 不管理这个值,完全由浏览器控制
- 用户输入的内容直接存储在 DOM 的
-
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>
)
}
精彩之处:
-
第一个 input:
value={value}+onChange→ 受控- 页面实时显示
{value} - 适合需要即时反馈的场景
- 页面实时显示
-
第二个 input:
ref={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 | 关键属性 | 获取值方式 |
|---|---|---|---|
| 受控组件 | useState | value + onChange | 从状态读取 |
| 非受控组件 | useRef | ref | ref.current.value |
Happy Coding! 🎉