12k
All articles

Создание пользовательского компонента загрузки файлов для React

Статья описывает создание React-компонента загрузки файлов с drag and drop, валидацией, предпросмотром и отслеживанием прогресса XHR.

OpenReplay Team
OpenReplay Team
Создание пользовательского компонента загрузки файлов для React

Нативный file input работает, но выглядит непривлекательно, негибок и не предоставляет никакой обратной связи во время загрузки. Большинство разработчиков используют библиотеки вроде react-dropzone для решения этой проблемы. Но если вы хотите полного контроля — или просто меньше зависимостей — создание пользовательского компонента загрузки файлов для React не так сложно, если понимать ограничения.

Это руководство охватывает основы: доступный выбор файлов, опциональный функционал drag-and-drop, валидацию, предпросмотр, отслеживание прогресса и отмену загрузки. Всё с использованием современных функциональных компонентов и хуков.

Ключевые выводы

  • File inputs по своей природе являются неконтролируемыми в React из-за ограничений безопасности браузера, которые предотвращают программное присвоение значений
  • Всегда сочетайте клиентскую валидацию с серверной, поскольку MIME-типы и атрибут accept можно обойти
  • Используйте XMLHttpRequest вместо fetch для надёжного отслеживания прогресса загрузки
  • Отзывайте object URL, созданные для предпросмотра файлов, чтобы предотвратить утечки памяти
  • Drag-and-drop должен быть прогрессивным улучшением, а не заменой взаимодействия с клавиатурой и кликами

Почему file inputs остаются неконтролируемыми

Вот что сбивает с толку разработчиков React: вы не можете программно установить значение <input type="file">. Безопасность браузера это предотвращает. Это означает, что file inputs по своей природе неконтролируемые — React может читать из них, но никогда не может записывать в них.

Это важно и для Form Actions в React 19. Хотя useFormStatus может отслеживать состояние ожидания, Server Actions не предназначены для загрузки больших файлов. Вы всё равно будете обрабатывать файлы на клиентской стороне перед отправкой куда-либо.

Основа: доступный выбор файлов

Начните с самого input. Скройте его визуально, но сохраните доступность:

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 носит только рекомендательный характер — пользователи могут его обойти. Всегда проводите валидацию на сервере.

Добавление drag-and-drop как прогрессивного улучшения

Функционал drag-and-drop улучшает пользовательский опыт, но не является обязательным. Оставьте взаимодействие с клавиатурой и кликами в качестве основного пути:

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 presigned URLs) или подходы с разбиением на части/multipart. Отправка многогигабайтных файлов через сервер приложения редко заканчивается хорошо.

Состояния ошибок

Отслеживайте состояние загрузки явно:

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

Это дискриминируемое объединение делает рендеринг простым и типобезопасным.

Когда использовать библиотеку вместо собственного решения

Создание собственного пользовательского компонента загрузки файлов для React имеет смысл, когда вам нужно специфическое поведение или вы хотите минимизировать размер бандла. Библиотеки вроде react-dropzone обрабатывают граничные случаи, которые вы можете упустить — но они также добавляют зависимости и подходы, которые могут вам не подойти.

Основной паттерн прост: скрытый input, доступный label, опциональная зона drop, валидация и XHR для прогресса. Всё остальное — это доработка.

Заключение

Пользовательский компонент загрузки файлов даёт вам точный контроль над поведением, стилизацией и размером бандла. Ключевые ограничения просты: file inputs должны оставаться неконтролируемыми, клиентская валидация дополняет, но никогда не заменяет серверные проверки, а XMLHttpRequest остаётся надёжным выбором для отслеживания прогресса. Начните с доступных основ, добавьте drag-and-drop как опциональное улучшение и обрабатывайте очистку памяти для предпросмотра. Создавать ли собственное решение или использовать библиотеку — зависит от ваших конкретных потребностей, но теперь вы понимаете, что происходит под капотом в любом случае.

Часто задаваемые вопросы

Почему я не могу сбросить file input, установив его значение в пустую строку в React?

Безопасность браузера предотвращает программное присвоение значений file inputs. Для сброса либо вызовите inputRef.current.value = '' используя ref (что работает для очистки, но не для установки), либо размонтируйте и перемонтируйте input, изменив его prop key. Оба подхода эффективно очищают выбор, соблюдая ограничения браузера.

Как обрабатывать загрузку нескольких файлов с индивидуальным отслеживанием прогресса?

Создайте массив состояний загрузки, по одному на файл. Каждый объект состояния отслеживает свой собственный прогресс, статус и любые ошибки. Используйте имя файла или сгенерированный ID в качестве ключа. При загрузке пройдитесь по файлам и создайте отдельные экземпляры XMLHttpRequest, обновляя соответствующую запись состояния в каждом callback прогресса.

Почему моя зона drag-and-drop мерцает при перетаскивании над дочерними элементами?

Событие dragLeave срабатывает при перемещении между родительским и дочерними элементами. Исправьте это, проверяя, содержится ли связанная цель внутри зоны drop перед установкой isDragging в false, или используйте счётчик, который увеличивается на dragEnter и уменьшается на dragLeave, обновляя состояние только когда счётчик достигает нуля.

Следует ли использовать FormData или отправлять файлы как base64-кодированные строки?

Используйте FormData в большинстве случаев. Он эффективно обрабатывает бинарные данные и поддерживает кодировку multipart/form-data, которую ожидают серверы. Base64-кодирование увеличивает размер файла примерно на 33% и требует больше обработки с обеих сторон. Используйте base64 только когда ваш API специально этого требует или при встраивании небольших файлов в JSON-payload.

DevTools for the frontend

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.