【翻译】React 中受控组件与非受控组件

0 阅读7分钟

原文链接:certificates.dev/blog/contro…

理解React中的Controlled与Uncontrolled组件,关键在于一个问题:状态归谁所有?掌握表单输入与组件设计模式中的两种含义。

作者:Aurora Scharff

React 文档中频繁出现受控和非受控这两个术语,但它们在不同上下文中似乎具有不同含义,容易造成混淆。实际上,它们都在探讨同一个核心问题:状态的所有权归属——是父组件还是子组件本身?

本文将厘清这些术语的含义,并帮助你识别日常 React 代码中的模式。

核心问题:谁拥有状态?

受控与非受控的两种用法本质相同:

  • Controlled:状态由外部拥有并通过 props 传递
  • Uncontrolled:状态由组件自身内部拥有

对于表单输入,"内部状态"指 DOM 管理其值。对于自定义组件,"内部状态"通常指React的useState。但本质问题相同:是父组件控制状态,还是组件内部管理状态?

这种区别至关重要,因为它决定了"真实数据源"的存放位置及可修改权限。

表单输入:通用介绍

这些术语通常通过表单输入进行说明,核心问题在于:父组件是通过 props 控制输入值,还是输入通过 DOM 内部自行管理值。

受控输入的值由父组件通过 React 状态管理:

function SearchForm() {
  const [query, setQuery] = useState('');

  function handleSubmit(event) {
    event.preventDefault();
    console.log(query);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  );
}

React 完全控制此输入框的值。value 属性决定显示内容,onChange 事件则更新状态。任何更新query的行为——无论是输入文字、"清除"按钮还是选择建议项——都会更新输入框。

这意味着 valueonChange 必须协同工作。若仅传递 value 而未设置 onChange,则无法在输入框中输入内容——React 会将每次键入都还原为指定的值。

非受控输入通过 DOM 内部管理其值:

function SearchForm() {
  function handleSubmit(event) {
    event.preventDefault();
    const formData = new FormData(event.target);
    console.log(formData.get('query'));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="query" defaultValue="" />
      <button type="submit">Search</button>
    </form>
  );
}

React 并不知道这个输入框的值——它既不设置也不更新该值,更不知道值何时发生变化。defaultValue 属性仅用于设置初始值;此后,DOM 便掌控了状态。

组件:props 与内部状态

除了表单,"受控"与"不受控"描述了一种通用的组件设计模式。同样的问题依然存在:父组件是通过 props 拥有状态,还是由组件内部管理状态?

考虑一个简单的 Toggle 组件:

function Toggle({ label }) {
  const [isOn, setIsOn] = useState(false);

  return (
    <button onClick={() => setIsOn(!isOn)}>
      {label}: {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

该组件使用了useState,因此React在管理状态。但这并不意味着它在组件设计意义上属于"受控组件"。父组件无法读取或设置切换按钮的值:

function App() {
  return <Toggle label="Dark Mode" />;
}

由于父组件无法影响状态,此Toggle组件属于非受控组件——它管理自身的内部状态,父组件仅负责渲染。

实现控件化组件

要使Toogle组件成为控件组件,需将状态移至父组件并通过 props 传递:

function Toggle({ label, isOn, onToggle }) {
  return (
    <button onClick={onToggle}>
      {label}: {isOn ? 'ON' : 'OFF'}
    </button>
  );
}

function App() {
  const [darkMode, setDarkMode] = useState(false);

  return (
    <>
      <Toggle
        label="Dark Mode"
        isOn={darkMode}
        onToggle={() => setDarkMode(!darkMode)}
      />
      <p>Theme: {darkMode ? 'dark' : 'light'}</p>
    </>
  );
}

现在App控制切换按钮的值。它能读取状态来更新主题、重置状态或协调多个切换按钮。该组件通过 props 渲染而非内部状态。

请注意这与原生输入元素的对应关系:受控输入使用 valueonChange 属性,而我们的受控Toggle组件采用类似模式,使用 isOnonToggle 属性。基于原生 HTML 元素设计自定义组件,能使 API 更直观且可预测。

支持双模式

部分组件支持受控与非受控两种模式,如同原生 <input> 元素。以我们的Toggle组件为例,实现方式如下:

function Toggle({ label, isOn, onToggle, defaultOn = false }) {
  const [internalOn, setInternalOn] = useState(defaultOn);

  const isControlled = isOn !== undefined;
  const on = isControlled ? isOn : internalOn;

  function handleToggle() {
    if (isControlled) {
      onToggle?.();
    } else {
      setInternalOn(!internalOn);
    }
  }

  return (
    <button onClick={handleToggle}>
      {label}: {on ? 'ON' : 'OFF'}
    </button>
  );
}

这使得使用方式灵活:

// Uncontrolled - Toggle manages its own state
<Toggle label="Notifications" defaultOn={true} />

// Controlled - Parent manages the state
<Toggle label="Dark Mode" isOn={darkMode} onToggle={() => setDarkMode(!darkMode)} />

关键在于通过检查 isOn !== undefined 来确定模式。受控模式下,组件使用 props 并通过 onToggle 将变更委托给父级;无控模式下,组件内部管理自身状态。

重要规则:组件在其生命周期内不应在受控与无控模式间切换。若 isOn 初始为 undefined 随后变为有效值(或反之),行为将变得不可预测。你可能见过"组件正在将非受控输入转换为受控输入"的警告——这是 React 提示输入模式发生切换,自定义组件也应遵循相同原则。

许多 UI 库都采用此模式。但同时支持两种模式会增加复杂度。对于简单组件,通常更适合选择单一模式。

实际应用示例

受控/非受控模式贯穿于 React UI 库的各个组件:

  • Tabs:非受控选项卡组件在内部管理当前选中的标签页。受控版本则接受 activeTabonTabChange 属性,允许父组件通过编程方式切换标签页或与 URL 状态同步。
  • Modals:非受控模态框可能持有内部 isOpen 状态并暴露trigger元素。受控模态框接受 isOpenonClose 属性,允许父组件根据应用状态控制可见性。
  • Accordions:非受控折叠面板让每个面板自主管理开合状态。受控版本接受 expandedPanelsonChange 属性,实现需要协调的"单面板展开"行为。
  • Date Pickers:非受控日期选择器内部管理选项。受控版本接受 selectedDateonChange 属性,允许父组件验证日期、与其他字段同步或实现日期范围约束。

核心要义在于受控组件能实现协同。当多个组件需保持同步,或父组件需实现复杂行为时,受控组件便成为实现途径。

权衡与考量

选择控制与非控制归根结底取决于一个问题:父母是否需要了解或影响孩子的状态?

表单输入

对于表单而言,非受控输入通常更为简洁。您可让 DOM 处理状态,并在提交时通过 FormData 读取值。这种方法与 React 的现代表单特性(如 <form>action 属性、useFormStatususeActionState)配合良好。

当需要在用户输入时获取值时,受控输入便显得尤为重要——例如实时验证、转换输入(如格式化电话号码)或协调多个字段。其权衡在于:受控输入会在每次按键时重新渲染,而无控输入则避免了这种情况。

诸如 React Hook Form 之类的表单库通过默认使用带 ref 的无控输入来平衡性能需求,同时仍提供验证和错误处理功能。当父组件需要访问中间值时,它们通过 Controller 组件支持受控输入。

自定义组件

对于自定义组件,关键在于该组件应独立运行还是由父组件协调。

无控组件自主管理状态且开箱即用——父组件只需渲染即可。当状态属于父组件无需关心的实现细节时,这种模式更为简洁。

受控组件通过 props 接收状态,并在状态变更时通知父组件。这种模式支持协调机制:父组件可同步多个实例、实现"仅允许同时打开一个"等复杂行为,或将状态与应用程序其他部分集成。其代价是父组件需要更多连接逻辑。

构建可复用组件库时,同时支持两种模式能为使用者提供灵活性;但在应用程序代码中,选择单一模式能保持更简洁的结构。

结论

"受控"与"不受控"这两个术语始终指向同一个核心问题:状态归谁所有?无论是表单输入(其内部状态存在于DOM中)还是自定义组件(其内部状态存在于useState中),本质上都是在父组件的props与组件自身管理的内部状态之间进行选择。

关键洞见在于:使用 useState 并不使组件成为"受控"组件。从父组件视角看,若其无法影响子组件状态,该组件即为不受控。理解这一点有助于设计组件 API,为父组件提供符合其需求的恰当控制层级。


资料: