首页 / React 学习笔记 / 24

REACT · Vol.IV · LESSON 24 · 组件设计与工程化

受控组件与非受控组件

进阶核心#组件#表单

值到底归谁管:一个 input 的两种命运

第 23 篇把组合玩熟了,第 8 篇写 Todo 时你也照着写过受控 input:<input value={text} onChange={...} />。当时多半没多想。现在回答一个面试高频问题:用户敲进输入框的每一个字,到底存在哪?两种答案,对应两种模式:

  • 受控组件(Controlled):值存在 React state 里,DOM 只是「显示器」,不给 value + onChange 就不听你的。
  • 非受控组件(Uncontrolled):值存在 DOM 自己肚子里,React 不插手,要用时再去拿。
对比片段.tsx · 两种管法
// 受控:值在 state 里,每键都流经 React
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />

// 非受控:值在 DOM 里,React 只留一张「取件码」(第 12 篇)
const nameRef = useRef<HTMLInputElement>(null);
<input defaultValue="" ref={nameRef} />

判别一句话:看「当前值」是不是 React 说了算——value 由 state 提供、变化经 onChange 回 state 是受控;React 只给初始值、之后不闻不问是非受控。两条数据流画在一张图里:

受控:值在 state 里 非受控:值在 DOM 里 用户敲字 <input> state onChange setState 重渲染,下发 value 每敲一键走一圈,DOM 只是 state 的投影 用户敲字 <input> 自己持有值 React 全程不知情 提交时 ref 取值 ref.current.value 敲字过程零重渲染
图 1受控:每次输入都流经 state;非受控:值待在 DOM 里,提交时才取。

受控组件:value 与 onChange,一步都不能少

先看受控的完整样子——一个带即时校验和联动的登录表单,注意校验写在哪、按钮禁用条件在哪:

LoginForm.tsx · 校验、联动、提交全覆盖
import { useState } from "react";

function LoginForm() {
  // 表单即 state:唯一数据源
  const [form, setForm] = useState({ email: "", password: "" });

  // 校验是 state 的派生值:state 一变自动重算,无需单独存储
  const emailInvalid = form.email !== "" && !form.email.includes("@");

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      if (emailInvalid) return;
      console.log("提交", form);  // 直接拿 state,全程不碰 DOM
    }}>
      <input
        value={form.email}
        onChange={(e) => setForm({ ...form, email: e.target.value })}
      />
      {emailInvalid && <p>邮箱格式不对</p>}  // 即时反馈
      <input
        type="password"
        value={form.password}
        onChange={(e) => setForm({ ...form, password: e.target.value })}
      />
      <button disabled={!form.email || !form.password}>登录</button>
    </form>
  );
}

三个关键认知:

  1. 为什么必须有 onChange?value 一旦设置,React 每次渲染都把 DOM 的值强制刷成 state 的值;没有 onChange,state 永远不变,输入框「打不进字」。这不是 bug,是「受控」的字面意思——DOM 被 React 掐着。真想只读就明说:readOnly
  2. 每敲一键重渲染一次。onChange → setState → 重渲染(第 5 篇)→ input 拿到新 value。diff 后实际只更新文本这一个节点,普通表单毫无感知。
  3. 校验和联动是「免费」的。校验是派生值、按钮禁用是表达式——任何时刻你手里都有全量表单数据,「勾选协议才能提交」「选了上海才显示区县」一行条件就够。

其余控件的受控写法速览——规律一致:值用对应属性管、变化走 onChange:

controls.tsx · checkbox / radio / select 受控速览
// checkbox:用 checked 管(不是 value),取 e.target.checked
<input type="checkbox" checked={agree}
  onChange={(e) => setAgree(e.target.checked)} />

// radio:一组框共享一份 state,靠各自 value 判断谁被选中
<input type="radio" checked={pay === "wechat"} onChange={() => setPay("wechat")} />
<input type="radio" checked={pay === "alipay"} onChange={() => setPay("alipay")} />

// select:value 挂在 <select> 标签上,不是 <option> 上
<select value={city} onChange={(e) => setCity(e.target.value)}>
  <option value="bj">北京</option>
  <option value="sh">上海</option>
</select>
后端类比:每次变更都过一遍 Controller

受控表单像「每次变更都实时过一遍 Controller」:onChange 是请求入口(参数绑定),form state 是绑定后的 DTO,每敲一键都跑一遍校验并回显错误。全量、实时、强一致,代价是「请求频率」高(每键一次重渲染)。第 32 篇的搜索框场景里受控 input 仍是标配——配防抖即可。

受控的隐藏福利:清空表单、回显编辑数据,都是一行 state 操作——改数据的语言永远比改 DOM 的语言好写,第 1 篇的承诺在表单上兑现了。

非受控组件:defaultValue 存进去,提交时再取出来

把 value 换成 defaultValue、加上 ref,就切到非受控模式:初始值只给一次,之后值在 DOM 里自生自灭,React 不参与、也不重渲染。读值用 ref——第 12 篇说过,useRef 是不触发重渲染的「便签」,正配「只要结果」的场景:

RegisterForm.tsx · 提交时才读取
import { useRef, type FormEvent } from "react";

function RegisterForm() {
  const emailRef = useRef<HTMLInputElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: FormEvent) {
    e.preventDefault();
    // 此刻才伸手拿值——此前敲了什么,React 全程不知情
    const email = emailRef.current?.value ?? "";
    const file = fileRef.current?.files?.[0] ?? null;
    console.log("提交", { email, file });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={emailRef} defaultValue="me@example.com" />
      <input ref={fileRef} type="file" />
      <button>注册</button>
    </form>
  );
}

注意 type="file":文件输入只能非受控——值是用户本地选的文件,React 无法用 state 赋值,也没理由在选文件时重渲染。这是非受控「天生合法」的场景。

经典坑:defaultValue 只在首次挂载生效

「非受控 + 想重置」是新手重灾区。看这个错误示范:

ResetBug.tsx · 这么写一动不动
function ResetBug() {
  const [draft, setDraft] = useState("初始草稿");
  return (
    <>
      <input defaultValue={draft} />  // draft 变了,输入框却纹丝不动
      <button onClick={() => setDraft("")}>重置</button>
    </>
  );
}

原因:defaultValue 的语义是「出厂设置」——只在 DOM 节点首次创建时用一次,之后 React 认为「这框归 DOM 管」,重渲染一百次也不碰它的 value。这反向印证了第 1 站的判别:非受控 = React 不掌值。两条出路:

  1. 受控化:真的需要随时改值,说明它就该受控——老老实实 value + onChange
  2. 换 key 强制重建<input key={version} defaultValue={draft} />。key 一变(第 7 篇)=「另一个元素」,卸载旧的、按新 defaultValue 重建——出厂设置重新生效。
后端类比:提交时一次性绑定 @ModelAttribute

非受控表单正是 Spring MVC 的经典姿势:字段值住在浏览器 DOM 里,后端不参与敲字过程,直到 form submit,@ModelAttribute 才把表单键值对一次性绑定成 Java 对象交给 Controller;校验也只能提交时做(@Valid + BindingResult),做不到「敲一个字就报错」。受控相当于把绑定和校验前移到每次按键。

在非受控表单里写 emailRef.current.value = "" 清空输入框?停一下——你在手写 DOM 操作,这正是切回受控的信号。

对比与选择:什么时候用哪种

两条路放在一起,差异一目了然:

维度受控组件非受控组件
值存在哪React stateDOM 内部
取值时机随时——state 就是最新值提交时读 ref
即时校验 / 字段联动天然支持(派生值 + 条件渲染)做不到;监听 onChange 即变相受控
输入时的重渲染每键 1 次0 次
每字段代码量value + onChange 成对出现一个 ref,多数时候连 ref 都省
典型场景搜索框、联动表单、登录框超大表单、文件上传、只要提交值

选择准则浓缩成三句:

  1. 要「过程」就受控:即时校验、字段联动、随时取值、错误提示跟手——本质是「值一变我就要参与」,受控是唯一解。
  2. 只要「结果」可非受控:几十个字段的登记表单,敲字过程无人关心,每键重渲染纯属浪费——defaultValue + 提交读 ref,代码量还省一半;文件输入更是只能非受控。

真实项目里的大表单,行业默认答案是 React Hook Form(第 21 篇)——底层恰恰是非受控:DOM 持有值,库替你把 ref 记账、校验、错误收集全包了。

面试答「受控和非受控的区别」,最忌背「一个有 onChange 一个没有」。答到点子上的是:表单值的真相源(source of truth)在谁手里——state 还是 DOM。顺手还展示了对单向数据流(第 14 篇)的理解。

React Hook Form:让库替你记账

感受一下 RHF 的写法——input 上没有任何 value/onChange:

LoginForm.tsx · 非受控打底
import { useForm } from "react-hook-form";

interface LoginForm { email: string; password: string; }

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();

  return (
    <form onSubmit={handleSubmit((values) => console.log(values))}>
      <input {...register("email", {
        required: "邮箱必填",
        pattern: { value: /@/, message: "格式不对" },
      })} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register("password", { required: true })} />
      <button>登录</button>
    </form>
  );
}

register("email") 返回一组 props(onChange、ref 等),展开到 input 上:值仍由 DOM 持有,敲字零重渲染,RHF 在这些事件里偷偷记账;提交时 handleSubmit 递给你干净的 { email, password },校验错误从 errors 拿——受控的体验、非受控的性能,细节留给第 21 篇。

核心要点
  • 判别标准:表单值的真相源在谁手里——state 手里是受控(value + onChange),DOM 手里是非受控(defaultValue + ref)。
  • 受控:随时有全量数据,即时校验、联动、清空、回显都是一行 state 操作;代价是每键一次重渲染。
  • 非受控:敲字零重渲染、代码量小,适合「只要提交结果」的大表单;文件输入只能非受控。
  • defaultValue 只在首次挂载生效——要重置就受控化,或换 key 强制重建。
  • 生产级大表单交给 React Hook Form(第 21 篇):非受控打底,库替你记账。

本章回顾

受控与非受控之争,本质是「值的管理权」之争:要参与每次变化就交给 state(受控),只要结果就留给 DOM(非受控)。加上第 23 篇的组合,搭表单页已不成问题。组合还有更漂亮的一招——把整段 JSX 当参数传进组件,让子组件隐式共享父状态:下一篇第 25 篇,children 与复合组件模式。

Comments · 评论