File upload на React.js шаг за шагом
В этой статье напишем компонент для загрузки файлов на сервер, который поддерживает:
В нашей конкретной реализации этот компонент будет оберткой и сам не будет отображать файлы, но, я надеюсь, приемы из этой статьи помогут написать свой компонент, и если вам понадобится сделать в нем отображение — легко справитесь. При наведении на обернутый компонент, будь это картинка, специальное поле, поле с текстом или все что угодно, поверх этого всего что угодно появится перекрытие со значком загрузки, что даст пользователю понять — здесь можно отправить картинку. По этой причине использование обертки оправдано.
Использование компонента будет выглядеть следующим образом:
const [url, setUrl] = useState();
return (
)
А также можно с помощью ref получить внутренний метод upload, который откроет окно с выбором файла для загрузки.
const [url, setUrl] = useState();
const uploadRef = useRef();
return (
)
Это позволит нам на любой элемент повесить возможность отправить файл, будь то кнопка, иконка, или что-либо еще.
Upload компонент. Структура
Прежде всего нужно использовать input с типом file
const Upload = () =>
Стилизовать этот инпут — неудобно, мы пойдем по другому пути — скроем инпут совсем с помощью display: none, а стилизовать будем другие элементы. Чтобы как-то взаимодействовать с инутом, понадобится обернуть его в label тег, любое взаимодействие с label автоматически перенаправляется в input.
const Upload = () => (
)
Также воспользуемся хуком useId, чтобы создать уникальные id и связать label и input. Строго говоря, это не обязательный шаг, простая вложенность input в label уже свяжет эти элементы, однако рекомендуемой практикой является связка с помощью id.
const Upload = () => {
const id = useId()
return (
)
}
Данный вариант — минимально рабочий код, теперь мы можем как угодно стилизовать label, как минимум нужно будет задать ширину и высоту, ну или добавить внутрь что угодно и при нажатии на label будет открываться окошко с выбором файлов. Свой вариант со стилизацией я напишу в конце, сейчас давайте не будем отвлекаться на стилизацию. Переключимся на отправку данных на сервер.
Upload функция. Отправка данных на сервер
Аргументы и типы
Функция отправки данных на сервер должна быть чистой и отделена от компонента загрузки, в соответствии с принципами программирования и чуйкой программиста. Эта функция должна быть прерываемой, а также должна каким-то образом предоставлять данные о прогрессе отправки данных на сервер. Можно, конечно, воспользоваться библиотекой axios и если в вашем проекте он уже есть — то отлично, если же нет, прикручивать библиотеку ради единственного подключения — такая себе идея. Предлагаю использовать нативные средства, а именно XMLHttpRequest, кстати тот же axios под капотом использует этот класс.
Функция upload будет принимать file, а также объект дополнительных опций. Пусть в нашем варианте в этом объекте будет только функция onProgress, которая в качестве аргумента будет принимать процент, на сколько файл отправлен.
export const upload = (
file: File,
url: string,
options?: { onProgress?: (progress: number) => void }
): Promise => {}
Почему именно объект в качестве третьего аргумента? Это опирается на принцип открытости/закрытости и книгу «чистый код».
Функция не должна принимать больше 3-х аргументов, это почерпнуто из книги «чистый код». Использование объекта в качестве аргумента позволяет нам не превышать «квоту», а также наша функция будет «закрыта» к изменениям, но «открыта» к дополнениям, мы в любой момент сможем добавить опцию и это никак не сломает уже существующий код.
Еще хочу обратить ваше внимание на вот эту конструкцию upload =
Это дженерик, с помощью него при вызове мы можем указать, что наша функция вернет конкретный тип, точнее промис (асинхронный код), который разрешится конкретным типом.
С аргументами и типами определились, давайте разберемся с внутренней кухней.
Запрос с помощью XMLHttpRequest
Будем использовать XMLHttpRequest, он простой, и работа с ним включает 4 обязательных шага:
Создать экземпляр XMLHttpRequest
Открыть соединение
Создать обработку ответа сервера
Отправить данные
// Шаг 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 = (file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise => {
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 = (file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise => {
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 = (file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise => {
// Достали 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 = (file: File, url: string, options?: { onProgress?: (progress: number) => void }): Promise => {
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 = () => { promise: Promise; abort: () => void }
// Лучше сохранить это решение
const upload = () => Promise
Воспользуемся свойством js, все в js — объекты и в них можно добавлять свои свойства. Так и сделаем, добавит в промис свойство abort. Типы будут выглядеть так:
export type UploadPromise = Promise & { abort: () => void };
const upload = () => UploadPromise
Технически реализуем так:
export type UploadPromise = Promise & { abort: () => void };
export const upload = (file: File, url: string, options?: { onProgress?: (progress: number) => void }): UploadPromise => {
// Вытащили 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;
// Присвоили свойство 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 (
)
}
Давайте напишем функцию handleFileChange
для input
const handleFileChange = (event: React.ChangeEvent) => {
// event.target - ссылка на инпут
// target.files - список файлов инпута, из которого берем единственный файл
handleFile(event.target.files[0]);
};
В ней просто достаем файл и отправляем в другую функцию handleFile
, которую будем использовать здесь и в загрузке файлов с помощью drag and drop.
Напишем функцию handleFile
. Простейший вариант будет выглядеть вот так:
const handleFile = (file: File) => {
// Если файла нет - ничего не делать
if (!file) return;
const uploading = upload(file, url);
uploading
.then(onUpload) // То что получили с сервера - отдаем наружу
.catch((e) => {/* обработка ошибок */})
};
А наш компонент будет выглядеть так:
export type UploadProps = {
onUpload: (data: unknown) => void;
}
const Upload: FC = ({ onUpload }) => {
const handleFile = (file: File) => {
if (!file) return;
const uploading = upload(file, url);
uploading
.then(onUpload)
.catch((e) => {})
};
const handleFileChange = (event: React.ChangeEvent) => {
handleFile(event.target.files[0]);
};
const id = useId()
return (
)
}
Это уже будет работать, но хотелось бы сделать обратную связь для пользователя. Давайте реализуем логику состояния загрузки и состояния прогресса, для этого добавим два состояния и будем использовать их в функции 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(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 (
{children}
{loading && (
)}
);
Я использую 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
.
В двух словах я сделал 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 = ({
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(file, url, { onProgress: setProgress });
uploading
.then(onUpload)
.catch((e) => {})
.finally(reset)
};
const handleFileChange = (event: React.ChangeEvent) => {
handleFile(event.target.files[0]);
};
const id = useId()
return (
{children}
{loading && (
)}
);
}
Прерывание запроса
Чтобы прервать запрос, создадим функцию 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(file, url, { onProgress: setProgress });
// Сохраняем функцию abort
abortUploading.current = uploading.abort;
uploading
.then(onUpload)
.catch((e) => {})
.finally(reset)
};
// ---
return (
...
{loading && (
// Добавили кнопку, прерывающую запрос
)}
...
);
Теперь наш компонент выглядит так
export type UploadProps = {
onUpload: (data: unknown) => void;
className?: string;
children: React.ReactNode;
overlay?: boolean;
disabled?: boolean;
}
const Upload: FC = ({
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(file, url, { onProgress: setProgress });
abortUploading.current = uploading.abort;
uploading
.then(onUpload)
.catch((e) => {})
.finally(reset)
};
const handleFileChange = (event: React.ChangeEvent) => {
handleFile(event.target.files[0]);
};
const id = useId()
return (
{children}
{loading && (
)}
);
}
Drag and drop
Drag and drop реализуется навешиванием обработчиков на onDrag и onDrop. Эти обработчики можно навешивать на любой компонент. Учитывая, что наш компонент — компонент обертка, и label может быть скрыт, повесим эти обработчики на корневой div.
const [drop, setDrop] = useState(false);
const onDragLeave = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(false);
};
const onDragOver = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(true);
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
const droppedFile = e.dataTransfer.files[0];
setDrop(false);
handleFile(droppedFile);
};
return (
...
);
Почему обработчиков именно три? Вообще достаточно 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 = ({
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(file, url, { onProgress: setProgress });
abortUploading.current = uploading.abort;
uploading
.then(onUpload)
.catch((e) => {})
.finally(reset)
};
const onDragLeave = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(false);
};
const onDragOver = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(true);
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
const droppedFile = e.dataTransfer.files[0];
setDrop(false);
handleFile(droppedFile);
};
const handleFileChange = (event: React.ChangeEvent) => {
handleFile(event.target.files[0]);
};
const id = useId()
return (
{children}
// добавили класс s.drop если drop
{loading && (
)}
);
}
Компонент почти готов, остались последнии штрихи. Давайте добавим возможность с помощью 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(({
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(file, url, { onProgress: setProgress });
abortUploading.current = uploading.abort;
uploading
.then(onUpload)
.catch((e) => {})
.finally(reset)
};
const onDragLeave = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(false);
};
const onDragOver = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDrop(true);
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
const droppedFile = e.dataTransfer.files[0];
setDrop(false);
handleFile(droppedFile);
};
const handleFileChange = (event: React.ChangeEvent) => {
handleFile(event.target.files[0]);
};
// Ссылка на инпут нужна для внешнего метода upload
const input = useRef();
useImperativeHandle(ref, () => ({
// upload открывает документы для выбора файлов, другими словами
// это то же самое что и нажатие на input
upload: () => input.current?.click(),
// функция abort уже готова, она сбрасывает отображение компонента
abort,
}));
const id = useId()
return (
{children}
// добавили класс s.drop если drop
{loading && (
)}
);
});
Upload.displayName = "Upload";
И теперь этот компонент можно использовать вот так
const [url, setUrl] = useState();
const uploadRef = useRef();
return (
)
В своем проекте я использую этот компонент для загрузки новых обложек, а также для вставки изображений в markdown
В данном компоненте не показал, как обрабатывать ошибки, но оставляю это на ваше усмотрение, потому как ничего особенного там нет.
Благодарю, что дочитали до конца и надеюсь эта информация оказалась полезной.
А в преддверии запуска нового потока курса React.js Developer хочу пригласить вас на бесплатные мероприятия про тестирование React-приложений, а также про хуки и мемоизацию. Регистрируйтесь, будет интересно!