React 表单双雄:受控 vs 非受控组件,从原理到表单校验的完整实战

2 阅读11分钟

React 表单双雄:受控 vs 非受控组件,从原理到表单校验的完整实战

表单是前端最核心的交互场景之一。React 对表单的处理有两种截然不同的哲学——受控组件让 React 状态成为"唯一数据源",非受控组件则让 DOM 自己管理数据、用 useRef 按需读取。本文从数据控制权之争出发,带你完整掌握两种模式的原理、单字段表单、多字段表单、表单校验、组件清单模式,以及实战中的选型策略。全文代码可直接运行,建议收藏后动手实践。


一、表单的本质:数据归谁管?

HTML 表单元素(input、textarea、select)天然有自己的状态——用户输入时,DOM 节点自己维护着当前值。React 的设计哲学是"状态驱动 UI",这就产生了一个根本矛盾:

HTML 表单:DOM 自己管数据
    用户输入 → DOM 更新 value → 页面显示
    React 不知道数据变了

React 理念:状态驱动 UI
    setState → React 重新渲染 → DOM 更新
    一切数据由 React 状态控制

矛盾:表单数据到底归谁管?

React 给出了两种解法:

┌──────────────────────────────────────────────────────────┐
│                   React 表单两种模式                      │
│                                                          │
│  受控组件(Controlled)                                   │
│  ┌─────────────────────────────────────────┐            │
│  │  React State 是唯一数据源                │            │
│  │                                         │            │
│  │  useState → value={state}               │            │
│  │             onChange → setState         │            │
│  │                                         │            │
│  │  数据流:State → UI → 用户输入 → State  │            │
│  │  React 完全掌控数据                      │            │
│  └─────────────────────────────────────────┘            │
│                                                          │
│  非受控组件(Uncontrolled)                               │
│  ┌─────────────────────────────────────────┐            │
│  │  DOM 自己管数据,React 按需读取          │            │
│  │                                         │            │
│  │  useRef → ref={ref}                     │            │
│  │           需要时 ref.current.value       │            │
│  │                                         │            │
│  │  数据流:用户输入 → DOM → 按需读取       │            │
│  │  React 不干预过程,只在需要时拿数据       │            │
│  └─────────────────────────────────────────┘            │
└──────────────────────────────────────────────────────────┘

二、受控组件:React 全权掌控

2.1 最简单的受控组件

// ControlledInput.jsx
import { useState } from 'react';

// 受控组件:响应式状态控制 input
// 收集用户输入,React 状态是唯一数据源
function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <>
      <p>ControlledInput</p>
      <input
        type="text"
        value={value}                    // State 驱动 UI
        onChange={(e) => setValue(e.target.value)}  // UI 变化驱动 State
      />
      <p>当前值:{value}</p>
    </>
  );
}

export default ControlledInput;

数据流详解:

初始渲染:value = ''
    │
    ▼
<input value="" />  ←──── State 驱动渲染
    │
    │  用户输入 "H"
    ▼
onChange 触发
    │
    ▼
setValue('H')  ←──── 更新 State
    │
    ▼
React 重新渲染
    │
    ▼
<input value="H" />  ←──── 新 State 驱动渲染
    │
    │  用户输入 "i"(变成 "Hi")
    ▼
onChange 触发 → setValue('Hi') → 重新渲染
    │
    ▼
<input value="Hi" />

核心特征:每输入一个字符,都会经历 onChange → setState → 重新渲染 的完整循环。React 始终知道数据的最新值。

2.2 多字段受控表单:单一状态对象

当表单有多个字段时,不需要为每个字段创建独立的 useState,可以用一个状态对象统一管理:

// RegisterForm.jsx
import { useState } from 'react';

function RegisterForm() {
  const [form, setForm] = useState({
    username: '',
    password: ''
  });

  // 统一的 change 处理函数
  // 利用 name 属性动态更新对应字段
  const handleChange = (e) => {
    const { name, value } = e.target;
    setForm(prev => ({
      ...prev,              // 展开之前的状态
      [name]: value         // 动态 key 更新对应字段
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('提交注册:', form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="username"
        value={form.username}
        onChange={handleChange}
        placeholder="请输入用户名"
        type="text"
      />
      <input
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="请输入密码"
        type="password"
      />
      <button type="submit">提交</button>
    </form>
  );
}

export default RegisterForm;

关键技巧:动态 key 更新

// input 的 name 属性 = state 的 key
<input name="username" ... />   // e.target.name === 'username'
<input name="password" ... />   // e.target.name === 'password'

// handleChange 中用计算属性名动态更新
const { name, value } = e.target;
setForm(prev => ({
  ...prev,
  [name]: value   // [name] 是动态 key,等价于 { username: value } 或 { password: value }
}));

为什么用 prev => ({ ...prev, [name]: value }) 而不是 { ...form, [name]: value }?

// ⚠️ 潜在问题:使用 form(闭包中的旧值)
const handleChange = (e) => {
  setForm({ ...form, [name]: value });
  // 如果用户快速连续输入,form 可能是过时的闭包值
};

// ✅ 安全:使用 prev(React 保证是最新的)
const handleChange = (e) => {
  setForm(prev => ({ ...prev, [name]: value }));
  // 函数式更新,React 保证 prev 是最新的 state
};

2.3 受控表单 + 校验:LoginForm 完整实战

// LoginForm.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(prev => ({
      ...prev,
      [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="password"
            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;

校验流程图:

用户在 username 输入框输入 "ab"
        │
        ▼
handleChange 触发
        │
        ├──▶ setForm: { username: 'ab', password: '' }
        │
        └──▶ validate('username', 'ab')
                  │
                  ▼
              'ab'.length < 3 → msg = '用户名长度不能小于 3'
                  │
                  ▼
              setErrors: { username: '用户名长度不能小于 3', password: '' }
                  │
                  ▼
        React 重新渲染
        │
        ├── <input value="ab" />         ← 更新后的值
        ├── <span>用户名长度不能小于 3</span>  ← 显示错误
        └── <button disabled>登录</button>  ← 按钮禁用

用户继续输入 "abc"(长度 ≥ 3)
        │
        ▼
        validate('username', 'abc') → msg = ''
        │
        ▼
        setErrors: { username: '', password: '' }
        │
        ▼
        isValid = true → 按钮启用 ✅

2.4 受控组件的完整数据模型

LoginForm 状态结构:

┌─────────────────────────────────────────┐
│  form: { username: '', password: '' }   │  ← 表单数据
├─────────────────────────────────────────┤
│  errors: { username: '', password: '' } │  ← 校验错误
├─────────────────────────────────────────┤
│  isValid: boolean                        │  ← 派生值(计算得出)
└─────────────────────────────────────────┘

数据流:
  用户输入 → handleChange
    ├── setForm(更新表单数据)
    └── validate → setErrors(更新校验状态)
         │
         ▼
  重新渲染
    ├── input value ← form(受控显示)
    ├── error span ← errors(条件渲染)
    └── button disabled ← !isValid(按钮状态)

三、非受控组件:DOM 自治,按需读取

3.1 最简单的非受控组件

// UncontrolledInput.jsx
import { useRef } from 'react';

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

  const handleClick = () => {
    // 需要时才通过 ref 读取 DOM 中的值
    console.log(inputRef.current.value);
  };

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

export default UncontrolledInput;

与受控组件的核心差异:

受控组件:
  <input value={state} onChange={setState} />
  React 始终知道 value 是什么
  每输入一个字符都触发渲染

非受控组件:
  <input ref={inputRef} />
  React 不管 value,DOM 自己维护
  需要时才 ref.current.value 读取
  输入过程不触发任何渲染

3.2 非受控评论框

// CommentBox.jsx
import { useRef } from 'react';

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

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

    console.log('评论内容:', comment);

    // 提交后清空 textarea
    textareaRef.current.value = '';
  };

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

export default CommentBox;

为什么评论框适合用非受控?

评论框的特点:
  - 用户输入过程中不需要实时校验
  - 不需要实时显示字数统计
  - 不需要根据输入内容禁用/启用按钮
  - 只在提交时才需要拿到值

→ 没有必要每次输入都触发重新渲染
→ 非受控组件更高效

四、受控 vs 非受控:如何选型?

4.1 核心对比

维度受控组件非受控组件
数据源React StateDOM 自身
读取方式直接读 stateref.current.value
实时性每次输入都同步到 State需要时才读取
重新渲染每次输入触发渲染输入过程不渲染
校验实时校验,即时反馈提交时校验
默认值useState(defaultValue)defaultValue 属性
代码量较多(state + onChange)较少(ref + 按需读取)
可控性完全可控有限控制

4.2 选型决策树

表单需要实时校验吗?
│
├── 是 → 受控组件
│   ├── 需要实时显示错误信息
│   ├── 需要根据输入禁用/启用按钮
│   └── 需要格式化输入内容(如手机号)
│
└── 否 → 表单字段多吗?
    │
    ├── 多(>5个字段)且只需提交时取值
    │   └── 非受控组件(性能更好)
    │
    └── 少,且需要实时处理
        └── 受控组件

4.3 典型场景对照

适合受控组件的场景:
  ✅ 登录/注册表单(需要校验)
  ✅ 搜索框(需要实时搜索建议)
  ✅ 手机号/金额输入(需要格式化)
  ✅ 联动表单(省市区级联)
  ✅ 需要禁用提交按钮的表单

适合非受控组件的场景:
  ✅ 评论框(提交时取值即可)
  ✅ 文件上传(input type="file" 必须非受控)
  ✅ 简单的一次性表单
  ✅ 集成第三方 DOM 库(如富文本编辑器)
  ✅ 性能敏感的大表单

五、组件清单模式:index.js 统一导出

5.1 什么是 Barrel File?

当组件越来越多时,导入路径会变得冗长且混乱。Barrel File(桶文件/清单文件)是一种统一导出的架构模式:

// components/index.js —— 清单文件,统一导出所有组件
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
};

5.2 使用前后的对比

// ❌ 没有 index.js:每个组件都要写完整路径
import ControlledInput from './components/ControlledInput';
import UncontrolledInput from './components/UncontrolledInput';
import CommentBox from './components/CommentBox';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';

// ✅ 有 index.js:一行搞定,路径统一
import {
  ControlledInput,
  UncontrolledInput,
  CommentBox,
  RegisterForm,
  LoginForm
} from './components';

5.3 项目结构

src/
├── App.jsx                      # 根组件
├── components/                   # 组件目录
│   ├── index.js                # 清单文件(统一导出)
│   ├── ControlledInput.jsx     # 受控输入
│   ├── UncontrolledInput.jsx   # 非受控输入
│   ├── CommentBox.jsx          # 评论框(非受控)
│   ├── RegisterForm.jsx        # 注册表单(受控)
│   └── LoginForm.jsx           # 登录表单(受控+校验)
└── index.css                    # 样式文件

清单文件的价值:

  1. 导入路径统一:所有组件从 ./components 导入,不用记具体文件路径
  2. 增删组件只改一处:新增组件只需在 index.js 加一行导出
  3. API 清晰:index.js 就是组件库的"目录",一目了然有哪些组件
  4. 解耦:消费方不依赖组件的具体文件路径,文件重命名不影响消费方

六、App.jsx:组件展示墙

// App.jsx
import {
  ControlledInput,
  UncontrolledInput,
  CommentBox,
  RegisterForm,
  LoginForm
} from './components';

function App() {
  return (
    <>
      <ControlledInput />
      <UncontrolledInput />
      <CommentBox />
      <RegisterForm />
      <LoginForm />
    </>
  );
}

export default App;

通过清单文件导入,App 组件干净清爽——只关心"用哪些组件",不关心"组件在哪"。


七、表单进阶:defaultValue 与受控的中间态

7.1 非受控组件的默认值

// 非受控组件设置默认值用 defaultValue,不是 value
function UncontrolledWithDefault() {
  const inputRef = useRef(null);
  return (
    <input
      type="text"
      defaultValue="默认文字"   // ✅ 非受控用 defaultValue
      ref={inputRef}
    />
  );
}

// ⚠️ 非受控组件不能用 value(会变成受控)
<input type="text" value="固定值" />  // ❌ 这会让 input 变成只读
<input type="text" value={state} />   // ❌ 没有 onChange 会报警告

7.2 value 和 defaultValue 的区别

value 属性:
  ├── 受控模式:React 控制 value
  │   必须配合 onChange,否则 input 变成只读
  │   value={state} onChange={e => setState(e.target.value)}
  │
  └── 如果只写 value 不写 onChange
      React 会警告:提供了 value 但没有 onChange handler

defaultValue 属性:
  └── 非受控模式:只在初始渲染时设置 DOM 的 value
      之后 DOM 自行管理,React 不再干预
      需要时用 ref.current.value 读取

7.3 file input:必须非受控

// file input 是天生的非受控组件
// 因为它的 value 是只读的,只能由用户通过文件选择器设置
function FileUpload() {
  const fileRef = useRef(null);

  const handleSubmit = () => {
    const file = fileRef.current.files[0];
    console.log('选择的文件:', file.name);
  };

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

八、表单状态管理的设计模式

8.1 三种状态管理方式对比

// 方式一:每个字段独立 useState(适合字段少、无联动)
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');

// 方式二:单一状态对象(适合中等规模表单)
const [form, setForm] = useState({
  username: '',
  password: ''
});

// 方式三:useReducer(适合复杂表单,多字段联动、多动作)
const [state, dispatch] = useReducer(formReducer, initialState);
// dispatch({ type: 'CHANGE', name: 'username', value: 'abc' })
// dispatch({ type: 'RESET' })
// dispatch({ type: 'VALIDATE' })

8.2 单一状态对象的更新模式

// 更新单个字段
setForm(prev => ({
  ...prev,
  [name]: value
}));

// 更新多个字段
setForm(prev => ({
  ...prev,
  username: '',
  password: ''
}));

// 重置表单
setForm({
  username: '',
  password: ''
});

8.3 派生状态:不要把能算出来的值存进 state

// ❌ 不需要把 isValid 存进 state,它可以计算得出
const [isValid, setIsValid] = useState(false);
// 每次 handleChange 都要手动 setIsValid

// ✅ 直接计算,React 每次渲染都会重新计算
const isValid = form.username && form.password &&
  !errors.username && !errors.password;
// 自动响应 form 和 errors 的变化

九、总结

9.1 知识体系图

React 表单处理
│
├── 受控组件(Controlled)
│   ├── 原理:useState + value + onChange
│   ├── 数据流:State → UI → 用户输入 → State
│   ├── 单字段:ControlledInput
│   ├── 多字段:RegisterForm(单一状态对象 + 动态 key)
│   ├── 带校验:LoginForm(实时校验 + 错误状态 + 按钮禁用)
│   └── 适用:需要实时校验、格式化、联动的表单
│
├── 非受控组件(Uncontrolled)
│   ├── 原理:useRef + ref + 按需读取
│   ├── 数据流:用户输入 → DOM → 按需 ref.current.value
│   ├── 基础:UncontrolledInput
│   ├── 实战:CommentBox(textarea + 提交时读取)
│   ├── 默认值:defaultValue(不是 value)
│   └── 适用:简单表单、file input、性能敏感场景
│
├── 组件清单模式(Barrel File)
│   ├── index.js 统一导出
│   ├── 导入路径统一:from './components'
│   ├── 增删组件只改 index.js
│   └── 消费方与文件路径解耦
│
└── 表单设计模式
    ├── 状态管理:独立 useState / 单一对象 / useReducer
    ├── 派生状态:isValid 等可计算值不存入 state
    ├── 函数式更新:setForm(prev => ...) 避免闭包陷阱
    └── 校验策略:实时校验 vs 提交时校验

9.2 核心概念速查

概念要点
受控组件value={state} + onChange={setState},React 全权控制
非受控组件ref={ref} + 按需 ref.current.value,DOM 自治
value vs defaultValuevalue 是受控(需 onChange),defaultValue 是非受控(仅初始值)
单一状态对象多字段用 { ...prev, [name]: value } 统一管理
动态 key 更新name 属性映射 state key,[name]: value 动态更新
函数式更新setForm(prev => ...) 避免闭包中的旧值
派生状态isValid 等可计算值不存 state,每次渲染计算
Barrel Fileindex.js 统一导出,简化导入路径
file input天生非受控,value 只读

9.3 一句话总结

受控组件让 React 掌控一切——适合需要实时校验和复杂交互的表单;非受控组件让 DOM 自治——适合简单场景和性能敏感的大表单。选择的关键不是"哪个更好",而是"数据归谁管更合适"。


如果这篇文章对你有帮助,欢迎点赞和收藏!