Skip to content

非受控组件详解

学习目标

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

  • 非受控组件的概念和原理
  • useRef获取DOM值
  • defaultValue的使用
  • 受控vs非受控的深入对比
  • 文件上传处理
  • 第三方库集成
  • 混合使用策略
  • React 19中的最佳实践

第一部分:非受控组件基础

1.1 什么是非受控组件

非受控组件是指表单元素的值由DOM自己管理,React通过ref在需要时获取值。

jsx
import { useRef } from 'react';

// 非受控组件
function UncontrolledInput() {
  const inputRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('值:', inputRef.current.value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="初始值" />
      <button type="submit">提交</button>
    </form>
  );
}

// 对比:受控组件
function ControlledInput() {
  const [value, setValue] = useState('初始值');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('值:', value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button type="submit">提交</button>
    </form>
  );
}

1.2 非受控组件的特点

jsx
function UncontrolledCharacteristics() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 特点1:只在需要时读取值
    const formData = {
      name: nameRef.current.value,
      email: emailRef.current.value,
      password: passwordRef.current.value
    };
    
    console.log('表单数据:', formData);
    
    // 特点2:可以直接操作DOM
    nameRef.current.focus();
    nameRef.current.select();
    
    // 特点3:可以重置表单
    nameRef.current.value = '';
    emailRef.current.value = '';
    passwordRef.current.value = '';
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="" placeholder="姓名" />
      <input ref={emailRef} defaultValue="" placeholder="邮箱" />
      <input ref={passwordRef} type="password" defaultValue="" placeholder="密码" />
      <button type="submit">提交</button>
    </form>
  );
}

1.3 defaultValue vs value

jsx
function DefaultValueVsValue() {
  // defaultValue(非受控)
  function DefaultValueExample() {
    const inputRef = useRef(null);
    
    return (
      <div>
        <input ref={inputRef} defaultValue="初始值" />
        {/* 用户可以自由修改,React不控制 */}
        
        <button onClick={() => {
          console.log('当前值:', inputRef.current.value);
        }}>
          获取值
        </button>
        
        <button onClick={() => {
          inputRef.current.value = '新值';
        }}>
          设置值
        </button>
      </div>
    );
  }
  
  // value(受控)
  function ValueExample() {
    const [value, setValue] = useState('初始值');
    
    return (
      <div>
        <input
          value={value}
          onChange={e => setValue(e.target.value)}
        />
        {/* React完全控制值的变化 */}
        
        <button onClick={() => console.log('当前值:', value)}>
          获取值
        </button>
        
        <button onClick={() => setValue('新值')}>
          设置值
        </button>
      </div>
    );
  }
  
  return (
    <div>
      <h3>非受控(defaultValue)</h3>
      <DefaultValueExample />
      
      <h3>受控(value)</h3>
      <ValueExample />
    </div>
  );
}

第二部分:各种非受控元素

2.1 input元素

jsx
function UncontrolledInputs() {
  const textRef = useRef(null);
  const numberRef = useRef(null);
  const dateRef = useRef(null);
  const emailRef = useRef(null);
  const passwordRef = useRef(null);
  const urlRef = useRef(null);
  const telRef = useRef(null);
  const searchRef = useRef(null);
  const colorRef = useRef(null);
  const rangeRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    const formData = {
      text: textRef.current.value,
      number: numberRef.current.value,
      date: dateRef.current.value,
      email: emailRef.current.value,
      password: passwordRef.current.value,
      url: urlRef.current.value,
      tel: telRef.current.value,
      search: searchRef.current.value,
      color: colorRef.current.value,
      range: rangeRef.current.value
    };
    
    console.log('表单数据:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>文本:</label>
        <input ref={textRef} type="text" defaultValue="" />
      </div>
      
      <div>
        <label>数字:</label>
        <input ref={numberRef} type="number" defaultValue={0} />
      </div>
      
      <div>
        <label>日期:</label>
        <input ref={dateRef} type="date" />
      </div>
      
      <div>
        <label>邮箱:</label>
        <input ref={emailRef} type="email" defaultValue="" />
      </div>
      
      <div>
        <label>密码:</label>
        <input ref={passwordRef} type="password" defaultValue="" />
      </div>
      
      <div>
        <label>网址:</label>
        <input ref={urlRef} type="url" defaultValue="" />
      </div>
      
      <div>
        <label>电话:</label>
        <input ref={telRef} type="tel" defaultValue="" />
      </div>
      
      <div>
        <label>搜索:</label>
        <input ref={searchRef} type="search" defaultValue="" />
      </div>
      
      <div>
        <label>颜色:</label>
        <input ref={colorRef} type="color" defaultValue="#000000" />
      </div>
      
      <div>
        <label>范围:</label>
        <input ref={rangeRef} type="range" min="0" max="100" defaultValue={50} />
        <span id="rangeValue">50</span>
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

2.2 textarea元素

jsx
function UncontrolledTextarea() {
  const textareaRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('内容:', textareaRef.current.value);
  };
  
  const handleClear = () => {
    textareaRef.current.value = '';
    textareaRef.current.focus();
  };
  
  const handleInsertText = () => {
    const textarea = textareaRef.current;
    const start = textarea.selectionStart;
    const end = textarea.selectionEnd;
    const text = textarea.value;
    
    const before = text.substring(0, start);
    const after = text.substring(end);
    const insert = '[插入的文本]';
    
    textarea.value = before + insert + after;
    textarea.selectionStart = textarea.selectionEnd = start + insert.length;
    textarea.focus();
  };
  
  return (
    <div>
      <textarea
        ref={textareaRef}
        defaultValue="默认内容"
        rows={10}
        cols={50}
        placeholder="请输入内容..."
      />
      
      <div>
        <button onClick={handleClear}>清空</button>
        <button onClick={handleInsertText}>插入文本</button>
        <button onClick={handleSubmit}>提交</button>
      </div>
    </div>
  );
}

2.3 select下拉框

jsx
function UncontrolledSelect() {
  const selectRef = useRef(null);
  const multiSelectRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 单选
    const singleValue = selectRef.current.value;
    
    // 多选
    const multiOptions = Array.from(multiSelectRef.current.selectedOptions);
    const multiValues = multiOptions.map(opt => opt.value);
    
    console.log({
      single: singleValue,
      multiple: multiValues
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>单选下拉框:</label>
        <select ref={selectRef} defaultValue="banana">
          <option value="">请选择</option>
          <option value="apple">苹果</option>
          <option value="banana">香蕉</option>
          <option value="orange">橙子</option>
        </select>
      </div>
      
      <div>
        <label>多选下拉框:</label>
        <select
          ref={multiSelectRef}
          multiple
          defaultValue={['apple', 'banana']}
        >
          <option value="apple">苹果</option>
          <option value="banana">香蕉</option>
          <option value="orange">橙子</option>
          <option value="grape">葡萄</option>
        </select>
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

2.4 checkbox和radio

jsx
function UncontrolledCheckboxRadio() {
  const checkboxRef = useRef(null);
  const radioRefs = {
    option1: useRef(null),
    option2: useRef(null),
    option3: useRef(null)
  };
  
  // 多个checkbox的refs
  const hobbiesRefs = {
    reading: useRef(null),
    sports: useRef(null),
    music: useRef(null),
    travel: useRef(null)
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 单个checkbox
    const agreeToTerms = checkboxRef.current.checked;
    
    // 单选框
    let selectedOption = null;
    for (const [key, ref] of Object.entries(radioRefs)) {
      if (ref.current.checked) {
        selectedOption = key;
        break;
      }
    }
    
    // 多个checkbox
    const selectedHobbies = [];
    for (const [hobby, ref] of Object.entries(hobbiesRefs)) {
      if (ref.current.checked) {
        selectedHobbies.push(hobby);
      }
    }
    
    console.log({
      agreeToTerms,
      selectedOption,
      selectedHobbies
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 单个checkbox */}
      <div>
        <label>
          <input
            ref={checkboxRef}
            type="checkbox"
            defaultChecked={false}
          />
          同意条款
        </label>
      </div>
      
      {/* 单选框组 */}
      <div>
        <h4>选择一个选项:</h4>
        {Object.keys(radioRefs).map(key => (
          <label key={key}>
            <input
              ref={radioRefs[key]}
              type="radio"
              name="choice"
              value={key}
              defaultChecked={key === 'option1'}
            />
            {key}
          </label>
        ))}
      </div>
      
      {/* 多个checkbox */}
      <div>
        <h4>选择爱好:</h4>
        {Object.entries(hobbiesRefs).map(([hobby, ref]) => (
          <label key={hobby}>
            <input
              ref={ref}
              type="checkbox"
              defaultChecked={false}
            />
            {hobby}
          </label>
        ))}
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

2.5 文件上传

jsx
function UncontrolledFileUpload() {
  const fileRef = useRef(null);
  const multipleFileRef = useRef(null);
  const [uploadedFiles, setUploadedFiles] = useState([]);
  const [uploadProgress, setUploadProgress] = useState(0);
  
  // 单文件上传
  const handleSingleUpload = (e) => {
    e.preventDefault();
    
    const file = fileRef.current.files[0];
    if (!file) {
      alert('请选择文件');
      return;
    }
    
    console.log('文件信息:', {
      name: file.name,
      size: file.size,
      type: file.type,
      lastModified: new Date(file.lastModified)
    });
    
    uploadFile(file);
  };
  
  // 多文件上传
  const handleMultipleUpload = (e) => {
    e.preventDefault();
    
    const files = Array.from(multipleFileRef.current.files);
    if (files.length === 0) {
      alert('请选择文件');
      return;
    }
    
    console.log(`选择了 ${files.length} 个文件`);
    files.forEach(file => uploadFile(file));
  };
  
  // 文件上传函数
  const uploadFile = (file) => {
    const formData = new FormData();
    formData.append('file', file);
    
    const xhr = new XMLHttpRequest();
    
    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const percent = (e.loaded / e.total) * 100;
        setUploadProgress(percent);
      }
    };
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        setUploadedFiles(prev => [...prev, file.name]);
        setUploadProgress(0);
        alert('上传成功!');
      }
    };
    
    xhr.onerror = () => {
      alert('上传失败');
      setUploadProgress(0);
    };
    
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  };
  
  return (
    <div className="file-upload">
      <h3>单文件上传</h3>
      <form onSubmit={handleSingleUpload}>
        <input
          ref={fileRef}
          type="file"
          accept="image/*"
        />
        <button type="submit">上传</button>
      </form>
      
      <h3>多文件上传</h3>
      <form onSubmit={handleMultipleUpload}>
        <input
          ref={multipleFileRef}
          type="file"
          multiple
          accept="image/*,application/pdf"
        />
        <button type="submit">上传全部</button>
      </form>
      
      {uploadProgress > 0 && (
        <div className="progress-bar">
          <div
            className="progress-fill"
            style={{ width: `${uploadProgress}%` }}
          />
          <span>{uploadProgress.toFixed(0)}%</span>
        </div>
      )}
      
      {uploadedFiles.length > 0 && (
        <div className="uploaded-files">
          <h4>已上传的文件:</h4>
          <ul>
            {uploadedFiles.map((filename, i) => (
              <li key={i}>{filename}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

第三部分:受控vs非受控对比

3.1 何时使用受控组件

jsx
// 场景1:需要实时验证
function RealTimeValidation() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  
  const handleChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    
    // 实时验证
    if (!value) {
      setError('');
    } else if (!/\S+@\S+\.\S+/.test(value)) {
      setError('邮箱格式不正确');
    } else {
      setError('');
    }
  };
  
  return (
    <div>
      <input
        value={email}
        onChange={handleChange}
        placeholder="输入邮箱"
      />
      {error && <span className="error">{error}</span>}
      {!error && email && <span className="success">格式正确</span>}
    </div>
  );
}

// 场景2:需要格式化输入
function FormattedInput() {
  const [phone, setPhone] = useState('');
  
  const handleChange = (e) => {
    let value = e.target.value.replace(/\D/g, '');  // 只保留数字
    
    // 格式化为 xxx-xxxx-xxxx
    if (value.length > 3 && value.length <= 7) {
      value = `${value.slice(0, 3)}-${value.slice(3)}`;
    } else if (value.length > 7) {
      value = `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(7, 11)}`;
    }
    
    setPhone(value);
  };
  
  return (
    <div>
      <input
        value={phone}
        onChange={handleChange}
        placeholder="输入手机号"
      />
      <p>格式化后: {phone}</p>
    </div>
  );
}

// 场景3:需要禁用某些输入
function RestrictedInput() {
  const [value, setValue] = useState('');
  
  const handleChange = (e) => {
    const newValue = e.target.value;
    
    // 限制长度
    if (newValue.length <= 10) {
      setValue(newValue);
    }
  };
  
  const handleKeyPress = (e) => {
    // 禁止输入数字
    if (/[0-9]/.test(e.key)) {
      e.preventDefault();
    }
  };
  
  return (
    <div>
      <input
        value={value}
        onChange={handleChange}
        onKeyPress={handleKeyPress}
        placeholder="最多10个字符,不能输入数字"
      />
      <p>{value.length}/10</p>
    </div>
  );
}

// 场景4:需要联动多个输入
function LinkedInputs() {
  const [startDate, setStartDate] = useState('');
  const [endDate, setEndDate] = useState('');
  
  const handleStartChange = (e) => {
    const value = e.target.value;
    setStartDate(value);
    
    // 联动:如果结束日期早于开始日期,清空结束日期
    if (endDate && value > endDate) {
      setEndDate('');
    }
  };
  
  return (
    <div>
      <input
        type="date"
        value={startDate}
        onChange={handleStartChange}
      />
      <span>至</span>
      <input
        type="date"
        value={endDate}
        onChange={e => setEndDate(e.target.value)}
        min={startDate}  // 最小日期为开始日期
      />
    </div>
  );
}

3.2 何时使用非受控组件

jsx
// 场景1:简单表单
function SimpleForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    const data = {
      name: nameRef.current.value,
      email: emailRef.current.value
    };
    
    console.log('提交:', data);
    
    // 重置表单
    e.target.reset();
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} name="name" placeholder="姓名" />
      <input ref={emailRef} name="email" placeholder="邮箱" />
      <button type="submit">提交</button>
    </form>
  );
}

// 场景2:文件上传
function FileUploadUncontrolled() {
  const fileRef = useRef(null);
  const [preview, setPreview] = useState(null);
  
  const handleFileChange = () => {
    const file = fileRef.current.files[0];
    
    if (file && file.type.startsWith('image/')) {
      const reader = new FileReader();
      reader.onload = (e) => {
        setPreview(e.target.result);
      };
      reader.readAsDataURL(file);
    }
  };
  
  const handleUpload = async () => {
    const file = fileRef.current.files[0];
    if (!file) return;
    
    const formData = new FormData();
    formData.append('file', file);
    
    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
      });
      
      if (response.ok) {
        alert('上传成功');
        setPreview(null);
        fileRef.current.value = '';
      }
    } catch (error) {
      alert('上传失败');
    }
  };
  
  return (
    <div>
      <input
        ref={fileRef}
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
      
      {preview && (
        <div className="preview">
          <img src={preview} alt="预览" style={{ maxWidth: '300px' }} />
        </div>
      )}
      
      <button onClick={handleUpload}>上传</button>
    </div>
  );
}

// 场景3:集成第三方库
function ThirdPartyLibraryIntegration() {
  const editorRef = useRef(null);
  const chartRef = useRef(null);
  const mapRef = useRef(null);
  
  useEffect(() => {
    // 初始化富文本编辑器
    const editor = new RichTextEditor(editorRef.current, {
      placeholder: '请输入内容...',
      theme: 'snow'
    });
    
    // 初始化图表
    const chart = new Chart(chartRef.current, {
      type: 'bar',
      data: { /* ... */ }
    });
    
    // 初始化地图
    const map = new LeafletMap(mapRef.current, {
      center: [51.505, -0.09],
      zoom: 13
    });
    
    return () => {
      // 清理
      editor.destroy();
      chart.destroy();
      map.remove();
    };
  }, []);
  
  return (
    <div>
      <div ref={editorRef} />
      <canvas ref={chartRef} />
      <div ref={mapRef} style={{ height: '400px' }} />
    </div>
  );
}

// 场景4:需要直接操作DOM
function DirectDOMManipulation() {
  const videoRef = useRef(null);
  const canvasRef = useRef(null);
  
  const handlePlay = () => {
    videoRef.current.play();
  };
  
  const handlePause = () => {
    videoRef.current.pause();
  };
  
  const handleCapture = () => {
    const video = videoRef.current;
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    ctx.drawImage(video, 0, 0);
  };
  
  return (
    <div>
      <video
        ref={videoRef}
        src="/video.mp4"
        width="640"
        height="360"
      />
      
      <div>
        <button onClick={handlePlay}>播放</button>
        <button onClick={handlePause}>暂停</button>
        <button onClick={handleCapture}>截图</button>
      </div>
      
      <canvas ref={canvasRef} style={{ display: 'block', marginTop: '20px' }} />
    </div>
  );
}

第四部分:高级用法

4.1 获取焦点

jsx
function FocusManagement() {
  const inputRef = useRef(null);
  const textareaRef = useRef(null);
  const selectRef = useRef(null);
  
  useEffect(() => {
    // 组件挂载时自动聚焦
    inputRef.current.focus();
  }, []);
  
  const focusInput = () => {
    inputRef.current.focus();
  };
  
  const focusTextarea = () => {
    textareaRef.current.focus();
  };
  
  const focusSelect = () => {
    selectRef.current.focus();
  };
  
  const selectAll = () => {
    inputRef.current.select();
  };
  
  return (
    <div>
      <input ref={inputRef} defaultValue="输入框" />
      <textarea ref={textareaRef} defaultValue="文本域" />
      <select ref={selectRef}>
        <option>选项1</option>
        <option>选项2</option>
      </select>
      
      <div>
        <button onClick={focusInput}>聚焦输入框</button>
        <button onClick={focusTextarea}>聚焦文本域</button>
        <button onClick={focusSelect}>聚焦下拉框</button>
        <button onClick={selectAll}>全选输入框</button>
      </div>
    </div>
  );
}

4.2 表单重置

jsx
function FormReset() {
  const formRef = useRef(null);
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  
  const handleReset = () => {
    // 方式1:使用表单的reset方法
    formRef.current.reset();
  };
  
  const handleManualReset = () => {
    // 方式2:手动重置每个字段
    nameRef.current.value = '';
    emailRef.current.value = '';
  };
  
  const handleResetToDefault = () => {
    // 方式3:重置为默认值
    nameRef.current.value = nameRef.current.defaultValue;
    emailRef.current.value = emailRef.current.defaultValue;
  };
  
  return (
    <form ref={formRef}>
      <input
        ref={nameRef}
        name="name"
        defaultValue="张三"
        placeholder="姓名"
      />
      <input
        ref={emailRef}
        name="email"
        defaultValue="zhangsan@example.com"
        placeholder="邮箱"
      />
      
      <div>
        <button type="button" onClick={handleReset}>
          表单重置
        </button>
        <button type="button" onClick={handleManualReset}>
          手动重置
        </button>
        <button type="button" onClick={handleResetToDefault}>
          恢复默认值
        </button>
      </div>
    </form>
  );
}

4.3 表单验证

jsx
function UncontrolledValidation() {
  const formRef = useRef(null);
  const [errors, setErrors] = useState({});
  
  const validateForm = () => {
    const formData = new FormData(formRef.current);
    const newErrors = {};
    
    const name = formData.get('name');
    if (!name) {
      newErrors.name = '姓名不能为空';
    } else if (name.length < 2) {
      newErrors.name = '姓名至少2个字符';
    }
    
    const email = formData.get('email');
    if (!email) {
      newErrors.email = '邮箱不能为空';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = '邮箱格式错误';
    }
    
    const age = formData.get('age');
    if (!age) {
      newErrors.age = '年龄不能为空';
    } else if (age < 0 || age > 120) {
      newErrors.age = '年龄必须在0-120之间';
    }
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (validateForm()) {
      const formData = new FormData(formRef.current);
      const data = Object.fromEntries(formData);
      console.log('表单数据:', data);
      alert('提交成功!');
    }
  };
  
  return (
    <form ref={formRef} onSubmit={handleSubmit}>
      <div>
        <label>姓名:</label>
        <input name="name" defaultValue="" />
        {errors.name && <span className="error">{errors.name}</span>}
      </div>
      
      <div>
        <label>邮箱:</label>
        <input name="email" type="email" defaultValue="" />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <label>年龄:</label>
        <input name="age" type="number" defaultValue="" />
        {errors.age && <span className="error">{errors.age}</span>}
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

第五部分:混合使用策略

5.1 部分字段受控,部分非受控

jsx
function HybridForm() {
  // 受控:需要实时反馈的字段
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  
  // 非受控:不需要实时反馈的字段
  const phoneRef = useRef(null);
  const addressRef = useRef(null);
  const remarksRef = useRef(null);
  
  // 实时验证username
  const isUsernameValid = username.length >= 3;
  
  // 实时验证email
  const isEmailValid = /\S+@\S+\.\S+/.test(email);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (!isUsernameValid || !isEmailValid) {
      alert('请检查表单');
      return;
    }
    
    const formData = {
      // 受控组件的值
      username,
      email,
      // 非受控组件的值
      phone: phoneRef.current.value,
      address: addressRef.current.value,
      remarks: remarksRef.current.value
    };
    
    console.log('提交:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* 受控字段:需要实时验证 */}
      <div>
        <label>用户名(受控):</label>
        <input
          value={username}
          onChange={e => setUsername(e.target.value)}
          placeholder="至少3个字符"
        />
        {username && (
          <span className={isUsernameValid ? 'valid' : 'invalid'}>
            {isUsernameValid ? '✓' : '用户名太短'}
          </span>
        )}
      </div>
      
      <div>
        <label>邮箱(受控):</label>
        <input
          value={email}
          onChange={e => setEmail(e.target.value)}
          placeholder="输入邮箱"
        />
        {email && (
          <span className={isEmailValid ? 'valid' : 'invalid'}>
            {isEmailValid ? '✓' : '邮箱格式错误'}
          </span>
        )}
      </div>
      
      {/* 非受控字段:不需要实时验证 */}
      <div>
        <label>电话(非受控):</label>
        <input ref={phoneRef} defaultValue="" placeholder="电话号码" />
      </div>
      
      <div>
        <label>地址(非受控):</label>
        <textarea ref={addressRef} defaultValue="" placeholder="详细地址" />
      </div>
      
      <div>
        <label>备注(非受控):</label>
        <textarea ref={remarksRef} defaultValue="" placeholder="其他信息" />
      </div>
      
      <button type="submit">提交</button>
    </form>
  );
}

5.2 条件切换受控/非受控

jsx
function ConditionalControl() {
  const [isControlled, setIsControlled] = useState(true);
  const [controlledValue, setControlledValue] = useState('');
  const uncontrolledRef = useRef(null);
  
  const getValue = () => {
    if (isControlled) {
      return controlledValue;
    } else {
      return uncontrolledRef.current?.value || '';
    }
  };
  
  const setValue = (value) => {
    if (isControlled) {
      setControlledValue(value);
    } else if (uncontrolledRef.current) {
      uncontrolledRef.current.value = value;
    }
  };
  
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={isControlled}
          onChange={e => setIsControlled(e.target.checked)}
        />
        使用受控组件
      </label>
      
      {isControlled ? (
        <input
          value={controlledValue}
          onChange={e => setControlledValue(e.target.value)}
          placeholder="受控输入"
        />
      ) : (
        <input
          ref={uncontrolledRef}
          defaultValue=""
          placeholder="非受控输入"
        />
      )}
      
      <div>
        <p>当前值: {getValue()}</p>
        <button onClick={() => setValue('新值')}>设置为"新值"</button>
        <button onClick={() => setValue('')}>清空</button>
      </div>
    </div>
  );
}

第六部分:实战案例

6.1 案例1:登录表单

jsx
function LoginForm() {
  const usernameRef = useRef(null);
  const passwordRef = useRef(null);
  const rememberRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setLoading(true);
    
    const credentials = {
      username: usernameRef.current.value,
      password: passwordRef.current.value,
      remember: rememberRef.current.checked
    };
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (response.ok) {
        const data = await response.json();
        console.log('登录成功:', data);
        
        if (credentials.remember) {
          localStorage.setItem('username', credentials.username);
        }
      } else {
        setError('用户名或密码错误');
      }
    } catch (error) {
      setError('登录失败,请重试');
    } finally {
      setLoading(false);
    }
  };
  
  // 组件挂载时恢复记住的用户名
  useEffect(() => {
    const savedUsername = localStorage.getItem('username');
    if (savedUsername) {
      usernameRef.current.value = savedUsername;
      rememberRef.current.checked = true;
    }
  }, []);
  
  return (
    <form onSubmit={handleSubmit} className="login-form">
      <h2>登录</h2>
      
      {error && <div className="error">{error}</div>}
      
      <div>
        <input
          ref={usernameRef}
          type="text"
          placeholder="用户名"
          required
        />
      </div>
      
      <div>
        <input
          ref={passwordRef}
          type="password"
          placeholder="密码"
          required
        />
      </div>
      
      <div>
        <label>
          <input
            ref={rememberRef}
            type="checkbox"
            defaultChecked={false}
          />
          记住我
        </label>
      </div>
      
      <button type="submit" disabled={loading}>
        {loading ? '登录中...' : '登录'}
      </button>
    </form>
  );
}

6.2 案例2:图片上传预览

jsx
function ImageUploadPreview() {
  const fileRef = useRef(null);
  const [images, setImages] = useState([]);
  
  const handleFileSelect = () => {
    const files = Array.from(fileRef.current.files);
    
    const newImages = files.map(file => ({
      id: Date.now() + Math.random(),
      file,
      preview: URL.createObjectURL(file),
      name: file.name,
      size: file.size
    }));
    
    setImages(prev => [...prev, ...newImages]);
  };
  
  const removeImage = (id) => {
    setImages(prev => {
      const image = prev.find(img => img.id === id);
      if (image) {
        URL.revokeObjectURL(image.preview);
      }
      return prev.filter(img => img.id !== id);
    });
  };
  
  const handleUploadAll = async () => {
    for (const image of images) {
      const formData = new FormData();
      formData.append('file', image.file);
      
      try {
        await fetch('/api/upload', {
          method: 'POST',
          body: formData
        });
        console.log('上传成功:', image.name);
      } catch (error) {
        console.error('上传失败:', image.name);
      }
    }
    
    alert('所有图片上传完成');
    setImages([]);
    fileRef.current.value = '';
  };
  
  // 清理预览URL
  useEffect(() => {
    return () => {
      images.forEach(image => {
        URL.revokeObjectURL(image.preview);
      });
    };
  }, [images]);
  
  return (
    <div className="image-upload">
      <input
        ref={fileRef}
        type="file"
        accept="image/*"
        multiple
        onChange={handleFileSelect}
        style={{ display: 'none' }}
      />
      
      <button onClick={() => fileRef.current.click()}>
        选择图片
      </button>
      
      {images.length > 0 && (
        <>
          <div className="preview-grid">
            {images.map(image => (
              <div key={image.id} className="preview-item">
                <img src={image.preview} alt={image.name} />
                <div className="image-info">
                  <p>{image.name}</p>
                  <p>{(image.size / 1024).toFixed(2)} KB</p>
                </div>
                <button
                  onClick={() => removeImage(image.id)}
                  className="remove-btn"
                >
                  ×
                </button>
              </div>
            ))}
          </div>
          
          <button onClick={handleUploadAll} className="upload-btn">
            上传全部 ({images.length})
          </button>
        </>
      )}
    </div>
  );
}

6.3 案例3:富文本编辑器集成

jsx
// 集成Quill编辑器
import { useRef, useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';

function RichTextEditor({ onSave }) {
  const editorRef = useRef(null);
  const quillRef = useRef(null);
  
  useEffect(() => {
    // 初始化Quill
    quillRef.current = new Quill(editorRef.current, {
      theme: 'snow',
      modules: {
        toolbar: [
          [{ header: [1, 2, 3, false] }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          [{ color: [] }, { background: [] }],
          ['link', 'image'],
          ['clean']
        ]
      },
      placeholder: '请输入内容...'
    });
    
    // 监听内容变化
    quillRef.current.on('text-change', () => {
      console.log('内容变化');
    });
    
    return () => {
      quillRef.current = null;
    };
  }, []);
  
  const handleSave = () => {
    const html = quillRef.current.root.innerHTML;
    const text = quillRef.current.getText();
    const delta = quillRef.current.getContents();
    
    onSave({
      html,
      text,
      delta
    });
  };
  
  const handleClear = () => {
    quillRef.current.setContents([]);
  };
  
  const handleInsertImage = () => {
    const range = quillRef.current.getSelection(true);
    quillRef.current.insertEmbed(range.index, 'image', 'https://example.com/image.jpg');
  };
  
  return (
    <div className="rich-text-editor">
      <div ref={editorRef} style={{ height: '300px' }} />
      
      <div className="editor-actions">
        <button onClick={handleSave}>保存</button>
        <button onClick={handleClear}>清空</button>
        <button onClick={handleInsertImage}>插入图片</button>
      </div>
    </div>
  );
}

6.4 案例4:数据采集表单

jsx
function DataCollectionForm() {
  const formRef = useRef(null);
  const [submittedData, setSubmittedData] = useState([]);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // 使用FormData API获取所有表单数据
    const formData = new FormData(formRef.current);
    const data = Object.fromEntries(formData);
    
    // 处理checkbox group
    const hobbies = formData.getAll('hobbies');
    data.hobbies = hobbies;
    
    // 添加时间戳
    data.timestamp = new Date().toISOString();
    
    setSubmittedData(prev => [...prev, data]);
    
    // 重置表单
    formRef.current.reset();
  };
  
  const exportData = () => {
    const json = JSON.stringify(submittedData, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    
    const link = document.createElement('a');
    link.href = url;
    link.download = `data-${Date.now()}.json`;
    link.click();
    
    URL.revokeObjectURL(url);
  };
  
  return (
    <div>
      <form ref={formRef} onSubmit={handleSubmit}>
        <h3>数据采集表单</h3>
        
        <div>
          <label>姓名:</label>
          <input name="name" defaultValue="" required />
        </div>
        
        <div>
          <label>年龄:</label>
          <input name="age" type="number" defaultValue="" required />
        </div>
        
        <div>
          <label>性别:</label>
          <label><input type="radio" name="gender" value="male" /> 男</label>
          <label><input type="radio" name="gender" value="female" /> 女</label>
        </div>
        
        <div>
          <label>爱好:</label>
          <label><input type="checkbox" name="hobbies" value="reading" /> 阅读</label>
          <label><input type="checkbox" name="hobbies" value="sports" /> 运动</label>
          <label><input type="checkbox" name="hobbies" value="music" /> 音乐</label>
        </div>
        
        <div>
          <label>城市:</label>
          <select name="city" defaultValue="">
            <option value="">请选择</option>
            <option value="beijing">北京</option>
            <option value="shanghai">上海</option>
            <option value="guangzhou">广州</option>
          </select>
        </div>
        
        <div>
          <label>备注:</label>
          <textarea name="remarks" defaultValue="" rows={3} />
        </div>
        
        <button type="submit">提交</button>
      </form>
      
      {submittedData.length > 0 && (
        <div>
          <h3>已收集数据 ({submittedData.length})</h3>
          <button onClick={exportData}>导出JSON</button>
          
          <table>
            <thead>
              <tr>
                <th>姓名</th>
                <th>年龄</th>
                <th>性别</th>
                <th>爱好</th>
                <th>城市</th>
                <th>时间</th>
              </tr>
            </thead>
            <tbody>
              {submittedData.map((data, i) => (
                <tr key={i}>
                  <td>{data.name}</td>
                  <td>{data.age}</td>
                  <td>{data.gender}</td>
                  <td>{data.hobbies.join(', ')}</td>
                  <td>{data.city}</td>
                  <td>{new Date(data.timestamp).toLocaleString()}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  );
}

第七部分:最佳实践

7.1 选择受控还是非受控

jsx
// 决策流程图
const DecisionTree = {
  // 需要实时验证 → 使用受控
  realtimeValidation: {
    answer: '是',
    use: '受控组件',
    example: '<input value={email} onChange={validate} />'
  },
  
  // 需要格式化输入 → 使用受控
  formatInput: {
    answer: '是',
    use: '受控组件',
    example: '<input value={phone} onChange={formatPhone} />'
  },
  
  // 需要禁用某些输入 → 使用受控
  restrictInput: {
    answer: '是',
    use: '受控组件',
    example: '<input value={value} onChange={restrict} />'
  },
  
  // 简单表单,只在提交时需要值 → 使用非受控
  simpleForm: {
    answer: '否',
    use: '非受控组件',
    example: '<input ref={inputRef} defaultValue="" />'
  },
  
  // 文件上传 → 使用非受控
  fileUpload: {
    answer: '总是',
    use: '非受控组件',
    example: '<input ref={fileRef} type="file" />'
  },
  
  // 集成第三方库 → 使用非受控
  thirdParty: {
    answer: '总是',
    use: '非受控组件',
    example: '<div ref={editorRef} />'
  }
};

7.2 性能考虑

jsx
// 受控组件的性能问题
function ControlledPerformance() {
  const [value, setValue] = useState('');
  const renderCount = useRef(0);
  
  useEffect(() => {
    renderCount.current++;
  });
  
  // 每次输入都触发渲染
  const handleChange = (e) => {
    setValue(e.target.value);  // 触发渲染
  };
  
  return (
    <div>
      <p>渲染次数: {renderCount.current}</p>
      <input value={value} onChange={handleChange} />
    </div>
  );
}

// 非受控组件避免频繁渲染
function UncontrolledPerformance() {
  const inputRef = useRef(null);
  const renderCount = useRef(0);
  
  useEffect(() => {
    renderCount.current++;
  });
  
  // 输入不触发渲染
  const getValue = () => {
    console.log('值:', inputRef.current.value);
  };
  
  return (
    <div>
      <p>渲染次数: {renderCount.current}</p>
      <input ref={inputRef} defaultValue="" />
      <button onClick={getValue}>获取值</button>
    </div>
  );
}

练习题

基础练习

  1. 实现一个非受控表单,包含多种输入类型
  2. 使用ref获取和设置input的值
  3. 实现表单的重置功能
  4. 对比受控和非受控组件的渲染次数

进阶练习

  1. 实现一个文件上传组件,支持多文件和预览
  2. 创建一个混合使用受控和非受控的复杂表单
  3. 集成一个第三方富文本编辑器
  4. 实现表单数据的导出功能

高级练习

  1. 实现一个性能优化的大型表单
  2. 创建一个表单构建器,支持动态添加字段
  3. 实现表单的自动保存功能(使用非受控组件)

通过本章学习,你已经掌握了非受控组件的完整知识。理解受控和非受控的区别,能让你选择最适合的方案。在实际开发中,根据具体需求灵活选择,有时混合使用能达到最佳效果!

继续学习,成为React表单处理专家!