陌上人如玉
公子世无双

1.5 受控与非受控组件

1.5 受控与非受控组件

一句话:受控组件的「值」由 React 的 state 掌管(每次输入都要 setState),非受控组件的「值」由 DOM 自己掌管(需要时用 ref 去取);选择标准是「你是否需要实时响应输入」。

一、受控组件

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

  return (
    <input
      value={value}                                  // ⭐ 值由 state 决定
      onChange={(e) => setValue(e.target.value)}     // ⭐ 输入时更新 state
    />
  );
}

数据流:

用户输入 → 触发 onChange → setState → 组件重渲染 → input 的 value 被设置为新值
         ↑                                                        │
         └──────────── DOM 上的值是「React 告诉你该显示什么」 ────────┘

特点:state 是唯一数据源(single source of truth)

典型用途:

// ① 实时校验
const [email, setEmail] = useState('');
const isValid = /^[^@]+@[^@]+$/.test(email);

<input value={email} onChange={(e) => setEmail(e.target.value)} />
{!isValid && email && <span>邮箱格式不正确</span>}

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

// ③ 强制大写 / 过滤非法字符
<input value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
<input value={num} onChange={(e) => setNum(e.target.value.replace(/\D/g, ''))} />

// ④ 联动禁用按钮 / 联动其它字段
<button disabled={!value.trim()}>提交</button>

// ⑤ 受控多选
<select value={city} onChange={(e) => setCity(e.target.value)}>
  <option value="bj">北京</option>
  <option value="sh">上海</option>
</select>

// ⑥ 受控 checkbox
<input
  type="checkbox"
  checked={agree}
  onChange={(e) => setAgree(e.target.checked)}     // ⭐ 用 checked 不是 value
/>

// ⑦ 受控多选 checkbox 组(数组)
{options.map((opt) => (
  <label key={opt.value}>
    <input
      type="checkbox"
      checked={selected.includes(opt.value)}
      onChange={(e) => {
        setSelected((prev) =>
          e.target.checked ? [...prev, opt.value] : prev.filter((v) => v !== opt.value)
        );
      }}
    />
    {opt.label}
  </label>
))}

受控组件的要素:

元素 受控属性 更新事件
<input type="text"> value onChange
<textarea> value(不是 children) onChange
<select> value(在 select 上,不在 option 上) onChange
<input type="checkbox"> checked onChange(取 e.target.checked)
<input type="radio"> checked + name onChange
<input type="file"> ❌ 无法受控 —

⚠️ file 类型是唯一无法受控的输入——因为出于安全考虑,浏览器不允许 JS 设置文件输入的值。它只能是「非受控 + ref 读取」。

二、非受控组件

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

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

  return (
    <>
      <input ref={inputRef} defaultValue="初始值" />
      <button onClick={handleSubmit}>提交</button>
    </>
  );
}

关键点:

① 用 defaultValue / defaultChecked 设置「初始值」(只在首次挂载生效)
② 用 ref 读取当前值
③ React 不参与值的更新 → 输入过程不会触发重渲染 → 性能更好
④ 适合「只关心提交时的最终值」的场景
// 也可以用 FormData(React 19 的 form action 会用到)
function Form() {
  const formRef = useRef<HTMLFormElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const data = new FormData(formRef.current!);
    console.log(Object.fromEntries(data));     // { username: 'a', age: '18' }
  };

  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <input name="username" defaultValue="张三" />
      <input name="age" type="number" defaultValue={18} />
      <button type="submit">提交</button>
    </form>
  );
}

重要:非受控组件要用 name 属性才能被 FormData 收集(这也与原生表单行为一致)。

三、对比与选择

维度 受控 非受控
值的来源 React state DOM 自身
读取方式 直接读 state ref.current.value / FormData
实时响应 ✅ 每一次输入都知道 ❌ 只能主动去取
代码量 多(要写 state + onChange) 少
重渲染 每次输入都重渲染 ❌ 不重渲染(性能更好)
实时校验/格式化 ✅ 容易 ❌ 困难(要监听原生事件)
联动(禁用按钮等) ✅ 容易 ❌ 困难
初值设置 value={initial} defaultValue={initial}
第三方表单库 需要包装(react-hook-form 用非受控模拟) 原生支持
文件输入 ❌ 不支持 ✅ 唯一选择

选择标准:

需要「实时响应输入」吗?
├─ 是 → 受控
│       (实时校验、格式化、联动、条件禁用、字数统计)
└─ 否 → 非受控可能更合适
        ① 表单字段非常多,逐个受控很繁琐且性能差
        ② 只关心「提交时」的最终值
        ③ 集成非 React 的第三方库(如富文本编辑器、日期选择器)
        ④ file 输入(只能非受控)
// 性能对比示例:100 个受控输入框
// ❌ 每个输入框一个 state → 输入一个字符会让整个表单重渲染
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 }))}
        />
        // ⚠️ 每敲一个字,100 个 input 全部重渲染
      ))}
    </form>
  );
}

// ✅ 方案一:非受控 + 提交时统一收集
function GoodForm() {
  const ref = useRef<HTMLFormElement>(null);
  const submit = () => console.log(Object.fromEntries(new FormData(ref.current!)));
  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 [v, setV] = useState('');     // 重渲染只影响这个 field
  return <input value={v} onChange={(e) => setV(e.target.value)} />;
}

四、混合模式与常见细节

// ① 受控 + defaultValue 混用会警告
<input value={x} defaultValue="a" />     // ⚠️ 同时提供会警告
<input value={x} />                       // ✅ 受控
<input defaultValue="a" />                // ✅ 非受控

// ② value 为 undefined 时会变成非受控(易踩坑)
const [v, setV] = useState<string | undefined>(undefined);
<input value={v} onChange={...} />
// 首次渲染:非受控(因为 value=undefined)
// 之后 setV('a'):变成受控
// ⚠️ 控制台报错:"A component is changing an uncontrolled input to be controlled"
// ✅ 用 `value={v ?? ''}` 保证始终是字符串

// ③ 受控但只读(不希望用户改但值来自 state)
<input value={computed} readOnly />

// ④ 用 state 控制「格式化后的显示」与「原始值的同步」
const [raw, setRaw] = useState('');
const display = useMemo(() => format(raw), [raw]);
<input value={display} onChange={(e) => setRaw(unformat(e.target.value))} />
// ⑤ 用第三方非受控组件(如日期选择器)时用 ref 读取
function DatePickerWrapper({ onChange }: { onChange: (d: Date) => void }) {
  const pickerRef = useRef<ThirdPartyPicker>(null);

  useEffect(() => {
    const picker = new ThirdPartyPicker(pickerRef.current!);
    picker.onChange((d: Date) => onChange(d));
    return () => picker.destroy();          // ⭐ 清理第三方实例
  }, [onChange]);

  return <div ref={pickerRef} />;
}

五、常见坑

// ① checkbox 用了 value 而不是 checked
<input type="checkbox" value={agree} onChange={...} />      // ❌
<input type="checkbox" checked={agree} onChange={...} />    // ✅

// ② 受控组件忘了 onChange
<input value={text} />                     // ⚠️ 输入无效果 + 控制台警告
// 报错:You provided a `value` prop to a form field without an `onChange` handler

// ③ 用 readOnly 消除上一个警告(只在确实只读时)
<input value={text} readOnly />            // ✅ 合法

// ④ textarea 用 children 而不是 value
<textarea>{value}</textarea>               // ❌
<textarea value={value} onChange={...} />  // ✅

// ⑤ select 的 value 写在 option 上
<select>
  <option value={v} selected>a</option>    // ❌ React 用 select 的 value
</select>
<select value={v} onChange={...}>          // ✅

// ⑥ 试图受控 file 输入
<input type="file" value={file} onChange={...} />   // ❌ 浏览器不允许

// ⑦ value 从 undefined 变成有值 → 受控/非受控切换警告
value={v ?? ''}                            // ✅ 保证类型稳定

// ⑧ 在 onChange 里做重计算导致输入卡顿
onChange={(e) => { setValue(e.target.value); heavyCompute(); }}   // ⚠️ 每次输入都算
// ✅ 用防抖 或 useDeferredValue 或提交时再算

// ⑨ 需要「受控 + 光标位置」的复杂输入(如带分隔符的手机号)
// ⚠️ 直接格式化会导致光标跳到末尾 —— 需要手动管理 selectionStart/End
// ⑨ 一个「受控 + 光标保持」的简化处理
function PhoneInput() {
  const [value, setValue] = useState('');
  const ref = useRef<HTMLInputElement>(null);

  const handleChange = (e) => {
    const input = e.target;
    const cursor = input.selectionStart ?? 0;
    const before = input.value.slice(0, cursor).replace(/\D/g, '').length;

    const digits = input.value.replace(/\D/g, '').slice(0, 11);
    const formatted = digits.replace(/(\d{3})(\d{4})(\d{0,4})/, '$1 $2 $3').trim();
    setValue(formatted);

    // 下一帧把光标移到「与输入前相同数量的数字之后」
    requestAnimationFrame(() => {
      let pos = 0, count = 0;
      while (pos < formatted.length && count < before) {
        if (/\d/.test(formatted[pos]!)) count++;
        pos++;
      }
      ref.current?.setSelectionRange(pos, pos);
    });
  };

  return <input ref={ref} value={value} onChange={handleChange} />;
}

六、现代替代:非受控 + 表单库

// react-hook-form:内部用非受控 + ref,性能好、代码少
import { useForm } from 'react-hook-form';

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

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('username', { required: '请输入用户名' })} />
      {errors.username && <span>{errors.username.message}</span>}

      <input {...register('age', { valueAsNumber: true })} />
      <button>提交</button>
    </form>
  );
}
方案 适用
受控(useState) 字段少、需要实时联动/校验
非受控(ref / FormData) 字段多、只需提交时的值、集成第三方控件
react-hook-form 中大型表单(性能好、校验内置)
Formik 传统受控方案(现在更推荐 react-hook-form)
React 19 的 <form action> + useActionState 新项目的表单提交(原生 FormData + Actions)

面试延伸

  1. 「受控组件和非受控组件的区别?」
    区别在于「值的所有者」:受控组件的值由 React state 掌管(value + onChange),每次输入都要更新 state 并重渲染,因此 React 随时知道当前值;非受控组件的值由 DOM 自己掌管(用 defaultValue 设初值、ref 读当前值),React 只在需要时去取。受控能实时响应,非受控代码更少、性能更好(不重渲染)。

  2. 「什么时候用非受控组件?」
    ① 只需要提交时的最终值(登录表单、搜索框提交时取值);② 字段非常多的大表单(受控会带来大量重渲染);③ 集成非 React 的第三方控件(富文本、日期选择器、地图);④ <input type="file">——它只能非受控(浏览器出于安全不允许 JS 设置其值);⑤ 需要用原生 FormData 收集时。

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

  4. 「受控组件为什么会警告『value without onChange』?」
    因为 React 认为你「想让 React 控制这个值」(提供了 value),但又没有提供「值怎么变化」的路径(onChange)——这样用户输入将永远被 value 覆盖,输入框变成只读,属于明显的用法错误。如果确实要只读,应该显式写 readOnly(这样 React 不会警告);如果是要显示但可改,就补上 onChange。

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

  6. 「<textarea> 和 <select> 的受控写法与原生有什么不同?」
    两点:① <textarea> 用 value 属性而不是把内容写在标签之间(<textarea>{value}</textarea> 在 React 里是错的,会触发警告);② <select> 的 value 写在 <select> 上(而不是给某个 <option> 加 selected),React 会自动选中对应的选项。此外多选 <select multiple> 的 value 要传数组。

  7. 「受控组件的性能问题怎么解决?」
    三个方向:① 拆分组件——把每个字段拆成独立子组件,让输入只重渲染那个字段(而不是整个表单);② 改用非受控 + 提交时收集(或用 FormData);③ 用 useDeferredValue / 防抖——当输入要驱动昂贵的计算(如实时搜索列表)时,把「昂贵部分」延迟处理。此外 react-hook-form 就是用「非受控 + 订阅」的方式规避了这个问题。

  8. 「受控输入里做格式化(如手机号加空格)有什么坑?」
    最大的坑是光标位置:如果 onChange 里返回了格式化后的字符串,React 会把 input.value 替换成它,光标会跳到末尾(用户无法在中间插入字符)。解决思路是「记录输入前的光标位置 → 计算对应的原始字符索引 → 格式化后根据同样的字符数把光标移回相应位置」(用 setSelectionRange,通常在 requestAnimationFrame 里设置)。

一句话速记

受控 = React 管值(value + onChange,实时可知、但每次输入都重渲染);非受控 = DOM 管值(defaultValue + ref / FormData,不重渲染、只取最终值);选择标准是「是否要实时响应输入」;file 输入只能非受控(浏览器安全限制);checkbox 用 checked、textarea/select 都用 value、select 的 value 写在 select 上;value={v ?? ''} 避免「受控/非受控切换」警告;大表单用「拆子组件 / 非受控 / react-hook-form」解决性能问题。

赞(0) 打赏
未经允许不得转载:陌上寒 » 1.5 受控与非受控组件

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫