React: 表单与受控组件

最后更新:2026-10-26

表单是 Web 应用和用户对话的 桥梁。在 React 中,表单不再是"填完提交"——而是"每填一个字符,React 都知道"。这种模式叫做受控组件。


1. 你将学到



2. 一个注册表单的演变

(1) 痛点:非受控表单数据难追踪

Alice 做了一个用户注册表单,用原生方式获取数据:

HTML
<!-- HTML 表单:提交时一次性读取数据 -->
<form id="register-form">
  <input name="username" id="username" />
  <input type="email" name="email" id="email" />
  <input type="password" name="password" id="password" />
  <select name="role" id="role">
    <option value="developer">开发者</option>
    <option value="designer">设计师</option>
  </select>
  <button type="submit">注册</button>
</form>

<script>
  document.getElementById('register-form').addEventListener('submit', function(e) {
    e.preventDefault()
    // 提交时才去 DOM 中拿数据
    const username = document.getElementById('username').value
    const email = document.getElementById('email').value
    const password = document.getElementById('password').value
    const role = document.getElementById('role').value
    // ... 手动校验每个字段
  })
</script>

问题:

(2) React 受控组件的解法

▶ 试一试

收益:React 实时追踪每个输入字段,实时校验、实时联动、提交时直接拿到完整数据。



3. 受控组件 vs 非受控组件

100%
graph TB
    subgraph "受控组件(推荐)"
        A1[用户输入] --> B1[onChange 事件]
        B1 --> C1[更新 State]
        C1 --> D1[React 重新渲染]
        D1 --> E1[value = State]
    end
    
    subgraph "非受控组件"
        A2[用户输入] --> B2[直接更新 DOM]
        B2 --> C2[提交时用 ref 读值]
    end
    
    style C1 fill:#52c41a,color:#fff
    style B2 fill:#faad14,color:#000
维度 受控组件 非受控组件
数据源 React State(唯一的真相源) DOM 自身
读取方式 直接从 State 读取 需要用 ref 或 document.getElementById
实时校验 ✅ 天然支持 ❌ 需要额外监听
即时联动 ✅ 数据实时同步 ❌ 需手动同步
适用场景 绝大部分表单 文件上传、简单一次性表单

规则:90% 的情况用受控组件。只有文件 <input type="file"> 必须用非受控。



4. 各类表单元素的受控写法

表单元素 绑定属性 onChange 取值 示例
<input type="text"> value e.target.value <input value={name} onChange={e => setName(e.target.value)} />
<input type="number"> value Number(e.target.value) 需手动转数字
<input type="checkbox"> checked e.target.checked 布尔值,不是 value
<input type="radio"> checked e.target.value 同 name 一组,checked 控制选中
<textarea> value e.target.value React 用 value,非子文本
<select> value e.target.value 多选用 multiple + 数组
<input type="file"> ❌ 非受控 e.target.files[0] 只能用非受控

(1) 文本输入

▶ 试一试

注意:<textarea> 在 React 中用 value 属性,而不是子文本内容。<input type="number"> 的 e.target.value 是字符串,记得转数字。

(2) 选择框

▶ 试一试

(3) 复选框和单选按钮

▶ 试一试

输出:

TEXT
含输入字段和提交处理的表单。使用 interface 定义 props 的 TypeScript 类型 React 组件

▶ 示例 1:表单元素类型速查表

▶ 试一试

输出:

TEXT
速查表:text(value/onChange)、number(Number() 类型转换)、email、password、textarea(value)、select(value)、checkbox(checked)、radio(checked)、file(非受控,files[0])


5. 表单验证

验证时机 触发事件 优点 缺点 推荐场景
输入时 onChange 实时反馈,用户立即修正 频繁触发,用户未完成输入就报错 密码强度、用户名查重
失焦时 onBlur 减少干扰,填完才校验 用户需离开字段才看到错误 大部分表单字段
提交时 onSubmit 最终把关,统一校验 用户填完才发现错误 最终兜底校验

(1) 实时验证

JSX
function ValidatedForm() {
  const [email, setEmail] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [errors, setErrors] = React.useState({})
  const [touched, setTouched] = React.useState({})  // 记录哪些字段被碰过

  function validate(fieldName, value) {
    const newErrors = { ...errors }
    
    if (fieldName === 'email') {
      if (!value) newErrors.email = '邮箱不能为空'
      else if (!value.includes('@')) newErrors.email = '邮箱格式不正确'
      else delete newErrors.email
    }
    
    if (fieldName === 'password') {
      if (!value) newErrors.password = '密码不能为空'
      else if (value.length < 6) newErrors.password = '密码至少 6 位'
      else if (!/[A-Z]/.test(value)) newErrors.password = '密码需要包含大写字母'
      else delete newErrors.password
    }

    setErrors(newErrors)
  }

  function handleBlur(e) {
    const { name, value } = e.target
    setTouched({ ...touched, [name]: true })
    validate(name, value)
  }

  function handleSubmit(e) {
    e.preventDefault()
    // 提交前验证所有字段
    const allErrors = {}
    if (!email || !email.includes('@')) allErrors.email = '请输入有效邮箱'
    if (!password || password.length < 6) allErrors.password = '密码至少 6 位'
    
    if (Object.keys(allErrors).length > 0) {
      setErrors(allErrors)
      setTouched({ email: true, password: true })
      return
    }

    alert('提交成功!')
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}>
      <div style={{ marginBottom: '16px' }}>
        <label>邮箱:</label>
        <input
          name="email"
          type="email"
          value={email}
          onChange={e => { setEmail(e.target.value); validate('email', e.target.value) }}
          onBlur={handleBlur}
          style={inputStyle(touched.email && errors.email)}
        />
        {touched.email && errors.email && (
          <p style={{ color: '#ff4d4f', fontSize: '12px', margin: '4px 0' }}>{errors.email}</p>
        )}
      </div>

      <div style={{ marginBottom: '16px' }}>
        <label>密码:</label>
        <input
          name="password"
          type="password"
          value={password}
          onChange={e => { setPassword(e.target.value); validate('password', e.target.value) }}
          onBlur={handleBlur}
          style={inputStyle(touched.password && errors.password)}
        />
        {touched.password && errors.password && (
          <p style={{ color: '#ff4d4f', fontSize: '12px', margin: '4px 0' }}>{errors.password}</p>
        )}
      </div>

      {/* 密码强度指示器 */}
      {password && (
        <div style={{ marginBottom: '16px' }}>
          <p style={{ fontSize: '12px', color: '#666' }}>
            密码强度:
            <span style={{ color: password.length < 6 ? '#ff4d4f' : password.length < 10 ? '#faad14' : '#52c41a' }}>
              {password.length < 6 ? '弱' : password.length < 10 ? '中' : '强'}
            </span>
          </p>
          <div style={{ height: '4px', backgroundColor: '#f0f0f0', borderRadius: '2px' }}>
            <div style={{
              height: '100%',
              width: `${Math.min(100, (password.length / 12) * 100)}%`,
              backgroundColor: password.length < 6 ? '#ff4d4f' : password.length < 10 ? '#faad14' : '#52c41a',
              borderRadius: '2px',
              transition: 'width 0.3s'
            }} />
          </div>
        </div>
      )}

      <button type="submit" disabled={Object.keys(errors).length > 0}
        style={{
          width: '100%', padding: '10px',
          backgroundColor: Object.keys(errors).length > 0 ? '#d9d9d9' : '#1890ff',
          color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer'
        }}>
        注册
      </button>
    </form>
  )
}

function inputStyle(hasError) {
  return {
    width: '100%', padding: '8px',
    border: `1px solid ${hasError ? '#ff4d4f' : '#d9d9d9'}`,
    borderRadius: '4px',
    outline: 'none'
  }
}
▶ 试一试

(2) 自定义 useForm Hook

把表单逻辑提取成自定义 Hook,方便复用:

▶ 展开运行示例(91 行)
▶ 试一试

6. 完整示例:用户注册表单

▶ 展开运行示例(104 行)
▶ 试一试

输出:

TEXT
显示:"Please select"。输入类型:text、email、number。下拉框:Please select、Beijing

▶ 示例 2:表单联动——选择国家自动填充区号

▶ 试一试

输出:

TEXT
国家选择器(US/CN/JP/UK/BR)→ 区号自动填充(US: +1,CN: +86)。电话输入框 + 只读区号。显示 "Full: +1 5551234"

输出:

TEXT
显示:"Phone Contact"。状态:form(setter: setForm)。输入框:Phone number。下拉框:{c}

▶ 示例 3:动态表单——添加/删除团队成员

▶ 试一试

输出:

TEXT
动态成员行(Name + Email + Role 下拉框)。"+ Add Member" 添加一行,"X" 删除一行(至少保留 1 行)。提交 → console.log("Team:", members)

输出:

TEXT
... ('Team:', members)

▶ 示例 4:多步骤表单向导

▶ 展开运行示例(77 行)
▶ 试一试

输出:

TEXT
带进度条的 3 步向导。第 1 步:Name+Email,第 2 步:Password(至少 6 位),第 3 步:City+Terms。下一步会校验,上一步可返回。提交 → "Done!"

输出:

TEXT
显示 "file upload (uncontrolled)"。状态:preview、filename。按钮:upload。使用 useRef

▶ 示例 5:非受控组件——文件上传与 useRef

JSX
function FileUploadForm() {
  const fileInputRef = useRef(null)
  const [preview, setPreview] = useState(null)
  const [fileName, setFileName] = useState('')

  function handleSubmit(e) {
    e.preventDefault()
    const file = fileInputRef.current?.files[0]
    if (!file) { alert('Please select a file'); return }
    console.log('Uploading:', file.name, file.size, 'bytes')
    alert(`File "${file.name}" (${(file.size / 1024).toFixed(1)} KB) selected for upload`)
  }

  function handleFileChange(e) {
    const file = e.target.files[0]
    if (!file) { setPreview(null); setFileName(''); return }
    setFileName(file.name)
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = (e) => setPreview(e.target.result)
      reader.readAsDataURL(file)
    } else {
      setPreview(null)
    }
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '0 auto' }}>
      <h3>File Upload (Uncontrolled)</h3>
      <div style={{ marginBottom: 12 }}>
        <input type="file" ref={fileInputRef} onChange={handleFileChange}
          accept="image/*,.pdf,.doc" style={{ marginBottom: 8 }} />
        {fileName && <p style={{ fontSize: 13, color: '#666' }}>Selected: {fileName}</p>}
      </div>
      {preview && (
        <div style={{ marginBottom: 12 }}>
          <img src={preview} alt="Preview" style={{ maxWidth: '100%', maxHeight: 200, borderRadius: 4, border: '1px solid #eee' }} />
        </div>
      )}
      <button type="submit" style={{ padding: '8px 24px', cursor: 'pointer' }}>Upload</button>
    </form>
  )
}
▶ 试一试

输出:

TEXT
文件输入 → 显示图片预览,或显示文件名。上传时 alert "File "photo.jpg" (245.3 KB) selected for upload"

❓ 常见问题

Q input[type="checkbox"] 用 checked 还是 value?
A checkbox 用 checked 属性(布尔值),不是 value。对应的 onChange 事件用 e.target.checked 获取新值。同理,radio 也是用 checked。
Q 表单验证的时机怎么选?
A 三种时机各有用途:① 用户输入时验证(onChange)—— 实时反馈,但频繁触发;② 失焦时验证(onBlur)—— 减少干扰,用户填完才显示错误;③ 提交时验证(onSubmit)—— 最终把关。建议组合使用:onBlur + onSubmit,用户名等关键字段再加 onChange 实时反馈。
Q 表单很多字段时,handleChange 的通用写法?
A 用 e.target.name 配合计算属性名:const { name, value } = e.target; setForm(prev => ({...prev, [name]: value}))。这样所有 <input name="xxx"> 都能用同一个 handleChange。checkbox 需特殊处理:const newValue = type === 'checkbox' ? checked : value。
Q 受控组件和非受控组件到底怎么选?
A 简单规则:需要实时验证/格式化/条件显示 → 受控组件(如密码强度实时提示、金额自动格式化)。只关心最终值/提交时才读取 → 非受控组件(如文件上传、简单的联系表单)。<input type="file"> 只能用非受控,因为其 value 只读。useForm Hook 可以封装受控组件的样板代码。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个 FeedbackForm 组件,包含姓名、邮箱、评价(下拉选择:好评/中评/差评)和内容(textarea),提交时 alert 输出所有数据。
  2. 进阶题(难度⭐⭐):创建一个 PasswordStrengthMeter 组件,实时显示密码强度(弱/中/强),规则:≥8位+大小写+数字=强,≥6位=中,否则=弱。
  3. 挑战题(难度⭐⭐⭐):创建一个 DynamicForm 组件,用户可动态添加/删除表单项(如"添加多个家庭成员"),每个表单项包含姓名、年龄、关系三个字段,提交时输出数组。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏