一个表单有三个字段,两个"受控"(value 绑 state)、一个"非受控"(defaultValue)——因为"那个字段输起来老卡"。上线后发现"重置按钮"对第三个字段无效,而"提交"时它能被读到。两套状态混在一起,于是"谁说了算"没人说得清。
受控/非受控不是"风格选择",而是"状态归谁管":React 管(受控)、DOM 管(非受控)。而"混用"会让"重置、校验、联动"三件事"一半生效".
1. 两种模式的"真相"
| 模式 | 状态在哪 |
|---|---|
| 受控 | React state(每次输入都 setState) |
| 非受控 | DOM(用 ref 读) |
第二项的"性能优势"常被夸大:"非受控更快"只在"字段极多、且每次输入都重渲染整表"时明显——而它可以用"拆分组件/useState 局部化"解决.
"重渲染成本"要"能看见",否则"改哪"全靠猜:
// 三步定位(不必装工具)
1 React DevTools 的 "Highlight updates":输入一个字,看"整片亮"还是"一格亮"
2 console.count() 打在表单组件顶部:一次输入计数几次
3 若"整片亮" → 状态在最顶层;若"一格亮" → 状态已下移,卡在别处
// 判据:一次输入"只亮一格"才是正常
2. "输入卡"的三种真因
| 真因 | 处理 |
|---|---|
| 整表重渲染(状态在最顶层) | 把状态下移 |
| 每个字符触发"昂贵计算" | 防抖/useDeferredValue |
| 大列表在同一个组件里 | 拆分/虚拟化 |
第一项的"量化"很直观:100 个字段、每个"重渲染 0.2ms"——于是"每个字符卡 20ms",用户感觉"打字掉帧".
第一项是"最常见"的:"一个字段 setState → 整个表单(含 100 个字段)重渲染"**——**于是"非受控"被当成"性能方案",而真正的问题是"状态放太高".
"受控"的写法只有两行,而"统一"的收益全在"后来的重置/校验"上:
// 受控(状态在 React)
<input value={name} onChange={e => setName(e.target.value)} />
// 非受控(状态在 DOM)
<input ref={nameRef} defaultValue={name} />
// 判据:需要"实时读值"就受控;只有"提交时读"就非受控——同一个表单别混
3. "混用"的三种后果
| 后果 | 表现 |
|---|---|
| 重置失效 | 非受控字段不跟着重置 |
| 校验漏字段 | 受控校验拿不到非受控值 |
| 联动不同步 | A 变了,B(非受控)不变 |
第一项最典型:"重置"只能清"受控"的部分第一项的"用户视角"是"我点了重置,它没清干净"——**而他不知道"有两个字段不归 React 管",只会觉得"这表单有 bug".
——**而用户会以为"重置没生效".
"状态下移"是"治卡顿"的正解,而它不改变"受控"的语义:
// 反例:状态在最顶层(整表重渲染)
function Form() { const [v, setV] = useState({a:'',b:'',...}); }
// 正解:状态下移到字段组件(只有它重渲染)
function Field({name}) {
const [v, setV] = useState('');
return <input value={v} onChange={e=>setV(e.target.value)} />;
}
// 判据:一次输入"重渲染的组件数"= 1,而不是"表单全部"
4. "该用哪个"的三条判据
| 判据 | 结论 |
|---|---|
| 需要"实时校验/联动" | 受控 |
| 需要"重置/回填" | 受控 |
| 只需"提交时取值"(大表单、文件) | 非受控 |
第三项的"额外好处"是"代码少":不需要 value + onChange 两行——于是"简单表单"用非受控,代码量能少一半,而"改起来"也更直观.
第三项是"非受控真正适合"的:"提交时才需要值"——如"文件上传""一次性的备注框"——**它不需要"每次输入都同步到 React".
"文件字段"是"必须非受控"的特例,而它常被"统一改造"误伤:
// 三种"不能受控"的输入
1 <input type=file>:值只能由用户选,代码不能设
2 部分富文本/编辑器:内部维护状态
3 某些第三方组件:只暴露 ref
// 判据:这类字段"单独成块",并在文档里注明"非受控"
5. "混用"要"分边界"而不是"分字段"
| 做法 | 效果 |
|---|---|
| 同一表单:部分受控部分非受控 | 差(谁说了算不清) |
| 整个表单受控(状态下移) | 好 |
| 整个表单非受控(提交时读) | 好(简单表单) |
第二项与第三项"各成一派"都比"混"好这个"各成一派"的判据是"重置与校验能不能"一次说清"":**能(都从同一个来源取值),就是统一;不能(一半在 state、一半在 ref),就是混用".
——**因为"规则统一",于是"重置/校验"能"一次做对".
"提交读到旧值"这件事,要"按模式"排查,而不是"怪浏览器":
// 三步排查(同一表单)
1 打印"提交时收集到的值":与"界面显示"是否一致
2 不一致 → 看该字段是"state(受控)"还是"ref(非受控)"
3 若是 ref 且"提交时读的是 state" → 读错了来源
// 判据:每个字段都"只有一个取值来源"
6. 排错:五种"表单状态问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 输入卡顿 | 状态在最顶层 | 状态下移 |
| 重置无效 | 混用非受控 | 统一模式 |
| 提交读到旧值 | 非受控 + 未读 ref | 提交时读 ref |
| 校验漏字段 | 校验只看 state | 统一取值来源 |
| 联动不生效 | 一边受控一边非 | 统一模式 |
7. 边界:什么情况"非受控"就是最优
| 场景 | 为什么 | 做法 |
|---|---|---|
文件上传(<input type=file>) |
值不能由代码设 | 非受控 |
| 超大表单(100+ 字段) | 每次输入全量重渲染 | 非受控 + 提交读 |
| 富文本/第三方组件 | 内部维护状态 | 非受控 + onChange 同步 |
| 一次性输入(搜索框回车才用) | 不需实时 | 非受控 |
最后一条与"迁移"有关的边界:"从非受控迁到受控"要"逐个表单做",而不是"逐字段":先统一到"受控 + 状态下移",再按需"把某个大表单整体改成非受控"——中间态(半受控)是最贵的.
判断口径一句话:"这个字段的值,需不需要"在输入过程中"被 React 用到"。需要(实时校验、联动、字数统计),受控;不需要(提交才读、文件、第三方组件),非受控——而"混着用",是两类问题同时出现的最快方式.
再补一个"团队"现实:"受控/非受控"的分歧往往在"两个人各自的表单"里——一组人习惯"受控 + 状态下移",另一组人习惯"非受控 + ref 读"——于是同一项目里两套规矩,而"公共组件被谁用"就决定了"它会不会出 bug"。所以这件事应当"写进前端约定":"表单默认受控;只有文件与第三方组件用非受控,且必须单独成块"——一条约定,省掉半年的"重置不生效".
最后一个"决策"顺序:先"统一模式",再"治性能"——因为"性能"有无数种解法(下移状态、memo、虚拟化),而"状态归属"只有一个正确答案;反过来做(先拆成非受控提性能)会让"重置与校验"变成"要单独再写一遍",于是"两套逻辑"长期并存。所以"受控 vs 非受控"这件事,应当"先定规矩、后谈优化"。
而"先定规矩"这件事,只需要一次讨论。
而"一次讨论"能省掉的,是后面每一次"这表单怎么又不重置"的排查。
这条账,很容易算。
而且它比"再写一套重置逻辑"便宜得多。
8. 自测
- 受控与非受控的"真相"差别是什么?为什么"非受控更快"常被夸大?
- "输入卡"的三种真因里,哪一项最常被误当成"该用非受控"?
- "混用"的三种后果里,哪一项最容易被用户察觉?
- "非受控"真正适合的三类场景是什么?
- 为什么"整表统一"比"分字段混用"好?
- 判断"该用哪个"的那个问题是什么?