FormData и отправка на сервер
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Форма уже хранит всё, что ввёл человек. FormData — способ забрать это одним движением и отдать серверу ровно в том виде, в котором он ждёт: со строками, галочками и файлами.
Конструктор new FormData(form) обходит поля сам, и берёт не всё подряд, а только то, что браузер считает отправляемым: у поля должен быть name, оно не должно быть disabled, а чекбокс или радиокнопка — должны быть отмечены.
value даёт историческое "on", а не true. У неотмеченного чекбокса и у выключенного поля пар в списке нет вовсе, поэтому get возвращает null — не undefined и не пустую строку. Отсюда и две записи вместо пяти полей.Это не каприз API: FormData повторяет ровно тот набор данных, который форма отправила бы и вообще без JavaScript. Значения в нём — строки или файлы: типов, которые были в разметке, там нет.
FormData похож на объект только на первый взгляд. Внутри — упорядоченный список пар, и одно имя может встречаться в нём несколько раз: два отмеченных чекбокса с одинаковым name дадут две записи. Поэтому и методов два: get возвращает первое значение, getAll — все.
get отдаёт только первое значение — «js», оба возвращает getAll. В списке ключей имя «tag» стоит дважды: внутри не объект, а упорядоченный список пар. А Object.fromEntries(fd) выглядит удобно, но молча теряет повторы — каждый следующий одноимённый ключ перезаписывает предыдущий, поэтому выжило «css».Для правки данных есть три метода: append добавляет ещё одну пару, set заменяет все значения этого имени одним, delete выносит их все.
Отправка — самая короткая часть темы: await fetch("/api/signup", { method: "POST", body: fd }). Никакой сериализации писать не нужно, fetch умеет принимать FormData телом напрямую. Обычно перехватывают событие submit, отменяют штатную отправку и собирают данные из event.target.
Заголовок Content-Type браузер выставляет сам — вместе со случайной строкой-разделителем. Убедиться в этом можно, не отправляя ничего: объект Request собирается по тем же правилам, что и запрос внутри fetch, но в сеть не ходит.
Самая частая ошибка: дописать к fetch заголовок Content-Type: multipart/form-data вручную. Тогда в заголовке не будет boundary, и сервер прочитает тело как кашу. С FormData заголовки не трогают вообще.
Тело multipart/form-data — это части, разделённые случайной строкой boundary (её браузер и вписывает в заголовок). У каждой части свой мини-заголовок: Content-Disposition: form-data; name="login", дальше пустая строка и значение.
У файловой части к этому добавляются filename="note.txt" и собственный Content-Type, а само содержимое лежит байтами. Именно поэтому формат так живуч: текст и двоичные данные едут в одном теле, и разбирать его умеет любой серверный фреймворк.
Файл — единственное значение в FormData, которое не строка. Его берут из input.files или собирают вручную конструктором File; в теле запроса он уедет байтами, без base64 и без роста объёма на треть.
Из формы файл попадает сам — достаточно, чтобы у input type="file" был name. Тонкость: если человек ничего не выбрал, поле всё равно окажется в FormData — пустым файлом с именем "" и нулевым размером. Поэтому «файл приложен» проверяют по size, а не по наличию ключа. При multiple перебирают input.files и делают append под одним именем.
fetch сообщает о скачивании ответа, но не об отдаче тела: событий вида «отправлено 40 процентов» у него нет. Стриминг запроса существует, но требует duplex и поддерживается не везде.
Поэтому полосу прогресса до сих пор делают на XMLHttpRequest: xhr.upload.onprogress даёт event.loaded и event.total, а телом отправляется тот же самый объект FormData — xhr.send(fd). Менять способ сбора данных не нужно, меняется только транспорт.
Разница не в моде, а в форме тела. FormData уезжает как multipart/form-data: плоский список пар, где файл лежит байтами, а всё остальное — строками. JSON — это текст: в нём есть вложенность и типы, но нет места для двоичных данных.
| Что сравниваем | FormData | JSON |
|---|---|---|
| Content-Type | multipart/form-data, ставит браузер | application/json, ставите вы |
| Файлы | байтами, прямо в теле | только base64-строкой |
| Структура | плоские пары имя-значение | объекты и массивы любой вложенности |
| Типы значений | строка или File | число, булево, null |
| На сервере | парсер multipart (multer, busboy) | JSON.parse из коробки |
FormData чисел не хранит, и Object.fromEntries переносит значения как есть. URLSearchParams из тех же пар собрал строку для тела запроса.Приведение типов — всегда чья-то отдельная работа, ваша перед JSON.stringify или серверная после разбора. Практическое правило простое: есть файлы — FormData; нужна вложенная структура — JSON; нужно и то и другое — кладите файлы в FormData, а сложную часть добавьте туда же строкой: fd.append("meta", JSON.stringify(meta)).
FormData переживёт и смену инструментов. В React форму читают тем же new FormData(form), а в React 19 форма и вовсе отдаёт его сама — это разбирает статья «Неуправляемые поля, ref и FormData».
- new FormData(form) собирает поля сам: без name, disabled и неотмеченные чекбоксы в него не попадают, а значения приходят строками.
- Внутри не объект, а список пар: одно имя может повторяться, поэтому есть get и getAll, а Object.fromEntries молча оставляет только последнее значение.
- Отправка — это fetch(url, { method: "POST", body: fd }); заголовок Content-Type с boundary браузер ставит сам, руками его не задают.
- Файл едет в теле байтами; пустой input type="file" всё равно даёт запись с нулевым размером, а прогресс отдачи показывает только XMLHttpRequest.
- FormData выбирают ради файлов, JSON — ради вложенности и типов; смешанный случай решается строкой fd.append("meta", JSON.stringify(meta)).
1/3Чекбокс с name="ads" не отмечен. Что вернёт fd.get("ads") для FormData, собранного из этой формы?
2/3Вы отправляете FormData через fetch и вручную дописываете заголовок Content-Type: multipart/form-data. Что произойдёт?
3/3В форме два отмеченных чекбокса с одним name="tag" и значениями js и css. Что даст Object.fromEntries(fd).tag?
Форма в объект без потерь
Напишите formToObject(form): одиночное поле попадает в объект строкой, а повторяющееся имя — массивом всех своих значений. Комментарии в примере показывают, что должно вывестись после вашей реализации.
Перебираем пары и превращаем значение в массив только тогда, когда имя встретилось второй раз.
Короткий вариант через Object.fromEntries здесь не годится: он оставил бы от tag только последнее значение. Если же схема данных известна заранее, можно не гадать, а взять нужные имена явно через getAll.
Не отправляй пустое
Напишите compactFormData(form): возвращает FormData, в котором нет полей с пустым строковым значением. Файлы и непустые строки должны сохраниться в исходном порядке.
Собираем новый объект, а не чистим старый.
Соблазнительное решение с fd.delete(name) прямо в цикле ломается на повторах: delete удаляет все значения этого имени сразу, поэтому вместе с одной пустой строкой уедут и заполненные соседи с тем же name. Проверка value !== "" заодно безопасна для файлов: там значение — объект File, и строке оно не равно никогда.