Задача: отправка и обработка файлов с помощью FormData и FileReader в форме со всеми возможными полями и пересылкой дополнительных параметров для каждого поля c объединением всех данных формы (кроме файлов и системных полей) в общий массив.

Поддержка: все современные браузеры, IE 10+.

Плагины: jquery-2.1.4

image

Для начала разберемся, что же такое FormData

Formdata — тип данных в рамках технологии XHR2, данные в нем хранятся в виде пар ключ / значение.
new Formdata () — это конструктор для создания объекта FormData.

Подробнее о FormData

image

FormData имеет множество методов для полноценной работы с ней, таких как:

  • .get() — возвращает данные по ключу;
  • .getAll() — возвращает массив всех значений, ассоциированных с этим ключом;
  • .has() — возвращает булевое значение касательно наличия объекта;
  • .set() — добавляет значение к уже существующему ключу и, если его нет, создает его;
  • .append() — создает новую пару ключ / значение;
  • .delete() — удаляет объект по ключу;
  • .forEach() — на нем остановимся подробнее:

    В начале работы с FormData появилась весьма сложная проблема из-за того что встал вопрос: как можно перебрать данные в этом объекте? На русскоязычных ресурсах данных найдено не было, зато при получении списка всех методов объекта был найден forEach(), который позволил очень легко перебирать данные. Но появилась проблема, связанная с поддержкой браузерами. Так что этот метод не годится — нужна полная поддержка.

Также FormData можно перебирать с помощью цикла for...of (доступно в ECMAScript 6, с нативной поддержкой которого также есть проблемы).

Главная проблема FormData заключается в Internet explorer (как всегда), а вернее, в его поддержке. Из всех методов, которые есть в FormData, Internet explorer поддерживает только append(), что уничтожает всю простоту использования. Следовательно, мы не можем собрать форму с помощью простого вызова конструктора и последующего изменения данных в ней, и придется это делать вручную:

  • Получим все данные формы через serializeArray(), переберем, проверим их на пустоту и, вместе с заголовком (data-title), если это не системное поле (type=”hidden”), занесем в ассоциативный массив, отдельный для каждого поля, а далее добавим в наш массив для данных формы.
  • Системные поля мы сразу добавляем методом append() в FormData.

Файлы будем собирать с помощью списка, который формирует пользователь при закачке и дальнейшими манипуляциями со списком на клиенте, то есть будем сравнивать те файлы, которые у нас остались в списке, с теми, что хранятся в input type=”file” и с помощью переборки добавлять только те, что оставил пользователь.

Теперь познакомимся с FileReader

FileReader — это объект, который позволяет веб-приложениям асинхронно читать содержимое файлов (или буферы данных), хранящиеся на компьютере пользователя, используя объекты File или Blob, с помощью которых задается файл или данные для чтения.

> Подробнее о filereader

С его помощью мы будем отслеживать загрузку файлов на клиенте, формировать список загруженных файлов и выводить для них прогресс бар.

Теперь к самой задаче
Форма, которую мы будет пересылать:

<form enctype="multipart/form-data" id="form">
        <!-- Тема письма (служебное поле)-->
        <input type="hidden" name="thm" data-title="Тема" value="Заполнить анкету">
        <div class="radio-list">
            <div class="radio">
                <input type="radio" name="radiobtn" value="Первый" data-title="Выбор пунктов" id="radio1" class="radio__input" checked>
                <label for="radio1" class="radio__label">Первый пункт</label>
            </div>
            <div class="radio">
                <input type="radio" name="radiobtn" value="Второй" data-title="Выбор пунктов" id="radio2" class="radio__input">
                <label for="radio2" class="radio__label">Второй пункт</label>
            </div>
        </div>
        <div class="checkbox-list">
            <div class="checkbox">
                <input type="checkbox" name="checkboxbtn" value="Первый" data-title="Выбор пунктов2" id="checkbox1" class="checkbox__input" checked>
                <label for="checkbox1" class="checkbox__label">Первый пункт</label>
            </div>
            <div class="checkbox">
                <input type="checkbox" name="checkboxbtn" value="Второй" data-title="Выбор пунктов2" id="checkbox2" class="checkbox__input">
                <label for="checkbox2" class="checkbox__label">Второй пункт</label>
            </div>
        </div>
        <input type="text" name="name" data-title="Текстовое поле" class="input-text">
        <textarea name="textarea" data-title="Сообщение" class="textarea"></textarea>
        <!-- input для файла  -->
        <input class="input-file js_file_check" type="file" name="file[]" data-title="документ" multiple="" accept="image">
        <!--Список файлов загруженных пользователем-->
        <ul class="js_file_list file-list">
        </ul>
        <!-- кнопка для отправки формы-->
        <button class="js_btn_submit">Отправка формы</button>
    </form>

Для удобства пользователей предоставим им возможность добавления сразу большого количества файлов. С этой целью укажем в поле name значение file[] и атрибут multiple, с ограничением только картинки accept=«image».

Для пользователей также будем выводить список файлов, которые они загрузили с раздельным progress bar-ом для каждого файла и возможностью удаления перед отправкой. И тут мы столкнулись с проблемой. Дело в том, что fileList (массив загруженных файлов) у нашего input предназначен только для чтения, и удалить только выбранный пользователем файл мы не можем. Так что было решено перед отправкой на сервер сверять список, который уже сформировал пользователь, с тем что уже загружено. И при совпадении со списком файл будет добавляться в FormData.

1) Создаем саму функцию отправки через ajax:

var form = form; //текущая форма

    function formSend(formObject, form) {
        $.ajax({
            type: "POST",
            url: 'form-handler.php',
            dataType: 'json',
            contentType: false,
            processData: false,
            data: formObject,
            success: function() {
                $(form).trigger('reset');
 //при успешной отправке сбрасываем форму в дефолтное состояние
                alert('Success');
            }
        });
    };

2) Создаем функцию сборки формы:


function formData_assembly(form) {
        var formSendAll = new FormData(), //создаем объект FormData
            form_arr = $(form).find(':input,select,textarea').serializeArray(), //собираем все данные с формы без файлов
            formdata = {}; //ассациативный массив для хранения данных с формы

        for (var i = 0; i < form_arr.length; i++) {
            if (form_arr[i].value.length > 0) { //перебераем массив с данными формы и проверяем на заполненность
                var current_input = $(form).find('input[name=' + 
                        form_arr[i].name + 
                        '],select[name=' + 
                        form_arr[i].name + 
                        '],textarea[name=' + 
                        form_arr[i].name + ']'),
                    value_arr = {}; // новые массив с данными каждого поля + заголовок
                var title = $(current_input).attr('data-title'); //заголовок поля
                if ($(current_input).attr('type') != 'hidden') { //проверяем не является ли поле системным
                    value_arr['value'] = form_arr[i].value;
                    value_arr['title'] = title;
                    formdata[form_arr[i].name] = value_arr;
                } else {
                    formSendAll.append(form_arr[i].name, form_arr[i].value); //системные поля пересылаем отдельно от общей формы
                }
            }
        }
        formdata = JSON.stringify(formdata);
        formSendAll.append('formData', formdata); // добавляем все поля в formdata

        // file
        if ($(form).find('input[type=file]').hasClass('js_file_check')) { //проверяем есть ли input type file для пересылки
            var current_input = $(form).find('input[type=file]');
            if ($(current_input).val().length > 0) { //проверяем на заполненность
                $('.js_file_list li').each(function() {
                    var list_file_name = $(this).find('span').text();
                    for (var k = 0; k < $(current_input)[0].files.length; k++) {
                        if (list_file_name == $(current_input)[0].files[k].name) { //сверяем список выбранных файлов для загрузки
                            formSendAll.append($(current_input).attr('name'), $(current_input)[0].files[k]); // добавляем только те что остались в списке
                        }
                    }
                })
            }
        }
        formSend(formSendAll, form);
    }
    formData_assembly(form);

3) Оборачиваем все это в функцию для удобного вызова по событию:


function submit_function(form){...}

4) Вешаем функцию на событие клика на кнопку отправки формы:


$('.js_btn_submit').click(function (e) {
	e.preventDefault();
	var current_form = $(this).closest('form');//Текущая форма
	submit_function(current_form);
})

Теперь у нас есть полноценный отправщик формы, осталось только написать обработчик для файлов.

1) Создадим функцию отслеживания состояния input type=file:


function checkFile(){
	var inputs = document.getElementsByClassName('js_file_check');
	for (var i = 0; i < inputs.length; i++) {
  	inputs[i].addEventListener('change', handleFileSelect, false);
	}
}
checkFile();

2) Напишем обработчик ошибок:


var reader;

function abortRead() {
    reader.abort();
}

function errorHandler(evt) {
    switch (evt.target.error.code) {
        case evt.target.error.NOT_FOUND_ERR:
            alert('File Not Found!');
            break;
        case evt.target.error.NOT_READABLE_ERR:
            alert('File is not readable');
            break;
        case evt.target.error.ABORT_ERR:
            break; // noop
        default:
            alert('An error occurred reading this file.');
    };
}

3) Напишем функцию для переборки файлов в fileList нашего input type=file:


function handleFileSelect(evt) {
    var thisInput = $(this); //input type file для множественных загрузок
    for (var i = 0; i < thisInput[0].files.length; i++) { //перебираем все загруженные файлы и запускаем обработчик для каждого
        reader_file(thisInput[0].files[i]); //добавляем обработчик для каждого файла
    }
}

4) Теперь непосредственно сам обработчик:


function reader_file(file) {
    var reader = new FileReader(),
        fileName = file.name;
    reader.onerror = errorHandler; //функция для обработки ошибок
    $('.js_file_list').append('<li><span>' + 
        fileName + 
        '</span><div class="js_file_remove file_remove"></div><div class="progress-bar js_progress_bar"></div></li>'); //добавляем все новые файлы в список на клиенте
    reader.onabort = function(e) {
        alert('File read cancelled');
    };
    reader.onload = function(e) { //событие успешного окончания загрузки
        //что-нибудь делаем
    }
    reader.onprogress = function(event) { // вывод процентной полосы загрузки
        if (event.lengthComputable) {
            var percent = parseInt(((event.loaded / event.total) * 100), 10);
            $('.js_progress_bar').css('width', percent + '%');
        }
    }
    if (reader.readAsBinaryString === undefined) { // если браузер не поддерживает readAsBinaryString
        reader.readAsBinaryString = function(fileData) {
            var binary = "",
                pt = this,
                reader = new FileReader();
            reader.onload = function(e) {
                var bytes = new Uint8Array(reader.result);
                var length = bytes.byteLength;
                for (var i = 0; i < length; i++) {
                    binary += String.fromCharCode(bytes[i]);
                }
                pt.content = binary;
                $(pt).trigger('onload');
            }
        }
        reader.readAsArrayBuffer(file);
    } else {
        reader.readAsBinaryString(file);
    }
}

5) Добавим возможность удаления файлов из списка:


$(document).on('click', '.js_file_remove', function() {
    var list_item = $(this).closest('li');
    $(list_item).remove();
});

6) Можем использовать наш отправщик, не забыв поднять локальный сервер:

ссылка на демо
Поделиться с друзьями
-->

Комментарии (11)


  1. maria-tyan
    31.03.2017 11:10
    +1

    Спасибо, очень полезная статья, как раз недавно столкнулась с этой проблемой.


    1. NeuroZ
      31.03.2017 11:24
      +1

      Ага, удобно то, что на сервере получаешь сгруппированные данные. Особенно если файл-обрабочик ajax запросов является общим (выступает в роли контроллера-маршрутизатора). Тогда просто необходимо отделять служебные данные (для маршрутизации запроса) от массива пользовательских данных.
      Респект за статью! Надеюсь многие возьмут ее себе на вооружение, а то нормального frontend+верстальщика на фрилансе днем с фонарем не сыскать.


  1. sashabeep
    31.03.2017 11:29
    -3

    >Вешаем функцию на событие клика на кнопку отправки формы:

    WAT?


  1. ShamanR
    31.03.2017 11:49
    +2

    >Главная проблема FormData заключается в Internet explorer (как всегда), а вернее, в его поддержке.
    А вы пробовали искать полифил? Из статьи не очень понял, ак вы в итоге решили проблему с ИЕ.


    1. TutmeeAgency
      31.03.2017 12:10

      Использовал только нативные способы без подключения дополнительных библиотек. С помощью полифила можно просто через for..of перебрать formdata и с помощью set менять данные как нам угодно, но это уже не так интересно. Проблема с ie в итоге обходится с помощью ручной сборки всех данных с формы, а не простым вызовом конструктора к текущей форме.


  1. seka19
    31.03.2017 13:17
    +1

    Сталкивался с ещё одной проблемой IE:
    Файл отправлялся на сервер, условно говоря, вот так:


    /**
     * @param {Blob} file
     */
    function sendFile(file) {
        var XmlHttpObject = new XMLHttpRequest(),
            form = new FormData();
        form.append('file', file);
        XmlHttpObject.send(form);
    }

    В результате запрос на бэкэнд приходил:


    • В случае любых браузеров, кроме IE: с заголовком Content-Type: multipart/form-data и содержимым файла в соответствующем виде в теле запроса.
    • В случае IE: с заголовком Content-Type: application/x-www-form-urlencoded (хотя тело запроса было как в случае multipart/form-data).
      Это, в частности, препятствовало привычному использованию php-шного массива $_FILES, пришлось допиливать обёртку для парсинга тела запроса.
      Не сталкивались с таким?


    1. TutmeeAgency
      31.03.2017 14:55

      Не сталкивался еще, попробовал повторить эту проблему со своим кодом, но все также.


      1. seka19
        31.03.2017 17:17
        +1

        Также — в смысле "проблемы нет", или в смысле "такая же проблема"?


        1. TutmeeAgency
          31.03.2017 17:55

          в смысле этой проблемы нет, хотя я очень пытался)


  1. PaulZi
    31.03.2017 15:20
    +1

    Занимался я как-то такой задачей, вылилось в такой репозиторий.


  1. Serginio1
    05.04.2017 15:55
    +1

    Кстати как выглядит отправка на Angular 2 ng2-file-upload