3、非受控组件实战:使用useRef获取表单值,处理文件上传等场景

好,咱们进入第三章。这一章聊点实在的——非受控组件。

说实话,我刚接触React那会儿,满脑子都是「受控组件才是正道」。每次写表单,必用useState,每个输入框都得绑个onChange。直到有一次,我接手一个老项目,里面有个超复杂的文件上传表单,光input就有二十多个,还夹杂着各种第三方UI库。改受控?改到一半我就放弃了。

后来我想明白了:不是所有场景都需要受控。有些时候,非受控组件反而更香。

3.1 什么是非受控组件?

说白了,非受控组件就是让DOM自己管自己的状态。React不插手,你通过ref去「偷看」它的值。

你想想看,传统的HTML表单怎么工作的?用户输入,浏览器存着,提交时一次性读取。非受控组件就是这个思路。

对比一下受控组件:

特性 受控组件 非受控组件
数据存储 React state DOM自身
读取时机 实时 需要时(如提交)
代码量 较多 较少
适用场景 需要实时校验、联动 简单表单、文件上传

3.2 useRef:非受控组件的核心工具

useRef这个Hook,我愿称之为「React里的DOM操作许可证」。它返回一个可变的ref对象,这个对象的.current属性指向真实的DOM节点。

来看个最简单的例子:

import React, { useRef } from 'react';

function SimpleForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('姓名:', nameRef.current.value);
    console.log('邮箱:', emailRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>姓名:</label>
        <input ref={nameRef} type="text" defaultValue="" />
      </div>
      <div>
        <label>邮箱:</label>
        <input ref={emailRef} type="email" defaultValue="" />
      </div>
      <button type="submit">提交</button>
    </form>
  );
}

注意这里我用的是defaultValue而不是value。这是非受控组件的关键——defaultValue只在初次渲染时生效,之后DOM自己管自己。

个人习惯:我一般给ref的初始值设为null,然后在JSX里绑定。这样能确保ref.current在组件挂载后才被赋值,避免一些诡异的bug。

3.3 文件上传:非受控组件的王牌场景

文件上传这块,我强烈建议你用非受控组件。为什么?因为文件输入框的value是只读的,你根本没法用受控的方式去设置它。

我曾经见过有人非要用受控方式处理文件上传,结果搞了个隐藏input,再通过点击事件去触发……绕了一大圈,最后还是用ref直接读文件。

直接上代码:

import React, { useRef, useState } from 'react';

function FileUploader() {
  const fileRef = useRef(null);
  const [preview, setPreview] = useState(null);

  const handleFileChange = () => {
    const file = fileRef.current.files[0];
    if (file) {
      // 生成预览URL
      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) {
      alert('请先选择文件');
      return;
    }

    const formData = new FormData();
    formData.append('file', file);

    try {
      // 模拟上传
      console.log('正在上传:', file.name);
      // const res = await fetch('/upload', { method: 'POST', body: formData });
      console.log('上传成功');
    } catch (error) {
      console.error('上传失败:', error);
    }
  };

  return (
    <div>
      <input
        ref={fileRef}
        type="file"
        accept="image/*"
        onChange={handleFileChange}
      />
      {preview && (
        <img src={preview} alt="预览" style={{ width: 200, marginTop: 10 }} />
      )}
      <button onClick={handleUpload}>上传</button>
    </div>
  );
}
避坑指南:我曾经犯过一个错——在文件上传后忘了清空input的value。结果用户想重新上传同一个文件时,onChange不触发。解决方案很简单:上传完成后手动设置 fileRef.current.value = ''

3.4 多文件上传与拖拽

实际项目中,单文件上传太少了。多数情况是批量上传,甚至带拖拽功能。

多文件上传,关键在multiple属性和files对象:

function MultiFileUploader() {
  const fileRef = useRef(null);
  const [fileList, setFileList] = useState([]);

  const handleFilesAdded = () => {
    const files = Array.from(fileRef.current.files);
    setFileList(prev => [...prev, ...files]);
  };

  const removeFile = (index) => {
    setFileList(prev => prev.filter((_, i) => i !== index));
  };

  return (
    <div>
      <input
        ref={fileRef}
        type="file"
        multiple
        onChange={handleFilesAdded}
      />
      <ul>
        {fileList.map((file, index) => (
          <li key={index}>
            {file.name} ({(file.size / 1024).toFixed(1)} KB)
            <button onClick={() => removeFile(index)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

拖拽上传呢?我一般结合onDrop事件和ref一起用:

function DragDropUploader() {
  const dropRef = useRef(null);
  const [dragging, setDragging] = useState(false);

  const handleDrop = (e) => {
    e.preventDefault();
    setDragging(false);
    const files = Array.from(e.dataTransfer.files);
    console.log('拖拽的文件:', files);
    // 处理文件...
  };

  const handleDragOver = (e) => {
    e.preventDefault();
    setDragging(true);
  };

  const handleDragLeave = () => {
    setDragging(false);
  };

  return (
    <div
      ref={dropRef}
      onDrop={handleDrop}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      style={{
        border: `2px dashed ${dragging ? '#4CAF50' : '#ccc'}`,
        padding: 40,
        textAlign: 'center',
        transition: 'border-color 0.3s'
      }}
    >
      {dragging ? '松开鼠标上传' : '拖拽文件到此处'}
    </div>
  );
}

3.5 非受控组件的表单验证

有人会问:「非受控组件怎么做实时验证?」

嗯,这个问题问得好。非受控不代表不能验证,只是验证的时机和方式不同。

我的做法是:提交时统一验证,配合onBlur做失焦验证。

function ValidatedForm() {
  const nameRef = useRef(null);
  const emailRef = useRef(null);
  const [errors, setErrors] = useState({});

  const validate = () => {
    const newErrors = {};
    const name = nameRef.current.value.trim();
    const email = emailRef.current.value.trim();

    if (!name) {
      newErrors.name = '姓名不能为空';
    } else if (name.length < 2) {
      newErrors.name = '姓名至少2个字符';
    }

    if (!email) {
      newErrors.email = '邮箱不能为空';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = '邮箱格式不正确';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) {
      console.log('提交数据:', {
        name: nameRef.current.value,
        email: emailRef.current.value
      });
    }
  };

  const handleBlur = (field) => {
    // 失焦时只验证当前字段
    const value = field === 'name' 
      ? nameRef.current.value 
      : emailRef.current.value;
    
    if (!value.trim()) {
      setErrors(prev => ({ ...prev, [field]: `${field === 'name' ? '姓名' : '邮箱'}不能为空` }));
    } else {
      setErrors(prev => {
        const newErrors = { ...prev };
        delete newErrors[field];
        return newErrors;
      });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>姓名:</label>
        <input
          ref={nameRef}
          type="text"
          defaultValue=""
          onBlur={() => handleBlur('name')}
        />
        {errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
      </div>
      <div>
        <label>邮箱:</label>
        <input
          ref={emailRef}
          type="email"
          defaultValue=""
          onBlur={() => handleBlur('email')}
        />
        {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
      </div>
      <button type="submit">提交</button>
    </form>
  );
}
核心思路:非受控组件的验证策略是「按需验证」——提交时全量验证,失焦时单字段验证。这样既保证了用户体验,又避免了受控组件那种「每次按键都触发验证」的性能开销。

3.6 什么时候用非受控?我的经验总结

做了这么多年表单,我总结了几条经验:

  • 文件上传:必须用非受控,没得选
  • 简单表单:3-5个字段,没有复杂联动,非受控更清爽
  • 第三方UI组件:有些库的组件不支持受控,用ref绕过去
  • 性能敏感场景:超长表单(50+字段),受控组件每次setState都触发重渲染,非受控能省不少
  • 表单值不需要实时响应:比如只提交不预览,非受控就够用了

但也要注意,非受控不是万能的。如果你需要:

  • 实时联动(比如选择国家后自动填充城市)
  • 复杂条件校验
  • 表单状态需要被其他组件共享

那还是老老实实用受控组件吧。

3.7 小结

这一章我们聊了非受控组件的核心用法。useRef就像一把钥匙,让你能直接打开DOM的大门。文件上传、简单表单、拖拽交互——这些场景用非受控组件,代码量少一半,可读性还更高。

下一章,我们会深入表单验证的体系,从基础校验到自定义规则,再到异步验证。到时候你会看到,受控和非受控其实可以配合得很好。

记住一句话:工具没有好坏,只有合不合适。非受控组件不是「退步」,而是另一种选择。用对了地方,它就是利器。