深入理解 React 受控组件与非受控组件:从源码到面试

在 React 开发中,表单处理是绕不开的核心场景。你是选择"受控组件"让 React 状态全面接管,还是用"非受控组件"通过 ref 直接操作 DOM?本文将逐行拆解真实代码,带你彻底掌握两者的区别、选型与最佳实践。

一、为什么会有受控与非受控?

React 的核心思想是 UI = f(state),即页面完全由状态驱动。但表单元素(input、textarea、select 等)天生具有自己的内部状态(比如用户输入时立即显示字符)。这就产生了两种设计模式:

  • 受控组件(Controlled Component) :表单元素的值完全由 React 状态控制,value 绑定 state,onChange 更新 state。React 是唯一的"数据源"。
  • 非受控组件(Uncontrolled Component) :表单元素的值保存在 DOM 节点自身内部,React 通过 ref 在需要时"拿过来用"。DOM 是数据源。

下面我们将通过项目中的五个文件,逐行看透这两种模式的实现细节、连接关系以及适用场景。

完整项目链接:gitee.com/hong-strong...


二、受控组件实例:LoginForm.jsx 逐行解析

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

解析 :从 React 中导入 useState Hook。受控组件的核心就是依赖 state 存储表单数据,所以必须引入该 Hook。

jsx 复制代码
export default function LoginForm() {

解析 :导出一个函数组件 LoginForm,展示一个登录表单(用户名 + 密码)。

jsx 复制代码
  const [form, setForm] = useState({
    username: "",
    password: ""
  });

解析 :声明一个 state 对象 form,包含 usernamepassword 两个字段。初始值均为空字符串。这个 state 将成为两个输入框的 唯一真实数据源

jsx 复制代码
  const handleChange = (e) => {
    // 返回一个新的对象
    setForm({
      ...form, 
      [e.target.name]: e.target.value
    })
  }

逐行解释

  • handleChange 作为所有输入框的公共事件处理函数。
  • 参数 e 是合成事件对象,e.target 指向触发事件的 <input> 元素。
  • ...form:展开运算符复制原 state 中的其他字段(例如修改用户名时保留密码字段)。
  • [e.target.name]: e.target.value:计算属性名。因为每个 input 都有 name 属性(usernamepassword),所以可以动态更新对应的字段。
  • setForm 触发重新渲染,新值会立刻写回 state。这就是 "单向数据流 + 显式变更" 的受控模式。
jsx 复制代码
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(form);
  }

解析

  • 表单提交时触发,e.preventDefault() 阻止页面刷新。
  • 直接打印 form 对象(此时已包含用户最新输入)。因为 state 始终与输入同步,所以提交时无需再读取 DOM。
jsx 复制代码
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text" 
        placeholder="请输入用户名" 
        name="username"
        onChange={handleChange}
        value={form.username}
      />
      <input 
        type="password" 
        placeholder="请输入密码" 
        name="password"
        onChange={handleChange}
        value={form.password}
      />
      <button type="submit">注册</button>
    </form>
  )
}

关键点解析

  • 每个 <input> 都显式设置了 value={form.xxx} :这使 input 变为受控组件。用户输入时,value 不会改变(因为 React 会强制使用 state 中的值),只有通过 onChange 更新 state 后,value 才会变化。
  • name 属性与 handleChange 中的计算属性名配合,实现多字段复用。
  • 按钮 type="submit" 会触发表单的 onSubmit 事件。

受控组件特点总结

  • 实时同步:每次按键都会更新 state → 重新渲染 → 输入框显示新值。
  • 便于校验、动态启用/禁用按钮、格式化输入(如手机号自动加空格)。
  • 性能代价:频繁的 state 更新和重渲染。

三、非受控组件实例:CommentBox.jsx 逐行解析

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

解析 :从 React 中导入 useRef Hook。非受控组件通常通过 ref 获取 DOM 节点的值。

jsx 复制代码
export default function CommentBox() {
  // UnControlled

解析:注释明确说明这是一个非受控组件。

jsx 复制代码
  const textareaRef = useRef(null);

解析 :创建一个 ref 对象,初始值为 null。该 ref 将赋值给 <textarea>ref 属性。之后 textareaRef.current 会指向真实的 DOM 节点。

jsx 复制代码
  const handleSubmit = () => {
    const comment = textareaRef.current.value;
    if (!comment) return alert('请输入评论');
    console.log(comment);
  }

逐行解释

  • 提交时直接通过 textareaRef.current.value 从 DOM 中读取当前文本域的内容。
  • 如果为空则弹窗警告。
  • 非受控组件 不需要 为每次输入维护 state,只有提交时才去获取值。这减少了渲染次数,尤其适合长文本或大量表单。
jsx 复制代码
  return (
    <div>
      <textarea 
        ref={textareaRef} 
        placeholder="输入评论...">  
      </textarea>
      <button onClick={handleSubmit}>提交</button>
    </div>
  )
}

解析

  • <textarea> 没有 value 属性,也没有 onChange 处理。它的内容完全由用户直接操控 DOM 来决定。
  • ref={textareaRef} 将 DOM 节点挂载到 textareaRef.current
  • 按钮的 onClick 直接调用 handleSubmit(注意没有使用 <form>,因此不会自动触发提交事件)。

非受控组件特点

  • 代码更少,不需要写 onChange 和 state。
  • 性能更好,没有实时渲染开销。
  • 无法在输入过程中做实时校验、格式化或条件控制。
  • 适合一次性获取数据(如表单提交、文件上传)。

四、App.jsx:串联两个组件

jsx 复制代码
import CommentBox from './components/CommentBox';
import LoginForm from './components/LoginForm';

解析:分别导入上面写好的受控组件和非受控组件。

jsx 复制代码
export default function App () {
  return(
  <>
    <CommentBox />
    < LoginForm />
  </>
  )
}

解析

  • 使用 Fragment(<>...</>)同时渲染两个组件。
  • 注意注释写法 < LoginForm /> 中间有空格,但不影响运行。
  • 这个 App 展示了在同一个应用中,开发者可以根据场景灵活选择受控或非受控模式。

链接关系

  • LoginForm 演示典型的受控表单(实时更新 state,提交时直接使用 state)。
  • CommentBox 演示非受控表单(提交时通过 ref 获取数据)。
  • 两者互不干扰,可以共存。

五、混合示例:App2.jsx 逐行解析

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

解析:同时导入 state 和 ref,表示该组件中两种模式都会出现。

jsx 复制代码
export default function App () {
  //  受控组件 组件里有被状态控制的表单元素
  // 单向数据流 单向绑定 
  // 状态绑定输入框 输入框被控制了
  // 状态控制 输入框的值
  const [value, setValue] = useState("");
  const inputRef = useRef(null);

解析

  • valuesetValue 用于第一个受控输入框。
  • inputRef 用于第二个非受控输入框。
  • 注释再次强调受控的本质:状态控制输入框的值。
jsx 复制代码
  const doLogin = (e) => {
    e.preventDefault();
    console.log(inputRef.current.value)
  }

解析:提交时只打印了非受控输入框的值,没有打印受控输入框的值。这说明了混合使用下可以按需获取。

jsx 复制代码
  return(
    <form onSubmit={doLogin}>
      {value}
      <input type="text" 
      value={value} 
      onChange={(e) => setValue(e.target.value)}/>
      <input type="text" ref={inputRef} />
      <button type="submit">登录</button>
    </form>
  )
}

逐行解释

  • {value}:在表单顶部实时显示受控输入框的值,直观展示受控数据的流动性。
  • 第一个 input:受控组件,value 绑定 state,onChange 更新 state。用户输入 → 触发 setValue → 组件重渲染 → value 更新 → 输入框显示最新值,同时上方的 {value} 也会同步。
  • 第二个 input:非受控组件,没有 value 也没有 onChange,只通过 ref 标记,后续获取其值。
  • 提交时只打印了非受控组件的值,说明受控组件的值其实早已存在于 state 中(value),可以直接使用。

这个示例完美展示了

  • 受控组件数据实时反馈,可被页面其他部分消费。
  • 非受控组件仅在需要时读取,且读取时机完全由开发者控制。
  • 两者可以在同一个表单中并存。

六、main.jsx:项目入口

jsx 复制代码
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

解析

  • StrictMode:React 的严格模式工具,用于检测潜在问题(如不安全的生命周期、意外副作用)。
  • createRoot:React 18+ 的新根渲染 API。
  • 引入样式文件和根组件 App
jsx 复制代码
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

解析 :获取 HTML 中 id 为 root 的节点,并将 <App /> 渲染进去。整个应用从 App 开始,而 App 又引用了 LoginFormCommentBox,所以受控和非受控的对比将直接呈现在页面上。


七、核心差异对比表

对比维度 受控组件 非受控组件
数据存储 React 组件的 state DOM 节点自身
获取值方式 直接从 state 读取 通过 ref.current.value 获取
输入实时反应 每次输入都会触发 setState 和重渲染 无重渲染,性能更优
代码复杂度 需要维护 state + onChange 只需创建 ref 并在需要时读取
表单校验 可实时校验(即时提示错误) 只能在提交时校验或通过 ref 手动触发
动态控制 可根据其他状态动态修改输入框的值(如清空、填充默认值) 需要直接操作 DOM(不推荐)
适用场景 表单联动、实时校验、动态输入(如信用卡号格式化) 简单表单、文件上传、性能敏感场景、与第三方非 React 代码集成
默认值设置 通过初始 state 设置 使用 defaultValue / defaultChecked
React 官方推荐 大多数场景优先使用受控组件 仅在必要或简单场景使用

八、面试高频问题与深度扩展

Q1:受控组件每次输入都 setState,会不会导致性能问题?

A:对于大多数普通表单(几十个输入框以内),React 的 diff 和批量更新机制完全可以接受。如果确实有极高频率的输入(比如富文本编辑器、画板),可以考虑:

  • 使用 useMemo / React.memo 减少子组件重渲染。
  • 使用非受控组件 + ref,只在提交时读取。
  • 采用 debounce 或 throttle 减少 setState 频率(如搜索框)。
  • 选用第三方表单库(React Hook Form)------它默认使用非受控模式,性能极佳。

Q2:什么时候必须用非受控组件?

A :最常见的场景是 文件上传<input type="file">)。因为文件对象的 value 是只读的,无法通过 state 控制,只能通过 ref 获取 files 属性。另外,与 jQuery 插件或原生 DOM 操作库集成时,非受控组件更方便。

Q3:非受控组件如何设置默认值?

A :使用 defaultValue 属性(对于 checkbox/radio 用 defaultChecked)。例如:

jsx 复制代码
<input type="text" defaultValue="初始文本" ref={inputRef} />

这样输入框一开始会显示"初始文本",但后续用户修改不会影响任何 React 状态。

Q4:为什么 React 官方文档说"大多数情况下推荐使用受控组件"?

A:因为受控组件符合 React 声明式编程范式------UI 与状态一一对应,数据流清晰可追踪,便于调试和测试。而非受控组件引入命令式操作(直接读 DOM),可能会让代码逻辑分散,在复杂表单中难以维护。

Q5:如何在不使用 ref 的情况下实现非受控?

A:非受控的本质就是不将 value 绑定到 state。你甚至可以直接忽略 value:

jsx 复制代码
<input name="email" onChange={() => {}} />

但没有 ref 的话你无法获取到它的值。所以通常还是需要 ref,或者通过表单的 onSubmit 事件配合 FormData 获取所有字段(现代浏览器支持)。

Q6:useRef 和 createRef 的区别?

AuseRef 用于函数组件,每次渲染返回同一个 ref 对象。createRef 主要用于类组件,在函数组件中每次重新渲染都会创建新的 ref,所以不要混用。


九、进阶实践:受控组件的性能优化与 React Hook Form

1. 受控组件避免不必要的重渲染

jsx 复制代码
// 错误示范:每次渲染都新建函数
<input onChange={(e) => setValue(e.target.value)} />

// 优化:使用 useCallback 稳定函数引用
const handleChange = useCallback((e) => {
  setValue(e.target.value);
}, []);

2. React Hook Form ------ 现代表单解决方案

该库结合了非受控组件的性能和受控组件的灵活性:

  • 默认使用 ref 注册表单字段,避免频繁重渲染。
  • 提供 watch 方法实现部分受控效果。
  • 内置校验、错误处理、联动等。

示例对比:

jsx 复制代码
// 传统受控组件需要写大量样板代码
const [name, setName] = useState('');
<input value={name} onChange={e => setName(e.target.value)} />

// React Hook Form 只需一句
const { register, handleSubmit } = useForm();
<input {...register('name')} />

十、总结与建议

你的需求 推荐方案
简单表单(登录、注册、搜索) 受控组件(清晰、可靠)
复杂表单(几十个字段,需要高性能) React Hook Form(非受控核心)
文件上传 / 与第三方非 React 代码集成 非受控组件 + ref
实时校验、动态依赖字段(如国家-城市联动) 受控组件
一次性获取数据,无需中间反馈 非受控组件
相关推荐
Yue1681 小时前
天津理工大学前端组大一末期考核随记(2)
前端·javascript
冰凌时空1 小时前
Swift 类型系统入门:从 Int、String 到自定义类型
前端·ios·ai编程
hexu_blog1 小时前
前端vue后端java+springboot如何实现pdf,word,excel之间的相互转换
java·前端·vue.js·spring boot·文档转换
贺国亚1 小时前
synchronized- 并发
java·面试
代码柏拉图1 小时前
AI时代如何提问面试者
人工智能·面试·职场和发展
Kiyra1 小时前
Interview Agent:从面试平台到 Agent 工程实战的进化之路
面试·职场和发展
w_t_y_y2 小时前
vue父子组件通信(二)祖先调用inject
前端·javascript·vue.js
哆哆啦002 小时前
URL 重写规则和静态资源解析逻辑
前端·浏览器·url
IT_陈寒2 小时前
Java的Stream.peek()千万别乱用,血泪教训
前端·人工智能·后端