面试必问!React 受控与非受控组件,这篇彻底讲透了

从项目实战出发,完整讲解两种模式的核心概念、本质区别、选择策略以及完整表单实战。

📦 本文代码示例均来自项目 uncontrolled-demo,循序渐进,从入门到实战。

🎯 读完本文,你将彻底告别表单相关的"奇怪" bug,清楚地知道什么时候用哪种模式。


📖 目录


一、背景:React 与表单的"相遇"

React 的核心思想是 "数据驱动视图" ------你只管改数据(state),界面自动跟着变,完全不用手动操作 DOM:

jsx 复制代码
// 原生 JS:手动改 DOM
document.getElementById("myInput").value = "新值";

// React:改 state,界面自动刷新
setValue("新值");

一切看起来都很美好,直到我们遇到了 表单元素

<input><textarea><select> 这些表单元素非常特殊------它们自己内部就有状态(浏览器帮你记着用户敲了什么)。这就产生了一个核心问题:

🤔 表单的值到底归 React 的 state 管,还是归浏览器 DOM 管?

两种答案,两种模式:受控组件非受控组件


💡 为什么这很重要?

表单是 Web 应用最常见的交互入口------登录、注册、搜索、评论,几乎每个页面都有表单。但 React 初学者在处理表单时,经常遇到一堆看似"奇怪"的 bug:

  • 😵 输入框敲不出字
  • 😵 填写第二个字段时,第一个字段的值凭空消失
  • 😵 校验提示总是慢半拍

这些问题的根源几乎都是同一个------搞混了受控和非受控,或者用错了方式。

理解本章内容,可以帮你从根本上避开这些坑,写出既优雅又健壮的表单代码。


二、受控组件(Controlled Component)

🎯 先理解概念

受控组件的核心思想很简单:React 接管表单的一切。

你把用户输入存进 state,又从 state 读出来显示在界面上。用户每敲一下键盘,数据都要经过 React 的手------先更新 state,state 变了再驱动界面刷新。整个过程中,React 是唯一的"真相来源",DOM 只是一个听话的展示工具。

打个比方:想象你是一个传令兵,用户每敲一个字,你都得跑一趟------记下新内容 → 报告给 state → state 下令更新界面。没有你的报告,界面纹丝不动。这就是"受控"的含义:每一帧数据流动都经过 React 的手。

这种方式虽然需要多写几行代码(value={} + onChange={}),但收益巨大:

✅ 你可以在数据流动的每一个环节插手------做校验、做格式化、根据当前值控制其他 UI 元素的状态。


📊 数据流向

perl 复制代码
用户敲键盘 → onChange 触发 → setState 更新 state → state 变 → value 变 → 界面刷新
     ↑________________________________________________________________________↓
                        闭环,React 时刻掌握最新值

💻 看看代码(对照项目 ControlledInput.jsx

jsx 复制代码
import { useState } from 'react'
//      ↑ 从 react 包里引入 useState。它是 React 提供的"状态管理"函数

function ControlledInput() {
    const [value, setValue] = useState('')
    //    ↑         ↑            ↑
    //  当前值   修改函数     初始值为空字符串
    //  这行叫"数组解构":useState 返回一个数组 [当前值, 修改函数],解构后各取各的名字

    return (
        <>
            ControlledInput(受控组件)
            <input
                type="text"
                value={value}
                //     ↑ input 显示什么内容,完全由 value 这个 state 决定
                //     只要 state 不变,用户在键盘上敲什么都没用------input 不会变
                onChange={(e) => setValue(e.target.value)}
                //       ↑ 每次敲字,浏览器触发 onChange,传一个事件对象 e
                //         e.target 就是触发的这个 <input> 元素
                //         e.target.value 是用户刚敲进去的最新文本
                //         用 setValue 把它存进 state → state 变了 → value 变了 → input 刷新
            />
        </>
    )
}

🔍 代码详解

这段代码虽然短,但已经包含了受控组件的全部核心要素:

部分 作用 关键点
useState('') 创建 state,初始为空字符串 它是 input 值的"主人"
value={value} 把 state 绑定到 input input 永远显示 state 的当前值
onChange={...} 唯一的"入口" 用户在键盘上敲的任何东西都通过这个入口进入 React

🚀 这条闭环就是 React 单向数据流的精髓。 数据永远沿着"用户操作 → 更新 state → 重新渲染"的方向流动,清晰可控。


✨ 受控组件能做什么

因为 React 时刻知道表单的值,所以你可以轻松实现:

功能 说明
🔍 实时校验 每敲一个字就检查规则,立刻给出提示
📝 格式化输入 输入 13800000000,自动变成 138 0000 0000
🔗 字段联动 选了"广东省",城市列表自动变成广州、深圳
🚫 按钮状态控制 表单没填完,提交按钮灰色不可点
📊 字数统计 实时显示"已输入 15/100 字"

三、非受控组件(Uncontrolled Component)

🎯 先理解概念

非受控组件走的是完全相反的路线:React 撒手不管,让浏览器自己处理。

用户敲了什么字,直接存在 DOM 里,React 不追踪、不拦截、不关心。只有当你主动去"问"的时候------比如用户点了提交按钮------React 才去 DOM 里读取当前值。

数据流向非常简短:

复制代码
用户敲键盘 → DOM 自己存着值 → ...(React 不知道)... → 点按钮 → 去 DOM 里取

⚖️ 代价 :你在用户输入过程中什么都不知道------不能做实时校验,不能做格式化,不能根据输入状态控制按钮。React 退居二线,只有当"前台"------用户写完了过来问"刚才写了啥?"


💻 看看代码(对照项目 UncontrolledInput.jsx

jsx 复制代码
import { useRef } from 'react'
//      ↑ useRef 创建一个"引用",用来抓取 DOM 元素

function UncontrolledInput() {
    const inputRef = useRef(null)
    //    ↑ 创建一个 ref 对象,初始 .current = null
    //    可以想象成一根绳子,暂时还没系到任何东西上

    function handClick() {
        console.log(inputRef.current.value)
        //              ↑     ↑       ↑
        //           ref对象  .current  真实的 DOM 元素
        //           绳子       顺着绳子    找到了!读取它的 value 属性
    }

    return (
        <>
            UncontrolledInput(非受控组件)
            <input type="text" ref={inputRef} />
            {/*                    ↑ 把绳子系到 input 的 DOM 元素上 */}
            <button onClick={handClick}>获取输入值</button>
        </>
    )
}

🔍 代码详解

这段代码的关键在于 useRef

部分 作用 比喻
useRef(null) 创建一个引用对象 一根绳子,还没系到任何东西上
ref={inputRef} 把引用绑定到 DOM 元素 把绳子系到 input 上
inputRef.current 获取真实的 DOM 元素 顺着绳子找到那个元素

📌 核心区别 :用户在 input 里敲了一堆字,React 完全不知道,只有 inputRef.current.value 被调用的那一刻才去读取。和受控组件"每敲一下都报告"不同,非受控是"等你问了我再去看"。


🔄 不用 useRef 也能做非受控

非受控不一定非得用 useRef。浏览器触发事件时,会把 e.target(触发事件的元素)白送给你:

jsx 复制代码
function MyForm() {
    const handleSubmit = (e) => {
        e.preventDefault();                       // 阻止 <form> 默认刷新页面
        const formData = new FormData(e.target);
        //    ↑ 浏览器内置 API,自动收集 <form> 内所有带 name 属性的输入框的值
        //    相当于把表单里每个 input 的 name 和 value 打包成一个"键值对集合"
        console.log(formData.get("username"));
        //    ↑ 通过 name 属性名取出对应字段的值,就像查字典
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name="username" />
            {/* 注意:没有 value={},也没有 onChange={},React 完全不管 */}
            <button type="submit">提交</button>
        </form>
    );
}

这里没用 useRef,但照样是非受控------数据从头到尾存在 DOM 里,提交时才一次性取出来。

💡 那什么时候必须用 useRef 当你需要操作 DOM 但没有事件帮你传引用的时候。比如组件一加载就自动聚焦输入框:

jsx 复制代码
// useEffect 是 React 提供的另一个 hook,在组件首次渲染后自动执行一次
useEffect(() => {
    inputRef.current.focus();  // 顺着绳子找到 DOM,让它获得焦点
}, []);

📝 非受控进阶:CommentBox(textarea + 提交校验)

下面再看一个非受控的实际例子------评论框。和 UncontrolledInput 同理,用 useRef 抓取 DOM,提交时再读取值:

jsx 复制代码
// components/CommentBox.jsx
import { useRef } from 'react'

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

    function handleSubmit() {
        const value = textareaRef.current.value
        // 提交前做非空校验
        if (!value.trim()) {
            alert('评论内容不能为空!')
            return
        }
        console.log('提交评论:', value)
        // 实际项目中这里调接口
    }

    return (
        <>
            <h3>💬 评论框(非受控)</h3>
            <textarea
                ref={textareaRef}
                placeholder="请输写评论内容..."
                rows={4}
                cols={40}
            />
            <br />
            <button onClick={handleSubmit}>提交评论</button>
        </>
    )
}

export default CommentBox

📌 注意 :这里用的是 onClick 而不是 onSubmit,因为不存在 <form> 元素,不需要阻止默认刷新行为。useRef 的用法和 UncontrolledInput 完全一致------抓 DOM → 存引用 → 需要时读值。


四、本质区别:数据归谁管

前面分别看过了两种模式的代码,现在把它们放在一起对比。核心区别只有一句话:数据存在 React 的 state 里,还是存在浏览器的 DOM 里。

📊 对比表格

维度 受控组件 非受控组件
数据存在哪 React state(内存) 浏览器 DOM
怎么读值 直接读 state 变量 ref.current.valueFormData,需要主动去取
实时性 每次按键都能感知 只在"主动去读"那一刻才知道
用的 Hook useState useRef(但不是必须的)
input 属性 value={} + onChange={} ref={} 或什么都不写

⚠️ 为什么不能简单用 hook 来区分?

初学者容易觉得"用了 useState 就是受控,用了 useRef 就是非受控",但这不对。

🔑 关键认知useStateuseRef 只是"工具",真正的区别是数据归谁管。同一个工具可以服务不同的设计,同一个设计也可以用不同工具实现。

看两个反例:

jsx 复制代码
// 反例 1:没用 useRef,用 FormData + e.target,照样是非受控 ✓
//   说明:非受控不一定要用 useRef,关键看数据在哪

// 反例 2:同时用了 useState 和 useRef
//   如果 ref 只用来操作 DOM(比如聚焦),数据还是 state 管的 → 这是受控组件!
function Input() {
    const [value, setValue] = useState('');
    const inputRef = useRef(null);     // 这个 ref 纯粹用来做聚焦,跟数据无关

    return (
        <input
            ref={inputRef}                            // ref:管 DOM 操作(聚焦等)
            value={value}                             // state:管数据
            onChange={(e) => setValue(e.target.value)}
        />
    );
}
// 这个组件同时用了 useState 和 useRef,但它毫无疑问是受控组件!
// 因为 input 显示的内容完全由 value state 决定,ref 只是辅助操作 DOM 而已。

判断标准表单的值存在 state 里还是 DOM 里,而不是看用了什么 hook。


🔗 数据绑定 vs 数据流(易混概念)

这两个词经常被混淆,但说的是不同层面的事:

维度 数据绑定 数据流
关注点 单个组件和数据怎么"接上" 数据在组件树中往哪"流"
粒度 微观(一个 input 和一个 state) 宏观(父组件→子组件→孙组件)
React 单向绑定:value={state} 单向数据流:父传子 props
Vue 双向绑定:v-model 单向数据流:父传子 props

📌 延伸 :React 两个都是单向的。Vue 的 v-model 本质是语法糖------底层拆开就是 :value="data" + @input="data=$event.target.value",还是受控,只是写起来像非受控一样省事。React 把每一步都亮出来让你控制,Vue 帮你盖了一层"自动挡"。


五、复杂表单实战

前面两章用单个 input 演示了两种模式的基本原理。但实际开发中,表单往往有多个字段,还需要校验、错误提示、按钮状态控制。

下面用项目中的两个真实组件,展示受控组件如何应对这些需求。


5.1 多字段表单(对照项目 RegisterForm.jsx

当表单有多个字段时,如果给每个字段都写一个 useState 和一个 handleXxx,代码会迅速膨胀:

jsx 复制代码
// ❌ 笨办法:每个字段一套 state + 处理函数
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleUsername = (e) => setUsername(e.target.value);
const handlePassword = (e) => setPassword(e.target.value);
// 加一个字段就要加两行,10 个字段就是 20 行,维护噩梦 😱

🎯 正确的做法

核心技巧:一个对象 state + 一个通用 handleChange + 每个 input 写 name 属性。

不管有多少字段,都是这一个模式,加字段只需在 state 初始化里加一行属性。

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

function RegisterForm() {
    // 用一个对象管理所有字段。加字段只需在这里加一行属性,比如 email: ""
    const [form, setForm] = useState({
        username: "",
        password: ""
    });

    /**
     * 通用处理函数 ------ 核心技巧在这里 🎯
     *
     * 不管哪个 input 变化,都走这个函数。奥秘在于 e.target.name:
     *   用户改了 name="username" 的 input → e.target.name = "username"
     *   用户改了 name="password" 的 input → e.target.name = "password"
     *
     * 然后用 ES6 的"计算属性名"语法 [e.target.name] 动态决定改对象的哪个属性
     */
    const handleChange = (e) => {
        setForm({
            ...form,  // ⚠️ 重要:先把旧对象的所有属性复制过来
            [e.target.name]: e.target.value  // 再更新变化的字段
        });
    };

    const handleSubmit = (e) => {
        e.preventDefault();   // 阻止 <form> 默认刷新
        console.log(form);    // 拿到完整的 { username: "...", password: "..." }
    };

    return (
        <div>
            <input
                name="username"
                value={form.username}
                onChange={handleChange}
                placeholder="用户名"
            />
            <input
                name="password"
                value={form.password}
                onChange={handleChange}
                placeholder="密码"
            />
            <button type="submit" onClick={handleSubmit}>提交</button>
        </div>
    )
}

⚠️ 为什么 ...form 不能丢?

这是一个非常容易踩的坑。setState替换而非合并------你传给它什么对象,它就用什么对象完全覆盖旧的:

js 复制代码
// ❌ 没有 ...form ------ 每次只保留一个字段,另一个被覆盖掉了
setForm({ [e.target.name]: e.target.value });
// 输入 username "abc" → form = { username: "abc" }              ← password 丢了!
// 再输 password "123" → form = { password: "123" }              ← username 又丢了!

// ✅ 有 ...form ------ 先复制旧数据,再覆盖变化的字段
setForm({ ...form, [e.target.name]: e.target.value });
// 输入 username → form = { username: "abc", password: "" }       ← 两个都在
// 再输 password → form = { username: "abc", password: "123" }    ← 两个都在

5.2 带实时校验的完整表单(对照项目 LoginForm/index.jsx

RegisterForm 解决了"多字段管理"的问题,但实际项目往往还需要校验。

🎯 完整需求

  • 用户输入时实时校验
  • 不合规时显示红色错误提示
  • 表单没填对之前提交按钮是灰色的

一句话:一个完整的登录表单!

🏗️ 整体思路

RegisterForm 的基础上再加一套机制:

用第二个 state 专门存储错误信息。 form 管数据,errors 管校验结果。

用户在 handleChange 中每敲一个字,既更新 form,又触发 validate 检查新值是否合规,不合规就把错误信息写进 errors


📝 完整代码解析

下面把代码拆成四个部分逐一讲解,方便理解每一块在做什么。


① 数据定义 ------ 两套 state 各管各的

先把"仓库"准备好:form 存用户写的内容,errors 存校验结果。

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

function LoginForm() {
    // 📦 form ------ 存用户输入的数据
    const [form, setForm] = useState({ username: "", password: "" });

    // 📋 errors ------ 存每个字段的错误信息
    //   空字符串代表"没错误",有文字代表"有问题"
    const [errors, setErrors] = useState({});

② 校验逻辑 ------ 检查输入是否合规

校验函数接收两个参数:哪个字段变了、新的值是什么。

规则很简单------为空、太短、太长都算错,其他情况算对(msg 保持空字符串)。

jsx 复制代码
    const validate = (name, value) => {
        let msg = '';    // 初始假设:没错误 ✅

        // 校验用户名
        if (name === 'username') {
            if (!value) {
                msg = '请输入用户名';
            } else if (value.length < 3) {
                msg = '用户名长度不能小于3位';
            } else if (value.length > 10) {
                msg = '用户名长度不能大于10位';
            }
        }

        // 校验密码
        if (name === 'password') {
            if (!value) {
                msg = '密码不能为空';
            } else if (value.length < 6) {
                msg = '密码长度不能小于6位';
            } else if (value.length > 10) {
                msg = '密码长度不能大于10位';
            }
        }

        // ⚠️ 用 prev => 的方式更新,保证快速连续输入时不会拿到旧数据
        setErrors(prev => ({
            ...prev,        // 保留其他字段的错误信息不变
            [name]: msg     // 只覆盖当前字段的错误
        }));
    };

③ 事件处理 + 合法性判断

用户每敲一个字,handleChange 做两件事:更新 form + 触发校验。

isValid 自动根据最新 formerrors 判断表单是否合法。

jsx 复制代码
    const handleChange = (e) => {
        const { name, value } = e.target;   // ES6 解构,一行取出 name 和 value
        setForm({ ...form, [name]: value }); // 更新表单数据
        validate(name, value);                // 立即校验刚输入的内容
    };

    // ✅ isValid ------ 四个条件全部满足才算 true
    const isValid = form.username && form.password && !errors.username && !errors.password;
    //   form.username :非空字符串为 true,空字符串 "" 为 false
    //   form.password :同上
    //   !errors.username:空字符串取反 → true(没错误);有文本取反 → false(有错误)
    //   !errors.password:同上

    const handleSubmit = (e) => {
        e.preventDefault();     // 阻止表单默认刷新
        if (!isValid) return;   // 双重保险:虽然按钮 disabled 了,再检查一次
        console.log(form);      // 实际项目中这里调接口登录
    };

④ 界面渲染 ------ 把数据和状态呈现出来

两个输入框各自绑定 name/value/onChange,底下用 {errors.xxx && <span>} 实现条件渲染------有错误才显示红色提示。

jsx 复制代码
    return (
        <form onSubmit={handleSubmit}>
            <h2>📝 登录</h2>

            <label>用户名</label>
            <input
                name="username"
                value={form.username}
                onChange={handleChange}
                placeholder="请输入用户名"
            />
            {errors.username && (
                <span className="error">{errors.username}</span>
            )}
            {/* "" && <span> → false,不渲染;"太短了" && <span> → 渲染红色提示 */}

            <label>密码</label>
            <input
                type="password"
                name="password"
                value={form.password}
                onChange={handleChange}
                placeholder="请输入密码"
            />
            {errors.password && (
                <span className="error">{errors.password}</span>
            )}

            <button type="submit" disabled={!isValid}>登录</button>
            {/* isValid=false → disabled=true → 按钮灰色不可点 */}
        </form>
    );
}

🔄 整个流程串起来看

arduino 复制代码
用户敲键盘
    ↓
handleChange 触发
    ↓
更新 form(数据变了)
    ↓
调用 validate(检查新数据是否合规)
    ↓
更新 errors(有错就记,没错就清空)
    ↓
isValid 自动重新计算
    ↓
disabled={!isValid} 自动生效(按钮灰不灰由 isValid 决定)
    ↓
如果 isValid=true 且用户点登录 → handleSubmit 执行 → 打印数据

🚀 这整条链路就是受控组件最大的优势:数据和 UI 始终同步,想做校验、控制、联动,全部基于 state 在一条链上完成。


🎬 实际运行效果

操作 界面反馈
页面刚打开 两个输入框,灰色"登录"按钮
输入 "ab"(不够3位) 🔴 红色文字"用户名长度不能小于3位",按钮保持灰色
补全到 "admin" ✅ 红色提示消失,但密码框还是空的,按钮仍然灰色
输入 "123456" ✅ 两个字段都填对了、都没报错 → 💙 按钮变亮,可以点击!

六、选择指南

看了这么多,到底什么时候用受控、什么时候用非受控?

🎯 一个原则定方向:需要"实时知道"用户输入的内容,就用受控;只需要"最后提交时"拿一次结果,非受控就够了。


✅ 优先用受控(90% 的场景)

需求 为什么非受控做不到
🔍 实时校验 非受控不知道当前值,无法实时判断
🔗 字段联动(选省份→出城市) 非受控无法监听变化
📝 格式化输入(手机号加空格) 非受控不会在输入过程中拦截和修改
🚫 按钮动态禁用 非受控不知道表单是否填完
🔄 动态增删字段 / 表单重置 ref 管不了动态列表,受控 setForm(初始值) 一行搞定

✅ 用非受控(约 10% 的场景)

场景 原因
📁 文件上传 <input type="file"> 浏览器安全机制,React 读不了本地文件路径
📄 简单一次性表单(搜一下就提交) 不需要中间过程,提交时拿值就够了
🔌 对接非 React 老代码 / jQuery 插件 别硬改,用 ref 配合

🔀 决策流程图

arduino 复制代码
需要"实时感知"用户的每次输入吗?
 ├── ✅ 是 → 受控组件(useState)
 └── ❌ 否 → 是不是文件上传?
              ├── ✅ 是 → 非受控(useRef,必须)
              └── ❌ 否 → 提交时取一下值就完事?
                          ├── ✅ 是 → 非受控也行(useRef 或 FormData)
                          └── ❌ 否 → 用受控,后期加功能不用重写

💡 新手建议:不确定 → 用受控。 简单直接,后期加校验、联动都不用重写。


七、项目结构与组件关系

📁 目录结构

css 复制代码
controlled/
├── readme.md                               ← 本文档
└── uncontrolled-demo/                       ← 项目代码
    └── src/
        ├── main.jsx                         ← 入口:把 App 挂到页面 #root 上
        ├── App.jsx                          ← 主组件,按顺序展示 5 个子组件
        └── components/
            ├── index.js                     ← "桶文件":把所有组件汇到一个出口
            ├── ControlledInput.jsx          ← ① 受控最简 demo
            ├── UncontrolledInput.jsx        ← ② 非受控最简 demo(useRef)
            ├── CommentBox.jsx               ← ③ 非受控:textarea + 提交校验
            ├── RegisterForm.jsx             ← ④ 受控进阶:一个对象管多字段
            └── LoginForm/                   ← ⑤ 受控实战:校验 + 提示 + 按钮控制
                ├── index.jsx
                └── index.css

🚪 入口文件 main.jsx

jsx 复制代码
// main.jsx ------ 入口文件
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

// React 18 使用 createRoot 替代 ReactDOM.render
// createRoot(document.getElementById('root')) 找到页面上的 #root 容器
// .render(<App />) 把 App 组件渲染进去
//
// StrictMode 是开发辅助工具,会在开发模式下额外执行一些检查
// (比如检测意外的副作用),帮助写出更健壮的代码
ReactDOM.createRoot(document.getElementById('root')).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
)

🧩 主组件 App.jsx

jsx 复制代码
/*
 * App 组件 ------ 项目的"大总管"
 * 把所有子组件组装在一起,按照顺序摆放在页面上
 * 
 * 注意:这里用 import { xxx } from './components' 
 * 实际找的是 components/index.js 这个"桶文件",一行引入所有组件
 */
import { 
  ControlledInput,    // 受控组件:state 管理 input 值
  UncontrolledInput,  // 非受控组件:useRef 直接读 DOM
  CommentBox,         // 非受控组件:textarea + ref,提交时取值 + 校验
  RegisterForm,       // 受控组件:一个 state 对象管多个字段
  LoginForm           // 受控组件:多字段 + 实时校验 + 错误提示
} from './components'

function App() {

  return(
    <>
      {/* 1. 受控组件基础 demo */}
      <ControlledInput />
      {/* 2. 非受控组件基础 demo */}
      <UncontrolledInput />
      {/* 3. 非受控组件:评论框(textarea) */}
      <CommentBox />
      {/* 4. 受控组件进阶:多字段表单(未加校验) */}
      <RegisterForm />
      {/* 5. 受控组件实战:多字段 + 实时校验 + 按钮状态控制 */}
      <LoginForm />
    </>
  )
}

export default App

📊 数据流向一览

xml 复制代码
main.jsx → <App />  ← 入口,只负责组装,不持有数据
              ├── <ControlledInput />     数据在组件内部 useState
              ├── <UncontrolledInput />   数据在浏览器 DOM
              ├── <CommentBox />          数据在浏览器 DOM
              ├── <RegisterForm />        数据在组件内部 useState 对象
              └── <LoginForm />           数据在组件内部两个 useState

📈 难度递进

序号 组件 知识点 数据归谁
ControlledInput 受控基础:useState + value + onChange React state
UncontrolledInput 非受控基础:useRef + ref.current.value 浏览器 DOM
CommentBox 非受控变体:textarea + 提交前非空校验 浏览器 DOM
RegisterForm 受控进阶:一个对象管多字段 + 通用 handleChange React state
LoginForm 受控实战:实时校验 + 错误提示 + 按钮禁用 React state

📦 桶文件 index.js

jsx 复制代码
// ❌ 不用桶文件时,App.jsx 要写一堆 import:
import ControlledInput from './components/ControlledInput'
import UncontrolledInput from './components/UncontrolledInput'
// 加一个组件就多一行,维护麻烦 😫

// ✅ 用了桶文件,一行搞定:
import { ControlledInput, UncontrolledInput, CommentBox, RegisterForm, LoginForm } from './components'
jsx 复制代码
// components/index.js ------ 桶文件

// 命名导出(Named Export)
export { default as ControlledInput } from './ControlledInput'
export { default as UncontrolledInput } from './UncontrolledInput'
export { default as CommentBox } from './CommentBox'
export { default as RegisterForm } from './RegisterForm'
export { default as LoginForm } from './LoginForm'

// ⚠️ 使用方式:命名导入(Named Import)
// import { ControlledInput, UncontrolledInput, CommentBox } from './components'
// 
// 注意:命名导出和命名导入必须对齐,都用花括号。
// 如果写成了 export default { Xxx },那就要用 import Xxx from './components',
// 但那样会报错 "does not provide an export",因为导出的对象不是一个组件。

八、常见错误

以下是新手最容易踩的几个坑,每个都源于对受控/非受控的理解不透彻。

现象 原因 修复
😱 输入框敲不出字 写了 value={state} 但没写 onChange,state 锁死了 加上 onChange={e => setXxx(e.target.value)}
😱 第二个字段把第一个字段覆盖了 setForm 没加 ...form,新对象替换了旧对象 改用 setForm({ ...form, [name]: value })
😱 输入框报错 "onChange is not a function" 写了 onChange={xxx}xxx 没定义 定义对应的处理函数,或改成受控写法
😱 import { Xxx } 报 "does not provide an export" index.js 里写成了 export default { Xxx } 而不是 export { Xxx } 命名导出和命名导入必须对齐,都用花括号
😱 校验提示总是慢半拍 直接读 errors 而不是用 prev => setErrors(prev => ...) 保证拿到最新值

九、总结

📌 核心要点

  1. 受控组件:state 管数据,每次按键都能感知,适合复杂表单 ✅
  2. 非受控组件:DOM 管数据,需要时才去取,适合文件上传和简单表单 📁
  3. useState 和 useRef 只是工具,判断标准是数据存在哪,不是用了什么 hook 🔧
  4. 复杂表单:一个对象 state + 通用 handleChange + input 的 name 属性 🎯
  5. 桶文件 index.js:统一导出,减少 import 行数 📦
  6. React 哲学:数据驱动视图,state 是唯一真相来源 🧠

🎯 一句话总结

大部分场景用受控组件,特殊场景用非受控组件。受控给你掌控感,非控给你自由度------根据需求选择合适的工具。

相关推荐
喜欢睡觉1 小时前
React 受控组件与非受控组件
react.js
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js
lllsure2 小时前
Vue&React Router
前端·vue.js·react.js
sugar__salt2 小时前
深入理解 React useContext:跨层级组件通信的核心利器
前端·javascript·react.js·前端框架·框架
用户9385156350716 小时前
React 受控/非受控组件与 React.memo 性能优化——从本质到实战
前端·javascript·react.js
半个落月17 小时前
从零梳理 React Router:路由、懒加载、嵌套页面与登录鉴权
前端·react.js
光影少年18 小时前
Codex 斜杠指令体系与业务场景调度实战
前端·react.js·reactivex
Darling噜啦啦20 小时前
别再逐层传 Props 了!useContext + 自定义 Hook 彻底搞定 React 跨层通信
react.js