REACT · Vol.IV · LESSON 24 · 组件设计与工程化
受控组件与非受控组件
值到底归谁管:一个 input 的两种命运
第 23 篇把组合玩熟了,第 8 篇写 Todo 时你也照着写过受控 input:<input value={text} onChange={...} />。当时多半没多想。现在回答一个面试高频问题:用户敲进输入框的每一个字,到底存在哪?两种答案,对应两种模式:
- 受控组件(Controlled):值存在 React state 里,DOM 只是「显示器」,不给
value+onChange就不听你的。 - 非受控组件(Uncontrolled):值存在 DOM 自己肚子里,React 不插手,要用时再去拿。
// 受控:值在 state 里,每键都流经 React
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />
// 非受控:值在 DOM 里,React 只留一张「取件码」(第 12 篇)
const nameRef = useRef<HTMLInputElement>(null);
<input defaultValue="" ref={nameRef} />
判别一句话:看「当前值」是不是 React 说了算——value 由 state 提供、变化经 onChange 回 state 是受控;React 只给初始值、之后不闻不问是非受控。两条数据流画在一张图里:
受控组件:value 与 onChange,一步都不能少
先看受控的完整样子——一个带即时校验和联动的登录表单,注意校验写在哪、按钮禁用条件在哪:
import { useState } from "react";
function LoginForm() {
// 表单即 state:唯一数据源
const [form, setForm] = useState({ email: "", password: "" });
// 校验是 state 的派生值:state 一变自动重算,无需单独存储
const emailInvalid = form.email !== "" && !form.email.includes("@");
return (
<form onSubmit={(e) => {
e.preventDefault();
if (emailInvalid) return;
console.log("提交", form); // 直接拿 state,全程不碰 DOM
}}>
<input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
{emailInvalid && <p>邮箱格式不对</p>} // 即时反馈
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
<button disabled={!form.email || !form.password}>登录</button>
</form>
);
}
三个关键认知:
- 为什么必须有 onChange?
value一旦设置,React 每次渲染都把 DOM 的值强制刷成 state 的值;没有 onChange,state 永远不变,输入框「打不进字」。这不是 bug,是「受控」的字面意思——DOM 被 React 掐着。真想只读就明说:readOnly。 - 每敲一键重渲染一次。onChange → setState → 重渲染(第 5 篇)→ input 拿到新 value。diff 后实际只更新文本这一个节点,普通表单毫无感知。
- 校验和联动是「免费」的。校验是派生值、按钮禁用是表达式——任何时刻你手里都有全量表单数据,「勾选协议才能提交」「选了上海才显示区县」一行条件就够。
其余控件的受控写法速览——规律一致:值用对应属性管、变化走 onChange:
// checkbox:用 checked 管(不是 value),取 e.target.checked
<input type="checkbox" checked={agree}
onChange={(e) => setAgree(e.target.checked)} />
// radio:一组框共享一份 state,靠各自 value 判断谁被选中
<input type="radio" checked={pay === "wechat"} onChange={() => setPay("wechat")} />
<input type="radio" checked={pay === "alipay"} onChange={() => setPay("alipay")} />
// select:value 挂在 <select> 标签上,不是 <option> 上
<select value={city} onChange={(e) => setCity(e.target.value)}>
<option value="bj">北京</option>
<option value="sh">上海</option>
</select>
受控表单像「每次变更都实时过一遍 Controller」:onChange 是请求入口(参数绑定),form state 是绑定后的 DTO,每敲一键都跑一遍校验并回显错误。全量、实时、强一致,代价是「请求频率」高(每键一次重渲染)。第 32 篇的搜索框场景里受控 input 仍是标配——配防抖即可。
非受控组件:defaultValue 存进去,提交时再取出来
把 value 换成 defaultValue、加上 ref,就切到非受控模式:初始值只给一次,之后值在 DOM 里自生自灭,React 不参与、也不重渲染。读值用 ref——第 12 篇说过,useRef 是不触发重渲染的「便签」,正配「只要结果」的场景:
import { useRef, type FormEvent } from "react";
function RegisterForm() {
const emailRef = useRef<HTMLInputElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: FormEvent) {
e.preventDefault();
// 此刻才伸手拿值——此前敲了什么,React 全程不知情
const email = emailRef.current?.value ?? "";
const file = fileRef.current?.files?.[0] ?? null;
console.log("提交", { email, file });
}
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} defaultValue="me@example.com" />
<input ref={fileRef} type="file" />
<button>注册</button>
</form>
);
}
注意 type="file":文件输入只能非受控——值是用户本地选的文件,React 无法用 state 赋值,也没理由在选文件时重渲染。这是非受控「天生合法」的场景。
经典坑:defaultValue 只在首次挂载生效
「非受控 + 想重置」是新手重灾区。看这个错误示范:
function ResetBug() {
const [draft, setDraft] = useState("初始草稿");
return (
<>
<input defaultValue={draft} /> // draft 变了,输入框却纹丝不动
<button onClick={() => setDraft("")}>重置</button>
</>
);
}
原因:defaultValue 的语义是「出厂设置」——只在 DOM 节点首次创建时用一次,之后 React 认为「这框归 DOM 管」,重渲染一百次也不碰它的 value。这反向印证了第 1 站的判别:非受控 = React 不掌值。两条出路:
- 受控化:真的需要随时改值,说明它就该受控——老老实实
value + onChange。 - 换 key 强制重建:
<input key={version} defaultValue={draft} />。key 一变(第 7 篇)=「另一个元素」,卸载旧的、按新 defaultValue 重建——出厂设置重新生效。
非受控表单正是 Spring MVC 的经典姿势:字段值住在浏览器 DOM 里,后端不参与敲字过程,直到 form submit,@ModelAttribute 才把表单键值对一次性绑定成 Java 对象交给 Controller;校验也只能提交时做(@Valid + BindingResult),做不到「敲一个字就报错」。受控相当于把绑定和校验前移到每次按键。
emailRef.current.value = "" 清空输入框?停一下——你在手写 DOM 操作,这正是切回受控的信号。对比与选择:什么时候用哪种
两条路放在一起,差异一目了然:
| 维度 | 受控组件 | 非受控组件 |
|---|---|---|
| 值存在哪 | React state | DOM 内部 |
| 取值时机 | 随时——state 就是最新值 | 提交时读 ref |
| 即时校验 / 字段联动 | 天然支持(派生值 + 条件渲染) | 做不到;监听 onChange 即变相受控 |
| 输入时的重渲染 | 每键 1 次 | 0 次 |
| 每字段代码量 | value + onChange 成对出现 | 一个 ref,多数时候连 ref 都省 |
| 典型场景 | 搜索框、联动表单、登录框 | 超大表单、文件上传、只要提交值 |
选择准则浓缩成三句:
- 要「过程」就受控:即时校验、字段联动、随时取值、错误提示跟手——本质是「值一变我就要参与」,受控是唯一解。
- 只要「结果」可非受控:几十个字段的登记表单,敲字过程无人关心,每键重渲染纯属浪费——defaultValue + 提交读 ref,代码量还省一半;文件输入更是只能非受控。
真实项目里的大表单,行业默认答案是 React Hook Form(第 21 篇)——底层恰恰是非受控:DOM 持有值,库替你把 ref 记账、校验、错误收集全包了。
React Hook Form:让库替你记账
感受一下 RHF 的写法——input 上没有任何 value/onChange:
import { useForm } from "react-hook-form";
interface LoginForm { email: string; password: string; }
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();
return (
<form onSubmit={handleSubmit((values) => console.log(values))}>
<input {...register("email", {
required: "邮箱必填",
pattern: { value: /@/, message: "格式不对" },
})} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password", { required: true })} />
<button>登录</button>
</form>
);
}
register("email") 返回一组 props(onChange、ref 等),展开到 input 上:值仍由 DOM 持有,敲字零重渲染,RHF 在这些事件里偷偷记账;提交时 handleSubmit 递给你干净的 { email, password },校验错误从 errors 拿——受控的体验、非受控的性能,细节留给第 21 篇。
- 判别标准:表单值的真相源在谁手里——state 手里是受控(value + onChange),DOM 手里是非受控(defaultValue + ref)。
- 受控:随时有全量数据,即时校验、联动、清空、回显都是一行 state 操作;代价是每键一次重渲染。
- 非受控:敲字零重渲染、代码量小,适合「只要提交结果」的大表单;文件输入只能非受控。
- defaultValue 只在首次挂载生效——要重置就受控化,或换 key 强制重建。
- 生产级大表单交给 React Hook Form(第 21 篇):非受控打底,库替你记账。
本章回顾
受控与非受控之争,本质是「值的管理权」之争:要参与每次变化就交给 state(受控),只要结果就留给 DOM(非受控)。加上第 23 篇的组合,搭表单页已不成问题。组合还有更漂亮的一招——把整段 JSX 当参数传进组件,让子组件隐式共享父状态:下一篇第 25 篇,children 与复合组件模式。
Comments · 评论