写了 5 个表单 Demo 后,我终于彻底搞懂了 React 受控与非受控组件

前几天整理 React 基础的时候,我特意拉了个小项目,专门写表单相关的 demo。说出来有点丢人,受控组件和非受控组件这俩词我背了快半年,真到写代码的时候还是会混 ------ 反正最后都能拿到输入值,到底差在哪?

直到我手滑给一个输入框绑了value却忘了写onChange,键盘敲烂了输入框都纹丝不动,控制台还甩给我一大段红色警告。我盯着屏幕愣了半分钟,决定不背概念了,从零开始写一遍,把这事儿彻底搞懂。

这是我当时拆的文件结构,为了对比清楚,我把每种写法都单独拎成了一个组件,挨个跑着试。

plaintext 复制代码
src
├── assets
├── components
│   ├── LoginForm
│   │   ├── index.css
│   │   └── index.jsx
│   ├── CommentBox.jsx
│   ├── ControlledInput.tsx
│   ├── index.js
│   ├── RegisterForm.jsx
│   └── UncontrolledInput.jsx
├── App.css
├── App.jsx
├── index.css
└── main.jsx

我先试了最简单的:非受控组件

最开始我写的是非受控组件,因为第一眼看上去最省事 ------ 不就是拿 ref 勾住 dom 节点,要用的时候去取 value 嘛,跟原生 JS 几乎没区别。

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

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

    const handleClick = () => {
        // 点按钮的时候才去读dom里的值
        console.log(inputRef.current.value);
    }

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

export default UncontrolledInput;

写完跑起来,点按钮确实能拿到值,我当时还心想,这多简单啊,为啥还要发明受控组件?直到我想加个功能:输入内容的时候,实时显示字符数。

这就麻烦了。非受控组件的值存在 dom 里,React 根本不知道里面写了啥。要想实时拿,我就得给 input 加个 onChange 事件,在事件里去读 ref 的值 ------ 那我为啥不直接把值存在 state 里?

非受控到底 "不受谁控制"

后来我才反应过来,所谓 "非受控",不受的是 React 状态的控制。

这个输入框的 value 由浏览器自己管,你敲什么它就显示什么,React 全程不插手。只有你主动通过 ref 去访问的时候,才能拿到当前的值。就像你办公室的饮水机,你接水的时候才过去按一下,平时它该加热加热,你根本不管它。

顺着这个思路我又写了评论框组件,textarea 也是一样的道理。

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

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

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

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

    export default CommentBox;

踩的第一个小坑:初始值别瞎写 value

这里还有个小坑,我亲手踩过一次。想给非受控输入框加个默认值,我顺手就写了value="默认文字",结果控制台直接警告:组件正在从非受控变成受控。

踩坑提醒非受控组件不能用 value 绑初始值,要用 defaultValue。只要你写了 value 属性,React 就会默认你要做受控组件,没有配套的 onChange 就会出问题。

正确写法是 <input defaultValue="默认文字" ref={inputRef} />,这样初始值有了,控制权还在 dom 手里。

后来我才 get 到受控组件的真正意思

带着刚才 "实时字符数" 的需求,我开始写受控组件的 demo。

最基础的单行输入

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

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

        return (
            <>
                <div>ControlledInput</div>
                <input
                    type='text'
                    value={value}
                    onChange={(e) => setValue(e.target.value)}
                />
                {/* 想实时显示?直接用value就行,不用碰dom */}
                <p>当前输入了 {value.length} 个字</p>
            </>
        )
    }

    export default ControlledInput;

写完我就懂了。受控组件的核心,是输入框显示的内容完全由 React 的 state 说了算

你敲一个字符,触发 onChange 事件,更新 state;state 一变,React 再把新的值回写到输入框上。整个流程都在 React 的数据流里转,输入框本身只是个 "傀儡",它自己说了不算。

打个不太恰当的比方:非受控是你直接拧水龙头,出水多少全看你手劲;受控是你在面板上设好出水量,机器帮你拧阀门,你只管调数字,不用碰阀门本身。

多字段表单怎么写不啰嗦

单个输入框不过瘾,我又写了个注册表单,两个字段。一开始我笨笨地给每个输入框都写了单独的 onChange 函数,写第二个的时候就嫌烦了,这字段多了还了得?

后来想起可以用name属性做匹配,一个函数搞定所有输入框。

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

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

        const handleChange = (e) => {
            // 注意这行,用name属性动态更新对应的字段
            // 别问我为什么知道要解构form,试过直接覆盖把另一个字段搞没了
            setForm({
                ...form,
                [e.target.name]: e.target.value,
            })
        }

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

    export default RegisterForm;

这个写法就舒服多了,后面再加字段,只要给 state 加个属性,给 input 加个 name,不用再写新的处理函数。

带实时校验的登录表单

真正让我觉得受控组件香的,是写登录表单的时候。要做实时校验,输入不对就提示错误,没填完就禁用按钮。这要是用非受控写,得费老大劲;用受控,一切都是顺理成章的。

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

你看,输入、校验、按钮状态,全靠 state 驱动。不用去查 dom,不用手动控制按钮禁用,state 一变,视图自动跟着变。这才是 React 数据驱动的味道。

我踩过的那几个经典坑

光写顺的没用,踩过的坑才记得牢。这几个都是我亲手踩过的,一个都没落下。

坑 1:只写 value 不写 onChange,输入框直接锁死

这是入门级的坑,但几乎每个人都踩过。我第一次写受控组件的时候,抄了个 value 就完事了,结果键盘怎么敲都没反应。

控制台会报一个警告:You provided a value prop to a form field without an onChange handler

原因很简单:你把输入框的值绑定成了 state,又不在输入的时候更新 state,那值当然永远不会变。

记住受控组件里,valueonChange 是一对,必须同时出现,缺一个都不行。

坑 2:value 和 ref 混用,状态混乱

第二个坑,我一开始写非受控的时候,顺手加了个 value 属性想设默认值,结果控制台又警告了。

本质就是你既想让 React 管(写了 value),又想自己用 ref 管(非受控),两边抢控制权,不打架才怪。

记住一个原则:一个表单元素,要么受控,要么非受控,别搞半吊子。

坑 3:textarea 的 value 写法

还有个小细节,原生 HTML 里 textarea 的值是写在标签中间的,比如<textarea>默认值</textarea>。但在 React 里,不管是 input 还是 textarea,统一用 value 属性。

我一开始按原生写法写,结果不生效,查了文档才改过来。CommentBox 那个组件我最开始就写错了,后来改成了 ref 绑定,或者用受控的 value 写法都可以。

那到底该用哪个?

这肯定是大家最关心的问题。我之前也总纠结,是不是用受控就比非受控高级?

写了这几个 demo 之后我的感受是:没有高低级,看场景。

  • 如果你需要实时控制输入内容:比如实时校验、输入格式化、联动禁用、字符数统计,直接用受控组件,逻辑写起来最顺。
  • 如果就是个简单的表单,点提交的时候才拿值,不需要实时处理,用非受控更省事,少写好多状态和事件处理。
  • 还有一种情况,接入第三方表单控件或者富文本编辑器,人家内部自己管状态,这时候用非受控的方式去接就好,硬改成受控反而容易出问题。

最后说个工程上的小习惯。组件写多了,一个个导入太麻烦,我就在 components 文件夹下建了个 index.js,把所有组件统一导出。

javascript 复制代码
    // 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,
    }

这样在 App 里导入的时候,一行就能全导进来,看着干净多了。

最后说几句掏心窝的

其实最开始我总觉得这俩概念很玄乎,背了好多定义还是模糊。真的自己动手拆成五个小 demo,挨个跑一遍,踩俩坑,反而一下就通透了。

回头想想,核心也就三句话:受控组件,状态在 React 里,视图全由状态说了算;非受控组件,状态在 DOM 里,React 只在需要的时候去取;别混着用,选合适场景的就行。

这东西也不是越复杂越好,有时候一个简单的搜索框,用 ref 拿一下值就完事了,没必要硬上受控组件徒增代码。但如果是复杂的业务表单,老老实实写受控,后期维护起来会舒服很多。

我当时把这些代码敲完,又对着报错调了半小时,才算真的把这俩概念刻进脑子里。你要是也一直对这个点模模糊糊,建议别光看,自己新建个项目写一遍,敲完你就懂了。搞懂了记得回来留个言,我也想看看你的理解。

相关推荐
Sterting1 小时前
第9课 Vue Router 路由
前端·vue.js
用户059540174461 小时前
LangChain Memory 测试踩坑实录:用 pytest 自动化回归对话记忆,我折腾了整整一个周末
前端·css
kyriewen1 小时前
Claude Code后天起默认Auto模式了——我第一时间改了这6个设置
前端·ai编程·claude
风骏时光牛马2 小时前
智能赋能型AI办公一体化系统架构
前端
满栀5852 小时前
vue动态路由效果
前端·javascript·vue.js·前端框架·vue
土豆~3 小时前
文件名没后缀就打不开:前端文件预览的内容嗅探改造
前端·状态模式
Euato_Key3 小时前
全栈视野学习Cookie——理解 Cookie 的本质
前端
前端开发呀3 小时前
我的 AI 终端配置清单
前端