React 受控组件与非受控组件:从一个输入框讲清表单数据流

做表单时,我们经常会遇到两个看起来很像的写法:一种给输入框绑定 valueonChange,另一种只给它挂一个 ref,等提交时再读取内容。

两种写法都能拿到用户输入,但背后的数据流完全不同。真正需要判断的不是"哪种写法更高级",而是:输入框当前的值,到底由 React state 管理,还是由 DOM 自己管理?

本文结合一个 React 19 表单示例,从单个输入框开始,逐步讲清受控组件、非受控组件、多字段表单和实时校验。

一、先抓住核心:谁是唯一数据源

在表单场景中,可以用一句话区分两类组件:

  • 受控组件:表单值保存在 React state 中,页面显示什么由 state 决定。
  • 非受控组件:表单值保存在 DOM 节点中,React 在需要时通过 ref 读取。

它们的输入过程可以简化为:

text 复制代码
受控组件:用户输入 -> onChange -> 更新 state -> React 重新渲染 -> value 更新

非受控组件:用户输入 -> DOM 自己更新 value -> 点击提交 -> 通过 ref 读取

这个区别会继续影响实时校验、按钮状态、字段联动、重置方式以及组件的渲染行为。

二、受控组件:输入值始终经过 React

先看示例中的受控输入框:

jsx 复制代码
import { useState } from 'react'

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

  return (
    <>
      <label htmlFor="nickname">昵称</label>
      <input
        id="nickname"
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <p>当前输入:{value || '空字符串'}</p>
    </>
  )
}

这里有两个关键属性:

  • value={value}:输入框显示的内容来自 React state。
  • onChange={...}:用户每次输入时,将最新内容写回 state。

假设用户输入字母 A,过程如下:

  1. 浏览器触发 onChange
  2. 通过 event.target.value 取得 A
  3. setValue('A') 更新状态。
  4. 组件重新渲染。
  5. 新的 value="A" 再传给输入框。

因此,React state 是唯一数据源。我们既可以在输入框中显示这个值,也可以用它同步更新预览区、剩余字数或提交按钮。

为什么只写 value 不行

如果给输入框设置了 value,却没有在 onChange 中更新它:

jsx 复制代码
<input value={value} />

用户虽然在键盘上输入了内容,但下一次渲染时,React 仍会把旧的 value 交给输入框。结果就是输入框看起来无法编辑。

所以,受控输入框通常成对出现的是 value + onChange,复选框则通常是 checked + onChange

三、非受控组件:让 DOM 保存当前值

再看非受控输入框:

jsx 复制代码
import { useRef, useState } from 'react'

function UncontrolledInput() {
  const inputRef = useRef(null)
  const [submittedValue, setSubmittedValue] = useState('')

  const handleRead = () => {
    setSubmittedValue(inputRef.current.value)
  }

  return (
    <>
      <label htmlFor="message">留言</label>
      <input id="message" ref={inputRef} />
      <button type="button" onClick={handleRead}>
        读取 DOM 的值
      </button>
      <p>本次读取:{submittedValue || '尚未读取'}</p>
    </>
  )
}

这里没有给输入框绑定 value,用户输入后,内容直接保存在 DOM 节点中。inputRef.current 指向真实的 <input>,所以点击按钮时可以读取:

js 复制代码
inputRef.current.value

需要特别注意:useRef 保存引用,但修改 ref.current 不会触发组件重新渲染。

输入过程中,DOM 的值一直在变化,React 并不会因此重新执行组件函数。示例中的 submittedValue 只是为了在页面上展示"上一次读取到的结果",它不负责控制输入框。

初始值应该怎么设置

非受控组件如果需要初始值,应使用 defaultValue

jsx 复制代码
<input ref={inputRef} defaultValue="默认昵称" />

defaultValue 只负责初始内容,后续输入仍由 DOM 管理。若改用 value,组件就进入了受控模式。

四、两种方案放在一起比较

对比项 受控组件 非受控组件
数据保存位置 React state DOM 节点
读取方式 直接读取 state 通过 ref.current.value
输入时是否更新 state
实时校验 容易实现 通常在读取或提交时处理
字段联动 容易实现 需要手动读取和同步
重置内容 更新 state 操作 DOM 或重置原生表单
与非 React 代码集成 需要同步状态 通常更直接
典型场景 登录、注册、搜索筛选、动态表单 简单提交、文件选择、接入旧页面

这里没有绝对的优劣。受控组件提供了更明确的数据流,非受控组件则减少了输入过程中的状态同步。

五、多个字段如何共用一个 change 处理函数

真正的业务表单通常不止一个字段。示例中的注册表单使用一个对象统一管理用户名和密码:

jsx 复制代码
const initialForm = {
  username: '',
  password: '',
}

function RegisterForm() {
  const [form, setForm] = useState(initialForm)

  const handleChange = (event) => {
    const { name, value } = event.target

    setForm((previousForm) => ({
      ...previousForm,
      [name]: value,
    }))
  }

  return (
    <form>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
      />
      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
      />
    </form>
  )
}

关键是每个输入框的 name 与 state 中的字段名保持一致:

js 复制代码
[name]: value

这是 JavaScript 的计算属性名。当用户名输入框发生变化时,nameusername;当密码输入框发生变化时,namepassword。这样就不必为每个字段重复编写一个处理函数。

这里使用函数式更新:

js 复制代码
setForm((previousForm) => ({
  ...previousForm,
  [name]: value,
}))

它明确表示"基于上一次状态生成下一次状态",在连续更新或逻辑继续扩展时更稳妥。

六、受控表单为什么适合实时校验

因为输入内容已经在 state 中,我们可以在每次变化后立即校验:

jsx 复制代码
function validate(form) {
  const errors = {}

  if (!form.username.trim()) {
    errors.username = '请输入用户名'
  } else if (form.username.trim().length < 3) {
    errors.username = '用户名至少 3 个字符'
  }

  if (!form.password) {
    errors.password = '请输入密码'
  } else if (form.password.length < 6) {
    errors.password = '密码至少 6 个字符'
  }

  return errors
}

输入时计算下一份表单和错误信息:

jsx 复制代码
const handleChange = (event) => {
  const { name, value } = event.target
  const nextForm = { ...form, [name]: value }

  setForm(nextForm)
  setErrors(validate(nextForm))
}

提交时再做一次完整校验:

jsx 复制代码
const handleSubmit = (event) => {
  event.preventDefault()

  const nextErrors = validate(form)
  setErrors(nextErrors)

  if (Object.keys(nextErrors).length > 0) return

  console.log('提交表单', form)
}

最后,校验结果还可以控制按钮状态:

jsx 复制代码
const isValid = Object.keys(validate(form)).length === 0

<button type="submit" disabled={!isValid}>
  提交注册
</button>

这就是受控组件的主要价值:同一份 state 同时驱动输入框、错误提示、按钮状态和最终提交数据,页面行为更容易推导。

七、不要在受控和非受控之间来回切换

下面这种写法容易产生问题:

jsx 复制代码
const [value, setValue] = useState()

<input value={value} onChange={(e) => setValue(e.target.value)} />

初始的 valueundefined,输入框一开始相当于非受控;用户输入后,value 变成字符串,它又切换成了受控组件。

如果准备使用受控输入框,初始值也应是对应类型的有效值:

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

对于复选框,可以使用布尔值:

jsx 复制代码
const [agreed, setAgreed] = useState(false)

<input
  type="checkbox"
  checked={agreed}
  onChange={(event) => setAgreed(event.target.checked)}
/>

八、文件输入框是一个典型例外

浏览器出于安全原因,不允许应用随意设置文件输入框的值。因此,文件输入通常按非受控方式读取:

jsx 复制代码
function FilePicker() {
  const fileRef = useRef(null)

  const handleSubmit = () => {
    const file = fileRef.current.files[0]
    console.log(file)
  }

  return (
    <>
      <input ref={fileRef} type="file" />
      <button type="button" onClick={handleSubmit}>
        上传
      </button>
    </>
  )
}

这也说明了实际项目不必强行统一:同一个表单可以让文本字段受控,同时通过 ref 读取文件。

九、实际开发应该怎么选

以下情况通常优先使用受控组件:

  • 输入时就要校验并显示错误。
  • 一个字段会影响另一个字段。
  • 要根据内容禁用按钮或切换界面状态。
  • 要实现格式化、字符统计、搜索建议或实时预览。
  • 表单数据需要随时交给其他组件或业务逻辑使用。

以下情况可以考虑非受控组件:

  • 只在提交时读取一次值。
  • 表单非常简单,不需要实时反馈。
  • 需要接入依赖真实 DOM 的第三方代码。
  • 使用文件输入框。
  • 正在将旧的原生表单逐步迁移到 React。

还有一个实用判断:如果你频繁写 ref.current.value,随后又把结果同步进 state,很可能直接使用受控组件会更清晰。

十、总结

受控组件和非受控组件的本质,不在于有没有使用 Hook,而在于谁保存并决定当前值。

  • value + onChange + state 构成受控数据流。
  • ref + DOM value 构成非受控数据流。
  • 受控组件适合实时校验、字段联动和状态驱动的界面。
  • 非受控组件适合简单读取、DOM 集成和文件输入。
  • 多字段表单可以使用 name 与计算属性名复用更新逻辑。
  • 一旦选择受控模式,就应从初始渲染开始保持类型稳定。

表单写法没有固定答案。先确认数据需要在什么时候被读取、是否参与界面计算,再决定让 React 管理,还是让 DOM 暂时保管。


相关推荐
尤乐娃子1 小时前
进入大厂(厂子大)实习Day12
前端·笔记·实习
程序员爱钓鱼1 小时前
Rust Copy详解:隐式复制与轻量数据类型
前端·后端·rust
四六的六3 小时前
端侧模型多端部署实战:从格式转换到灰度发布,Web 和移动端统一部署流水线
前端·人工智能·大模型·ai编程·ai模型·ai产品·端侧ai
独隅5 小时前
前端离线暂停更新策略:Service Worker 与 PWA 的静默控制实践
前端
妙码生花5 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十八):后台系统配置管理实现
前端·后端·go
用户938515635076 小时前
React 受控/非受控组件与 React.memo 性能优化——从本质到实战
前端·javascript·react.js
IT_陈寒6 小时前
Java并行流把我坑惨了:原来不是线程安全的!
前端·人工智能·后端
计科土狗6 小时前
GESP六级专题之类与对象
java·前端·数据库