React 受控组件与非受控组件详解:从输入框到表单校验

React 表单状态管理:从受控与非受控组件到登录表单校验

表单的核心任务是收集用户输入,但"输入值由谁保存"会直接影响组件的写法。

在 React 中,常见的处理方式可以分为两类:

  • 受控组件:输入值保存在 React 状态中,页面展示的值由状态决定。
  • 非受控组件:输入值由真实 DOM 自己保存,需要时再通过引用读取。

本文会从最小输入框开始,逐步扩展到评论框、注册表单以及带实时校验的登录表单,重点讲清楚 useStateuseRef、统一变更处理、动态属性名和表单校验之间的关系。

一、先认识应用的运行入口

React 应用需要先找到 HTML 中的挂载节点:

html 复制代码
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>

入口脚本通过 createRoot 创建 React 根节点,再渲染最外层的 App 组件:

jsx 复制代码
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

这里的调用关系很清楚:

text 复制代码
index.html 中的 #root
        ↓
createRoot(...).render(...)
        ↓
       App
        ↓
各个表单演示组件

index.css 是当前实际引入的全局样式,其中设置了页面宽度、字体、颜色变量、明暗主题和响应式字号。组件列表则集中由 App 渲染:

jsx 复制代码
function App() {
  return (
    <>
      <ControlledInput />
      <UncontrolledInput />
      <CommentBox />
      <RegisterForm />
      <LoginForm />
    </>
  )
}

这些组件按照"单个输入框 → 提交时读取 → 多字段表单 → 实时校验"的顺序,展示了表单处理方式逐渐完整的过程。

二、受控组件:由 React 状态控制输入值

先看一个最小的受控输入框:

jsx 复制代码
import { useState } from "react";

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

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

关键不在于使用了 <input>,而在于 valueonChange 同时出现:

jsx 复制代码
value={value}
onChange={(e) => setValue(e.target.value)}

它形成了一个完整的数据流:

text 复制代码
用户输入
  ↓
触发 onChange
  ↓
从 e.target.value 取得最新文本
  ↓
setValue 更新状态
  ↓
组件重新渲染
  ↓
新的 value 回到 input

初始状态是空字符串:

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

因此第一次渲染时输入框为空。用户每输入一个字符,onChange 都会执行。事件对象 etarget 就是当前输入框,e.target.value 是它最新的文本值。

由于输入框显示什么始终由 value 决定,所以 React 状态是这份数据的来源。这就是"受控"的含义。

如果只写 value={value} 却不更新状态,输入框展示值会一直受旧状态约束,用户的输入无法正常反映出来。因此,受控输入通常需要同时提供状态值和对应的更新逻辑。

三、非受控组件:需要时直接读取 DOM

非受控输入不把每一次输入同步进 React 状态,而是使用 useRef 保存对 DOM 节点的引用:

jsx 复制代码
import { useRef } from "react";

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

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

  return (
    <>
      UncontrolledInput
      <input type="text" ref={inputRef} />
      <button onClick={handleClick}>获取输入值</button>
    </>
  );
}

这段代码可以分成三步理解。

1. 创建引用对象

jsx 复制代码
const inputRef = useRef(null);

组件第一次执行时还没有拿到真实输入框,所以初始值写成 null

2. 将引用交给输入框

jsx 复制代码
<input type="text" ref={inputRef} />

React 完成 DOM 挂载后,inputRef.current 会指向这个输入框节点。

3. 在需要时读取值

jsx 复制代码
const handleClick = () => {
  console.log(inputRef.current.value);
};

用户输入时没有调用 setState。只有点击按钮后,程序才访问 DOM 的 value 属性。因此,此时输入值主要由 DOM 自己维护,React 只保留了找到该节点的方式。

四、同一个评论提交需求,用非受控方式完成

useRef 不只可以引用单行输入框,也可以引用 textarea

jsx 复制代码
import { useRef } from "react";

function CommentBox() {
  const textareaRef = useRef(null);

  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return;
    console.log(comment);
  };

  return (
    <div>
      <textarea
        placeholder="输入评论..."
        ref={textareaRef}
      ></textarea>
      <button onClick={handleSubmit}>提交评论</button>
    </div>
  );
}

提交时首先读取当前文本:

jsx 复制代码
const comment = textareaRef.current.value;

紧接着通过提前返回拦截空内容:

jsx 复制代码
if (!comment) return;

只有 comment 有值时才会输出。这里体现了非受控组件很典型的使用节奏:平时让 DOM 保存输入,真正提交时再集中读取。

对比受控输入可以发现,评论框没有 value,也没有 onChange,所以用户每次键入都不会主动同步到 React 状态。

五、受控与非受控的核心区别

两种方式的差别可以归纳如下:

对比项 受控组件 非受控组件
输入值主要保存在哪里 React 状态 DOM 节点
常用 Hook useState useRef
如何获取值 直接读取状态变量 读取 ref.current.value
用户输入时 onChange 更新状态 DOM 自己更新值
代码中的典型组合 value + onChange ref

还可以从代码数量上观察多字段场景。非受控表单如果有用户名和密码两个输入框,通常需要分别引用两个 DOM 节点;而受控表单可以把多个字段放进同一个对象状态,再通过字段名统一更新。

这正是注册表单所展示的思路。

六、多个字段共用一个对象状态

用户名和密码属于同一张表单,可以统一保存到一个对象中:

jsx 复制代码
const [form, setForm] = useState({
  username: "",
  password: ""
});

此时状态结构与表单字段一一对应:

js 复制代码
{
  username: "当前用户名",
  password: "当前密码"
}

两个输入框分别读取对应属性:

jsx 复制代码
<input
  name="username"
  value={form.username}
  onChange={handleChange}
  placeholder="请输入用户名"
  type="text"
/>

<input
  name="password"
  value={form.password}
  onChange={handleChange}
  placeholder="请输入密码"
  type="text"
/>

这里给每个输入框设置 name 非常重要。name 不只是普通标记,它还决定了接下来要更新对象中的哪个属性。

七、用计算属性名统一更新字段

两个输入框共用同一个 handleChange

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

其中有两个关键点。

1. 展开旧表单

jsx 复制代码
...form

更新对象状态时,需要保留没有变化的其他字段。

假设当前状态为:

js 复制代码
{
  username: "张三",
  password: "123456"
}

如果用户只修改用户名,展开旧对象可以先保留 password,再用新用户名覆盖原来的 username

2. 动态决定属性名

jsx 复制代码
[e.target.name]: e.target.value

方括号表示计算属性名。输入框的 nameusername,就更新 form.username;输入框的 namepassword,就更新 form.password

等价过程可以直观地理解为:

js 复制代码
// 修改用户名时
setForm({
  ...form,
  username: e.target.value
});

// 修改密码时
setForm({
  ...form,
  password: e.target.value
});

这样便不需要分别编写 handleUsernameChangehandlePasswordChange,字段越多,统一处理的价值越明显。

提交按钮则直接读取完整状态:

jsx 复制代码
const handleSubmit = () => {
  console.log(form);
};

因为受控表单已经持续把最新输入同步到了 form,提交时不需要再查找 DOM。

八、从收集数据扩展到实时校验

一个更完整的登录表单同时维护两类状态:

jsx 复制代码
const [form, setForm] = useState({
  username: "",
  password: ""
});

const [errors, setErrors] = useState({});

form 保存用户输入,errors 保存每个字段对应的错误信息。两份状态职责不同:

js 复制代码
form = {
  username: "ab",
  password: "123"
}

errors = {
  username: "用户名长度不能小于3",
  password: "密码长度不能小于6"
}

把输入值和错误信息分开后,校验逻辑与界面展示就更容易组织。

九、按字段名称执行不同校验规则

校验函数接收字段名和字段值:

jsx 复制代码
const validate = (name, value) => {
  let msg = "";

  if (name === "username") {
    if (!value) {
      msg = "用户名为空";
    } else if (value.length < 3) {
      msg = "用户名长度不能小于3";
    }
  }

  if (name === "password") {
    if (!value) {
      msg = "密码不能为空";
    } else if (value.length < 6) {
      msg = "密码长度不能小于6";
    }
  }

  setErrors(prev => ({
    ...prev,
    [name]: msg
  }));
};

校验规则完全由 name 决定:

  • username 不能为空,并且长度不能小于 3。
  • password 不能为空,并且长度不能小于 6。
  • 没有错误时,msg 保持为空字符串。

更新错误对象时使用了函数式写法:

jsx 复制代码
setErrors(prev => ({
  ...prev,
  [name]: msg
}));

prev 表示更新前的错误对象。先展开 prev,可以保留另一个字段的错误;随后用 [name] 更新当前字段。

例如当前用户名和密码都有错误,此时只重新校验用户名,就只覆盖 errors.username,不会把 errors.password 丢掉。

十、一次输入事件同时更新数据和错误

登录表单的变更处理函数先解构事件目标:

jsx 复制代码
const handleChange = e => {
  const { name, value } = e.target;

  setForm({
    ...form,
    [name]: value
  });

  validate(name, value);
};

一次 onChange 完成两件事:

  1. 将最新值写进 form
  2. 用同一个最新值执行 validate,更新 errors

这里校验使用的是事件中刚刚取得的 value,所以校验目标就是用户本次输入后的文本。

两个字段继续共享同一个处理函数:

jsx 复制代码
<input
  type="text"
  name="username"
  value={form.username}
  onChange={handleChange}
/>

<input
  type="text"
  name="password"
  value={form.password}
  onChange={handleChange}
/>

这说明 nameform 的属性名以及 errors 的属性名需要保持一致:

text 复制代码
input.name: username
form 属性: username
errors 属性: username

只有命名对应起来,动态属性更新和错误展示才能正确找到同一个字段。

十一、根据错误状态显示提示

错误提示采用条件渲染:

jsx 复制代码
{errors.username && (
  <span className="error">{errors.username}</span>
)}

errors.username 是空字符串或不存在时,不渲染 <span>;当它包含错误消息时,再显示具体文字。

密码错误的处理方式相同:

jsx 复制代码
{errors.password && (
  <span className="error">{errors.password}</span>
)}

因此,校验函数只负责生成消息,JSX 负责决定是否展示消息,职责比较明确。

需要注意的是,登录表单单独引入了一个样式表:

jsx 复制代码
import "./index.css";

当前样式表没有具体规则,所以 login-wrapperlogin-cardform-itemerror 暂时只是类名,还没有对应的视觉效果。错误信息依然会显示,只是不会获得额外样式。

十二、计算整张表单是否可提交

登录按钮是否可用,由 isValid 决定:

jsx 复制代码
const isValid =
  form.username &&
  form.password &&
  !errors.username &&
  !errors.password;

这个表达式要求四个条件同时成立:

  1. 用户名有内容。
  2. 密码有内容。
  3. 用户名没有错误消息。
  4. 密码没有错误消息。

然后把结果交给按钮的 disabled

jsx 复制代码
<button type="submit" disabled={!isValid}>登陆</button>

当表单无效时,!isValid 为真,按钮禁用;满足全部条件后,按钮恢复可用。

这里的 isValid 没有单独使用一个 useState,而是直接由现有的 formerrors 计算得到。每当这两份状态变化并触发重新渲染时,isValid 都会重新计算。

十三、使用 form 的 onSubmit 统一处理提交

登录表单与前面按钮点击提交的示例略有不同,它使用了原生表单的提交事件:

jsx 复制代码
<form className="login-card" onSubmit={handleSubmit}>
  {/* 表单内容 */}
  <button type="submit" disabled={!isValid}>登陆</button>
</form>

处理函数首先阻止表单默认行为:

jsx 复制代码
const handleSubmit = e => {
  e.preventDefault();
  if (!isValid) return;
  console.log("--------", form, "--------");
};

代码有两层保护:

  • 界面层通过 disabled={!isValid} 禁用按钮。
  • 逻辑层通过 if (!isValid) return 再次阻止无效数据继续提交。

通过校验后,form 已经包含用户名和密码的最新值,因此可以直接作为完整表单数据使用。

十四、完整登录表单

把状态管理、统一变更、校验、错误提示和提交组合起来,完整结构如下:

jsx 复制代码
import { useState } from "react";
import "./index.css";

function LoginForm() {
  const [form, setForm] = useState({
    username: "",
    password: ""
  });
  const [errors, setErrors] = useState({});

  const validate = (name, value) => {
    let msg = "";

    if (name === "username") {
      if (!value) {
        msg = "用户名为空";
      } else if (value.length < 3) {
        msg = "用户名长度不能小于3";
      }
    }

    if (name === "password") {
      if (!value) {
        msg = "密码不能为空";
      } else if (value.length < 6) {
        msg = "密码长度不能小于6";
      }
    }

    setErrors(prev => ({
      ...prev,
      [name]: msg
    }));
  };

  const handleChange = e => {
    const { name, value } = e.target;

    setForm({
      ...form,
      [name]: value
    });

    validate(name, value);
  };

  const isValid =
    form.username &&
    form.password &&
    !errors.username &&
    !errors.password;

  const handleSubmit = e => {
    e.preventDefault();
    if (!isValid) return;
    console.log("--------", form, "--------");
  };

  return (
    <div className="login-wrapper">
      <form className="login-card" onSubmit={handleSubmit}>
        <h2>登陆</h2>

        <div className="form-item">
          <label>用户名</label>
          <input
            type="text"
            name="username"
            value={form.username}
            onChange={handleChange}
          />
          {errors.username && (
            <span className="error">{errors.username}</span>
          )}
        </div>

        <div className="form-item">
          <label>密码</label>
          <input
            type="text"
            name="password"
            value={form.password}
            onChange={handleChange}
          />
          {errors.password && (
            <span className="error">{errors.password}</span>
          )}
        </div>

        <button type="submit" disabled={!isValid}>登陆</button>
      </form>
    </div>
  );
}

export default LoginForm;

这份表单的数据流可以概括为:

text 复制代码
用户输入
   ↓
handleChange
   ├── 更新 form
   └── validate → 更新 errors
                    ↓
          条件渲染错误信息
                    ↓
        重新计算 isValid
                    ↓
        控制提交按钮是否禁用
                    ↓
          handleSubmit 提交

十五、集中导出组件,简化导入路径

当组件数量增加时,可以使用一个清单模块集中导入并导出:

jsx 复制代码
import ControlledInput from "./ControlledInput";
import UncontrolledInput from "./UncontrolledInput";
import CommentBox from "./CommentBox";
import RegisterForm from "./RegisterForm";
import LoginForm from "./LoginForm";

export {
  ControlledInput,
  UncontrolledInput,
  CommentBox,
  RegisterForm,
  LoginForm,
};

使用方就可以从同一个路径一次性导入:

jsx 复制代码
import {
  ControlledInput,
  UncontrolledInput,
  CommentBox,
  RegisterForm,
  LoginForm
} from "./components";

这种写法把"组件目录里可以对外使用哪些组件"集中列在一起,也让最外层组件的导入语句更整洁。

十六、样式与组件的实际连接关系

全局入口引入了 index.css

jsx 复制代码
import './index.css'

因此其中的规则会生效,例如:

  • 使用 CSS 变量保存文字色、背景色、边框色和强调色。
  • 通过 prefers-color-scheme: dark 切换暗色变量。
  • #root 设置为有最大宽度的纵向 Flex 容器。
  • 在较窄屏幕下调整标题和基础字号。

另有一组 .counter.hero#center 等样式没有被当前组件结构使用,并且对应的 App.css 也没有被入口或 App 引入,所以它们不会影响这几个表单示例。判断一段样式是否生效,既要看选择器是否匹配页面元素,也要看样式文件是否进入了导入链路。

十七、几个容易混淆的细节

1. useRef 保存的是引用,不是输入值状态

jsx 复制代码
const inputRef = useRef(null);

这里创建的是一个带有 current 属性的对象。输入内容仍在 DOM 输入框中,读取时才通过 inputRef.current.value 取得。

2. 受控输入的状态必须与 value 对应

jsx 复制代码
value={form.username}
name="username"

value 决定显示内容,name 决定统一处理函数更新哪个属性。二者职责不同,但字段名需要保持一致。

3. 更新对象时不能只留下当前字段

多字段表单采用:

jsx 复制代码
setForm({
  ...form,
  [name]: value
});

...form 用于保留其他字段。如果只写 { [name]: value },新对象将只包含当前修改的字段。

4. 空字符串同时承担"没有错误"的含义

校验函数以空字符串初始化消息:

jsx 复制代码
let msg = "";

校验通过后,错误属性会被更新为空字符串。条件渲染利用空字符串是假值,不再展示错误提示;!errors.username 也会变为真,参与表单有效性判断。

5. 示例中的密码输入框仍然是文本类型

登录和注册示例都写成了:

jsx 复制代码
<input type="text" name="password" />

因此输入内容会按普通文本显示。这不影响本文讨论的受控状态、统一变更和校验数据流,但它是当前界面行为的一部分。

总结

从单个输入框到完整登录表单,核心知识可以串成一条清晰的主线:

  1. 使用 value + onChange + useState 构成受控组件。
  2. 使用 ref + useRef 在需要时读取非受控组件的 DOM 值。
  3. 使用对象状态统一保存多个表单字段。
  4. 依靠输入框的 name 和计算属性名 [name] 共用变更处理函数。
  5. formerrors 分开保存,分别表达输入数据和校验结果。
  6. onChange 中同步更新字段并执行实时校验。
  7. 根据错误信息和字段内容计算 isValid,同时控制按钮与提交逻辑。
  8. 通过集中导出组件简化上层组件的导入代码。

受控与非受控并不是两套相互割裂的语法,它们只是选择了不同的数据保存位置。理解"当前输入值究竟由 React 状态维护,还是由 DOM 维护",就抓住了两者最本质的区别;在此基础上,多字段更新、错误状态和提交校验都只是沿着同一条数据流继续扩展。

相关推荐
BreezeJiang1 小时前
写了 display:flex,为什么三栏布局还没完成?
前端·css
Hilaku1 小时前
高级前端如何优雅地拒绝不合理的产品需求?
前端·javascript·程序员
独立开阀者_FwtCoder1 小时前
这次更新:换新主题,同时把好计划分享出去
前端·javascript·vue.js
禁止摆烂_才浅1 小时前
JavaScript WebAPI(进阶)高频面试题
前端·javascript·面试
禁止摆烂_才浅1 小时前
ES6+ 高频面试题
前端·javascript·面试
J船长1 小时前
Vscode/Cursor开了太多项目怕搞混?上Peacock啊
前端
禁止摆烂_才浅1 小时前
React Hooks 高频面试题
前端·react.js·面试
禁止摆烂_才浅1 小时前
React 高频面试题
前端·react.js·面试
禁止摆烂_才浅1 小时前
JavaScript 高级面试题
前端·javascript·面试