Skip to content

受控组件详解

学习目标

通过本章学习,你将全面掌握:

  • 受控组件的概念和原理
  • 各种表单元素的受控实现
  • 受控组件的优势和应用场景
  • 表单验证的完整流程
  • 性能优化策略
  • 常见问题和解决方案
  • React 19中的表单处理新特性

第一部分:受控组件基础

1.1 什么是受控组件

受控组件是指表单元素的值完全由React State控制的组件。

jsx
// 受控组件的定义
function ControlledInput() {
  const [value, setValue] = useState('');
  
  return (
    <input
      value={value}  // 值由State控制
      onChange={e => setValue(e.target.value)}  // 通过事件更新State
    />
  );
}

// 非受控组件(对比)
function UncontrolledInput() {
  return (
    <input defaultValue="初始值" />  // 值由DOM自己管理
  );
}

// 为什么叫"受控"?
// 因为值完全受React控制,DOM只是展示

1.2 受控组件的工作流程

jsx
function ControlledFlow() {
  const [value, setValue] = useState('Hello');
  
  // 数据流:
  // 1. State (value = "Hello")
  // 2. ↓ 渲染到DOM
  // 3. <input value="Hello" />
  // 4. 用户输入 "Hi"
  // 5. onChange事件触发
  // 6. setValue("Hi")
  // 7. State更新 (value = "Hi")
  // 8. 重新渲染
  // 9. <input value="Hi" />
  
  const handleChange = (e) => {
    const newValue = e.target.value;
    console.log('旧值:', value);
    console.log('新值:', newValue);
    setValue(newValue);
  };
  
  return (
    <div>
      <input value={value} onChange={handleChange} />
      <p>当前值: {value}</p>
    </div>
  );
}

1.3 受控组件的优势

jsx
function ControlledAdvantages() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  
  // 优势1:实时验证
  const usernameError = username && username.length < 3 ? '用户名至少3个字符' : '';
  
  // 优势2:格式化输入
  const handleUsernameChange = (e) => {
    let value = e.target.value;
    
    // 自动转小写
    value = value.toLowerCase();
    
    // 只允许字母数字和下划线
    value = value.replace(/[^a-z0-9_]/g, '');
    
    // 限制长度
    if (value.length <= 20) {
      setUsername(value);
    }
  };
  
  // 优势3:条件禁用
  const handlePasswordChange = (e) => {
    const value = e.target.value;
    
    // 只在用户名有效时才允许输入密码
    if (username.length >= 3) {
      setPassword(value);
    }
  };
  
  // 优势4:实时比较
  const passwordMatch = password && confirmPassword && password === confirmPassword;
  const passwordMismatch = password && confirmPassword && password !== confirmPassword;
  
  // 优势5:动态提示
  const getPasswordStrength = () => {
    if (!password) return '';
    if (password.length < 6) return '弱';
    if (password.length < 10) return '中等';
    return '强';
  };
  
  return (
    <form>
      <div>
        <label>用户名:</label>
        <input
          value={username}
          onChange={handleUsernameChange}
          placeholder="3-20个字符"
        />
        {usernameError && <span className="error">{usernameError}</span>}
        <span className="info">{username.length}/20</span>
      </div>
      
      <div>
        <label>密码:</label>
        <input
          type="password"
          value={password}
          onChange={handlePasswordChange}
          disabled={username.length < 3}
          placeholder="至少6个字符"
        />
        {password && (
          <span className="strength">强度: {getPasswordStrength()}</span>
        )}
      </div>
      
      <div>
        <label>确认密码:</label>
        <input
          type="password"
          value={confirmPassword}
          onChange={e => setConfirmPassword(e.target.value)}
          placeholder="再次输入密码"
        />
        {passwordMatch && <span className="success">✓ 密码匹配</span>}
        {passwordMismatch && <span className="error">✗ 密码不匹配</span>}
      </div>
      
      <button
        type="submit"
        disabled={!username || !password || !passwordMatch}
      >
        注册
      </button>
    </form>
  );
}

第二部分:各种表单元素的受控实现

2.1 input文本框

jsx
function ControlledTextInputs() {
  const [text, setText] = useState('');
  const [number, setNumber] = useState(0);
  const [date, setDate] = useState('');
  const [time, setTime] = useState('');
  const [email, setEmail] = useState('');
  const [url, setUrl] = useState('');
  const [search, setSearch] = useState('');
  const [tel, setTel] = useState('');
  const [color, setColor] = useState('#000000');
  const [range, setRange] = useState(50);
  
  return (
    <div className="form-grid">
      <div>
        <label>text:</label>
        <input
          type="text"
          value={text}
          onChange={e => setText(e.target.value)}
          placeholder="文本"
        />
      </div>
      
      <div>
        <label>number:</label>
        <input
          type="number"
          value={number}
          onChange={e => setNumber(Number(e.target.value))}
          min="0"
          max="100"
        />
      </div>
      
      <div>
        <label>date:</label>
        <input
          type="date"
          value={date}
          onChange={e => setDate(e.target.value)}
        />
      </div>
      
      <div>
        <label>time:</label>
        <input
          type="time"
          value={time}
          onChange={e => setTime(e.target.value)}
        />
      </div>
      
      <div>
        <label>email:</label>
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </div>
      
      <div>
        <label>url:</label>
        <input
          type="url"
          value={url}
          onChange={e => setUrl(e.target.value)}
        />
      </div>
      
      <div>
        <label>search:</label>
        <input
          type="search"
          value={search}
          onChange={e => setSearch(e.target.value)}
        />
      </div>
      
      <div>
        <label>tel:</label>
        <input
          type="tel"
          value={tel}
          onChange={e => setTel(e.target.value)}
        />
      </div>
      
      <div>
        <label>color:</label>
        <input
          type="color"
          value={color}
          onChange={e => setColor(e.target.value)}
        />
      </div>
      
      <div>
        <label>range ({range}):</label>
        <input
          type="range"
          value={range}
          onChange={e => setRange(Number(e.target.value))}
          min="0"
          max="100"
        />
      </div>
    </div>
  );
}

2.2 textarea

jsx
function ControlledTextarea() {
  const [content, setContent] = useState('');
  const [wordCount, setWordCount] = useState(0);
  const [charCount, setCharCount] = useState(0);
  const maxLength = 500;
  
  const handleChange = (e) => {
    const value = e.target.value;
    
    if (value.length <= maxLength) {
      setContent(value);
      setCharCount(value.length);
      setWordCount(value.trim().split(/\s+/).filter(Boolean).length);
    }
  };
  
  const remaining = maxLength - charCount;
  const progress = (charCount / maxLength) * 100;
  
  return (
    <div>
      <textarea
        value={content}
        onChange={handleChange}
        placeholder="请输入内容..."
        rows={10}
        cols={50}
      />
      
      <div className="textarea-info">
        <span>字符: {charCount}/{maxLength}</span>
        <span>单词: {wordCount}</span>
        <span>剩余: {remaining}</span>
        
        <div className="progress-bar">
          <div
            className="progress-fill"
            style={{
              width: `${progress}%`,
              background: progress > 90 ? '#e74c3c' : '#28a745'
            }}
          />
        </div>
      </div>
    </div>
  );
}

2.3 select下拉框

jsx
function ControlledSelect() {
  const [single, setSingle] = useState('');
  const [multiple, setMultiple] = useState([]);
  
  // 单选
  const handleSingleChange = (e) => {
    setSingle(e.target.value);
  };
  
  // 多选
  const handleMultipleChange = (e) => {
    const options = Array.from(e.target.selectedOptions);
    const values = options.map(opt => opt.value);
    setMultiple(values);
  };
  
  return (
    <div>
      <div>
        <label>单选下拉框:</label>
        <select value={single} onChange={handleSingleChange}>
          <option value="">请选择</option>
          <option value="apple">苹果</option>
          <option value="banana">香蕉</option>
          <option value="orange">橙子</option>
        </select>
        <p>选择: {single || '未选择'}</p>
      </div>
      
      <div>
        <label>多选下拉框:</label>
        <select
          multiple
          value={multiple}
          onChange={handleMultipleChange}
          size={5}
        >
          <option value="apple">苹果</option>
          <option value="banana">香蕉</option>
          <option value="orange">橙子</option>
          <option value="grape">葡萄</option>
          <option value="watermelon">西瓜</option>
        </select>
        <p>选择: {multiple.join(', ') || '未选择'}</p>
      </div>
    </div>
  );
}

2.4 checkbox复选框

jsx
function ControlledCheckbox() {
  const [checked, setChecked] = useState(false);
  const [selected, setSelected] = useState([]);
  
  const options = ['阅读', '运动', '音乐', '旅行', '摄影'];
  
  // 单个checkbox
  const handleSingleCheck = (e) => {
    setChecked(e.target.checked);
  };
  
  // 多个checkbox
  const handleOptionChange = (option) => {
    setSelected(prev =>
      prev.includes(option)
        ? prev.filter(item => item !== option)
        : [...prev, option]
    );
  };
  
  // 全选/取消全选
  const toggleAll = () => {
    setSelected(prev =>
      prev.length === options.length ? [] : [...options]
    );
  };
  
  const allSelected = selected.length === options.length;
  const someSelected = selected.length > 0 && selected.length < options.length;
  
  return (
    <div>
      <div>
        <label>
          <input
            type="checkbox"
            checked={checked}
            onChange={handleSingleCheck}
          />
          同意条款
        </label>
      </div>
      
      <div>
        <h4>选择爱好:</h4>
        
        <label>
          <input
            type="checkbox"
            checked={allSelected}
            ref={input => {
              if (input) {
                input.indeterminate = someSelected;
              }
            }}
            onChange={toggleAll}
          />
          全选
        </label>
        
        {options.map(option => (
          <label key={option}>
            <input
              type="checkbox"
              checked={selected.includes(option)}
              onChange={() => handleOptionChange(option)}
            />
            {option}
          </label>
        ))}
        
        <p>已选择: {selected.join(', ') || '无'}</p>
      </div>
    </div>
  );
}

2.5 radio单选框

jsx
function ControlledRadio() {
  const [selected, setSelected] = useState('');
  const [paymentMethod, setPaymentMethod] = useState('alipay');
  
  const options = ['苹果', '香蕉', '橙子'];
  const paymentMethods = [
    { id: 'alipay', name: '支付宝', icon: '💰' },
    { id: 'wechat', name: '微信支付', icon: '💚' },
    { id: 'card', name: '银行卡', icon: '💳' }
  ];
  
  return (
    <div>
      <div>
        <h4>选择水果:</h4>
        {options.map(option => (
          <label key={option}>
            <input
              type="radio"
              name="fruit"
              value={option}
              checked={selected === option}
              onChange={e => setSelected(e.target.value)}
            />
            {option}
          </label>
        ))}
        <p>选择: {selected || '未选择'}</p>
      </div>
      
      <div>
        <h4>支付方式:</h4>
        <div className="payment-methods">
          {paymentMethods.map(method => (
            <label
              key={method.id}
              className={`payment-option ${paymentMethod === method.id ? 'selected' : ''}`}
            >
              <input
                type="radio"
                name="payment"
                value={method.id}
                checked={paymentMethod === method.id}
                onChange={e => setPaymentMethod(e.target.value)}
              />
              <span className="icon">{method.icon}</span>
              <span className="name">{method.name}</span>
            </label>
          ))}
        </div>
      </div>
    </div>
  );
}

第三部分:表单验证

3.1 实时验证

jsx
function RealTimeValidation() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });
  
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  // 验证规则
  const validators = {
    username: (value) => {
      if (!value) return '用户名不能为空';
      if (value.length < 3) return '用户名至少3个字符';
      if (value.length > 20) return '用户名最多20个字符';
      if (!/^[a-zA-Z0-9_]+$/.test(value)) return '只能包含字母、数字和下划线';
      return '';
    },
    
    email: (value) => {
      if (!value) return '邮箱不能为空';
      if (!/\S+@\S+\.\S+/.test(value)) return '邮箱格式不正确';
      return '';
    },
    
    password: (value) => {
      if (!value) return '密码不能为空';
      if (value.length < 8) return '密码至少8个字符';
      if (!/[A-Z]/.test(value)) return '密码必须包含大写字母';
      if (!/[a-z]/.test(value)) return '密码必须包含小写字母';
      if (!/[0-9]/.test(value)) return '密码必须包含数字';
      if (!/[!@#$%^&*]/.test(value)) return '密码必须包含特殊字符';
      return '';
    },
    
    confirmPassword: (value) => {
      if (!value) return '请确认密码';
      if (value !== formData.password) return '两次密码不一致';
      return '';
    }
  };
  
  // 处理变化
  const handleChange = (field) => (e) => {
    const value = e.target.value;
    
    setFormData(prev => ({
      ...prev,
      [field]: value
    }));
    
    // 如果字段已被访问过,立即验证
    if (touched[field]) {
      const error = validators[field](value);
      setErrors(prev => ({
        ...prev,
        [field]: error
      }));
    }
  };
  
  // 处理失焦
  const handleBlur = (field) => () => {
    setTouched(prev => ({
      ...prev,
      [field]: true
    }));
    
    const error = validators[field](formData[field]);
    setErrors(prev => ({
      ...prev,
      [field]: error
    }));
  };
  
  // 提交验证
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 标记所有字段为已访问
    const allTouched = {};
    Object.keys(formData).forEach(key => {
      allTouched[key] = true;
    });
    setTouched(allTouched);
    
    // 验证所有字段
    const allErrors = {};
    Object.keys(formData).forEach(field => {
      const error = validators[field](formData[field]);
      if (error) {
        allErrors[field] = error;
      }
    });
    setErrors(allErrors);
    
    // 检查是否有错误
    if (Object.keys(allErrors).length === 0) {
      console.log('提交数据:', formData);
      alert('注册成功!');
    }
  };
  
  return (
    <form onSubmit={handleSubmit} className="validation-form">
      <div className="form-field">
        <label>用户名:</label>
        <input
          value={formData.username}
          onChange={handleChange('username')}
          onBlur={handleBlur('username')}
          className={touched.username && errors.username ? 'error' : ''}
        />
        {touched.username && errors.username && (
          <span className="error-message">{errors.username}</span>
        )}
      </div>
      
      <div className="form-field">
        <label>邮箱:</label>
        <input
          type="email"
          value={formData.email}
          onChange={handleChange('email')}
          onBlur={handleBlur('email')}
          className={touched.email && errors.email ? 'error' : ''}
        />
        {touched.email && errors.email && (
          <span className="error-message">{errors.email}</span>
        )}
      </div>
      
      <div className="form-field">
        <label>密码:</label>
        <input
          type="password"
          value={formData.password}
          onChange={handleChange('password')}
          onBlur={handleBlur('password')}
          className={touched.password && errors.password ? 'error' : ''}
        />
        {touched.password && errors.password && (
          <span className="error-message">{errors.password}</span>
        )}
      </div>
      
      <div className="form-field">
        <label>确认密码:</label>
        <input
          type="password"
          value={formData.confirmPassword}
          onChange={handleChange('confirmPassword')}
          onBlur={handleBlur('confirmPassword')}
          className={touched.confirmPassword && errors.confirmPassword ? 'error' : ''}
        />
        {touched.confirmPassword && errors.confirmPassword && (
          <span className="error-message">{errors.confirmPassword}</span>
        )}
      </div>
      
      <button type="submit">注册</button>
    </form>
  );
}

3.2 异步验证

jsx
function AsyncValidation() {
  const [username, setUsername] = useState('');
  const [checking, setChecking] = useState(false);
  const [available, setAvailable] = useState(null);
  
  // 防抖检查用户名
  useEffect(() => {
    if (!username || username.length < 3) {
      setAvailable(null);
      return;
    }
    
    setChecking(true);
    const timer = setTimeout(async () => {
      try {
        const response = await fetch(`/api/check-username?username=${username}`);
        const data = await response.json();
        setAvailable(data.available);
      } catch (error) {
        console.error('检查失败', error);
      } finally {
        setChecking(false);
      }
    }, 500);
    
    return () => clearTimeout(timer);
  }, [username]);
  
  return (
    <div>
      <label>用户名:</label>
      <input
        value={username}
        onChange={e => setUsername(e.target.value)}
        placeholder="输入用户名"
      />
      
      {checking && <span className="checking">检查中...</span>}
      {!checking && available === true && (
        <span className="success">✓ 用户名可用</span>
      )}
      {!checking && available === false && (
        <span className="error">✗ 用户名已被使用</span>
      )}
    </div>
  );
}

第四部分:性能优化

4.1 大型表单优化

jsx
// 问题:每个字段变化都会重新渲染整个表单
function UnoptimizedLargeForm() {
  const [formData, setFormData] = useState({
    field1: '', field2: '', field3: '', /* ...100个字段 */
  });
  
  const handleChange = (field) => (e) => {
    setFormData(prev => ({
      ...prev,
      [field]: e.target.value
    }));
  };
  
  console.log('Form渲染');  // 每次输入都渲染
  
  return (
    <form>
      {/* 100个字段... */}
    </form>
  );
}

// 解决方案:拆分为独立组件
const FormField = React.memo(function FormField({ 
  label, 
  value, 
  onChange, 
  error 
}) {
  console.log('FormField渲染:', label);
  
  return (
    <div className="form-field">
      <label>{label}</label>
      <input value={value} onChange={onChange} />
      {error && <span className="error">{error}</span>}
    </div>
  );
});

function OptimizedLargeForm() {
  const [field1, setField1] = useState('');
  const [field2, setField2] = useState('');
  const [field3, setField3] = useState('');
  // 每个字段独立的State
  
  return (
    <form>
      <FormField label="字段1" value={field1} onChange={e => setField1(e.target.value)} />
      <FormField label="字段2" value={field2} onChange={e => setField2(e.target.value)} />
      <FormField label="字段3" value={field3} onChange={e => setField3(e.target.value)} />
      {/* field1变化时,只有FormField1重新渲染 */}
    </form>
  );
}

第五部分:React 19新特性

5.1 Server Actions表单

jsx
'use server';

async function createUser(formData) {
  const data = {
    name: formData.get('name'),
    email: formData.get('email')
  };
  
  await db.users.create(data);
  revalidatePath('/users');
  
  return { success: true };
}

// Client Component
'use client';

import { useActionState } from 'react';

function ServerActionForm() {
  const [state, formAction, isPending] = useActionState(createUser, null);
  
  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
      {state?.success && <p>创建成功!</p>}
    </form>
  );
}

练习题

基础练习

  1. 实现各种受控表单元素
  2. 创建实时表单验证
  3. 实现多个表单元素的联动

进阶练习

  1. 创建复杂的表单验证系统
  2. 实现表单的自动保存功能
  3. 优化大型表单性能

高级练习

  1. 使用React 19 Server Actions
  2. 实现表单的撤销/重做
  3. 创建动态表单生成器

通过本章学习,你已经掌握了受控组件的完整知识。受控组件是React表单处理的核心,掌握它对构建复杂表单至关重要!继续学习,成为表单处理专家!