React 受控组件与非受控组件:表单数据到底归谁管?

阅读前先想

  1. 一个 <input> 输入框里显示的文字,到底存在哪里------React 的 state 里,还是浏览器 DOM 里?
  2. 下面两种写法,哪种会在每次输入时触发重渲染,哪种不会?
  3. 如果一个组件根本没有 useState,它还能重渲染吗?

本文主线

从两段最简代码出发,回答同一个问题:表单数据由谁来管? 然后追踪用户每次按键后的完整执行链路------事件如何触发、数据流向哪里、页面是否重渲染------最终形成选择两种模式的判断标准。


表单数据归谁管

知识点

受控组件 把表单值存在 React state 里,输入框的显示内容由 state 驱动。非受控组件 把表单值留在浏览器 DOM 里,React 只在需要时通过 ref 读取。

理解这一点之前,先明确 React 的核心原则:UI 是 state 的投影。只要你管理好 state,界面就会自动跟随。受控组件完全遵守这一原则;非受控组件则在 state 体系之外开了一条"暗渠",数据直接放在 DOM 上,React 感知不到。

关键代码

两种模式的最简写法:

javascript 复制代码
// 非受控组件 ------ 值留在 DOM
import { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <>
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>获取输入值</button>
    </>
  );
}
javascript 复制代码
// 受控组件 ------ 值存在 state
import { useState } from 'react';

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

  return (
    <>
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
    </>
  );
}

运行过程

非受控组件 :用户输入 "hello" → DOM 的 value 从空变为 "h" 再变为 "he" 再到 "hello",整个过程 React 不感知,没有 setState,没有重渲染。用户点击按钮 → inputRef.current 指向真实的 DOM 节点 → 读取 .value 得到 "hello" → 打印到控制台。按钮点击后仍然没有重渲染,因为整个组件里根本没有 useState

受控组件 :用户按下 "h" → onChange 触发 → setValue("h") → state 从 "" 变为 "h" → React 调度重渲染 → 组件函数重新执行 → <input value="h"> 让 DOM 与 state 同步。用户再按 "e" → 上述流程再跑一遍。敲了 5 个字符,组件重渲染了 5 次。

为什么这样设计

React 选择把"受控"作为默认推荐,源于它的设计哲学:数据只在一个方向流动 。state 是唯一的真相来源,UI 只是 state 的投影------当输入框的 value 绑定到 state,你就永远不会遇到"state 说值是 A,但输入框显示的是 B"这种不一致。

非受控组件的存在则是务实的妥协:不是所有场景都需要 React 管表单。一个搜索框的下拉提示需要实时读取输入值,用受控;一个"联系我们"的单行留言表单,用户填完点发送就完事,用非受控可以减少不必要的渲染。

容易混淆

误区一:"受控组件必须用 onChange,非受控组件不能用 onChange。"

非受控组件也可以加 onChange------比如你想在用户输入时做点别的事(打日志、更新外部状态),只要不把 value 绑到 state 上,它仍然是非受控的。区分两者的标准不是有没有 onChange,而是 value 是否由 React state 控制。

误区二:"非受控组件用 useRef,受控组件用 useState。"

Hook 只是手段,不是定义。一个组件即使用了 useState,如果没有把 state 传给 <input value={...}>,它就不是受控组件。判断标准只有一个:输入框的 value 属性是否由 React state 驱动


非受控组件:让 DOM 自己管

知识点

useRef 返回一个 { current: null } 形状的普通 JavaScript 对象。这个对象在组件的整个生命周期中引用不变 ------每次渲染拿到的是同一个对象。挂载时 React 把 DOM 节点赋值到 ref.current,卸载时置为 null

关键在于:修改 ref.current 不会触发重渲染。它只是一个普通对象属性的赋值,React 不知道也不关心它的变化。

关键代码

javascript 复制代码
const inputRef = useRef(null);
// 此时 inputRef 是 { current: null }

const handleClick = () => {
  console.log(inputRef.current.value);
  // 读取 DOM 节点的 value 属性,没有触发任何 React 更新
};

// JSX 里
<input type="text" ref={inputRef} />

运行过程

复制代码
用户输入文字
  → DOM 的 value 更新
  → React 不感知,无重渲染

用户点击按钮
  → handleClick 执行
  → inputRef.current.value 读取当前 DOM 值
  → 打印结果
  → 仍然无重渲染(没有 setState)

每一步都是因果链。关键点在于:React 只在挂载时把 DOM 节点绑到 inputRef.current,之后用户怎么输入,React 一概不管------需要时再去"瞄一眼" DOM。

为什么这样设计

useRef 的核心价值在于:在多次渲染之间保持一个不触发重新渲染的"储物箱" 。对于非受控表单来说,这个储物箱里放的就是 DOM 节点的引用。因为 DOM 自身的值会随用户输入而更新,不需要 React 来同步,所以也就没必要让每次读值都触发渲染。

但这同时也意味着一个限制:你只能在事件回调 (如 onClickonSubmit)中读取 ref.current.value,因为那时用户操作已经发生,值一定是最新的。如果你试图在渲染期间直接读 ref.current.value 并渲染到页面上,React 不会在值变化时自动更新------因为 ref 的变化不触发渲染。

容易混淆

"ref 的值是响应式的吗?"

不是。如果你写 <p>{inputRef.current?.value}</p>,用户输入时这个 <p> 不会自动更新。因为 ref 的变化不是 state 变化,React 不知道需要重渲染。如果想让页面实时显示输入值,必须用 state------也就是受控组件。


受控组件:React 全权接管

知识点

受控组件的核心是由三个部分组成的闭环

perl 复制代码
state 值 → value={state} 传给 input
    → 用户输入触发 onChange
    → setState 更新 state
    → state 变化触发重渲染
    → 回到起点

这个闭环意味着任何时候,输入框里显示的内容都精确等于 state 的值------不多不少。

关键代码

ini 复制代码
const [value, setValue] = useState('');

return (
  <input
    type="text"
    value={value}
    onChange={(e) => setValue(e.target.value)}
  />
);

三行代码各自扮演明确角色:

  • value={value} --- state 驱动显示内容
  • onChange --- 用户操作的事件入口
  • setValue(e.target.value) --- 把用户输入写回 state,闭合循环

运行过程

perl 复制代码
用户按下 'a'
  → 浏览器更新 input 的 value(瞬态)
  → React 合成 onChange 事件
  → setValue('a') 被调用
  → React 将 state 从 '' 更新为 'a'
  → React 调度重渲染
  → 组件函数重新执行
  → value state 读为 'a'
  → <input value='a'> 让 DOM 与 state 同步

用户再按 'b' → 回到起点,循环一遍

一个微妙的事实:浏览器在触发 input 事件时已经暂时展示了新字符。但 React 随后用 value={state} 覆盖了它------如果 state 没更新,输入框会"弹回"旧值。这就是受控:React 有最终控制权,浏览器的瞬时更新不算数。

为什么这样设计

闭环设计带来两个关键收益:

第一,你可以随时操作值。onChange 里做任何事情------格式化手机号、限制只能输入数字、根据当前值动态调整 UI------因为这些逻辑都在 state 更新之前执行,更新后的 state 会自动反映到输入框。

第二,输入框和 state 永远不会不一致。 如果有多个来源想修改输入框内容(点击清空按钮、下拉选项自动填充),你只需要 setState,React 保证 DOM 跟上。不需要手动操作 DOM 和 JS 变量两边。


渲染行为的深层差异

知识点

React 在以下情况重渲染组件:state 变化、父组件重渲染、Context 值变化。两个组件的渲染差异正是源于它们对 setState 的依赖程度不同。

对比

对比项 受控组件 非受控组件
每次 keystroke 是否重渲染 是,setState 驱动 否,值在 DOM 里
点击按钮读值是否重渲染 读取 state,不额外渲染 读取 ref,不额外渲染
能否实时拿到最新值 随时,value state 只在事件回调中读 ref
值变化时 UI 是否自动更新 否,除非手动触发渲染

为什么这样设计

受控组件"每敲一下渲染一次"听起来像性能问题,但实际上对于单个输入框来说,一次 setState 触发的重渲染开销极小------React 的 reconciliation 算法只更新变化的部分。输入框本身在 diff 后发现 value 和上一次一致(state 已更新到新值),不会有额外的 DOM 操作。

不是性能缺陷,而是设计选择------用每次 keystroke 的一次渲染,换取"state 始终是最新的"这个保证。

使用边界

受控组件的"每个 keystroke 都渲染"在以下场景不可接受时,应考虑非受控或优化方案:高频输入(如实时协作编辑器的光标同步)、大量受控字段(几百个输入框同时渲染)、或需要在输入期间保持动画帧率的场景。这些在实际项目中极少遇到,不应该成为默认避开受控组件的理由。


什么时候用哪个

场景 推荐 原因
需要实时校验 受控 onChange 里拿到最新值即可校验
需要格式化输入 受控 setState 前处理值再写入
输入影响其他 UI 受控 state 变化自动驱动重渲染
仅表单提交时取值 两者皆可 非受控更轻量,受控更可控
集成第三方 DOM 库 非受控 让第三方直接操作 DOM,React 只读结果

React 官方文档的立场:大多数表单场景推荐受控组件。非受控组件是有用的逃生舱,但不是默认选择。


代码串起来

css 复制代码
flowchart TD
    subgraph 非受控组件
        A1["用户输入"] --> A2["DOM 更新 value"]
        A2 --> A3["React 不感知"]
        A3 --> A4["用户点击按钮"]
        A4 --> A5["ref.current.value 读取 DOM"]
        A5 --> A6["打印,无重渲染"]
    end

    subgraph 受控组件
        B1["用户输入"] --> B2["onChange 触发"]
        B2 --> B3["setState 更新 value"]
        B3 --> B4["React 调度重渲染"]
        B4 --> B5["组件重执行"]
        B5 --> B6["input value 同步 DOM"]
        B6 --> B7["循环:回到 B1"]
    end

左侧非受控:数据在 DOM 和 React 之间只有单向读取 ,路径短但 React 感知力弱。右侧受控:数据形成了闭合环,每次都经过 React,路径长但每一步都可控。


最后回顾

核心概念只有一句话:受控组件把表单值放在 state 里,非受控组件把表单值留在 DOM 里

推导出的设计原则:

  • 受控 = 每次输入都重渲染 = 数据实时可控 = 适合有交互逻辑的表单
  • 非受控 = 不触发重渲染 = 需要时才读值 = 适合简单取值的场景
  • useRefuseState 只是手段,判断标准是 value 是否由 React state 驱动
  • 单个输入框的 keystroke 渲染开销在 React 中可忽略,不必因此回避受控组件

相关推荐
嘟嘟07171 小时前
React 表单管理进阶:从单字段到带校验的完整登录表单
前端·javascript·react.js
光影少年1 小时前
react navite原生事件监听、全局事件通知
前端·react native·react.js
lvv1 小时前
不会被渲染的组件,为什么让我的首屏白屏了?(一次前端问题总结)
前端·webpack·性能优化
烬羽1 小时前
《memo 明明加了,为什么子组件还是渲染了?》
react.js·性能优化·全栈
做前端的娜娜子1 小时前
什么是浅拷贝(Shallow Copy)和深拷贝(Deep Copy)?如何手动实现一个深拷贝函数
javascript·面试·掘金·金石计划
xcLeigh1 小时前
编程语言的 AI 友好度排名:Python、JavaScript、TypeScript 谁更适合 AI 辅助
javascript·人工智能·python·ai·typescript·ai编程
码上成长2 小时前
微前端 Invalid hook call?先把「window 注入 + externals」整明白
前端·react.js·前端框架
宿6742 小时前
vue3-DOM树
前端·javascript·vue.js
谷哥的小弟2 小时前
TypeScript中的any
javascript·typescript