Пользователь регистрируется в сервисе: заполняет имя, email, загружает аватар. Простой JSON не подходит — файл нельзя сериализовать в JSON. Для отправки файлов вместе с текстовыми данными используется FormData и тип запроса multipart/form-data.
FormData — это объект, который представляет данные HTML-формы, включая файлы. Браузер умеет отправлять его через fetch с правильным Content-Type автоматически.
const formData = new FormData()
// append — добавляет значение (можно несколько с одним именем)
formData.append('firstName', 'Алексей')
formData.append('email', 'aleksei@company.ru')
// set — устанавливает, перезаписывая существующее
formData.set('email', 'aleksei.petrov@company.ru')
// Чтение и проверка
formData.get('firstName') // 'Алексей'
formData.has('email') // true
formData.delete('email')
formData.has('email') // falseСамый удобный способ — передать элемент формы напрямую:
form.addEventListener('submit', async (event) => {
event.preventDefault()
const formData = new FormData(form) // собирает все поля автоматически
const response = await fetch('/api/profile', {
method: 'POST',
body: formData,
// Content-Type НЕ устанавливаем — браузер сделает сам!
})
})async function uploadAvatar(file, userId) {
if (!file.type.startsWith('image/')) {
throw new Error('Файл должен быть изображением')
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Максимальный размер: 5 MB')
}
const formData = new FormData()
formData.append('avatar', file)
formData.append('userId', String(userId))
const response = await fetch('/api/upload/avatar', {
method: 'POST',
body: formData,
// НЕ устанавливай Content-Type вручную!
// Браузер добавит: multipart/form-data; boundary=----WebKitFormBoundaryXXX
})
if (!response.ok) throw new Error(`Ошибка: ${response.status}`)
return response.json()
}// ПРАВИЛЬНО — браузер добавит boundary автоматически
fetch('/api/upload', { method: 'POST', body: formData })
// НЕПРАВИЛЬНО — browser не добавит boundary, сервер не разберёт тело
fetch('/api/upload', {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' } // НЕ ДЕЛАЙ ТАК
})Без boundary сервер не знает, где заканчивается одно поле и начинается следующее.
// Несколько значений с одним именем (роли, теги)
formData.append('role', 'editor')
formData.append('role', 'moderator')
formData.getAll('role') // ['editor', 'moderator']
// Несколько файлов
for (const file of fileInput.files) {
formData.append('photos', file, file.name)
}Ошибка 1: устанавливают Content-Type вручную
// Сломано: boundary теряется, сервер получает битый запрос
fetch('/api/upload', {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' }, // УДАЛИ ЭТО
})
// Исправлено: убрать Content-Type — браузер добавит его сам с boundary
fetch('/api/upload', { method: 'POST', body: formData })Ошибка 2: числа без приведения к строке
formData.append('price', 1500) // браузер преобразует в '1500'
formData.append('price', String(1500)) // явное преобразование — лучшеОшибка 3: валидация только на сервере, не на клиенте
// Плохо: пользователь ждёт загрузки 500MB и только потом получает ошибку
// Хорошо: проверяем до отправки:
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой') // мгновенно, без трафика
return
}Симуляция FormData API: добавление полей, файлов и отправка данных регистрации
// Симуляция FormData для sandbox (без браузерного API)
class FormDataSim {
constructor() {
this._fields = new Map()
}
append(name, value, filename) {
if (!this._fields.has(name)) this._fields.set(name, [])
const entry = filename
? { value: String(value), filename }
: { value: String(value) }
this._fields.get(name).push(entry)
}
set(name, value) {
this._fields.set(name, [{ value: String(value) }])
}
get(name) {
const entries = this._fields.get(name)
return entries ? entries[0].value : null
}
getAll(name) {
return (this._fields.get(name) || []).map(e => e.value)
}
has(name) { return this._fields.has(name) }
delete(name) { this._fields.delete(name) }
entries() {
const result = []
for (const [name, list] of this._fields) {
for (const e of list) {
result.push([name, e.filename ? `[File: ${e.filename}]` : e.value])
}
}
return result
}
}
// Валидация файла перед отправкой (экономим трафик)
function validateFile(file) {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (!allowed.includes(file.type)) {
throw new Error(`Тип ${file.type} не разрешён. Можно: jpg, png, webp`)
}
if (file.size > 5 * 1024 * 1024) {
throw new Error(`Файл ${(file.size / 1024 / 1024).toFixed(1)} MB > 5 MB`)
}
return true
}
// Регистрация пользователя с аватаром
async function registerUser(userData, avatarFile) {
if (avatarFile) {
validateFile(avatarFile)
console.log(`Файл OK: ${avatarFile.name} (${(avatarFile.size / 1024).toFixed(0)} KB)`)
// Файл OK: avatar.jpg (240 KB)
}
const fd = new FormDataSim()
fd.append('firstName', userData.firstName)
fd.append('lastName', userData.lastName)
fd.append('email', userData.email)
fd.set('registeredAt', new Date('2026-03-04').toISOString())
// Множественные значения
userData.interests.forEach(tag => fd.append('interests', tag))
if (avatarFile) {
fd.append('avatar', avatarFile.data, avatarFile.name)
}
console.log('Данные формы:')
for (const [key, value] of fd.entries()) {
console.log(` ${key}: ${value}`)
}
// firstName: Екатерина
// lastName: Волкова
// email: kate@startup.ru
// registeredAt: 2026-03-04T00:00:00.000Z
// interests: design
// interests: ux
// avatar: [File: avatar.jpg]
// В браузере: const res = await fetch('/api/register', { method: 'POST', body: formData })
const userId = 'usr_k8x2m9'
return { success: true, userId, avatarUrl: `/uploads/${userId}/avatar.jpg` }
}
const mockAvatar = {
name: 'avatar.jpg',
type: 'image/jpeg',
size: 245760,
data: '[binary data]',
}
registerUser(
{ firstName: 'Екатерина', lastName: 'Волкова', email: 'kate@startup.ru', interests: ['design', 'ux'] },
mockAvatar
).then(result => {
console.log('\nОтвет сервера:')
console.log(' userId:', result.userId) // usr_k8x2m9
console.log(' avatarUrl:', result.avatarUrl) // /uploads/usr_k8x2m9/avatar.jpg
})Пользователь регистрируется в сервисе: заполняет имя, email, загружает аватар. Простой JSON не подходит — файл нельзя сериализовать в JSON. Для отправки файлов вместе с текстовыми данными используется FormData и тип запроса multipart/form-data.
FormData — это объект, который представляет данные HTML-формы, включая файлы. Браузер умеет отправлять его через fetch с правильным Content-Type автоматически.
const formData = new FormData()
// append — добавляет значение (можно несколько с одним именем)
formData.append('firstName', 'Алексей')
formData.append('email', 'aleksei@company.ru')
// set — устанавливает, перезаписывая существующее
formData.set('email', 'aleksei.petrov@company.ru')
// Чтение и проверка
formData.get('firstName') // 'Алексей'
formData.has('email') // true
formData.delete('email')
formData.has('email') // falseСамый удобный способ — передать элемент формы напрямую:
form.addEventListener('submit', async (event) => {
event.preventDefault()
const formData = new FormData(form) // собирает все поля автоматически
const response = await fetch('/api/profile', {
method: 'POST',
body: formData,
// Content-Type НЕ устанавливаем — браузер сделает сам!
})
})async function uploadAvatar(file, userId) {
if (!file.type.startsWith('image/')) {
throw new Error('Файл должен быть изображением')
}
if (file.size > 5 * 1024 * 1024) {
throw new Error('Максимальный размер: 5 MB')
}
const formData = new FormData()
formData.append('avatar', file)
formData.append('userId', String(userId))
const response = await fetch('/api/upload/avatar', {
method: 'POST',
body: formData,
// НЕ устанавливай Content-Type вручную!
// Браузер добавит: multipart/form-data; boundary=----WebKitFormBoundaryXXX
})
if (!response.ok) throw new Error(`Ошибка: ${response.status}`)
return response.json()
}// ПРАВИЛЬНО — браузер добавит boundary автоматически
fetch('/api/upload', { method: 'POST', body: formData })
// НЕПРАВИЛЬНО — browser не добавит boundary, сервер не разберёт тело
fetch('/api/upload', {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' } // НЕ ДЕЛАЙ ТАК
})Без boundary сервер не знает, где заканчивается одно поле и начинается следующее.
// Несколько значений с одним именем (роли, теги)
formData.append('role', 'editor')
formData.append('role', 'moderator')
formData.getAll('role') // ['editor', 'moderator']
// Несколько файлов
for (const file of fileInput.files) {
formData.append('photos', file, file.name)
}Ошибка 1: устанавливают Content-Type вручную
// Сломано: boundary теряется, сервер получает битый запрос
fetch('/api/upload', {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' }, // УДАЛИ ЭТО
})
// Исправлено: убрать Content-Type — браузер добавит его сам с boundary
fetch('/api/upload', { method: 'POST', body: formData })Ошибка 2: числа без приведения к строке
formData.append('price', 1500) // браузер преобразует в '1500'
formData.append('price', String(1500)) // явное преобразование — лучшеОшибка 3: валидация только на сервере, не на клиенте
// Плохо: пользователь ждёт загрузки 500MB и только потом получает ошибку
// Хорошо: проверяем до отправки:
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой') // мгновенно, без трафика
return
}Симуляция FormData API: добавление полей, файлов и отправка данных регистрации
// Симуляция FormData для sandbox (без браузерного API)
class FormDataSim {
constructor() {
this._fields = new Map()
}
append(name, value, filename) {
if (!this._fields.has(name)) this._fields.set(name, [])
const entry = filename
? { value: String(value), filename }
: { value: String(value) }
this._fields.get(name).push(entry)
}
set(name, value) {
this._fields.set(name, [{ value: String(value) }])
}
get(name) {
const entries = this._fields.get(name)
return entries ? entries[0].value : null
}
getAll(name) {
return (this._fields.get(name) || []).map(e => e.value)
}
has(name) { return this._fields.has(name) }
delete(name) { this._fields.delete(name) }
entries() {
const result = []
for (const [name, list] of this._fields) {
for (const e of list) {
result.push([name, e.filename ? `[File: ${e.filename}]` : e.value])
}
}
return result
}
}
// Валидация файла перед отправкой (экономим трафик)
function validateFile(file) {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (!allowed.includes(file.type)) {
throw new Error(`Тип ${file.type} не разрешён. Можно: jpg, png, webp`)
}
if (file.size > 5 * 1024 * 1024) {
throw new Error(`Файл ${(file.size / 1024 / 1024).toFixed(1)} MB > 5 MB`)
}
return true
}
// Регистрация пользователя с аватаром
async function registerUser(userData, avatarFile) {
if (avatarFile) {
validateFile(avatarFile)
console.log(`Файл OK: ${avatarFile.name} (${(avatarFile.size / 1024).toFixed(0)} KB)`)
// Файл OK: avatar.jpg (240 KB)
}
const fd = new FormDataSim()
fd.append('firstName', userData.firstName)
fd.append('lastName', userData.lastName)
fd.append('email', userData.email)
fd.set('registeredAt', new Date('2026-03-04').toISOString())
// Множественные значения
userData.interests.forEach(tag => fd.append('interests', tag))
if (avatarFile) {
fd.append('avatar', avatarFile.data, avatarFile.name)
}
console.log('Данные формы:')
for (const [key, value] of fd.entries()) {
console.log(` ${key}: ${value}`)
}
// firstName: Екатерина
// lastName: Волкова
// email: kate@startup.ru
// registeredAt: 2026-03-04T00:00:00.000Z
// interests: design
// interests: ux
// avatar: [File: avatar.jpg]
// В браузере: const res = await fetch('/api/register', { method: 'POST', body: formData })
const userId = 'usr_k8x2m9'
return { success: true, userId, avatarUrl: `/uploads/${userId}/avatar.jpg` }
}
const mockAvatar = {
name: 'avatar.jpg',
type: 'image/jpeg',
size: 245760,
data: '[binary data]',
}
registerUser(
{ firstName: 'Екатерина', lastName: 'Волкова', email: 'kate@startup.ru', interests: ['design', 'ux'] },
mockAvatar
).then(result => {
console.log('\nОтвет сервера:')
console.log(' userId:', result.userId) // usr_k8x2m9
console.log(' avatarUrl:', result.avatarUrl) // /uploads/usr_k8x2m9/avatar.jpg
})Напиши асинхронную функцию uploadDocument(name, content, metadata), которая формирует FormData с полями "file", "category" и "tags" (массив — несколько отдельных append), и возвращает объект { success, docId, tags }.
fd.append('file', content); fd.append('category', metadata.category); metadata.tags.forEach(tag => fd.append('tags', tag))