FormData и отправка на сервер

формы≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Форма уже хранит всё, что ввёл человек. FormData — способ забрать это одним движением и отдать серверу ровно в том виде, в котором он ждёт: со строками, галочками и файлами.

Разделы темы:·

Конструктор new FormData(form) обходит поля сам, и берёт не всё подряд, а только то, что браузер считает отправляемым: у поля должен быть name, оно не должно быть disabled, а чекбокс или радиокнопка — должны быть отмечены.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Отмеченный чекбокс без атрибута value даёт историческое "on", а не true. У неотмеченного чекбокса и у выключенного поля пар в списке нет вовсе, поэтому get возвращает null — не undefined и не пустую строку. Отсюда и две записи вместо пяти полей.

Это не каприз API: FormData повторяет ровно тот набор данных, который форма отправила бы и вообще без JavaScript. Значения в нём — строки или файлы: типов, которые были в разметке, там нет.

FormData похож на объект только на первый взгляд. Внутри — упорядоченный список пар, и одно имя может встречаться в нём несколько раз: два отмеченных чекбокса с одинаковым name дадут две записи. Поэтому и методов два: get возвращает первое значение, getAll — все.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
get отдаёт только первое значение — «js», оба возвращает getAll. В списке ключей имя «tag» стоит дважды: внутри не объект, а упорядоченный список пар. А Object.fromEntries(fd) выглядит удобно, но молча теряет повторы — каждый следующий одноимённый ключ перезаписывает предыдущий, поэтому выжило «css».

Для правки данных есть три метода: append добавляет ещё одну пару, set заменяет все значения этого имени одним, delete выносит их все.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отправка — самая короткая часть темы: await fetch("/api/signup", { method: "POST", body: fd }). Никакой сериализации писать не нужно, fetch умеет принимать FormData телом напрямую. Обычно перехватывают событие submit, отменяют штатную отправку и собирают данные из event.target.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заголовок Content-Type браузер выставляет сам — вместе со случайной строкой-разделителем. Убедиться в этом можно, не отправляя ничего: объект Request собирается по тем же правилам, что и запрос внутри fetch, но в сеть не ходит.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Самая частая ошибка: дописать к 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 и без роста объёма на треть.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Из формы файл попадает сам — достаточно, чтобы у input type="file" был name. Тонкость: если человек ничего не выбрал, поле всё равно окажется в FormData — пустым файлом с именем "" и нулевым размером. Поэтому «файл приложен» проверяют по size, а не по наличию ключа. При multiple перебирают input.files и делают append под одним именем.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

fetch сообщает о скачивании ответа, но не об отдаче тела: событий вида «отправлено 40 процентов» у него нет. Стриминг запроса существует, но требует duplex и поддерживается не везде.

Поэтому полосу прогресса до сих пор делают на XMLHttpRequest: xhr.upload.onprogress даёт event.loaded и event.total, а телом отправляется тот же самый объект FormData — xhr.send(fd). Менять способ сбора данных не нужно, меняется только транспорт.

Разница не в моде, а в форме тела. FormData уезжает как multipart/form-data: плоский список пар, где файл лежит байтами, а всё остальное — строками. JSON — это текст: в нём есть вложенность и типы, но нет места для двоичных данных.

Что сравниваемFormDataJSON
Content-Typemultipart/form-data, ставит браузерapplication/json, ставите вы
Файлыбайтами, прямо в телетолько base64-строкой
Структураплоские пары имя-значениеобъекты и массивы любой вложенности
Типы значенийстрока или Fileчисло, булево, null
На серверепарсер multipart (multer, busboy)JSON.parse из коробки
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
разбор вывода
Возраст остался строкой: 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): одиночное поле попадает в объект строкой, а повторяющееся имя — массивом всех своих значений. Комментарии в примере показывают, что должно вывестись после вашей реализации.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Перебираем пары и превращаем значение в массив только тогда, когда имя встретилось второй раз.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Короткий вариант через Object.fromEntries здесь не годится: он оставил бы от tag только последнее значение. Если же схема данных известна заранее, можно не гадать, а взять нужные имена явно через getAll.

Не отправляй пустое

Напишите compactFormData(form): возвращает FormData, в котором нет полей с пустым строковым значением. Файлы и непустые строки должны сохраниться в исходном порядке.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Собираем новый объект, а не чистим старый.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Соблазнительное решение с fd.delete(name) прямо в цикле ломается на повторах: delete удаляет все значения этого имени сразу, поэтому вместе с одной пустой строкой уедут и заполненные соседи с тем же name. Проверка value !== "" заодно безопасна для файлов: там значение — объект File, и строке оно не равно никогда.