陌上人如玉
公子世无双

12.6 受控与非受控怎么选

12.6 受控与非受控怎么选

一句话:看「是否需要实时响应输入」——需要(实时校验、格式化、联动)就用受控;只需要「提交时的最终值」、字段很多、或集成非 React 控件就用非受控。

一、结论先行

问题:受控组件和非受控组件怎么选?

一句话回答:
  「你需要【实时】知道用户输入的内容吗?」
  ├─ 需要(实时校验/格式化/联动/字数统计/条件禁用)→ 受控
  └─ 不需要(只取提交时的值、字段太多、集成第三方控件)→ 非受控

⭐ 补充三条硬性事实:
① <input type="file"> 【只能】非受控(浏览器不允许 JS 设置它的值)
② 受控的性能代价是「每次输入都重渲染」(大表单要注意)
③ 大表单的现实选择是「非受控 + FormData」或「react-hook-form」
维度 受控 非受控
值的所有者 React state DOM 自己
读取方式 直接读 state ref.current.value / FormData
实时知道值 ✅ ❌(要主动读)
每次输入都重渲染 ✅(代价) ❌
实时校验/格式化 ✅ 容易 ⚠️ 要监听原生事件
控件联动(禁用按钮) ✅ 容易 ❌ 困难
file 输入 ❌ 不支持 ✅ 唯一选择
集成第三方控件 ⚠️ 需要包装 ✅ 更自然
代码量 多(state + onChange) 少

二、受控组件的写法与要点

function Controlled() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}                                   {/* ⭐ 值由 state 决定 */}
      onChange={(e) => setValue(e.target.value)}       {/* ⭐ 必须有 onChange */}
    />
  );
}
⭐ 受控的「数据流」:
  用户输入 → onChange → setState → 重渲染 → input 的 value 被设为新值
  → state 是唯一数据源(Single Source of Truth)

⭐ 三类元素的关键属性:
· text / textarea / select → 用 value
· checkbox / radio → 用 checked(读 e.target.checked)
· file → 【无法受控】
// ⚠️ 最常见的三个坑
// ① 有 value 没 onChange(React 会警告,输入无效果)
<input value={value} />                              // ❌

// ② value 从 undefined 变成有值(受控/非受控切换警告)
const [v, setV] = useState<string | undefined>(undefined);
<input value={v} onChange={...} />                   // ⚠️ 首次非受控 → 之后受控
<input value={v ?? ''} onChange={...} />             // ✅ 始终是字符串

// ③ checkbox 用 value 而不是 checked
<input type="checkbox" value={agree} onChange={...} />     // ❌
<input type="checkbox" checked={agree} onChange={...} />   // ✅
// ✅ 受控的典型价值(实时响应)
// ① 实时校验 + 失焦后显示错误(体验最好)
const [email, setEmail] = useState('');
const [touched, setTouched] = useState(false);
const error = !email ? '请输入邮箱' : !/^\S+@\S+\.\S+$/.test(email) ? '邮箱格式不正确' : '';

<input
  value={email}
  onChange={(e) => setEmail(e.target.value)}
  onBlur={() => setTouched(true)}
  aria-invalid={!!error && touched}
/>
{touched && error && <p role="alert">{error}</p>}

// ② 实时格式化(手机号加空格)
const handleChange = (e) => {
  const digits = e.target.value.replace(/\D/g, '').slice(0, 11);
  setPhone(digits.replace(/(\d{3})(\d{4})(\d{0,4})/, '$1 $2 $3').trim());
};

// ③ 控件联动(禁用提交按钮)
<button disabled={!value.trim() || !agree}>提交</button>

三、非受控组件的写法与要点

function Uncontrolled() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = () => {
    console.log(inputRef.current?.value);            // ⭐ 需要时去 DOM 取
  };

  return (
    <>
      <input ref={inputRef} defaultValue="初始值" />   {/* ⭐ defaultValue 设初值 */}
      <button onClick={handleSubmit}>提交</button>
    </>
  );
}
// ✅ 更推荐的方式:用 FormData 收集(一次拿全,代码最少)
function SimpleForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.currentTarget));
    console.log(data);                                // { username: '张三', age: '18' }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" defaultValue="张三" />   {/* ⭐ 必须有 name */}
      <input name="age" type="number" defaultValue={18} />
      <input type="file" name="avatar" />             {/* ✅ file 只能这样 */}
      <button type="submit">提交</button>
    </form>
  );
}
⭐ 非受控的三个关键点:
① 用 `defaultValue` / `defaultChecked` 设【初始值】(只在首次挂载生效)
② 用 `ref` 或 `FormData` 读【当前值】
   (用 FormData 时,元素必须有 `name`,否则收集不到)
③ 输入过程【不触发重渲染】→ 性能更好

四、性能对比:大表单怎么办

// ❌ 100 个受控输入框:每次输入让整个表单重渲染
function BadForm() {
  const [fields, setFields] = useState<Record<string, string>>({});

  return (
    <form>
      {Array.from({ length: 100 }, (_, i) => (
        <input
          key={i}
          value={fields[i] ?? ''}
          onChange={(e) => setFields((p) => ({ ...p, [i]: e.target.value }))}
        />
      ))}
    </form>
  );
}
// ⚠️ 每敲一个字,100 个 input 全部重渲染
// ✅ 方案一:非受控 + FormData(提交时收集)
function GoodForm() {
  const ref = useRef<HTMLFormElement>(null);

  const submit = () => {
    const data = Object.fromEntries(new FormData(ref.current!));
    console.log(data);
  };

  return (
    <form ref={ref}>
      {Array.from({ length: 100 }, (_, i) => <input key={i} name={`f${i}`} />)}
      <button type="button" onClick={submit}>提交</button>
    </form>
  );
}
// ✅ 方案二:拆成独立子组件(每个输入框自己管理 state)
function Field({ name }: { name: string }) {
  const [value, setValue] = useState('');            // ⭐ 重渲染只影响这个 Field
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// → 输入时只有「当前这个 Field」重渲染

// ⚠️ 但如果「父组件需要一次性拿到所有值」,需要「状态提升」或「用一个 store」
// ✅ 方案三:react-hook-form(非受控 + 订阅,性能与开发体验都好)
import { useForm } from 'react-hook-form';

function Form() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('username', { required: '请输入用户名' })} />
      {errors.username && <span>{errors.username.message}</span>}
      <button disabled={isSubmitting}>提交</button>
    </form>
  );
}
方案 适合 代价
受控(useState) 字段少 + 需要实时联动 每次输入重渲染
拆分受控子组件 中等数量 + 需要受控 状态分布在子组件里
非受控 + FormData 只需提交时的值、含 file 无法实时响应
react-hook-form 中大型表单 引入依赖
React 19 的 Actions 新项目 + 服务端提交 需要新版本

五、混合模式(现实中很常见)

// ✅ 「受控 + 非受控」的折中:用「本地 state 承载输入,防抖/失焦时同步到上层」
function HybridInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  const [draft, setDraft] = useState(value);           // 本地草稿

  // 外部变化时同步回来(⭐ 注意避免「输入中被打断」)
  useEffect(() => {
    setDraft(value);
  }, [value]);

  return (
    <input
      value={draft}
      onChange={(e) => setDraft(e.target.value)}         {/* 本地实时更新(不重渲染父组件)*/}
      onBlur={() => onChange(draft)}                     {/* 失焦时才通知父组件 */}
    />
  );
}
// ⭐ 这样「输入过程」只影响这个组件,「父组件」只在失焦时更新
// ✅ 「受控的值为准 + 非受控的输入」:用 ref 读值,用 state 做只读展示
function AmountInput({ max }: { max: number }) {
  const ref = useRef<HTMLInputElement>(null);
  const [display, setDisplay] = useState('');

  return (
    <>
      <input
        ref={ref}
        defaultValue=""
        onChange={(e) => setDisplay(e.target.value.replace(/\D/g, '').slice(0, max))}
        // ⚠️ 注意:这里的 display 与输入框的「真实值」需要自己保持同步
      />
      <p>已输入 {display.length} / {max} 个字符</p>
    </>
  );
}
// ⚠️ 这种「混合」写法要小心「值不一致」(通常不是好选择,受控更清晰)

六、file 输入为什么只能非受控

// ❌ 无法受控
<input type="file" value={file} onChange={...} />       // 浏览器会忽略 value

// ✅ 只能这样
const fileRef = useRef<HTMLInputElement>(null);
const file = fileRef.current?.files?.[0];
⭐ 原因(安全):
浏览器出于【安全考虑】不允许 JavaScript「设置」文件输入的值——
否则恶意脚本就能「伪造『用户选择了一个文件』」(如自动上传磁盘上的敏感文件)。
所以 `value` 对它无效,React 也无法让它受控。

七、常见追问

Q: 受控组件一定比非受控「慢」吗?
A: 不一定的。字段少时差异可以忽略;只有「字段极多」或「输入触发重计算」时明显。
   关键不是「受控慢」,而是「输入驱动的重渲染范围有多大」。

Q: 为什么「受控」会「每次输入都重渲染」?
A: 因为每次输入 onChange 都要 setState → 触发组件重新渲染。
   (可以用「拆分组件」把重渲染限制在「当前这个输入框」)

Q: 「非受控」能实时校验吗?
A: 能,但要「自己监听原生事件」(用 ref + addEventListener('input')),
   比受控麻烦,且状态来源不唯一(DOM 与 state 两份)。

Q: 受控组件里「输入中文」有问题吗?
A: 有——「拼音输入过程中」onChange 会以「拼音字母」触发(composition 过程),
   某些「实时格式化」逻辑会打断输入法。
   ✅ 用 e.nativeEvent.isComposing 判断(见 1.6);
      或在 onCompositionEnd 后再处理。

Q: 「实时格式化」导致的「光标跳末尾」怎么解决?
A: 需要「手动保存/恢复光标位置」(setSelectionRange)——
   记录「输入前的光标位置对应的原始字符索引」,格式化后再映射回去(见 1.5)。

Q: React 19 的 form action 属于哪一类?
A: 它更接近「非受控」——`<form action={fn}>` 会把「FormData」传给 action,
   由 React 处理「preventDefault 与表单重置」;
   不需要为每个字段维护 state(配合 useActionState/useFormStatus 管状态)。

Q: 用 `ref` 拿值的「受控组件」算受控还是非受控?
A: 算非受控(值的所有者是 DOM)。
   「受控」的定义是「值由 React state 决定」。

Q: 如何判断「值是不是受控」?
A: 看「输入时是否必须经过 React」——
   如果「删掉 onChange 输入框就动不了」→ 受控;
   如果「输入框能自由输入」→ 非受控。

八、常见坑

// ① 受控组件忘了 onChange(输入无效果 + 警告)
// ② checkbox 用了 value(应该用 checked)
// ③ select 的 value 写在 option 上(应该写在 select 上)
// ④ textarea 用 children 而不是 value
<textarea>{value}</textarea>                          // ❌
<textarea value={value} onChange={...} />             // ✅

// ⑤ 试图受控 file 输入
// ⑥ value 从 undefined 变有值(受控/非受控切换)
// ⑦ 非受控组件忘了 name(FormData 收集不到)
// ⑧ 用 FormData 时「忘记 preventDefault」(页面刷新)
// ⑨ 大表单「每个字段一个 state 且都在父组件」(输入卡顿)
// ⑩ 在 onChange 里做「昂贵计算/请求」(输入卡顿)→ 用防抖/useDeferredValue
// ⑪ 实时格式化导致「光标跳末尾」
// ⑫ 实时格式化「打断中文输入法」
// ⑬ 认为「非受控就不能校验」(能在提交时校验)
// ⑭ 认为「受控一定更好」(字段多时是负担)
// ⑮ 在「第三方非受控控件」上硬套受控(应该用 ref 包装)
// ⑯ 忘记「表单重置」时受控字段也需要重置(要手动 setState 或设计好 key)
// ⑥ 的正确处理
const [v, setV] = useState<string | undefined>(undefined);
<input value={v ?? ''} onChange={(e) => setV(e.target.value)} />   // ✅ 保证类型稳定
// ⑯ 的重置方案
// ✅ 方案一:手动重置所有 state
const handleReset = () => setForm(INITIAL_FORM);

// ✅ 方案二:用 key 强制重建表单(⭐ 更彻底)
<Form key={formKey} />
// 点「重置」时 setFormKey((k) => k + 1)

九、面试延伸

  1. 「受控组件和非受控组件怎么选?」
    判断标准是「是否需要实时响应输入」:① 需要(实时校验、格式化、字数统计、条件禁用按钮、字段联动)→ 受控;② 不需要(只要提交时的最终值)→ 非受控(代码更少、不重渲染);③ 字段很多(几十上百个)→ 非受控(或拆分受控子组件、或用 react-hook-form);④ 集成非 React 的第三方控件(富文本、日期选择器)→ 非受控更自然;⑤ <input type="file"> → 只能非受控。

  2. 「为什么受控组件必须写 onChange?」
    因为 React 认为你「想让 React 控制这个值」(你提供了 value),但没有提供「值怎么变」的路径(onChange)——于是用户的输入会被 value 立即覆盖回去,输入框变成只读,React 会发出警告(You provided a value prop to a form field without an onChange handler)。如果确实要只读,要显式写 readOnly(这样不会警告)。

  3. 「<input type="file" /> 为什么不能受控?」
    因为浏览器出于安全考虑不允许 JavaScript 设置文件输入的值——否则恶意脚本就能「伪造『用户选择了一个文件』」(如自动上传用户磁盘上的敏感文件)。所以 value 属性对它无效,React 也无法让它受控,只能用 ref 读取 files(或用 FormData 收集)。它是表单元素里唯一无法受控的类型。

  4. 「大表单的性能问题怎么解决?」
    三个方向:① 拆分组件——每个字段自己管理 state(输入只重渲染那个字段,而不是整张表单);② 改用非受控(ref/FormData)——完全不触发重渲染;③ 用 react-hook-form——它内部用「非受控 + 订阅」,只有「注册了该字段的组件」会在它变化时重渲染(这是它性能好的原因)。此外要「避免在 onChange 里做昂贵计算/请求」(用防抖或 useDeferredValue)。

  5. 「受控组件里做实时格式化有什么坑?」
    两个坑:① 光标位置——如果你在 onChange 里返回「格式化后的字符串」,React 会替换 input.value,光标会跳到末尾(用户无法在中间插入字符);解法是「记录输入前的光标位置 → 映射到格式化后的位置 → 用 setSelectionRange 恢复」(通常在 requestAnimationFrame 里设置);② 中文输入法——拼音输入过程中 onChange 会以「拼音字母」触发,实时格式化会打断输入法;解法是用 e.nativeEvent.isComposing 判断(为 true 时跳过格式化)。

  6. 「defaultValue 和 value 有什么区别?」
    value 是「受控」——React 持续控制它的显示(必须配 onChange,输入过程经过 React);defaultValue 是「非受控」——只在「首次挂载」把值写进 DOM,之后由 DOM 自己管(React 不再干预)。同理 defaultChecked 与 checked。两者同时提供会警告(React 不知道该听谁的)。

  7. 「value={undefined} 为什么会导致『受控变非受控』的警告?」
    因为 React 用「value 是否为 undefined」来判断「这个组件是受控还是非受控」:首次渲染时 value === undefined → 视为非受控;后续某次渲染 value 变成字符串 → React 认为它变成了受控。这种「中途切换」让 React 无法确定「该由谁管理值」,于是发出警告。修复方式是 value={v ?? ''}(保证始终是字符串)。

  8. 「React 19 里的 form action 属于哪一类?」
    更接近「非受控」:<form action={fn}> 提交时 React 会把 FormData 传给 fn,并自动处理 preventDefault 与「表单重置」——所以你不需要为每个字段维护 state。配合 useActionState(管理返回值与 pending)、useFormStatus(子组件读提交状态)、useOptimistic(乐观更新),可以覆盖「受控表单」的绝大部分需求,代码量更少。它代表 React 官方在「表单」上的方向:减少手写 state。

一句话速记

选择标准是「是否需要实时响应输入」:需要(实时校验/格式化/联动/字数统计/条件禁用)→ 受控(value + onChange,代价是每次输入重渲染);只要提交时的值 / 字段多 / 集成第三方控件 → 非受控(defaultValue + ref/FormData,元素要有 name);<input type="file"> 只能非受控(浏览器安全限制);checkbox 用 checked、textarea/select 用 value、select 的 value 写在 select 上;value={v ?? ''} 避免「受控/非受控切换」警告;大表单用「拆分受控子组件 / 非受控 + FormData / react-hook-form(非受控 + 订阅)」;实时格式化要注意「光标跳末尾」与「打断中文输入法(isComposing)」;React 19 的 <form action> 更接近非受控(配合 useActionState/useFormStatus/useOptimistic)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.6 受控与非受控怎么选

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫