前几天整理 React 基础的时候,我特意拉了个小项目,专门写表单相关的 demo。说出来有点丢人,受控组件和非受控组件这俩词我背了快半年,真到写代码的时候还是会混 ------ 反正最后都能拿到输入值,到底差在哪?
直到我手滑给一个输入框绑了value却忘了写onChange,键盘敲烂了输入框都纹丝不动,控制台还甩给我一大段红色警告。我盯着屏幕愣了半分钟,决定不背概念了,从零开始写一遍,把这事儿彻底搞懂。
这是我当时拆的文件结构,为了对比清楚,我把每种写法都单独拎成了一个组件,挨个跑着试。
plaintext
src
├── assets
├── components
│ ├── LoginForm
│ │ ├── index.css
│ │ └── index.jsx
│ ├── CommentBox.jsx
│ ├── ControlledInput.tsx
│ ├── index.js
│ ├── RegisterForm.jsx
│ └── UncontrolledInput.jsx
├── App.css
├── App.jsx
├── index.css
└── main.jsx
我先试了最简单的:非受控组件
最开始我写的是非受控组件,因为第一眼看上去最省事 ------ 不就是拿 ref 勾住 dom 节点,要用的时候去取 value 嘛,跟原生 JS 几乎没区别。
javascript
import { useRef } from 'react';
const UncontrolledInput = () => {
const inputRef = useRef(null);
const handleClick = () => {
// 点按钮的时候才去读dom里的值
console.log(inputRef.current.value);
}
return (
<>
<div>UncontrolledInput</div>
<input type='text' ref={inputRef} />
<button onClick={handleClick}>获取输入值</button>
</>
)
}
export default UncontrolledInput;
写完跑起来,点按钮确实能拿到值,我当时还心想,这多简单啊,为啥还要发明受控组件?直到我想加个功能:输入内容的时候,实时显示字符数。
这就麻烦了。非受控组件的值存在 dom 里,React 根本不知道里面写了啥。要想实时拿,我就得给 input 加个 onChange 事件,在事件里去读 ref 的值 ------ 那我为啥不直接把值存在 state 里?
非受控到底 "不受谁控制"
后来我才反应过来,所谓 "非受控",不受的是 React 状态的控制。
这个输入框的 value 由浏览器自己管,你敲什么它就显示什么,React 全程不插手。只有你主动通过 ref 去访问的时候,才能拿到当前的值。就像你办公室的饮水机,你接水的时候才过去按一下,平时它该加热加热,你根本不管它。
顺着这个思路我又写了评论框组件,textarea 也是一样的道理。
javascript
import { useRef } from 'react';
const CommentBox = () => {
const textareaRef = useRef(null);
const handleSubmit = () => {
const comment = textareaRef.current.value;
if(!comment) return;
console.log(comment);
}
return (
<div>
<textarea
ref={textareaRef}
placeholder='请输入评论内容'
></textarea>
<button onClick={handleSubmit}>提交评论</button>
</div>
)
}
export default CommentBox;
踩的第一个小坑:初始值别瞎写 value
这里还有个小坑,我亲手踩过一次。想给非受控输入框加个默认值,我顺手就写了value="默认文字",结果控制台直接警告:组件正在从非受控变成受控。
踩坑提醒非受控组件不能用
value绑初始值,要用defaultValue。只要你写了value属性,React 就会默认你要做受控组件,没有配套的onChange就会出问题。
正确写法是 <input defaultValue="默认文字" ref={inputRef} />,这样初始值有了,控制权还在 dom 手里。
后来我才 get 到受控组件的真正意思
带着刚才 "实时字符数" 的需求,我开始写受控组件的 demo。
最基础的单行输入
javascript
import { useState } from 'react';
const ControlledInput = () => {
const [value, setValue] = useState('');
return (
<>
<div>ControlledInput</div>
<input
type='text'
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{/* 想实时显示?直接用value就行,不用碰dom */}
<p>当前输入了 {value.length} 个字</p>
</>
)
}
export default ControlledInput;
写完我就懂了。受控组件的核心,是输入框显示的内容完全由 React 的 state 说了算。
你敲一个字符,触发 onChange 事件,更新 state;state 一变,React 再把新的值回写到输入框上。整个流程都在 React 的数据流里转,输入框本身只是个 "傀儡",它自己说了不算。
打个不太恰当的比方:非受控是你直接拧水龙头,出水多少全看你手劲;受控是你在面板上设好出水量,机器帮你拧阀门,你只管调数字,不用碰阀门本身。
多字段表单怎么写不啰嗦
单个输入框不过瘾,我又写了个注册表单,两个字段。一开始我笨笨地给每个输入框都写了单独的 onChange 函数,写第二个的时候就嫌烦了,这字段多了还了得?
后来想起可以用name属性做匹配,一个函数搞定所有输入框。
javascript
import { useState } from 'react';
const RegisterForm = () => {
const [form, setForm] = useState({
username: '',
password: '',
});
const handleChange = (e) => {
// 注意这行,用name属性动态更新对应的字段
// 别问我为什么知道要解构form,试过直接覆盖把另一个字段搞没了
setForm({
...form,
[e.target.name]: e.target.value,
})
}
return (
<div>
<input
type='text'
name='username'
value={form.username}
onChange={handleChange}
placeholder='请输入用户名'
/>
<input
type='password'
name='password'
value={form.password}
onChange={handleChange}
placeholder='请输入密码'
/>
<button type='submit'>提交</button>
</div>
)
}
export default RegisterForm;
这个写法就舒服多了,后面再加字段,只要给 state 加个属性,给 input 加个 name,不用再写新的处理函数。
带实时校验的登录表单
真正让我觉得受控组件香的,是写登录表单的时候。要做实时校验,输入不对就提示错误,没填完就禁用按钮。这要是用非受控写,得费老大劲;用受控,一切都是顺理成章的。
javascript
import { useState } from 'react';
import './index.css';
function LoginForm() {
const [form, setForm] = useState({
username: '',
password: '',
})
const [errors, setErrors] = useState({})
const validate = (name, value) => {
let msg = '';
if(name === 'username') {
if(!value) msg = '用户名不能为空';
else if (value.length < 3) msg = '用户名长度不能小于3';
}
if(name === 'password') {
if(!value) msg = '密码不能为空';
else if (value.length < 6) msg = '密码长度不能小于6';
}
setErrors(prev => ({ ...prev, [name]: msg }));
}
const handleChange = e => {
const { name, value } = e.target;
setForm({ ...form, [name]: value });
// 输入的同时立刻校验
validate(name, value);
}
// 直接通过状态判断表单是否有效
const isValid = form.username && form.password && !errors.username && !errors.password;
const handleSubmit = e => {
e.preventDefault();
if(!isValid) return;
console.log(form);
}
return (
<div className='login-wrapper'>
<form className='login-form' onSubmit={handleSubmit}>
<h2>登录</h2>
<div className='form-item'>
<label>用户名</label>
<input type='text' name='username' value={form.username} onChange={handleChange} />
{errors.username && (<span className='error'>{errors.username}</span>)}
</div>
<div className='form-item'>
<label>密码</label>
<input type='password' name='password' value={form.password} onChange={handleChange} />
{errors.password && (<span className='error'>{errors.password}</span>)}
</div>
<button type='submit' disabled={!isValid}>登录</button>
</form>
</div>
)
}
export default LoginForm;
你看,输入、校验、按钮状态,全靠 state 驱动。不用去查 dom,不用手动控制按钮禁用,state 一变,视图自动跟着变。这才是 React 数据驱动的味道。
我踩过的那几个经典坑
光写顺的没用,踩过的坑才记得牢。这几个都是我亲手踩过的,一个都没落下。
坑 1:只写 value 不写 onChange,输入框直接锁死
这是入门级的坑,但几乎每个人都踩过。我第一次写受控组件的时候,抄了个 value 就完事了,结果键盘怎么敲都没反应。
控制台会报一个警告:You provided a value prop to a form field without an onChange handler。
原因很简单:你把输入框的值绑定成了 state,又不在输入的时候更新 state,那值当然永远不会变。
记住受控组件里,
value和onChange是一对,必须同时出现,缺一个都不行。
坑 2:value 和 ref 混用,状态混乱
第二个坑,我一开始写非受控的时候,顺手加了个 value 属性想设默认值,结果控制台又警告了。
本质就是你既想让 React 管(写了 value),又想自己用 ref 管(非受控),两边抢控制权,不打架才怪。
记住一个原则:一个表单元素,要么受控,要么非受控,别搞半吊子。
坑 3:textarea 的 value 写法
还有个小细节,原生 HTML 里 textarea 的值是写在标签中间的,比如<textarea>默认值</textarea>。但在 React 里,不管是 input 还是 textarea,统一用 value 属性。
我一开始按原生写法写,结果不生效,查了文档才改过来。CommentBox 那个组件我最开始就写错了,后来改成了 ref 绑定,或者用受控的 value 写法都可以。
那到底该用哪个?
这肯定是大家最关心的问题。我之前也总纠结,是不是用受控就比非受控高级?
写了这几个 demo 之后我的感受是:没有高低级,看场景。
- 如果你需要实时控制输入内容:比如实时校验、输入格式化、联动禁用、字符数统计,直接用受控组件,逻辑写起来最顺。
- 如果就是个简单的表单,点提交的时候才拿值,不需要实时处理,用非受控更省事,少写好多状态和事件处理。
- 还有一种情况,接入第三方表单控件或者富文本编辑器,人家内部自己管状态,这时候用非受控的方式去接就好,硬改成受控反而容易出问题。
最后说个工程上的小习惯。组件写多了,一个个导入太麻烦,我就在 components 文件夹下建了个 index.js,把所有组件统一导出。
javascript
// components/index.js
import ControlledInput from './ControlledInput'
import UncontrolledInput from './UncontrolledInput'
import CommentBox from './CommentBox'
import RegisterForm from './RegisterForm'
import LoginForm from './LoginForm'
export {
ControlledInput,
UncontrolledInput,
CommentBox,
RegisterForm,
LoginForm,
}
这样在 App 里导入的时候,一行就能全导进来,看着干净多了。
最后说几句掏心窝的
其实最开始我总觉得这俩概念很玄乎,背了好多定义还是模糊。真的自己动手拆成五个小 demo,挨个跑一遍,踩俩坑,反而一下就通透了。
回头想想,核心也就三句话:受控组件,状态在 React 里,视图全由状态说了算;非受控组件,状态在 DOM 里,React 只在需要的时候去取;别混着用,选合适场景的就行。
这东西也不是越复杂越好,有时候一个简单的搜索框,用 ref 拿一下值就完事了,没必要硬上受控组件徒增代码。但如果是复杂的业务表单,老老实实写受控,后期维护起来会舒服很多。
我当时把这些代码敲完,又对着报错调了半小时,才算真的把这俩概念刻进脑子里。你要是也一直对这个点模模糊糊,建议别光看,自己新建个项目写一遍,敲完你就懂了。搞懂了记得回来留个言,我也想看看你的理解。