2209 字
约 7 分钟
17
S14-A25 · 受控与非受控:表单的状态到底该放哪

一个表单有三个字段,两个"受控"(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. 自测

  1. 受控与非受控的"真相"差别是什么?为什么"非受控更快"常被夸大?
  2. "输入卡"的三种真因里,哪一项最常被误当成"该用非受控"?
  3. "混用"的三种后果里,哪一项最容易被用户察觉?
  4. "非受控"真正适合的三类场景是什么?
  5. 为什么"整表统一"比"分字段混用"好?
  6. 判断"该用哪个"的那个问题是什么?
S14-A25 · 受控与非受控:表单的状态到底该放哪
https://www.clxhxhhr.top/posts/6685/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。