Уменьшение числа запросов
В теории
С формальной точки зрения, после того как первые два предложения воплощены в жизнь, у нас появляется дерево зависимостей. Например, такое:
— dom.js — array.map.js — array.js — sprinf.js — calendar.js — date.js — mycoolcombobox.js — dom.js — array.map.js — array.js — sprinf.js — animated.pane.js — pane.js — dom.js — array.map.js — array.js — sprinf.js — animation.js — transition.js ... и так далее ...
Дальше мы выбираем непосредственно нужные сайту вершины. Пусть это будут dom.js и animated.pane.js . Теперь это дело техники — обойти получившийся набор деревьев в глубину:
— array.js — array.map.js — sprinf.js — dom.js — array.js — array.map.js — sprinf.js — dom.js — pane.js — transition.js — animation.js — animated.pane.js
...удалить повторяющиеся элементы:
— array.js — array.map.js — sprinf.js — dom.js — pane.js — transition.js — animation.js — animated.pane.js
и слить соответствующие модули воедино.
На практике
Хранить информацию о зависимостях можно, например, следующим образом (добавляя в "модули" служебные комментарии):
// #REQUIRE: array.map.js // #REQUIRE: sprintf.js .... Код
Выделить подобные метки из текстового файла не составляет труда. Естественно, чтобы получить полное дерево зависимостей, надо будет пройтись по всем доступных файлам— но полное дерево обычно не нужно.
К чему мы пришли?
Затратив один раз кучу времени на формирование модулей и зависимостей между ними, мы экономим время каждый раз, когда хотим уменьшить объем загружаемого клиентов внешнего файла. Это приятно. Но все-таки часть проблемы осталась— пользователь загружает весь JavaScript-код, который используется на сайте в течение первого захода на произвольную страницу, даже если на текущей странице этот код не нужен.
Итак, мы оставили нового пользователя наедине с единственным JavaScript-файлом, не включающим ничего лишнего. Стал ли при этом пользователь счастливее? Ничуть. Наоборот, в среднем пользователь стал более несчастным, чем раньше, а причина этому — увеличившееся время загрузки страницы.
Немного из теории HTTP-запросов
Время загрузки ресурса через HTTP-соединение складывается из следующих основных элементов:
- время отсылки запроса на сервер T1— для большинства запросов величина практически постоянная;
- время формирования ответа сервера— для статических ресурсов, которые мы сейчас и рассматриваем, пренебрежимо мало;
- время получения ответа сервера T2, которое, в свою очередь, состоит из постоянной для сервера сетевой задержки L и времени получения ответа R, прямо пропорционального размеру ресурса.
Итак, время загрузки страницы будет состоять из времени загрузки HTML-кода и всех внешних ресурсов: изображений, CSS- и JavaScript-файлов. Основная проблема в том, что CSS и JavaScript-файлы загружаются последовательно (разработчики браузеров уже работают над решением этой проблемы в последних версиях, однако пока еще 99% пользователей страдают от последовательной загрузки). В этом случае общение с сервером выглядит так:
— запросили страницу — получили HTML — запросили ресурс A: T1 — получили ресурс A: L + R(A) — запросили ресурс B: T1 — получили ресурс B: L + R(B) — запросили ресурс C: T1 — получили ресурс C: L + R(C)
Общие временные затраты при этом составят 3(T1+L) + R(A+B+C).
Объединяя файлы, мы уменьшаем количество запросов на сервер:
— запросили страницу — получили HTML — запросили ресурс A+B+C: T1 — получили ресурс A+B+C: L + R(A + B + C)
Очевидна экономия в 2(T1 + L).
Для 20 ресурсов эта экономия составит уже 19(T1 + L). Если взять достаточно типичные сейчас для домашнего/офисного Интернета значения скорости в 256 Кбит/с и пинга ~20–30 мс, получим экономию в 950 мс— одну секунду загрузки страницы. У людей же, пользующихся мобильным или спутниковым интернетом с пингом более 300 мс, разница времен загрузки страниц составит 6-7 секунд.
На первый взгляд, теория говорит, что загрузка страниц должна стать быстрее. В чем же она разошлась с практикой?
Суровая реальность
Пусть у нашего сайта есть три страницы — P1, P2 и P3, поочередно запрашиваемые новым пользователем. P1 использует ресурсы A, B и C, P2— A, С и D, а P3— A, С, E и F. Если ресурсы не объединять, получаем следующее:
- P1— тратим время на загрузку A, B и C.
- P2— тратим время на загрузку только D.
- P3— тратим время на загрузку E и F.
Если мы слили воедино абсолютно все JavaScript-модули сайта, получаем:
- P1— тратим время на загрузку (A+B+C+D+E+F).
- P2— внешние ресурсы не требуются.
- P3— внешние ресурсы не требуются.
Результатом становится увеличение времени загрузки самой первой страницы, на которую попадает пользователь. При типовых значениях скорости/пинга мы начинаем проигрывать уже при дополнительном объеме загрузки в 23Кб.
Если мы объединили только модули, необходимые для текущей страницы, получаем следующее:
- P1— тратим время на загрузку (A+B+C).
- P2— тратим время на загрузку (A+C+D).
- P3— тратим время на загрузку (A+С+E+F).
Каждая отдельно взятая страница при пустом кэше будет загружаться быстрее, но все они вместе— медленнее, чем в исходном случае. Получаем, что слепое использование модного сейчас объединения ресурсов часто только ухудшает жизнь пользователя.
Возможное решение
Конечно же, выход из сложившегося положения есть. В большинстве случаев для получения реального выигрыша достаточно выделить "ядро"— набор модулей, используемых на всех (или, по крайней мере, на часто загружаемых) страницах сайта. Например, в нашем примере достаточно выделить в ядро ресурсы A и B, чтобы получить преимущество:
- P1— тратим время на загрузку (A + B) и C.
- P2— тратим время на загрузку D.
- P3— тратим время на загрузку (E + F).
Вдумчивый читатель сейчас возмутится и спросит: "А что, если ядра нет? Или ядро получается слишком маленьким?". Ответ: это легко решается вручную выделением 2-3 независимых групп со своими собственными ядрами. При желании задачу разбиения можно формализовать и получить точное машинное решение— но это обычно не нужно; руководствуясь простейшим правилом— чем больше ядро, тем лучше, — можно добиться вполне приличного результата.
Реализация на PHP
После разделения JavaScript- и CSS-кода по файлам для поддержания модульной структуры можно в контроллере создать список файлов, которые надо присоединить к данному документу (вместо того чтобы прописывать это вручную в шаблоне отображения). Но теперь надо сделать так, чтобы до показа шаблона вызывалась функция кэширования, которая проходилась бы по списку, проверяла из него локальные файлы на время изменения, объединяла в один файл и создавала или перезаписывала gz-файл с именем, сформированным из md5-хэша имен входящих файлов.
В качестве рабочего примера можно привести следующую функцию:
function cache_js(){ $arrNewJS=array(); $strHash=''; $strGzipContent=''; $intLastModified=0; //проходимся по списку файлов foreach ((array)$this->scripts as $file){ if (substr($file,0,5)=='http:') continue; if ($file[0]=='/') $strFilename=sys_root.$file; else $strFilename=sys_root.'app/front/view/'.$file; $strHash.=$file; //читаем содержимое в одну строку $strGzipContent.=file_get_contents($strFilename); $intLastModified=$intLastModified<filemtime($strFilename) ? filemtime($strFilename) : $intLastModified; } $strGzipHash=md5($strHash); $strGzipFile=sys_root.'app/front/view/js/bin/'.$strGzipHash.'.gz'; //проверяем, надо ли перезаписать gz-файл if (file_exists($strGzipFile) && $intLastModified>filemtime($strGzipFile) || !file_exists($strGzipFile)){ if (!file_exists($strGzipFile)) touch($strGzipFile); //используем функции встроенной в php библиотеки zlib для архивации $gz = gzopen($strGzipFile,'w9'); gzputs ($gz, $strGzipContent); gzclose($gz); } //перезаписываем список на один файл $arrNewJS[]='js/bin/'.$strGzipHash.'.gz'; $this->scripts=$arrNewJS; }
Для CSS основные теоретические моменты описаны выше, а реализация даже несколько проще. Если использовать YUI Compressor, то решение будет совершенно одинаково (вычислили зависимости, склеили файлы, сжали, переименовали, сделали архив) для обоих типов файлов.