Как ускорить загрузку изображений, используя встроенный предварительный просмотр

Чаще всего ленивая загрузка используется для вывода версий изображения для предварительного просмотра. При этом сначала создается некачественное изображение для предварительного просмотра, которое затем будет заменено исходным изображением. Но можно ли предоставить изображение для предварительного просмотра, не загружая дополнительные данные?

Формат progressive JPEG позволяет сохранять информацию таким образом, чтобы сначала отображалось нечеткое, а затем более качественное изображения.

Описанный в этой статье способ загружает размытое изображение быстро, которое постепенно становится все более резким (прогрессивный способ).

Базовый способ

Прогрессивный способ

Но даже progressive JPEG-файлы загружаются один за другим из-за того, что современные браузеры допускают только шесть одновременных подключений к домену.

Рассматриваемый способ позволяет загружать с сервера столько байтов progressive JPEG, сколько нужно для быстрого отображения содержимого. Затем (когда все изображения для предварительного просмотра загружены), остальная часть изображения должна быть загружена без запроса той части, которая была получена ранее.

Загрузка progressive JPEG с двумя запросами

Но нельзя указать тегу img, какая часть изображения должно быть загружено в определенное время. Для этого нужно использовать Ajax. Но только на серверах, на которых хранятся изображения, поддерживающих запросы HTTP Range. С их помощью клиент может сообщить серверу в заголовке HTTP, какие байты запрашиваемого файла должны содержаться в ответе.

Данная технология поддерживается всеми популярными серверами (Apache, IIS, nginx). Она используется для загрузки видео при воспроизведении. С помощью HTTP Range сервер запрашивает только ту часть видео, которую запросил пользователь.

Но даже использование HTTP Range не решает следующие проблемы:

  1. Создание прогрессивного JPEG.
  2. Определение размера, до которого первый запрос HTTP Range должен загрузить изображение для предварительного просмотра.
  3. Реализация кода JavaScript.

1. Создание progressive JPEG

Progressive JPEG состоит из нескольких сегментов, каждый из которых содержит часть конечного изображения. Первая загрузка показывает изображение нечетко. Но следующие добавляют все больше информации к уже загруженным данным и в конце формируют окончательное (полновесное) изображение.

Как выглядят отдельные сканы изображения, определяется программой, которая генерирует файлы JPEG. В программах CMD, таких как cjpeg из проекта mozjpeg, можно определить, какие данные содержат эти сканирования.

В файле wizard.txt проекта mozjpeg описаны точные параметры, которые необходимо передать программе в скрипте сканирования. Это удачный компромисс между быстрой прогрессивной структурой и размером файла.

Чтобы преобразовать исходный JPEG в progressive JPEG, воспользуемся jpegtran из проекта mozjpeg. Это инструмент для внесения изменений в существующий JPEG без каких-либо потерь. Предварительно скомпилированные сборки для Windows и Linux доступны здесь.

Создадим progressive JPEG из командной строки:

$ jpegtran input.jpg > progressive.jpg

При этом данные изображения не будут модифицированы. Изменяется только расположение данных изображения в файле.

Метаданные, не относящиеся к внешнему виду изображения (такие как данные Exif, IPTC или XMP), должны быть удалены из JPEG. С помощью утилиты exiftool, работающей из командной строки,  можно удалить эти метаданные:

$ exiftool -all= progressive.jpg

Также можно воспользоваться онлайн-сервисом сжатия compress-or-die.com, чтобы создать progressive JPEG без метаданных.

2. Определение размера, до которого первый запрос HTTP Range должен загрузить изображение

Файл JPEG разделен на разные сегменты. Они содержат разные компоненты (данные изображения, метаданные, встроенные цветовые профили и т. д.). Каждый из этих сегментов начинается с маркера, заданного шестнадцатеричным байтом FF. Затем следует байт, указывающий тип сегмента. Например, D8 завершает маркер до маркера SOI FF D8 (Start Of Image), с которого начинается файл JPEG.

Каждый запуск сканирования отмечен маркером SOS (Start Of Scan, шестнадцатеричный FF DA). Данные, следующие за маркером SOS, шифруются с помощью кодирования Хаффмана.

Но существует еще один сегмент с таблицами Хаффмана (DHT, шестнадцатеричный FF C4), необходимый для декодирования перед сегментом SOS. Поэтому интересующая нас область progressive JPEG состоит из чередующихся таблиц Хаффмана (сегментов данных сканирования).

Чтобы отобразить первый скан изображения, необходимо запросить с сервера все байты до второго вхождения сегмента DHT (шестнадцатеричный FF C4).

Определение размера, до которого первый запрос HTTP Range должен загрузить изображение

Структура файла JPEG

В PHP для этого можно использовать приведенный ниже код:

<?php
$img = "progressive.jpg";
$jpgdata = file_get_contents($img);
$positions = [];
$offset = 0;
while ($pos = strpos($jpgdata, "xFFxC4", $offset)) {
$positions[] = $pos+2;
$offset = $pos+2;
}

Нужно добавить два к найденной позиции. Потому что браузер отображает только последнюю строку изображения предварительного просмотра, когда встречает новый маркер (который состоит из двух байтов).

Нас интересует первый скан изображения предварительного просмотра. Для этого мы находим правильную позицию в $position[1], до которой нужно запросить файл через HTTP Range.

Чтобы запросить изображение лучшего качества, используйте более дальнюю позицию в массиве. Например, $positions[3].

3. Написание кода JavaScript

Сначала определяем тег img, которому мы передаем заданную позицию байта:

<img data-src="progressive.jpg" data-bytes="<?= $positions[1] ?>">

Мы не определяем атрибут src напрямую, чтобы при парсинге HTML-кода браузер не сразу начал запрашивать изображение с сервера.

Затем загружаем изображение предварительного просмотра, используя приведенный ниже JavaScript-код:

var $img = document.querySelector("img[data-src]");
var URL = window.URL || window.webkitURL;

var xhr = new XMLHttpRequest();
xhr.onload = function(){
    if (this.status === 206){
        $img.src_part = this.response;
        $img.src = URL.createObjectURL(this.response);
    }
}

xhr.open('GET', $img.getAttribute('data-src'));
xhr.setRequestHeader("Range", "bytes=0-" + $img.getAttribute('data-bytes'));
xhr.responseType = 'blob';
xhr.send();

Этот код создает запрос Ajax, который сообщает серверу в HTTP-заголовке range вернуть маркер файла в позицию, указанную в data-bytes. После чего сервер вернет двоичный код изображения в ответе HTTP-206 (частичное содержимое) в виде объекта blob. С помощью createObjectURL из него можно сгенерировать внутренний URL-адрес для браузера. Мы используем этот URL в качестве значения для src тега img. Мы дополнительно сохраняем данные blob в объекте в свойстве src_part.

На вкладке Network в инструментах для разработчика, встроенных в браузер, видно, что мы загрузили не все изображение, а лишь его небольшую часть. Кроме этого загрузка URL-адреса в объекте blob-объекта должна отображаться размером в 0 байт.

Написание кода JavaScript

Вкладка Network в инструментах разработчика.

Мы уже загрузили заголовок JPEG исходного файла, поэтому изображение для предварительного просмотра имеет правильный размер. Благодаря этому можно не задавать высоту и ширину в теге img.

Альтернатива: загрузка изображения для предварительного просмотра через HTML

Для повышения производительности изображение можно передать в виде URI-данных непосредственно в HTML-код. При этом изображения для предварительного просмотра доступны сразу, и пользователь не замечает задержек при загрузке веб-страницы.

Сначала мы сгенерируем URI ресурса, который используем в теге img (как значение src):

<?php
…

$fp = fopen($img, 'r');
$data_uri = 'data:image/jpeg;base64,'. base64_encode(fread($fp, $positions[1]));
fclose($fp);

URI созданных данных теперь добавляется в тег img как src.

<img src="<?= $data_uri ?>" data-src="progressive.jpg" alt="">

Адаптируем JavaScript:

<script>
var $img = document.querySelector("img[data-src]");

var binary = atob($img.src.slice(23));
var n = binary.length;
var view = new Uint8Array(n);
while(n--) { view[n] = binary.charCodeAt(n); }

$img.src_part = new Blob([view], { type: 'image/jpeg' });
$img.setAttribute('data-bytes', $img.src_part.size - 1);
</script>

После этого создадим объект blob из URI данных. Мы получаем их из той части URI, которая не содержит данных изображения: data:image/jpeg;base64.

Затем расшифруем их с помощью команды atob. Чтобы создать большой blob-объект из двоичных строковых данных, нам нужно перенести их в массив Uint8. Это гарантирует, что данные не будут обрабатываться как текст в кодировке UTF-8.

К коду предыдущей версии реализации добавляем атрибут data-bytes к тегу img. В нем указано смещение в байтах, из которого должна быть загружена вторая часть изображения.

На вкладке network в инструментах разработчика видно, что загрузка изображения для предварительного просмотра не создает дополнительного запроса. Но при этом размер файла HTML-страницы увеличился.

Альтернатива: загрузка изображения для предварительного просмотра через HTML

Вкладка network при загрузке предварительного изображения в качестве данных URI.

Загружаем окончательное изображение

Теперь загрузим оставшуюся часть файла изображения через две секунды.

setTimeout(function(){
    var xhr = new XMLHttpRequest();
    xhr.onload = function(){
        if (this.status === 206){
            var blob = new Blob([$img.src_part, this.response], { type: 'image/jpeg'} );
            $img.src = URL.createObjectURL(blob);
        }
    }
    xhr.open('GET', $img.getAttribute('data-src'));
    xhr.setRequestHeader("Range", "bytes="+ (parseInt($img.getAttribute('data-bytes'), 10)+1) +'-');
    xhr.responseType = 'blob';
    xhr.send();
}, 2000);

В этот раз в HTTP-заголовке Range мы указываем, что хотим запросить данные с конечной позиции изображения до конца файла. Ответ на первый запрос хранится в свойстве src_part объекта DOM.

Мы используем ответы от обоих запросов, чтобы создать новый blob-объект для каждого new Blob(), который содержит данные всего изображения. URL-адрес blob-объекта используется как src объекта DOM. Теперь изображение загружено полностью.

Загружаем окончательное изображение

Вкладка network во время загрузки изображения целиком (31,7 КБ)

Прототип

Прототип примера реализации с различными параметрами доступен здесь. Репозиторий GitHub доступен по этой ссылке.

Заключение

Используя представленный в этом руководстве метод, можно загружать разные изображения для предварительного просмотра из progressive JPEG с помощью Ajax и HTTP-запросов Range. При этом данные файла не удаляются, а используются повторно, чтобы отобразить изображение полностью.

Благодаря этому не нужно создавать отдельные изображения для предварительного просмотра. На стороне сервера должно быть определено и сохранено только байтовое смещение, при котором заканчивается изображение.

Для пользователей с медленным интернет-соединением можно загружать не весь JPEG, а пропустить последние два сканирования. В результате этого мы получим гораздо меньший размер данных.