前端知库

React 中的受控组件和非受控组件是什么?

受控和非受控说的是“重要的状态由谁决定”。受控组件的值由父组件通过 props 决定,比如带 value 和 onChange 的 input;非受控组件自己保存状态,父组件只能给初值,比如只写 defaultValue 的 input。受控更灵活,非受控更省事,同一个输入框不能在两者之间切换。

参考答案

简答

看一个输入框:写 <input value={name} onChange={e => setName(e.target.value)} />,输入框显示什么完全由 name 这个 state 决定,这是受控的。只写 <input defaultValue="张三" />,React 只给了一个初值,之后的值由浏览器的输入框自己保存,这是非受控的。

这对概念不只用于表单。react.dev 的说法是:组件的重要信息由自己的局部 state 决定,父组件影响不了,就叫非受控;由 props 驱动、父组件能完全指定它的行为,就叫受控。官方也说明这不是严格的技术术语,一个组件常常两者混用。

选择的依据是灵活性和使用成本。非受控组件用起来简单,父组件不用管它;可是多个组件要协调时就不方便。受控组件最灵活,父组件可以随时改它、校验它、让它和别的组件联动,代价是父组件要把 props 全部配好。

输入框有几条硬规则:传了 value 就必须同时传 onChange,否则输入框变成只读;onChange 里要同步把 state 更新为 e.target.value;value 不能先是 undefined 后来变成字符串,因为 React 会把它从非受控切换成受控并发出警告,初值应该写成 ''。

两种输入框写法对比

JSX
import { useState } from 'react';

function Controlled() {
  const [name, setName] = useState('');
  return (
    <>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <p>你好,{name || '陌生人'}</p>
    </>
  );
}

function Uncontrolled() {
  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    console.log(formData.get('name'));
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" defaultValue="张三" />
      <button type="submit">提交</button>
    </form>
  );
}

受控版本里,每敲一个字都先进 onChange,更新 name,React 重新渲染,输入框再显示新的 name。所以下面的问候语能跟着实时变化。

非受控版本里,React 不知道用户输入了什么,要用的时候再去读,比如提交时用 FormData 按 name 取值,或者用 ref 读 DOM 的 value。

为什么受控的输入框必须同步更新

受控的意思是“React 强制输入框显示你传的 value”。所以只传 value 不传 onChange,用户敲字时 state 不变,输入框就一直显示原来的值,React 会在控制台报错,提示你要么加 onChange,要么改用 defaultValue,要么明确写 readOnly。

onChange 里的更新也有讲究。官方文档举了两个会出问题的写法:把 state 更新成跟 e.target.value 不一样的值,比如直接转大写;或者放进 setTimeout 里异步更新。这两种都可能让光标在每次按键后跳到开头。正确的做法是在 onChange 里同步地 setName(e.target.value)。

为什么不能在受控和非受控之间切换

value 是 undefined 时,React 认为输入框是非受控的;后来变成字符串,又成了受控的。React 不知道你到底想要哪种,就会报“A component is changing an uncontrolled input to be controlled”。

这个问题常见于数据从接口来的时候:user.name 一开始是 undefined,加载完才有值。官方的改法是初值写成 '',或者写 value={user.name ?? ''},保证 value 从头到尾都是字符串。复选框同理,checked 要始终是布尔值。

自己写组件时怎么同时支持两种用法

很多组件库的组件既能受控也能非受控:传了 value 就听父组件的,没传就用自己的内部状态。下面用一个普通对象模拟这个判断,省掉了 React 的渲染过程:

JavaScript
function createToggle({ value, defaultValue = false, onChange } = {}) {
  let inner = defaultValue;
  const isControlled = value !== undefined;
  return {
    get checked() {
      return isControlled ? value : inner;
    },
    click() {
      const next = !this.checked;
      if (!isControlled) inner = next;
      onChange?.(next);
    },
  };
}

const uncontrolled = createToggle({ defaultValue: false });
uncontrolled.click();
console.log(uncontrolled.checked); // true

let parentState = false;
const controlled = createToggle({ value: parentState, onChange: (next) => { parentState = next; } });
controlled.click();
console.log(controlled.checked, parentState); // false true

非受控的开关点一下就变了,因为它改的是自己的 inner。受控的开关点一下,checked 还是 false,只是通过 onChange 把新值告诉了父组件。为什么不自己变?因为受控组件的值只能由父组件传进来,在 React 里,父组件 setState 后重新渲染,把新的 value 传下来,开关才会变。这就是“单一数据源”。

常见追问

表单应该选受控还是非受控?

看什么时候要用这个值。输入时就要校验、格式化或联动,用受控;只在提交时要值,用非受控加 FormData 更省事。具体对比见《受控组件与非受控组件应该如何选择?》。

受控输入框每敲一个字都渲染,会不会卡?

如果 state 所在的组件下面有一棵很大的树,可能会。官方的建议是把 state 放到离输入框更近的组件里,避免整棵树跟着渲染;实在避免不了,可以用 useDeferredValue 让输入框保持响应。

改了 defaultValue,为什么输入框没变?

defaultValue 只决定初值,之后的值由输入框自己保存。想让它重新取初值,可以给它换一个 key,让 React 重新创建这个输入框。