← JavaScript/FormData и загрузка файлов#130 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

FormData и загрузка файлов

Пользователь регистрируется в сервисе: заполняет имя, email, загружает аватар. Простой JSON не подходит — файл нельзя сериализовать в JSON. Для отправки файлов вместе с текстовыми данными используется FormData и тип запроса multipart/form-data.

Что решает FormData

FormData — это объект, который представляет данные HTML-формы, включая файлы. Браузер умеет отправлять его через fetch с правильным Content-Type автоматически.

На основе предыдущих уроков

  • fetch: отправка HTTP-запросов
  • async/await: асинхронная отправка данных
  • Map/Set: FormData по интерфейсу похож на Map
  • try/catch: обработка ошибок сети
  • Создание FormData вручную

    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

    FormData из HTML-формы

    Самый удобный способ — передать элемент формы напрямую:

    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()
    }

    Почему нельзя устанавливать Content-Type вручную

    // ПРАВИЛЬНО — браузер добавит 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 с File — стандарт для загрузки изображений профиля
  • Документы: договоры, счета — офисные файлы вместе с метаданными в одном запросе
  • Импорт данных: CSV/Excel загружают через FormData, сервер парсит на лету
  • Прогресс загрузки: fetch не поддерживает upload progress — используют XMLHttpRequest.upload.onprogress
  • Примеры

    Симуляция 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
    })

    FormData и загрузка файлов

    Пользователь регистрируется в сервисе: заполняет имя, email, загружает аватар. Простой JSON не подходит — файл нельзя сериализовать в JSON. Для отправки файлов вместе с текстовыми данными используется FormData и тип запроса multipart/form-data.

    Что решает FormData

    FormData — это объект, который представляет данные HTML-формы, включая файлы. Браузер умеет отправлять его через fetch с правильным Content-Type автоматически.

    На основе предыдущих уроков

  • fetch: отправка HTTP-запросов
  • async/await: асинхронная отправка данных
  • Map/Set: FormData по интерфейсу похож на Map
  • try/catch: обработка ошибок сети
  • Создание FormData вручную

    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

    FormData из HTML-формы

    Самый удобный способ — передать элемент формы напрямую:

    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()
    }

    Почему нельзя устанавливать Content-Type вручную

    // ПРАВИЛЬНО — браузер добавит 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 с File — стандарт для загрузки изображений профиля
  • Документы: договоры, счета — офисные файлы вместе с метаданными в одном запросе
  • Импорт данных: CSV/Excel загружают через FormData, сервер парсит на лету
  • Прогресс загрузки: fetch не поддерживает upload progress — используют XMLHttpRequest.upload.onprogress
  • Примеры

    Симуляция 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))

    Загружаем среду выполнения...
    Загружаем AI-помощника...