从零搞懂React受控与非受控组件——一个Demo串起表单处理全流程

从零搞懂React受控与非受控组件------一个Demo串起表单处理全流程

刚学 React 的时候,我被"受控""非受控"这两个词唬住了,觉得很高深。后来发现不过是"数据存在哪"的区别------就像你记账,要么记在自己脑子里(state),要么翻本子看(DOM)。这篇文章用 5 个层层递进的组件把它讲透。

前言

学 React 表单处理的时候,你一定会遇到两个词:受控组件非受控组件。刚开始我总搞混------哪个是 state 管,哪个是 ref 管?什么时候该用哪个?

这篇文章用一个完整的 demo 项目,从最基础的 <input> 到带校验的登录表单,把这两个概念一次讲清楚。

你将会收获:

  • 🎯 用大白话理解"受控"和"非受控"的本质区别
  • 🧩 手写从简单到复杂的 5 个组件,层层递进
  • 📊 一张对比表搞懂 useState vs useRef 的选择
  • 🔐 受控表单实时校验的完整实现思路
  • React.memo 性能优化的入门用法
  • 🏗️ 组件导出清单 index.js 的工程化习惯

技术栈: React 19 + Vite + JSX


一、用大白话讲清楚:什么叫"受控"和"非受控"

痛点场景

假设页面上有一个输入框,你想拿到用户输入的内容。最简单的想法是------"输入框里有什么,我去读一下不就行了?"

没问题,这确实是一种方式。但 React 还有另一种思路:"输入框里是什么,由我来决定"

这就是受控和非受控的本质区别。

大白话类比

受控组件:就像银行的柜员窗口------你填完单子递进去,柜员帮你处理,钱从哪来到哪去,每笔都记录在系统里。银行(React)全程掌握你账户的状态。

非受控组件:就像街边的自助取款机------你自己操作,取钱的时候才验一下身份。机器(DOM)只是工具,ATM 不关心你卡里还有多少钱。

换个更生活化的说法:

受控组件 非受控组件
类比 你口头告诉会计"记一笔账",会计写在账本上 你自己写在自己本子上,会计不管
数据存哪 React state DOM 节点
React 知道值吗 实时知道(每次按键都更新 state) 不知道(需要时才去 DOM 读取)
核心 API useState + value + onChange useRef + ref
一句话 "输入框的值,由我 React 说了算" "输入框的值,你自己管,我要的时候再问你"

数据流对比

perl 复制代码
受控组件(数据从 state → DOM,再通过 onChange → state):
┌─────────────┐    value属性     ┌──────────┐
│  useState   │ ──────────────→ │  <input> │
│  (value)    │                 │  (DOM)   │
└─────────────┘                 └──────────┘
       ↑                              │
       └──────── onChange ────────────┘
           每次按键都更新 state


非受控组件(数据留在 DOM,需要时用 ref 拿):
┌──────────┐       ref绑定        ┌──────────┐
│  useRef  │ ← ─ ─ ─ ─ ─ ─ ─ →  │  <input> │
│ (ref)    │                     │  (DOM)   │
└──────────┘                     └──────────┘
       │                              │
       └── ref.current.value ─────────┘
          需要时才读取,React 不追踪中间变化

二、受控组件:一切尽在 state 掌控中

2.1 最简受控输入------ControlledInput

先看一个极简的受控组件,只有 5 行核心代码:

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

function ControlledInput() {
  const [value, setValue] = useState('');  // state 是数据的"唯一真相来源"

  return (
    <>
      <h3>受控组件</h3>
      <input
        value={value}                          // ① input 的值由 state 决定
        onChange={(e) => setValue(e.target.value)}  // ② 每次输入都更新 state
      />
      <p>当前输入:{value}</p>               // ③ 实时显示,state 一变就渲染
    </>
  )
}

export default ControlledInput;

逐行拆解:

做什么 为什么
const [value, setValue] = useState('') 声明状态 这是数据的"家",所有值都存这里
value={value} state 同步到 input 让 React 接管 input 的显示,而不是 DOM 自己管
onChange={(e) => setValue(e.target.value)} 用户输入同步回 state 形成一个闭环:输入 → 更新 state → 重新渲染 → input 显示新值

核心机制:单向数据流闭环

scss 复制代码
用户按键 → onChange 触发 → setValue(新值) → React重渲染 → value属性更新 → input显示新字符

这就是"受控"的含义------input 的值完全受 React state 控制,没有 state 的允许,input 的值不会改变。state 是唯一数据源(Single Source of Truth)。

2.2 多字段受控表单------RegisterForm

一个输入框搞懂了,两个输入框(用户名 + 密码)呢?每个字段都写一个 state 当然可以,但更好的做法是用一个对象统一管理:

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

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

  // 一个 handleChange 处理所有字段
  const handleChange = (e) => {
    const { name, value } = e.target;          // 从事件中解构出 name 和 value
    setForm({
      ...form,                                  // 保留其他字段不变
      [name]: value,                            // 动态属性名,精准更新当前字段
    });
  };

  const handleSubmit = () => {
    if (!form.username || !form.password) return;
    console.log('提交:', form);
  };

  return (
    <div>
      <input
        name="username"                          // name 用来区分是哪个字段
        value={form.username}
        onChange={handleChange}
        placeholder="请输入用户名"
        type="text"
      />
      <input
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="请输入密码"
        type="password"
      />
      <button type="submit" onClick={handleSubmit}>提交</button>
    </div>
  )
}

export default RegisterForm;

这里有两个关键技巧:

[name]: value 动态属性名

jsx 复制代码
[name]: value   // 相当于 name='username' 时写 username: value

不同字段的 input 只要设不同的 name,就能用同一个 handleChange 处理。这就是为什么 input 上要写 name="username"------它不是摆设,是函数里用来区分字段的钥匙。

...form 展开保留其他字段

jsx 复制代码
setForm({ ...form, [name]: value })
//         ^^^^^^ 不写这个,改 username 时 password 就丢了!

setForm整体替换 ,不是合并。如果不展开 ...form,更新 username 时 password 变成 undefined

⚠️ 常见坑: 新手容易写成 setForm({ [name]: value }),结果改一个字段另一个就消失了。记住:setState 不会自动合并,得自己展开。

2.3 受控组件小结

perl 复制代码
受控组件三要素:
┌──────────────────────────────────────┐
│  ① useState 声明状态                  │
│  ② value 属性绑定 state               │
│  ③ onChange 回写 state                │
└──────────────────────────────────────┘

三、非受控组件:让 DOM 自己做主

3.1 最简非受控输入------UncontrolledInput

受控组件每次按键都触发渲染,那如果我不需要实时跟踪输入呢?比如我只是提交的时候拿一下值。

这时候就用非受控组件------input 的值 DOM 自己管,React 不插手,需要时通过 ref 去拿:

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

function UncontrolledInput() {
  const inputRef = useRef(null);            // 创建一个"引用",初始值是 null

  const handleClick = () => {
    console.log(inputRef.current.value);    // 从 DOM 节点上直接拿值
  }

  return (
    <>
      <h3>非受控组件</h3>
      <input
        type="text"
        ref={inputRef}                       // ref 绑定到 DOM 元素上
      />
      <button onClick={handleClick}>获取输入值</button>
    </>
  )
}

export default UncontrolledInput;

和非受控的关键区别:

jsx 复制代码
// ❌ 受控 ------ 有 value 和 onChange,state 管着一切
<input value={value} onChange={(e) => setValue(e.target.value)} />

// ✅ 非受控 ------ 没有 value,没有 onChange,只有 ref 绑定
<input ref={inputRef} />

输入过程中 React 根本不知道用户打了什么字,只有点击按钮时通过 inputRef.current.value 才读到值。

3.2 为什么要用 useRef 而不是普通变量?

你可能会想:为什么要 useRef?直接声明一个变量不行吗?

jsx 复制代码
// ❌ 错误:每次渲染都会重新创建变量
let myRef = null;
// 组件重新渲染 → myRef 又变成 null → 绑定丢失

useRef 的关键特性: 返回的对象在组件的整个生命周期中保持不变 ------无论组件渲染多少次,inputRef 始终是同一个对象。这就是为什么它必须用 useRef 而不是 useState 或普通变量。

普通变量 useState useRef
渲染后保持值 ❌ 每次重置
值变化触发重渲染
适合场景 --- UI 数据 DOM 引用/持久化值

一句话记住:useRef 是 React 给你的"保险箱"------东西放进去,不管组件渲染多少次,它都在那里,而且取东西的时候不需要重新渲染。

3.3 更实用的例子------CommentBox

文本框 + 提交按钮是典型场景:不需要实时跟踪输入,提交时一次性读取并清空即可。

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

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

  const handleSubmit = () => {
    const comment = textareaRef.current.value;   // ① 从 DOM 取值
    if (!comment) return;                         // ② 空值拦截
    console.log(comment);                         // ③ 提交
    textareaRef.current.value = '';               // ④ 提交后清空输入框
  }

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

export default CommentBox;

注意第 ④ 步: textareaRef.current.value = ''直接操作 DOM 来清空输入框。这在受控组件里绝不可能出现------受控组件通过 setValue('') 更新 state 来清空。非受控意味着你直接和 DOM 打交道,这正是它灵活的地方。


四、受控 vs 非受控:什么时候用谁?

核心对比

维度 受控组件 非受控组件
数据存在哪 React state DOM 节点
更新方式 onChangesetState 直接操作 DOM(用户输入)
读取方式 直接读 state 变量 ref.current.value 按需读取
实时校验 ✅ 天然支持(state 每次都变) ❌ 麻烦(要手动触发)
条件禁用按钮 ✅ 直接算 isValid ❌ 需要额外的 onChange 或 submit 时校验
渲染次数 每次按键都渲染 不触发渲染
代码量 稍多(需要 value + onChange) 更少(只需 ref)
"React 味" 地道 ✓ 偶尔用

选择速查

markdown 复制代码
需要实时反馈?(搜索建议、即时校验、按钮状态联动)
  ├── 是 → 受控组件(useState)
  └── 否 → 只需要提交时一次性取值?
            ├── 是 → 非受控组件(useRef)
            └── 否 → 看情况,大多数场景受控更灵活

一句话记住:"实时关注用受控,一锤子买卖用非受控。"


五、进阶实战:受控表单 + 实时校验 ------ LoginForm

掌握基础后,来一个真正实用的:带实时输入校验的登录表单。这是受控组件的"完全体"------把所有优势发挥出来。

5.1 完整代码

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

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                              // 只更新当前字段的 error
    }));
  }

  const handleChange = e => {
    const {name, value} = e.target;
    setForm({ ...form, [name]: value });       // ① 更新 form
    validate(name, value);                      // ② 同步校验
  }

  // 派生状态:不存 state,每次渲染当场算
  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;

5.2 设计思路逐层拆解

第一层:两个 state,各司其职

jsx 复制代码
const [form, setForm] = useState({ username: "", password: "" });  // 存值
const [errors, setErrors] = useState({});                           // 存校验信息

数据和错误分开管理。为什么不合在一起?

因为 error 是"派生数据"------由输入值计算得出,不是用户直接输入的。单独存放职责更清晰,更新时也不用解构 errors。

第二层:validate 函数------校验逻辑独立抽离

jsx 复制代码
const validate = (name, value) => {
    let msg = "";
    // 按字段名分发校验规则...
    setErrors(prev => ({ ...prev, [name]: msg }));
}

这里用了函数式更新 setErrors(prev => ...),这是一个重要细节。

jsx 复制代码
// ❌ 错误:直接读 errors,可能拿到过期值
setErrors({ ...errors, [name]: msg });

// ✅ 正确:用 prev 参数,保证总是拿到最新值
setErrors(prev => ({ ...prev, [name]: msg }));

连续快速输入时,handleChange 还没来得及把上一次的 setErrors 反映到 errors 变量上,下一次又调了。用 prev 参数可以避免这种"闭包陷阱",React 保证 prev 永远是最新的。

第三层:isValid ------ 派生状态,不存 state

jsx 复制代码
// 每次渲染即时计算,不额外声明 state
const isValid = form.username && form.password
             && !errors.username && !errors.password;

为什么不写成 const [isValid, setIsValid] = useState(false) 然后在 validate 里更新?

因为 isValid 完全由 formerrors 决定------它俩变了,isValid 自然就变了。多存一份 state 不仅冗余,还可能因为没有同步更新而出 bug。能算出来的东西就不要存。

这个 isValid 用在了两个地方:

jsx 复制代码
<button disabled={!isValid}>登录</button>  // ① 按钮视觉禁用
// handleSubmit 里 if (!isValid) return;   // ② 二次拦截(防止绕过)

第四层:handleSubmit ------ 最后一道防线

jsx 复制代码
const handleSubmit = e => {
    e.preventDefault();   // 阻止表单默认提交(会刷新页面)
    if (!isValid) return; // 拦截:即使绕过了 disabled,校验也拦住
    console.log(form);    // 真正的提交逻辑
}

你不能只依赖 disabled 属性来限制提交------HTML 表单按回车也可能触发提交。handleSubmit 里的 if (!isValid) return 是最后一道保险。

5.3 整体数据流

scss 复制代码
                        ┌──────────────┐
                        │   用户输入    │
                        └──────┬───────┘
                               ▼
                         handleChange
                         ╱          ╲
               setForm(form)       validate(name, value)
                    │                    │
                    ▼                    ▼
              form 更新            setErrors(...)
                    │                    │
                    └──────┬─────────────┘
                           ▼
                   组件重新渲染
                           │
                ┌──────────┴──────────┐
                ▼                     ▼
         input 显示新值         error 红色提示
                │                     │
                └──────────┬──────────┘
                           ▼
                       isValid 计算
                           │
                           ▼
                     button disabled

六、组件管理的工程化习惯------index.js 导出清单

组件多了以后,App.jsx 的 import 会变成这样:

jsx 复制代码
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';

5 个组件就 5 行,20 个呢?所以加一个 components/index.js统一导出清单

jsx 复制代码
// components/index.js ------ "组件清单文件"
import ControlledInput from './ControlledInput';
import UncontrolledInput from './UncontrolledInput';
import CommentBox from './components/CommentBox';
import RegisterForm from './RegisterForm';
import LoginForm from './components/LoginForm';

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

然后 App.jsx 就可以一行搞定:

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

这个 index.js 不包含任何业务逻辑,只是一个"出口"。就像超市收银台------消费者不需要知道货物从哪个仓库来的,统一从这里出去就对了。


七、延伸:React.memo 性能优化入门

上面讲的都是"怎么管理数据",再简单延伸一个相关的性能话题------组件重渲染

7.1 问题:无关状态变化也触发子组件重渲染

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

function RegularChild({ name }) {
  console.log('RegularChild 渲染了');
  return <h1>{name}</h1>;
}

const MemoChild = memo(({ name }) => {
  console.log('MemoChild 渲染了');
  return <div>Hello, {name}</div>;
});

function App() {
  const [count, setCount] = useState(0);       // count 和 name 无关
  const [name, setName] = useState('少林队');

  return (
    <>
      <button onClick={() => setCount(count + 1)}>点击计数 {count}</button>
      <button onClick={() => setName("峨眉队")}>改变名字</button>

      <RegularChild name={name} />    {/* count 变了它也重新渲染! */}
      <MemoChild name={name} />        {/* count 变了它不动,只有 name 变才渲染 */}
    </>
  );
}

export default App;

运行效果:

操作 RegularChild MemoChild
点击"计数"(count+1) 🔄 重新渲染(浪费!) ✅ 不渲染
点击"改变名字"(name改) 🔄 重新渲染(合理) 🔄 重新渲染(合理)

7.2 memo 做了什么?

arduino 复制代码
React.memo 就是一个"门卫":
  → 每次父组件渲染,子组件也准备渲染
  → memo 拦住:"等等,让我看看你的 props 变了没"
  → props 没变 → 不渲染,直接用上一次的结果
  → props 变了 → 放行,正常渲染
jsx 复制代码
const MemoChild = memo(RegularChild);
// 相当于给 RegularChild 包了一层"智能防抖"

⚠️ 注意:memo 做的是浅比较 ------只比 prop 的引用是否相同。如果你传了一个对象 { a: 1 },每次渲染都会创建新对象,memo 就失效了。这时候需要配合 useMemo / useCallback,但那是另一个话题了。
一句话记住:"不想让子组件跟着瞎渲染,就用 memo 包一层。"


总结

核心概念速查

概念 一句话
受控组件 表单数据存在 React state 里,value + onChange 绑定
非受控组件 表单数据留在 DOM,用 useRef 按需读取
用谁? 需要实时反馈→受控;一次性取值→非受控
form 对象 多字段用 {...form, [name]: value} 统一管理
validate 抽离 校验逻辑独立成函数,和 handleChange 解耦
派生状态 能从已有 state 算出的值,不要存成新 state
prev => 函数式更新避免闭包陷阱
index.js 导出清单 多组件的统一出口,减少 import 行数
React.memo props 没变就不重渲染,给子组件加个"门卫"

核心代码骨架

jsx 复制代码
// 受控组件的"最小公式"
const [value, setValue] = useState('');
<input value={value} onChange={e => setValue(e.target.value)} />

// 非受控组件的"最小公式"
const ref = useRef(null);
<input ref={ref} />
// 需要时:ref.current.value

这个 demo 的完整代码我放在了 GitHub(项目地址),从 ControlledInputUncontrolledInputCommentBoxRegisterFormLoginForm 五个组件,按照本文的顺序阅读就能从零理解受控和非受控。

希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥

相关推荐
用户938515635071 小时前
Type vs Interface:读完这篇就没有面试官能难倒你了
前端·面试·typescript
windliang2 小时前
Claude Code 源码分析(九):子 Agent 如何分叉、继续与回到父会话
前端·javascript·面试
触底反弹4 小时前
🚀 浏览器里跑 1.5B 参数大模型?我用 WebGPU + DeepSeek 做到了
人工智能·面试·typescript
测试19986 小时前
2026全新软件测试面试八股文(含答案+文档)
自动化测试·软件测试·python·测试工具·面试·职场和发展·测试用例
GitLqr8 小时前
Impeller 时代:Shader Jank 消失了,但渲染性能的战场也变了
flutter·面试·性能优化
Tsuki_tl9 小时前
【面试高频】 HashMap, HashTable,ConcurrentHashMap 之间的区别
面试·hashmap·synchronized·哈希表·多线程并发·hashtable·分段锁
城管不管10 小时前
第八次面试2026.8.10
面试·职场和发展
JieE21210 小时前
单例模式:为什么大模型只加载一次就够了?
面试
windliang10 小时前
Claude Code 源码分析(八):Memory 如何被写入、整理与按需召回
前端·算法·面试
lingran__11 小时前
C++ 高阶数据结构:红黑树万字详解|完整原理推导 + 插入实现 + 完整性校验【STL 底层】
数据结构·c++·面试·红黑树·二叉搜索树·平衡二叉树·stl底层