Back

为 React 构建自定义文件上传组件

为 React 构建自定义文件上传组件

原生文件输入框虽然能用,但样式丑陋、缺乏灵活性,且在上传过程中没有任何反馈。大多数开发者会选择像 react-dropzone 这样的库来解决这个问题。但如果你想要完全的控制权——或者只是想减少依赖——那么一旦理解了其中的限制,构建一个自定义的 React 文件上传组件并不困难。

本指南涵盖了核心要素:无障碍文件选择、可选的拖放功能、验证、预览、进度跟踪和取消操作。全部使用现代函数组件和 Hooks 实现。

核心要点

  • 由于浏览器安全限制阻止以编程方式赋值,文件输入框在 React 中本质上是非受控的
  • 始终将客户端验证与服务端验证配对使用,因为 MIME 类型和 accept 属性可以被绕过
  • 使用 XMLHttpRequest 而非 fetch 来实现可靠的上传进度跟踪
  • 撤销为文件预览创建的对象 URL 以防止内存泄漏
  • 拖放功能应该作为渐进增强,而不是替代键盘和点击交互

为什么文件输入框保持非受控状态

这里有一个让 React 开发者困惑的问题:你无法以编程方式设置 <input type="file"> 的值。浏览器安全机制阻止了这一点。这意味着文件输入框本质上是非受控的——React 可以从中读取但永远无法写入。

这对 React 19 的表单操作(Form Actions)也很重要。虽然 useFormStatus 可以跟踪待处理状态,但服务器操作(Server Actions)并不是为大文件上传设计的。你仍然需要在客户端处理文件,然后再将它们发送到任何地方。

基础:无障碍文件选择

从输入框本身开始。视觉上隐藏它,但保持其可访问性:

const FileUploader = () => {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files))
    }
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        id="file-upload"
        className="sr-only"
        onChange={handleChange}
        multiple
        aria-describedby="file-upload-help"
      />
      <label htmlFor="file-upload" className="upload-button">
        Select files
      </label>
      <span id="file-upload-help" className="sr-only">
        Accepted formats: images, PDFs up to 10MB
      </span>
    </div>
  )
}

accept 属性仅作为建议——用户可以绕过它。始终在服务端进行验证。

将拖放功能作为渐进增强添加

拖放文件上传功能可以改善用户体验,但不是必需的。保持键盘和点击交互作为主要路径:

const [isDragging, setIsDragging] = useState(false)

const handleDragOver = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(true)
}

const handleDragLeave = () => setIsDragging(false)

const handleDrop = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(false)
  
  const droppedFiles = Array.from(e.dataTransfer.files)
  setFiles(droppedFiles)
}

注意:DataTransferItem.getAsFileSystemHandle() 在现代浏览器中提供目录访问,但支持仍然有限。为了广泛的兼容性,请坚持使用 dataTransfer.files

客户端验证

在上传前进行验证以改善用户体验:

const validateFile = (file: File): string | null => {
  const maxSize = 10 * 1024 * 1024 // 10MB
  const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
  
  if (file.size > maxSize) return 'File exceeds 10MB limit'
  if (!allowedTypes.includes(file.type)) return 'Invalid file type'
  return null
}

记住:MIME 类型可以被伪造。服务端验证是强制性的。

文件预览与正确的清理

对于图片预览,URL.createObjectURL 创建临时 URL。撤销它们以防止内存泄漏:

const [preview, setPreview] = useState<string | null>(null)

useEffect(() => {
  if (files[0]?.type.startsWith('image/')) {
    const url = URL.createObjectURL(files[0])
    setPreview(url)
    return () => URL.revokeObjectURL(url)
  }
}, [files])

进度跟踪和取消操作

这里有一个不太舒服的事实:fetch 不能可靠地支持上传进度。要实现真正的进度跟踪,请使用 XMLHttpRequest:

const [progress, setProgress] = useState(0)
const xhrRef = useRef<XMLHttpRequest | null>(null)

const upload = (file: File) => {
  const xhr = new XMLHttpRequest()
  xhrRef.current = xhr
  
  xhr.upload.onprogress = (e) => {
    if (e.lengthComputable) {
      setProgress(Math.round((e.loaded / e.total) * 100))
    }
  }
  
  const formData = new FormData()
  formData.append('file', file)
  
  xhr.open('POST', '/api/upload')
  xhr.send(formData)
}

const cancel = () => xhrRef.current?.abort()

对于大文件,考虑直接上传到对象存储(如 S3 预签名 URL)或分块/多部分方法。通过应用服务器发送数 GB 的文件很少会有好结果。

错误状态

显式跟踪上传状态:

type UploadState = 
  | { status: 'idle' }
  | { status: 'uploading'; progress: number }
  | { status: 'success'; url: string }
  | { status: 'error'; message: string }

这个可辨识联合类型使渲染变得简单且类型安全。

何时使用库

当你需要特定行为或想要最小化打包体积时,构建自己的自定义文件上传 React 组件是有意义的。像 react-dropzone 这样的库可以处理你可能遗漏的边缘情况——但它们也会增加你可能不想要的依赖和固有观点。

核心模式很简单:隐藏的输入框、无障碍标签、可选的拖放区域、验证以及用于进度的 XHR。其他一切都是改进。

结论

自定义文件上传组件让你能够精确控制行为、样式和打包体积。关键限制很简单:文件输入框必须保持非受控状态,客户端验证是补充而非替代服务端检查,XMLHttpRequest 仍然是进度跟踪的可靠选择。从无障碍基础开始,将拖放作为可选改进分层添加,并处理预览的内存清理。无论你是自定义构建还是使用库,都取决于你的具体需求——但现在你已经理解了底层原理。

常见问题

浏览器安全机制阻止对文件输入框进行编程赋值。要重置,可以使用 ref 调用 inputRef.current.value = ''(这适用于清除但不适用于设置),或者通过更改其 key 属性来卸载并重新挂载输入框。这两种方法都能有效清除选择,同时尊重浏览器限制。

创建一个上传状态数组,每个文件一个。每个状态对象跟踪自己的进度、状态和任何错误。使用文件名或生成的 ID 作为键。上传时,遍历文件并创建单独的 XMLHttpRequest 实例,在每个进度回调中更新相应的状态条目。

在父元素和子元素之间移动时会触发 dragLeave 事件。通过在将 isDragging 设置为 false 之前检查相关目标是否包含在拖放区域内来修复此问题,或者使用一个在 dragEnter 时递增、在 dragLeave 时递减的计数器,仅在计数器达到零时更新状态。

在大多数情况下使用 FormData。它能高效处理二进制数据并支持服务器期望的 multipart/form-data 编码。Base64 编码会使文件大小增加约 33%,并且需要在两端进行更多处理。仅在你的 API 明确要求或在 JSON 负载中嵌入小文件时使用 base64。

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay