XMLHttpRequest: индикация прогресса

Запрос XMLHttpRequest состоит из двух фаз:

  1. Стадия закачки (upload). На ней данные загружаются на сервер. Эта фаза может быть долгой для POST-запросов. Для отслеживания прогресса на стадии закачки существует объект типа XMLHttpRequestUpload, доступный как xhr.upload и события на нём.
  2. Стадия скачивания (download). После того, как данные загружены, браузер скачивает ответ с сервера. Если он большой, то это может занять существенное время. На этой стадии используется обработчик xhr.onprogress.

Далее – обо всём по порядку.

Стадия закачки

На стадии закачки для получения информации используем объект xhr.upload. У этого объекта нет методов, он только генерирует события в процессе закачки. А они-то как раз и нужны.

Вот полный список событий:

  • loadstart
  • progress
  • abort
  • error
  • load
  • timeout
  • loadend

Пример установки обработчиков на стадию закачки:

xhr.upload.onprogress = function(event) {
  alert( 'Загружено на сервер ' + event.loaded + ' байт из ' + event.total );
}

xhr.upload.onload = function() {
  alert( 'Данные полностью загружены на сервер!' );
}

xhr.upload.onerror = function() {
  alert( 'Произошла ошибка при загрузке данных на сервер!' );
}

Стадия скачивания

После того, как загрузка завершена, и сервер соизволит ответить на запрос, XMLHttpRequest начнёт скачивание ответа сервера.

На этой фазе xhr.upload уже не нужен, а в дело вступают обработчики событий на самом объекте xhr. В частности, событие xhr.onprogress содержит информацию о количестве принятых байт ответа.

Пример обработчика:

xhr.onprogress = function(event) {
  alert( 'Получено с сервера ' + event.loaded + ' байт из ' + event.total );
}

Все события, возникающие в этих обработчиках, имеют тип ProgressEvent, то есть имеют свойства loaded – количество уже пересланных данных в байтах и total – общее количество данных.

Демо: загрузка файла с индикатором прогресса

Современный XMLHttpRequest позволяет отправить на сервер всё, что угодно. Текст, файл, форму.

Мы, для примера, рассмотрим загрузку файла с индикацией прогресса. Это требует от браузера поддержки File API, то есть исключает IE9-.

File API позволяет получить доступ к содержимому файла, который перенесён в браузер при помощи Drag’n’Drop или выбран в поле формы, и отправить его при помощи XMLHttpRequest.

Форма для выбора файла с обработчиком submit:

<form name="upload">
  <input type="file" name="myfile">
  <input type="submit" value="Загрузить">
</form>

<script>
  document.forms.upload.onsubmit = function() {
    var input = this.elements.myfile;
    var file = input.files[0];
    if (file) {
      upload(file);
    }
    return false;
  }
</script>

Мы получаем файл из формы через свойство files элемента <input> и передаём его в функцию upload:

function upload(file) {

  var xhr = new XMLHttpRequest();

  // обработчик для закачки
  xhr.upload.onprogress = function(event) {
    log(event.loaded + ' / ' + event.total);
  }

  // обработчики успеха и ошибки
  // если status == 200, то это успех, иначе ошибка
  xhr.onload = xhr.onerror = function() {
    if (this.status == 200) {
      log("success");
    } else {
      log("error " + this.status);
    }
  };

  xhr.open("POST", "upload", true);
  xhr.send(file);

}

Этот код отправит файл на сервер и будет сообщать о прогрессе при его закачке (xhr.upload.onprogress), а также об окончании запроса (xhr.onload, xhr.onerror).

Полный пример индикации прогресса при загрузке, основанный на коде выше:

Результат
server.js
index.html
var http = require('http');
var url = require('url');
var querystring = require('querystring');
var static = require('node-static');
var file = new static.Server('.', {
  cache: 0
});


function accept(req, res) {

  if (req.url == '/upload') {
    var length = 0;
    req.on('data', function(chunk) {
      // ничего не делаем с приходящими данными, просто считываем
      length += chunk.length;
      if (length > 50 * 1024 * 1024) {
        res.statusCode = 413;
        res.end("File too big");
      }
    }).on('end', function() {
      res.end('ok');
    });

  } else {
    file.serve(req, res);
  }

}


// ------ запустить сервер -------

if (!module.parent) {
  http.createServer(accept).listen(8080);
} else {
  exports.accept = accept;
}
<!DOCTYPE HTML>
<html>

<body>

  <head>
    <meta charset="utf-8">
  </head>
  <form name="upload">
    <input type="file" name="myfile">
    <input type="submit" value="Загрузить">
  </form>

  <div id="log">Прогресс загрузки</div>

  <script>
    function log(html) {
      document.getElementById('log').innerHTML = html;
    }

    document.forms.upload.onsubmit = function() {
      var file = this.elements.myfile.files[0];
      if (file) {
        upload(file);
      }
      return false;
    }


    function upload(file) {

      var xhr = new XMLHttpRequest();

      // обработчики можно объединить в один,
      // если status == 200, то это успех, иначе ошибка
      xhr.onload = xhr.onerror = function() {
        if (this.status == 200) {
          log("success");
        } else {
          log("error " + this.status);
        }
      };

      // обработчик для закачки
      xhr.upload.onprogress = function(event) {
        log(event.loaded + ' / ' + event.total);
      }

      xhr.open("POST", "upload", true);
      xhr.send(file);

    }
  </script>
</body>

</html>

Событие onprogress в деталях

При обработке события onprogress есть ряд важных тонкостей.

Можно, конечно, их игнорировать, но лучше бы знать.

Заметим, что событие, возникающее при onprogress, имеет одинаковый вид на стадии закачки (в обработчике xhr.upload.onprogress) и при получении ответа (в обработчике xhr.onprogress).

Оно представляет собой объект типа ProgressEvent со свойствами:

loaded

Сколько байт уже переслано.

Имеется в виду только тело запроса, заголовки не учитываются.

lengthComputable

Если true, то известно полное количество байт для пересылки, и оно хранится в свойстве total.

total

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

А может ли оно быть неизвестно?

  • При закачке на сервер браузер всегда знает полный размер пересылаемых данных, так что total всегда содержит конкретное количество байт, а значение lengthComputable всегда будет true.
  • При скачивании данных – обычно сервер в начале сообщает их общее количество в HTTP-заголовке Content-Length. Но он может и не делать этого, например если сам не знает, сколько данных будет или если генерирует их динамически. Тогда total будет равно 0. А чтобы отличить нулевой размер данных от неизвестного – как раз служит lengthComputable, которое в данном случае равно false.

Ещё особенности, которые необходимо учитывать при использовании onprogress:

  • Событие происходит при каждом полученном/отправленном байте, но не чаще чем раз в 50 мс.

    Это обозначено в спецификации progress notifications.

  • В процессе получения данных, ещё до их полной передачи, доступен xhr.responseText, но он не обязательно содержит корректную строку.

    Можно до окончания запроса заглянуть в него и прочитать текущие полученные данные. Важно, что при пересылке строки в кодировке UTF-8 кириллические символы, как, впрочем, и многие другие, кодируются 2 байтами. Возможно, что в конце одного пакета данных окажется первая половинка символа, а в начале следующего – вторая. Поэтому полагаться на то, что до окончания запроса в responseText находится корректная строка нельзя. Она может быть обрезана посередине символа.

    Исключение – заведомо однобайтные символы, например цифры или латиница.

  • Сработавшее событие xhr.upload.onprogress не гарантирует, что данные дошли.

    Событие xhr.upload.onprogress срабатывает, когда данные отправлены браузером. Но оно не гарантирует, что сервер получил, обработал и записал данные на диск. Он говорит лишь о самом факте отправки.

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

Файлы и формы

Выше мы использовали xhr.send(file) для передачи файла непосредственно в теле запроса.

При этом посылается только содержимое файла.

Если нужно дополнительно передать имя файла или что-то ещё – это можно удобно сделать через форму, при помощи объекта FormData:

Создадим форму formData и прибавим к ней поле с файлом file и именем "myfile":

var formData = new FormData();
formData.append("myfile", file);
xhr.send(formData);

Данные будут отправлены в кодировке multipart/form-data. Серверный фреймворк увидит это как обычную форму с файлом, практически все серверные технологии имеют их встроенную поддержку. Индикация прогресса реализуется точно так же.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.