React: 表单与受控组件

最后更新:2026-08-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 受控组件的解法

JSX
function RegisterForm() {
  const [form, setForm] = React.useState({
    username: '',
    email: '',
    password: '',
    role: 'developer'
  })

  // 每个输入变化 React 都知道
  function handleChange(e) {
    const { name, value } = e.target
    setForm(prev => ({ ...prev, [name]: value }))
  }

  function handleSubmit(e) {
    e.preventDefault()
    console.log('注册数据:', form)  // 拿到完整表单数据
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" value={form.username} onChange={handleChange} />
      <input name="email" value={form.email} onChange={handleChange} />
      <input name="password" value={form.password} onChange={handleChange} />
      <select name="role" value={form.role} onChange={handleChange}>
        <option value="developer">开发者</option>
        <option value="designer">设计师</option>
      </select>
      <button type="submit">注册</button>
    </form>
  )
}
▶ 试一试

收益: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) 文本输入

JSX
function TextInputs() {
  const [name, setName] = React.useState('')
  const [bio, setBio] = React.useState('')
  const [age, setAge] = React.useState(18)

  return (
    <div>
      {/* 单行文本 */}
      <label>姓名:</label>
      <input
        type="text"
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="请输入姓名"
      />

      {/* 数字 */}
      <label>年龄:</label>
      <input
        type="number"
        value={age}
        onChange={e => setAge(Number(e.target.value))}  // 转数字!
        min={0} max={150}
      />

      {/* 多行文本 */}
      <label>个人简介:</label>
      <textarea
        value={bio}
        onChange={e => setBio(e.target.value)}
        rows={4}
        placeholder="介绍一下自己..."
      />
    </div>
  )
}
▶ 试一试

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

(2) 选择框

JSX
function SelectInputs() {
  const [city, setCity] = React.useState('')
  const [hobbies, setHobbies] = React.useState([])

  return (
    <div>
      {/* 单选下拉框 */}
      <label>城市:</label>
      <select value={city} onChange={e => setCity(e.target.value)}>
        <option value="">-- 请选择 --</option>
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="shenzhen">深圳</option>
      </select>

      {/* 多选下拉框 */}
      <label>爱好:</label>
      <select
        multiple
        value={hobbies}
        onChange={e => {
          const selected = Array.from(e.target.options)
            .filter(o => o.selected)
            .map(o => o.value)
          setHobbies(selected)
        }}
      >
        <option value="coding">编程</option>
        <option value="reading">阅读</option>
        <option value="gaming">游戏</option>
      </select>
    </div>
  )
}
▶ 试一试

(3) 复选框和单选按钮

JSX
function CheckboxAndRadio() {
  const [agree, setAgree] = React.useState(false)
  const [gender, setGender] = React.useState('')
  const [interests, setInterests] = React.useState([])

  // 多选:切换包含/排除
  function toggleInterest(value) {
    setInterests(prev =>
      prev.includes(value)
        ? prev.filter(i => i !== value)
        : [...prev, value]
    )
  }

  return (
    <div>
      {/* 单个复选框 */}
      <label>
        <input
          type="checkbox"
          checked={agree}
          onChange={e => setAgree(e.target.checked)}  // 注意是 checked 不是 value
        />
        同意用户协议
      </label>
      {agree && <p style={{ color: '#52c41a' }}>✅ 已同意</p>}

      {/* 单选按钮组:同一个 name */}
      <p>性别:</p>
      {['男', '女', '其他'].map(g => (
        <label key={g} style={{ marginRight: '12px' }}>
          <input
            type="radio"
            name="gender"
            value={g}
            checked={gender === g}
            onChange={e => setGender(e.target.value)}
          />
          {g}
        </label>
      ))}

      {/* 多选复选框组 */}
      <p>感兴趣的领域:</p>
      {['前端', '后端', 'AI', '设计'].map(item => (
        <label key={item} style={{ marginRight: '12px' }}>
          <input
            type="checkbox"
            checked={interests.includes(item)}
            onChange={() => toggleInterest(item)}
          />
          {item}
        </label>
      ))}
    </div>
  )
}

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

JSX
// ============================================
// 示例:所有常见表单元素的受控写法
// ============================================

function FormElementReference() {
  const [text, setText] = React.useState('')
  const [number, setNumber] = React.useState(0)
  const [email, setEmail] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [bio, setBio] = React.useState('')
  const [city, setCity] = React.useState('')
  const [agree, setAgree] = React.useState(false)
  const [gender, setGender] = React.useState('')
  const [file, setFile] = React.useState(null)

  return (
    <div>
      {/* 文本输入 */}
      <input type="text" value={text} onChange={e => setText(e.target.value)} />
      
      {/* 数字输入 */}
      <input type="number" value={number} onChange={e => setNumber(Number(e.target.value))} />
      
      {/* 邮箱输入 */}
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      
      {/* 密码输入 */}
      <input type="password" value={password} onChange={e => setPassword(e.target.value)} />
      
      {/* 多行文本 */}
      <textarea value={bio} onChange={e => setBio(e.target.value)} />
      
      {/* 下拉选择 */}
      <select value={city} onChange={e => setCity(e.target.value)}>
        <option value="">请选择</option>
        <option value="beijing">北京</option>
      </select>
      
      {/* 单个复选框 */}
      <input type="checkbox" checked={agree} onChange={e => setAgree(e.target.checked)} />
      
      {/* 单选按钮 */}
      <input type="radio" name="gender" value="male" checked={gender === 'male'}
        onChange={e => setGender(e.target.value)} />
      
      {/* 文件上传(非受控) */}
      <input type="file" onChange={e => setFile(e.target.files[0])} />
      {/* 注意:文件输入必须用非受控模式 */}
    </div>
  )
}
▶ 试一试

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,方便复用:

JSX
// ============================================
// 示例:自定义 useForm Hook
// 功能:统一管理表单状态、验证、提交
// ============================================

function useForm(initialValues, validateFn) {
  const [values, setValues] = React.useState(initialValues)
  const [errors, setErrors] = React.useState({})
  const [touched, setTouched] = React.useState({})

  // 处理输入变化
  function handleChange(e) {
    const { name, value, type, checked } = e.target
    const newValue = type === 'checkbox' ? checked : value
    const newValues = { ...values, [name]: newValue }
    setValues(newValues)
    
    // 实时验证
    if (validateFn) {
      const newErrors = validateFn(newValues)
      setErrors(newErrors)
    }
  }

  // 处理失去焦点
  function handleBlur(e) {
    const { name } = e.target
    setTouched({ ...touched, [name]: true })
  }

  // 处理提交
  function handleSubmit(callback) {
    return (e) => {
      e.preventDefault()
      // 标记所有字段为已触碰
      const allTouched = Object.keys(values).reduce((acc, key) => {
        acc[key] = true
        return acc
      }, {})
      setTouched(allTouched)
      
      // 验证
      if (validateFn) {
        const newErrors = validateFn(values)
        setErrors(newErrors)
        if (Object.keys(newErrors).length > 0) return
      }
      
      callback(values)
    }
  }

  // 重置表单
  function reset() {
    setValues(initialValues)
    setErrors({})
    setTouched({})
  }

  return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset }
}

// ---- 使用 useForm ----
function LoginForm() {
  function validate(values) {
    const errors = {}
    if (!values.email) errors.email = '请输入邮箱'
    else if (!values.email.includes('@')) errors.email = '邮箱格式不正确'
    if (!values.password) errors.password = '请输入密码'
    else if (values.password.length < 6) errors.password = '密码至少 6 位'
    return errors
  }

  const form = useForm({ email: '', password: '' }, validate)

  return (
    <form onSubmit={form.handleSubmit(data => console.log('提交:', data))}
      style={{ maxWidth: '400px', margin: '0 auto' }}>
      <h2>登录</h2>
      
      <input
        name="email"
        value={form.values.email}
        onChange={form.handleChange}
        onBlur={form.handleBlur}
        placeholder="邮箱"
        style={{ width: '100%', padding: '8px', marginBottom: '4px', border: `1px solid ${form.touched.email && form.errors.email ? '#ff4d4f' : '#d9d9d9'}`, borderRadius: '4px' }}
      />
      {form.touched.email && form.errors.email && (
        <p style={{ color: '#ff4d4f', fontSize: '12px', margin: '0 0 12px 0' }}>{form.errors.email}</p>
      )}

      <input
        name="password"
        type="password"
        value={form.values.password}
        onChange={form.handleChange}
        onBlur={form.handleBlur}
        placeholder="密码"
        style={{ width: '100%', padding: '8px', marginBottom: '4px', border: `1px solid ${form.touched.password && form.errors.password ? '#ff4d4f' : '#d9d9d9'}`, borderRadius: '4px' }}
      />
      {form.touched.password && form.errors.password && (
        <p style={{ color: '#ff4d4f', fontSize: '12px', margin: '0 0 12px 0' }}>{form.errors.password}</p>
      )}

      <button type="submit" style={{
        width: '100%', padding: '10px', backgroundColor: '#1890ff',
        color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer'
      }}>
        登录
      </button>
      <button type="button" onClick={form.reset}
        style={{ width: '100%', padding: '8px', marginTop: '8px', cursor: 'pointer' }}>
        重置
      </button>
    </form>
  )
}


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

JSX
// ============================================
// 完整示例:用户注册表单
// 功能:受控组件 + 实时验证 + 表单联动
// ============================================

function RegistrationForm() {
  const [form, setForm] = React.useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    country: '',
    gender: '',
    acceptTerms: false
  })
  const [errors, setErrors] = React.useState({})

  function validate() {
    const newErrors = {}
    if (!form.username || form.username.length < 2)
      newErrors.username = '用户名至少 2 个字符'
    if (!form.email || !form.email.includes('@'))
      newErrors.email = '请输入有效邮箱'
    if (!form.password || form.password.length < 6)
      newErrors.password = '密码至少 6 位'
    if (form.password !== form.confirmPassword)
      newErrors.confirmPassword = '两次密码不一致'
    if (!form.country)
      newErrors.country = '请选择国家'
    if (!form.acceptTerms)
      newErrors.acceptTerms = '请同意用户协议'
    return newErrors
  }

  function handleChange(e) {
    const { name, value, type, checked } = e.target
    const newValue = type === 'checkbox' ? checked : value
    const newForm = { ...form, [name]: newValue }
    setForm(newForm)
    setErrors(validate())
    // 联动:选择国家后自动调整密码规则提示
  }

  function handleSubmit(e) {
    e.preventDefault()
    const finalErrors = validate()
    setErrors(finalErrors)
    if (Object.keys(finalErrors).length === 0) {
      alert(`注册成功!欢迎 ${form.username}`)
    }
  }

  function inputStyle(fieldName) {
    return {
      width: '100%', padding: '8px', marginBottom: '4px',
      border: `1px solid ${errors[fieldName] ? '#ff4d4f' : '#d9d9d9'}`,
      borderRadius: '4px'
    }
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '500px', margin: '0 auto' }}>
      <h2>📝 用户注册</h2>

      <input name="username" value={form.username} onChange={handleChange}
        placeholder="用户名" style={inputStyle('username')} />
      {errors.username && <p style={errorStyle}>{errors.username}</p>}

      <input name="email" type="email" value={form.email} onChange={handleChange}
        placeholder="邮箱" style={inputStyle('email')} />
      {errors.email && <p style={errorStyle}>{errors.email}</p>}

      <input name="password" type="password" value={form.password} onChange={handleChange}
        placeholder="密码(至少 6 位)" style={inputStyle('password')} />
      {errors.password && <p style={errorStyle}>{errors.password}</p>}

      <input name="confirmPassword" type="password" value={form.confirmPassword}
        onChange={handleChange} placeholder="确认密码" style={inputStyle('confirmPassword')} />
      {errors.confirmPassword && <p style={errorStyle}>{errors.confirmPassword}</p>}

      <select name="country" value={form.country} onChange={handleChange}
        style={inputStyle('country')}>
        <option value="">-- 选择国家 --</option>
        <option value="CN">中国</option>
        <option value="US">美国</option>
        <option value="JP">日本</option>
        <option value="BR">巴西</option>
        <option value="SA">沙特阿拉伯</option>
      </select>
      {errors.country && <p style={errorStyle}>{errors.country}</p>}

      <div style={{ margin: '12px 0' }}>
        <p>性别:</p>
        {['男', '女', '其他'].map(g => (
          <label key={g} style={{ marginRight: '16px' }}>
            <input type="radio" name="gender" value={g}
              checked={form.gender === g} onChange={handleChange} />
            {g}
          </label>
        ))}
      </div>

      <label style={{ display: 'block', margin: '12px 0' }}>
        <input type="checkbox" name="acceptTerms" checked={form.acceptTerms}
          onChange={handleChange} />
        我已阅读并同意 <a href="#">用户协议</a>
      </label>
      {errors.acceptTerms && <p style={errorStyle}>{errors.acceptTerms}</p>}

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

const errorStyle = {
  color: '#ff4d4f', fontSize: '12px',
  margin: '0 0 8px 0'
}

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

JSX
function PhoneForm() {
  const [form, setForm] = useState({ country: 'US', phone: '' })
  const areaCodes = { US: '+1', CN: '+86', JP: '+81', UK: '+44', BR: '+55' }

  function handleCountryChange(e) {
    const country = e.target.value
    setForm(prev => ({
      ...prev,
      country,
      phone: prev.phone.replace(/^\+\d+\s*/, '') + ' ',
    }))
  }

  function handleChange(e) {
    const { name, value } = e.target
    setForm(prev => ({ ...prev, [name]: value }))
  }

  return (
    <div style={{ maxWidth: 400, margin: '0 auto' }}>
      <h3>Phone Contact</h3>
      <select name="country" value={form.country} onChange={handleCountryChange}
        style={{ width: '100%', padding: 8, marginBottom: 8, borderRadius: 4 }}>
        {Object.keys(areaCodes).map(c => (
          <option key={c} value={c}>{c}</option>
        ))}
      </select>
      <div style={{ display: 'flex', gap: 8 }}>
        <input value={areaCodes[form.country]} readOnly style={{ width: 60, padding: 8, borderRadius: 4 }} />
        <input name="phone" value={form.phone} onChange={handleChange}
          placeholder="Phone number" style={{ flex: 1, padding: 8, borderRadius: 4 }} />
      </div>
      <p style={{ color: '#666', fontSize: 13, marginTop: 8 }}>
        Full: {areaCodes[form.country]} {form.phone.trim()}
      </p>
    </div>
  )
}
▶ 试一试

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

JSX 📖 仅展示
function TeamForm() {
  const [members, setMembers] = useState([{ name: '', email: '', role: 'developer' }])

  function addMember() {
    setMembers(prev => [...prev, { name: '', email: '', role: 'developer' }])
  }

  function removeMember(index) {
    setMembers(prev => prev.filter((_, i) => i !== index))
  }

  function updateMember(index, field, value) {
    setMembers(prev => prev.map((m, i) => i === index ? { ...m, [field]: value } : m))
  }

  function handleSubmit(e) {
    e.preventDefault()
    console.log('Team:', members)
  }

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: 500, margin: '0 auto' }}>
      <h3>Team Members</h3>
      {members.map((m, i) => (
        <div key={i} style={{ display: 'flex', gap: 8, marginBottom: 8, alignItems: 'center' }}>
          <input value={m.name} onChange={e => updateMember(i, 'name', e.target.value)}
            placeholder="Name" style={{ flex: 1, padding: 6, borderRadius: 4 }} />
          <input value={m.email} onChange={e => updateMember(i, 'email', e.target.value)}
            placeholder="Email" style={{ flex: 1, padding: 6, borderRadius: 4 }} />
          <select value={m.role} onChange={e => updateMember(i, 'role', e.target.value)}
            style={{ padding: 6, borderRadius: 4 }}>
            <option value="developer">Dev</option>
            <option value="designer">Design</option>
            <option value="manager">PM</option>
          </select>
          {members.length > 1 && (
            <button type="button" onClick={() => removeMember(i)}
              style={{ padding: '6px 10px', color: '#ff4d4f', border: 'none', cursor: 'pointer' }}>
              X
            </button>
          )}
        </div>
      ))}
      <button type="button" onClick={addMember}
        style={{ padding: '6px 16px', marginRight: 8, cursor: 'pointer' }}>
        + Add Member
      </button>
      <button type="submit" style={{ padding: '6px 16px', cursor: 'pointer' }}>Submit</button>
    </form>
  )
}
逻辑代码 46 行(超过 40 行限制,仅展示)

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

JSX 📖 仅展示
function MultiStepForm() {
  const [step, setStep] = useState(1)
  const [form, setForm] = useState({ name: '', email: '', password: '', city: '', agree: false })
  const [errors, setErrors] = useState({})

  function updateField(e) {
    const { name, value, type, checked } = e.target
    setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
  }

  function validate() {
    const errs = {}
    if (step === 1) {
      if (!form.name.trim()) errs.name = 'Name required'
      if (!form.email.includes('@')) errs.email = 'Valid email required'
    } else if (step === 2) {
      if (form.password.length < 6) errs.password = 'At least 6 characters'
    } else if (step === 3) {
      if (!form.city) errs.city = 'Select a city'
      if (!form.agree) errs.agree = 'Must agree to terms'
    }
    setErrors(errs)
    return Object.keys(errs).length === 0
  }

  function next() { if (validate()) setStep(s => Math.min(s + 1, 3)) }
  function back() { setStep(s => Math.max(s - 1, 1)) }

  return (
    <div style={{ maxWidth: 400, margin: '0 auto' }}>
      <h3>Step {step} of 3</h3>
      <div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
        {[1, 2, 3].map(s => (
          <div key={s} style={{ flex: 1, height: 4, borderRadius: 2,
            backgroundColor: s <= step ? '#1890ff' : '#f0f0f0' }} />
        ))}
      </div>
      {step === 1 && (
        <>
          <input name="name" value={form.name} onChange={updateField} placeholder="Name"
            style={{ width: '100%', padding: 8, marginBottom: 4, borderRadius: 4, border: errors.name ? '1px solid #ff4d4f' : undefined }} />
          {errors.name && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{errors.name}</p>}
          <input name="email" value={form.email} onChange={updateField} placeholder="Email"
            style={{ width: '100%', padding: 8, marginBottom: 4, borderRadius: 4, border: errors.email ? '1px solid #ff4d4f' : undefined }} />
          {errors.email && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{errors.email}</p>}
        </>
      )}
      {step === 2 && (
        <>
          <input name="password" type="password" value={form.password} onChange={updateField}
            placeholder="Password" style={{ width: '100%', padding: 8, marginBottom: 4, borderRadius: 4 }} />
          {errors.password && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{errors.password}</p>}
        </>
      )}
      {step === 3 && (
        <>
          <select name="city" value={form.city} onChange={updateField}
            style={{ width: '100%', padding: 8, marginBottom: 4, borderRadius: 4 }}>
            <option value="">Select city</option>
            <option value="beijing">Beijing</option>
            <option value="shanghai">Shanghai</option>
          </select>
          {errors.city && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{errors.city}</p>}
          <label style={{ display: 'block', marginTop: 8 }}>
            <input type="checkbox" name="agree" checked={form.agree} onChange={updateField} />
            I agree to the terms
          </label>
          {errors.agree && <p style={{ color: '#ff4d4f', fontSize: 12 }}>{errors.agree}</p>}
        </>
      )}
      <div style={{ marginTop: 16 }}>
        {step > 1 && <button type="button" onClick={back} style={{ marginRight: 8, cursor: 'pointer' }}>Back</button>}
        {step < 3 ? (
          <button type="button" onClick={next} style={{ cursor: 'pointer' }}>Next</button>
        ) : (
          <button type="button" onClick={() => { if (validate()) alert('Done!') }} style={{ cursor: 'pointer' }}>Submit</button>
        )}
      </div>
    </div>
  )
}
逻辑代码 77 行(超过 40 行限制,仅展示)

▶ 示例 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>
  )
}
▶ 试一试

❓ 常见问题

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 的通用写法?
Ae.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%

🙏 帮我们做得更好

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

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