В этой статье напишем компонент для загрузки файлов на сервер, который поддерживает:

  • Индикатор загрузки

  • Прерывание отправки

  • Drag and drop

В нашей конкретной реализации этот компонент будет оберткой и сам не будет отображать файлы, но, я надеюсь, приемы из этой статьи помогут написать свой компонент, и если вам понадобится сделать в нем отображение - легко справитесь. При наведении на обернутый компонент, будь это картинка, специальное поле, поле с текстом или все что угодно, поверх этого всего что угодно появится перекрытие со значком загрузки, что даст пользователю понять - здесь можно отправить картинку. По этой причине использование обертки оправдано.

Использование компонента будет выглядеть следующим образом:

const [url, setUrl] = useState();

return (
  <div>
    <Upload onUpload={setUrl}>
      <img src={url} alt="" />
    </Upload>
  </div>
)

А также можно с помощью ref получить внутренний метод upload, который откроет окно с выбором файла для загрузки.

const [url, setUrl] = useState();

const uploadRef = useRef();
return (
  <div>
    <Upload ref={uploadRef} onUpload={setUrl}>
      <img src={url} alt="" />
    </Upload>
    <button onClick={() => uploadRef.current.upload()}>
      Отправить файл
    </button>
  </div>
)

Это позволит нам на любой элемент повесить возможность отправить файл, будь то кнопка, иконка, или что-либо еще.

Upload компонент. Структура

Прежде всего нужно использовать input с типом file

const Upload = () => <input type="file" />

Стилизовать этот инпут - неудобно, мы пойдем по другому пути - скроем инпут совсем с помощью display: none, а стилизовать будем другие элементы. Чтобы как-то взаимодействовать с инутом, понадобится обернуть его в label тег, любое взаимодействие с label автоматически перенаправляется в input.

const Upload = () => (
  <label>
    <input type="file" />
  </label>
)

Также воспользуемся хуком useId, чтобы создать уникальные id и связать label и input. Строго говоря, это не обязательный шаг, простая вложенность input в label уже свяжет эти элементы, однако рекомендуемой практикой является связка с помощью id.

const Upload = () => {
  const id = useId()
  return (
    <label htmlFor={id}>
      <input type="file" id={id} />
    </label>
  )
}

Данный вариант - минимально рабочий код, теперь мы можем как угодно стилизовать label, как минимум нужно будет задать ширину и высоту, ну или добавить внутрь что угодно и при нажатии на label будет открываться окошко с выбором файлов. Свой вариант со стилизацией я напишу в конце, сейчас давайте не будем отвлекаться на стилизацию. Переключимся на отправку данных на сервер.

Upload функция. Отправка данных на сервер

Аргументы и типы

Функция отправки данных на сервер должна быть чистой и отделена от компонента загрузки, в соответствии с принципами программирования и чуйкой программиста. Эта функция должна быть прерываемой, а также должна каким-то образом предоставлять данные о прогрессе отправки данных на сервер. Можно, конечно, воспользоваться библиотекой axios и если в вашем проекте он уже есть - то отлично, если же нет, прикручивать библиотеку ради единственного подключения - такая себе идея. Предлагаю использовать нативные средства, а именно XMLHttpRequest, кстати тот же axios под капотом использует этот класс.

Функция upload будет принимать file, url, а также объект дополнительных опций. Пусть в нашем варианте в этом объекте будет только функция onProgress, которая в качестве аргумента будет принимать процент, на сколько файл отправлен.

export const upload = <T>(
  file: File,
  url: string,
  options?: { onProgress?: (progress: number) => void }
): Promise<T> => {}

Почему именно объект в качестве третьего аргумента? Это опирается на принцип открытости/закрытости и книгу "чистый код".

Функция не должна принимать больше 3-х аргументов, это почерпнуто из книги "чистый код". Использование объекта в качестве аргумента позволяет нам не превышать "квоту", а также наша функция будет "закрыта" к изменениям, но "открыта" к дополнениям, мы в любой момент сможем добавить опцию и это никак не сломает уже существующий код.

Еще хочу обратить ваше внимание на вот эту конструкцию upload = <T>(): Promise<T>

Это дженерик, с помощью него при вызове мы можем указать, что наша функция вернет конкретный тип, точнее промис (асинхронный код), который разрешится конкретным типом.

С аргументами и типами определились, давайте разберемся с внутренней кухней.

Запрос с помощью XMLHttpRequest

Будем использовать XMLHttpRequest, он простой, и работа с ним включает 4 обязательных шага:

  1. Создать экземпляр XMLHttpRequest

  2. Открыть соединение

  3. Создать обработку ответа сервера

  4. Отправить данные

// Шаг 1 Создать экземпляр XMLHttprequest
const xhr = new XMLHttpRequest();

// Шаг 2 Открыть соединение
xhr.open('POST', url);

// Шаг 3 Создать обработку ответа сервера
xhr.onload = () => {
  if (xhr.status === 200) {
    // обработка ответа
  } else {
    // обработка ошибок
  }
};

// Шаг 4 Отправить данные
xhr.send(myData);

Если сравнивать с fetch, этот способ более громоздкий, однако он позволяет узнать, сколько данных было отправлено, чем не может похвастаться fetch и предоставляет из коробки функцию abort для прерывания запроса.

Завернем этот код в нашу функцию. Обращаю внимание, функция upload должна обладать промис подобным синтаксисом, то есть методами then, catch, finally, это не только хороший тон для асинхронных методов, но и удобное api.

export const upload = <T>(file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise<T> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        // обработка ответа
        resolve(/* сюда передадим данные, что получили с сервера и обработали */)
      } else {
        // обработка ошибок
        reject(/* сюда передадим ошибку */)
      }
    };
    
    xhr.send(myData);
  })
}
Если вы незнакомы с промисами, то в двух словах это выглядит так:
const fn = () => new Promise((resolve, reject) => {
  // Нет смысла вызывать одно за другим, просто академический пример
  resolve(data); // То что положим в resolve получим в .then()
  reject(error); // То что положим в reject получим в .catch()
});

fn().then(data => {}).catch(error => {});

Давайте разберемся как отправлять файлы.

Подготовка и отправка файла

Не вполне очевидно, как отправить файл, часто мы отправляем на сервер текст в json формате, указываем заголовок Content-Type вручную. В случае с файлом нужно использовать нативный класс FormData и с помощью метода append добавить в него файл и больше ничего, заголовок Content-Type будет создан автоматически.

// Создаем экземпляр
const myData = new FormData();
// Добавляем файл с ключом 'my_file', тут важный нюанс, об этом ниже
myData.append('my_file' /* <- это ключ */, file);

// Отправляем файл на сервер
xhr.send(myData);

Чтобы все сработало, надо точно знать, какой ключ указывать. В примере выше я указал ключ "my_file", однако это зависит от настроек сервера. Например, вот так может выглядит обработчик загрузки файлов на express.js (бекенд)

Обработка запроса загрузки файла express.js упрощенный код
const app = express();

app.post('/upload', (request, response) => {
  const file = request.files.my_file; // <-  вот использование ключа
  // Проверка файла
  // ...
  // Форматирование
  // ...
  // Сохранение 
  // ...
  // Отправка ответа на клиент с указанием url файла
  response.send({ url: file_url })
});

С учетом подготовки файла, функция upload выглядит следующим образом.

export const upload = <T>(file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise<T> => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(/*  */)
      } else {
        reject(/*  */)
      }
    };

    // Добавили подготовку файла
    const myData = new FormData();
    myData.append('my_file', file);
    
    xhr.send(myData);
  })
}

Прогресс отправки данных на сервер

У XMLHttpRequest есть свойство upload, и на это свойство можно навесить обработчик onprogress, внутри которого можно получить данные о суммарном и отправленном количестве байт. Эту информацию мы и будем использовать для обработчика onProgress и создания индикатора прогресса.

xhr.upload.onprogress = (event) => {
  // event.total - общий вес файла в байтах
  // event.loaded - количество загруженных байт
  // Math.round((event.loaded / event.total) * 100) - вычисление процента в формате от 1 до 100
  onProgress(Math.round((event.loaded / event.total) * 100));
};

С этим дополнением наш код будет выглядеть так

export const upload = <T>(file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise<T> => {
  // Достали onProgress из options
  const onProgress = options?.onProgress;
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);

    // Добавили обработку прогресса
    xhr.upload.onprogress = (event) => {
      onProgress?.(Math.round((event.loaded / event.total) * 100));
    };
    
    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(/*  */)
      } else {
        reject(/*  */)
      }
    };

    const myData = new FormData();
    myData.append('my_file', file);
    
    xhr.send(myData);
  })
}

Обработка ответа сервера

По умолчанию все ответы с сервера XMLHttpRequest представляет в виде текста, в современной же разработке чаще всего используется json как для данных, так и для ошибок. По этой причине нужно сказать тип ответов json: xhr.responseType = 'json'

Так в xhr.response получим уже распарсенный json, что удобно и соответственно положим xhr.response в resolve и reject.

export const upload = <T>(file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise<T> => {
  const onProgress = options?.onProgress;
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    // Указали тип ответа. xhr.response будет распарсенным json-ом
    xhr.responseType = 'json';
    
    xhr.open('POST', url);

    xhr.upload.onprogress = (event) => {
      onProgress?.(Math.round((event.loaded / event.total) * 100));
    };

    // Добавили обработку ответа. В xhr.response может быт как данными, так и ошибкой
    // Отличие будет в статусе ответа (если сервер правильно настроен)
    xhr.onload = () => {
      if (xhr.status === 200) resolve(xhr.response);
      else reject(xhr.response);
    };

    const myData = new FormData();
    myData.append('my_file', file);
    
    xhr.send(myData);
  })
}

Прерывание запроса

Существует специальный метод xhr.abort, который прерывает отправку запроса. Пользователю это может понадобится, если у него слабое соединение и он хочет отменить зависшую отправку файла. Проблема, как передать его наружу.

Не хочется нарушать лаконичность и возвращать вместо промиса объект с промисом и функцией прерывания.

// Рабочее решение, но не стройное и не лаконичное
const upload = <T>() => { promise: Promise<T>; abort: () => void }

// Лучше сохранить это решение
const upload = <T>() => Promise<T>

Воспользуемся свойством js, все в js - объекты и в них можно добавлять свои свойства. Так и сделаем, добавит в промис свойство abort. Типы будут выглядеть так:

export type UploadPromise<T> = Promise<T> & { abort: () => void };

const upload = <T>() => UploadPromise<T>

Технически реализуем так:

export type UploadPromise<T> = Promise<T> & { abort: () => void };

export const upload = <T>(file: File, url: string, options?: { onProgress?: (progress: number) => void }): UploadPromise<T> => {
  // Вытащили xhr из Promise, чтобы прокинуть abort
  const xhr = new XMLHttpRequest();
  xhr.responseType = 'json';

  const onProgress = options?.onProgress;
  
  const promise = new Promise((resolve, reject) => {
    xhr.open('POST', url);

    xhr.upload.onprogress = (event) => {
      onProgress?.(Math.round((event.loaded / event.total) * 100));
    };

    xhr.onload = () => {
      if (xhr.status === 200) resolve(xhr.response);
      else reject(xhr.response);
    };

    const myData = new FormData();
    myData.append('my_file', file);
    
    xhr.send(myData);
  }) as UploadPromise<T>;
  
  // Присвоили свойство abort, которое прервет запрос
  promise.abort = () => xhr.abort();

  return promise;
}

Важное замечание, в лучших традициях функционального программирования есть соблазн написать не так promise.abort = () => xhr.abort();, а вот так promise.abort = xhr.abort;, но, в этом конкретном случае, это работать не будет, получите ошибку Illegal invocation. Дело в том, что такое присваивание потеряет контекст вызова функции, поэтому все же используем promise.abort = () => xhr.abort();.

Отлично! Функция upload готова! Теперь мы можем ее использовать в любом месте проекта, чем и займемся ниже.

Загрузка файлов с помощью input file

Взглянем еще раз на наш компонент

const Upload = () => {
  const id = useId()
  return (
    <label htmlFor={id}>
      <input type="file" id={id} />
    </label>
  )
}

Давайте напишем функцию handleFileChange для input

const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  // event.target - ссылка на инпут
  // target.files - список файлов инпута, из которого берем единственный файл
  handleFile(event.target.files[0]);
};

В ней просто достаем файл и отправляем в другую функцию handleFile, которую будем использовать здесь и в загрузке файлов с помощью drag and drop.

Напишем функцию handleFile. Простейший вариант будет выглядеть вот так:

const handleFile = (file: File) => {
  // Если файла нет - ничего не делать 
  if (!file) return;

  const uploading = upload<T>(file, url);
  uploading
    .then(onUpload) // То что получили с сервера - отдаем наружу
    .catch((e) => {/* обработка ошибок */})
};

А наш компонент будет выглядеть так:

export type UploadProps = {
  onUpload: (data: unknown) => void;
}

const Upload: FC<UploadProps> = ({ onUpload }) => {
  const handleFile = (file: File) => {
    if (!file) return;
  
    const uploading = upload<T>(file, url);
    uploading
      .then(onUpload)
      .catch((e) => {})
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    handleFile(event.target.files[0]);
  };
  
  const id = useId()
  return (
    <label htmlFor={id}>
      <input type="file" id={id} onChange={handleFileChange} />
    </label>
  )
}

Это уже будет работать, но хотелось бы сделать обратную связь для пользователя. Давайте реализуем логику состояния загрузки и состояния прогресса, для этого добавим два состояния и будем использовать их в функции handleFile

Состояние загрузки

const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);

// Создадим функцию сброса, она нам понадобится в двух местах
const reset = () => {
  setLoading(false);
  setProgress(0);
}

const handleFile = (file: File) => {
  // Если уже загружаем файл или файла нет - ничего не делать 
  if (loading || !file) return;

  setLoading(true);
  // onProgress будет изменять состояние progress
  const uploading = upload<T>(file, url, { onProgress: setProgress });
  uploading
    .then(onUpload)
    .catch((e) => {})
    .finally(reset) // Хоть ошибка, хоть успех - сбрасываем загрузку и прогресс
};

А также создадим отображение загрузки. На данном этапе я оберну label в div и положу рядом с label еще один div. Этот div будет отображать состояние загрузки, а также он будет перекрывать label, тем самым блокировать взаимодействие с ним на время загрузки файла. JSX разметка будет выглядеть следующим образом. Также давайте добавим несколько свойств в наши props.

export type UploadProps = {
  onUpload: (data: unknown) => void;
  className?: string;
  children: React.ReactNode;
  overlay?: boolean;
  disabled?: boolean;
}

return (
  <div className={cn(s.root, className)}>
    {children}
    <label htmlFor={id} className={cn(s.label, overlay && s.visible)}>
      <input
        disabled={disabled}
        type="file"
        className={s.input}
        onChange={handleFileChange}
        id={id}
      />
      <UploadOutlined className={s.icon} />
    </label>
    {loading && (
      <div className={s.loading}>
        <LoadingOutlined className={s.icon} />
        <ProgressIndicator progress={progress} className={s.progress} theme="green" />
        <CloseCircleOutlined className={s.abort} onClick={abort} />
      </div>
    )}
  </div>
);

Я использую css модули, потому пусть вас не пугают css классы s.root, s.label, s.input - все они автоматически преобразуются в строки. А мне это позволяет писать css не волнуясь о коллизии имен css классов. Вот эта запись cn(s.root, className) - объединение моего текущего css класса с классом снаружи компонента. А вот эта запись cn(s.label, overlay && s.visible) , означает - если overlay true, то к label добавляется класс visible, благодаря которому, при наведении на элемент, появится перекрытие с upload иконкой.

<Upload overlay /> подходит для обертки картинок, но будет мешать при обертке текстовых инпутов
<Upload overlay /> подходит для обертки картинок, но будет мешать при обертке текстовых инпутов

В двух словах я сделал position relative для корневого div, чтобы label и div.loading сделать position absolute. Растягиваю их по всему div, так div.loading будет перекрывать label, а label можно будет скрыть, если захотим компонент Upload использовать, как обертку для текстовых инпутов.

В целом не хочу вас утомлять объяснением всех стилей. Привожу окончательный sass файл, в котором есть стили и для drag and drop и для иконки отмены загрузки. Если получится упростить - буду рад комментарию.

Стили на sass
.root
  position: relative
  font-size: 0
  z-index: 0

  &:hover
    .label
      opacity: 0.5

  .progress
    margin-top: 32px
    margin-bottom: 12px
    width: 62%

.icon
  font-size: 48px
  color: var(--whiteColor)

.abort
  font-size: 21px
  color: var(--whiteColor)

.label
  pointer-events: none
  position: absolute
  display: flex
  align-items: center
  justify-content: center
  opacity: 0
  background-color: var(--gray1Color)
  transition: 0.3s
  top: 0
  left: 0
  right: 0
  bottom: 0
  width: 100%
  height: 100%
  z-index: 1
  visibility: hidden

  &.visible
    pointer-events: auto
    visibility: visible

  &.drop
    visibility: visible
    opacity: 0.5

.input
  display: none

.loading
  position: absolute
  display: flex
  align-items: center
  justify-content: center
  flex-direction: column
  top: 0
  left: 0
  right: 0
  bottom: 0
  width: 100%
  height: 100%
  z-index: 2
  background-color: rgba(var(--gray1Color), 0.5)

Теперь наш компонент отображает состояние загрузки и прогресс. Обращаю внимание, у меня состояние загрузки реализовано в виде вращающейся иконки и заполняющегося индикатора прогресса. У вас это может выглядеть как угодно.

export type UploadProps = {
  onUpload: (data: unknown) => void;
  className?: string;
  children: React.ReactNode;
  overlay?: boolean;
  disabled?: boolean;
}

const Upload: FC<UploadProps> = ({ 
  onUpload,
  disabled,
  overlay = true,
  children,
  className
}) => {
  const [progress, setProgress] = useState(0);
  const [loading, setLoading] = useState(false);
  
  const reset = () => {
    setLoading(false);
    setProgress(0);
  }
  
  const handleFile = (file: File) => {
    if (loading || !file) return;
  
    setLoading(true);
    const uploading = upload<T>(file, url, { onProgress: setProgress });
    uploading
      .then(onUpload)
      .catch((e) => {})
      .finally(reset)
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    handleFile(event.target.files[0]);
  };
  
  const id = useId()
  return (
    <div className={cn(s.root, className)}>
      {children}
      <label htmlFor={id} className={cn(s.label, overlay && s.visible)}>
        <input
          disabled={disabled}
          type="file"
          className={s.input}
          onChange={handleFileChange}
          id={id}
        />
        <UploadOutlined className={s.icon} />
      </label>
      {loading && (
        <div className={s.loading}>
          <LoadingOutlined className={s.icon} />
          <ProgressIndicator progress={progress} className={s.progress} theme="green" />
        </div>
      )}
    </div>
  );
}

Прерывание запроса

Чтобы прервать запрос, создадим функцию abort, внутри ее будем использовать reset (то самое второе место использования), и abortUploading ref, который нам поможет сохранять ссылку на функцию abort из upload функции.

const abortUploading = useRef<() => void>();

const abort = () => {
  // Проверка ?. на случай, если отправки не было, а пытаемся ее прервать
  abortUploading.current?.();
  reset();
};

const handleFile = (file: File) => {
  if (loading || !file) return;

  setLoading(true);
  const uploading = upload<T>(file, url, { onProgress: setProgress });
  // Сохраняем функцию abort
  abortUploading.current = uploading.abort;
  uploading
    .then(onUpload)
    .catch((e) => {})
    .finally(reset)
};

// ---

  return (
    ...
      {loading && (
        <div className={s.loading}>
          <LoadingOutlined className={s.icon} />
          <ProgressIndicator progress={progress} className={s.progress} theme="green" />
          // Добавили кнопку, прерывающую запрос
          <CloseCircleOutlined className={s.abort} onClick={abort} />
        </div>
      )}
    ...
  );

Теперь наш компонент выглядит так

export type UploadProps = {
  onUpload: (data: unknown) => void;
  className?: string;
  children: React.ReactNode;
  overlay?: boolean;
  disabled?: boolean;
}

const Upload: FC<UploadProps> = ({ 
  onUpload,
  disabled,
  overlay = true,
  children,
  className
}) => {
  const [progress, setProgress] = useState(0);
  const [loading, setLoading] = useState(false);

  const abortUploading = useRef<() => void>();
  
  const abort = () => {
    abortUploading.current?.();
    reset();
  };
  
  const reset = () => {
    setLoading(false);
    setProgress(0);
  }
  
  const handleFile = (file: File) => {
    if (loading || !file) return;
  
    setLoading(true);
    const uploading = upload<T>(file, url, { onProgress: setProgress });
    abortUploading.current = uploading.abort;
    uploading
      .then(onUpload)
      .catch((e) => {})
      .finally(reset)
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    handleFile(event.target.files[0]);
  };
  
  const id = useId()
  return (
    <div className={cn(s.root, className)}>
      {children}
      <label htmlFor={id} className={cn(s.label, overlay && s.visible)}>
        <input
          disabled={disabled}
          type="file"
          className={s.input}
          onChange={handleFileChange}
          id={id}
        />
        <UploadOutlined className={s.icon} />
      </label>
      {loading && (
        <div className={s.loading}>
          <LoadingOutlined className={s.icon} />
          <ProgressIndicator progress={progress} className={s.progress} theme="green" />
          <CloseCircleOutlined className={s.abort} onClick={abort} />
        </div>
      )}
    </div>
  );
}

Drag and drop

Drag and drop реализуется навешиванием обработчиков на onDrag и onDrop. Эти обработчики можно навешивать на любой компонент. Учитывая, что наш компонент - компонент обертка, и label может быть скрыт, повесим эти обработчики на корневой div.

const [drop, setDrop] = useState(false);

const onDragLeave = (e: React.DragEvent<HTMLElement>) => {
  if (disabled) return;
  e.preventDefault();
  setDrop(false);
};

const onDragOver = (e: React.DragEvent<HTMLElement>) => {
  if (disabled) return;
  e.preventDefault();
  setDrop(true);
};

const handleDrop = (e: React.DragEvent<HTMLElement>) => {
  if (disabled) return;
  e.preventDefault();
  const droppedFile = e.dataTransfer.files[0];
  setDrop(false);

  handleFile(droppedFile);
};

return (
  <div
    className={cn(s.root, className)}
    onDrop={handleDrop}
    onDragOver={onDragOver}
    onDragLeave={onDragLeave}
  >
    ...
  </div>
);

Почему обработчиков именно три? Вообще достаточно 2-х, это onDragOver должен выполнить e.preventDefault(); иначе файл по умолчанию откроется в новой вкладке браузера. Также обязательно нужен onDrop, именно он получит список файлов, которые мы "уронили" на компонент, другие обработчики не получают этих данных, если файлы перемещаемы из файловой системы. Мы хотим, чтобы компонент при наведении файла показывал, что в него можно "уронить" файл, и когда мы убираем мышь с компонента, он принимал свое обычное состояние - нам нужно повесить обработчик onDragLeave, который будет срабатывать, когда мышь с файлом покинула компонент. onDragLeave - это тот самый обработчик для сброса отображения drop.

Когда мы "уроним" файлы в div, они попадут в функцию handleDrop, где мы сбросим отображение состояния drop и будем пытаться отправить этот файл на сервер с помощью уже написанной handleFile.

Обновленный код

export type UploadProps = {
  onUpload: (data: unknown) => void;
  className?: string;
  children: React.ReactNode;
  overlay?: boolean;
  disabled?: boolean;
}

const Upload: FC<UploadProps> = ({ 
  onUpload,
  disabled,
  overlay = true,
  children,
  className
}) => {
  const [progress, setProgress] = useState(0);
  const [drop, setDrop] = useState(false);
  const [loading, setLoading] = useState(false);

  const abortUploading = useRef<() => void>();
  
  const abort = () => {
    abortUploading.current?.();
    reset();
  };
  
  const reset = () => {
    setLoading(false);
    setProgress(0);
  }
  
  const handleFile = (file: File) => {
    if (loading || !file) return;
  
    setLoading(true);
    const uploading = upload<T>(file, url, { onProgress: setProgress });
    abortUploading.current = uploading.abort;
    uploading
      .then(onUpload)
      .catch((e) => {})
      .finally(reset)
  };

  const onDragLeave = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    setDrop(false);
  };
  
  const onDragOver = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    setDrop(true);
  };
  
  const handleDrop = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    const droppedFile = e.dataTransfer.files[0];
    setDrop(false);
  
    handleFile(droppedFile);
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    handleFile(event.target.files[0]);
  };
  
  const id = useId()
  return (
    <div 
      className={cn(s.root, className)}
      onDrop={handleDrop}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
    >
      {children}
      // добавили класс s.drop если drop
      <label htmlFor={id} className={cn(s.label, overlay && s.visible, drop && s.drop)}>
        <input
          disabled={disabled}
          type="file"
          className={s.input}
          onChange={handleFileChange}
          id={id}
        />
        <UploadOutlined className={s.icon} />
      </label>
      {loading && (
        <div className={s.loading}>
          <LoadingOutlined className={s.icon} />
          <ProgressIndicator progress={progress} className={s.progress} theme="green" />
          <CloseCircleOutlined className={s.abort} onClick={abort} />
        </div>
      )}
    </div>
  );
}

Компонент почти готов, остались последнии штрихи. Давайте добавим возможность с помощью ref получать метод upload и метод abort.

Добавляем методы для ref на компонент

Для этого нужно обернуть наш компонент в forwardRef, создать тип UploadRef и использовать useImperativeHandle для создания этих методов.

export type UploadProps = {
  onUpload: (data: unknown) => void;
  className?: string;
  children: React.ReactNode;
  overlay?: boolean;
  disabled?: boolean;
}

export type UploadRef = {
  upload: () => void;
  abort: () => void;
}

export const Upload = forwardRef<UploadRef, UploadProps>(({ 
  onUpload,
  disabled,
  overlay = true,
  children,
  className
}, ref) => {
  const [progress, setProgress] = useState(0);
  const [drop, setDrop] = useState(false);
  const [loading, setLoading] = useState(false);

  const abortUploading = useRef<() => void>();
  
  const abort = () => {
    abortUploading.current?.();
    reset();
  };
  
  const reset = () => {
    setLoading(false);
    setProgress(0);
  }
  
  const handleFile = (file: File) => {
    if (loading || !file) return;
  
    setLoading(true);
    const uploading = upload<T>(file, url, { onProgress: setProgress });
    abortUploading.current = uploading.abort;
    uploading
      .then(onUpload)
      .catch((e) => {})
      .finally(reset)
  };

  const onDragLeave = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    setDrop(false);
  };
  
  const onDragOver = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    setDrop(true);
  };
  
  const handleDrop = (e: React.DragEvent<HTMLElement>) => {
    if (disabled) return;
    e.preventDefault();
    const droppedFile = e.dataTransfer.files[0];
    setDrop(false);
  
    handleFile(droppedFile);
  };

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    handleFile(event.target.files[0]);
  };

  // Ссылка на инпут нужна для внешнего метода upload
  const input = useRef<HTMLInputElement>();

  useImperativeHandle(ref, () => ({
    // upload открывает документы для выбора файлов, другими словами
    // это то же самое что и нажатие на input
    upload: () => input.current?.click(),
    // функция abort уже готова, она сбрасывает отображение компонента
    abort,
  }));

  const id = useId()
  return (
    <div 
      className={cn(s.root, className)}
      onDrop={handleDrop}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
    >
      {children}
      // добавили класс s.drop если drop
      <label htmlFor={id} className={cn(s.label, overlay && s.visible, drop && s.drop)}>
        <input
          ref={input}
          disabled={disabled}
          type="file"
          className={s.input}
          onChange={handleFileChange}
          id={id}
        />
        <UploadOutlined className={s.icon} />
      </label>
      {loading && (
        <div className={s.loading}>
          <LoadingOutlined className={s.icon} />
          <ProgressIndicator progress={progress} className={s.progress} theme="green" />
          <CloseCircleOutlined className={s.abort} onClick={abort} />
        </div>
      )}
    </div>
  );
});

Upload.displayName = "Upload";

И теперь этот компонент можно использовать вот так

const [url, setUrl] = useState();

const uploadRef = useRef<UploadRef>();
return (
  <div>
    <Upload ref={uploadRef} onUpload={setUrl}>
      <img src={url} alt="" />
    </Upload>
    <button onClick={() => uploadRef.current.upload()}>
      Отправить файл
    </button>
    <button onClick={() => uploadRef.current.abort()}>
      Прервать загрузку
    </button>
  </div>
)

В своем проекте я использую этот компонент для загрузки новых обложек, а также для вставки изображений в markdown

В данном компоненте не показал, как обрабатывать ошибки, но оставляю это на ваше усмотрение, потому как ничего особенного там нет.

Благодарю, что дочитали до конца и надеюсь эта информация оказалась полезной.

А в преддверии запуска нового потока курса React.js Developer хочу пригласить вас на бесплатные мероприятия про тестирование React-приложений, а также про хуки и мемоизацию. Регистрируйтесь, будет интересно!

Комментарии (3)


  1. Solar5503
    28.10.2023 18:49

    Благодарю за интересную статью! Прочитал на одном дыхании! ????


  1. Zukomux
    28.10.2023 18:49

    Все отлично, но зачем вы везде используете React.FC ? Признано устаревшим в т.ч. и в 18 реакте.


    1. igor_zvyagin Автор
      28.10.2023 18:49

      Хорошее замечание. Привычка, а что советуют использовать для типизации?