Статьи

Автор: bkonst
Опубликована: 29 июля 2008, habrahabr.ru/blog/webdev/47829.html

Объединение JS-файлов 2.0

В последнее время стало модно объединять все внешние JavaScript-файлы вашего сайта в один большой, загружаемый один раз и навсегда. Это, скажем прямо, хорошо — браузер не делает сто миллионов запросов на сервер для отображения одной страницы 1, скорость загрузки повышается, пользователи счастливы, разработчики отдыхают.

Как всегда, в бочке меда есть ложка дегтя — в объединенный файл в этом случае попадает много того, что при первом запросе можно было бы и не загружать.2 (Здесь должна была быть ссылка на хабратопик с соответствующим обсуждением. Успешно потеряна.) Чаще всего для борьбы с этим предлагают выкидывать ненужные части руками... Лично у меня перспектива каждый раз перелопачивать несколько десятков (а то и сотен 3) килобайт JavaScript кода вызывает острое нежелание работать — а у вас?

Описание простейшего алгоритма разрешения зависимости между модулями.

Конструктивные предложения

Предложение первое: разобрать используемый вами фреймворк на составные части. JSON — отдельно, AJAX — отдельно, работа с DOM — отдельно, формы — отдельно. После этого задача «выкидывания ненужного» превращается в задачу «собери только нужное». Несомненный плюс — результат сборки стал меньше. Несомненный минус — если что-то из «нужного» забыто, все перестает работать.

Предложение второе: сохранить информацию о зависимостях между составными частями. (Формы используют функции DOM, JSON — AJAX и так далее.) На этом шаге забыть что-то нужное становится заметно труднее, а сборка превращается из увлекательной головоломки «...@#$, почему все перестало работать...» в рутинную и нудную операцию.

Предложение третье: сохранить информацию о том, какие именно модули нужны сайту в целом. Используется ли AJAX? Если ли формы? Какие-то необычные элементы управления?

Предложение четвертое: подумать, и заставить работать машину.

Теория

С формальной точки зрения, после того, как первый и второй шаг выполнены, у нас появляется дерево4 зависимостей. Например.... (не стреляйте в пианиста — пример высосан из пальца).

— 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
....
код

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

Естественно, чтобы получить полное дерево зависимостей, надо будет пройтись по всем доступных файлам — но полное дерево обычно не нужно.

К чему мы пришли?

Затратив один раз кучу времени на формирование модулей и зависимостей между ними, мы экономим время каждый раз, когда хотим уменьшить объем загружаемого клиентов внешнего файла. Приятно. Но все-таки часть проблемы осталась — пользователь загружает весь JS-код, который используется на сайте за раз, даже если на текущей странице этот код не нужен.

Итак, мы оставили нового пользователя наедине с единственным JS-файлом, не включающем ничего лишнего. Стал ли при этом пользователь счастливее? Ничуть. Наоборот, в среднем пользователь5 стал более несчастным, чем раньше, а причина этому — увеличившееся время загрузки страницы.

Приятная теория

... вы находитесь на воздушном шаре! ...

Время загрузки ресурса через HTTP складывается из следующих основных элементов:

  1. время отсылки запроса на сервер T1 — для большинства6 запросов величина практически постоянная;
  2. время формирования ответа сервера — для статических ресурсов, которые мы сейчас и рассматриваем, пренебрежимо мало;
  3. время получения ответа сервера T2, в свою очередь состоящее из постоянной для сервера сетевой задержки L и времени получения ответа R, прямо пропорциональному размеру ресурса.

В свою очередь, время загрузки страницы будет состоять из времени загрузки HTML-кода и всех внешних ресурсов: изображений, CSS и JS файлов. Основная проблема в том, что CSS и JS-файлы грузятся последовательно7. В этом случае общение с сервером выглядит так:

— запросили страницу
— получили 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

Если мы слили воедино абсолютно все JS-модули сайта, получаем:

  • P1 — тратим время на загрузку (A+B+C+D+E+F)
  • P2 — внешние ресурсы не требуются
  • P3 — внешние ресурсы не требуются

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

Если мы объединили только модули, необходимые для текущей страницы, получаем следующее:

  • P1 — тратим время на загрузку (A+B+C)
  • P2 — тратим время на загрузку (A+C+D)
  • P3 — тратим время на загрузку (A+С+E+F)

Каждая отдельно взятая страница при пустом кеше будет загружаться быстрее, но все они вместе — медленнее, чем в исходном случае.

Получаем, что слепое использование модного сейчас объединения ресурсов часто только ухудшает жизнь пользователя.

Решение

— можно ли проглотить бильярдный шар?
— можно, но, как правило, не нужно.

Конечно же, выход из сложившегося положения есть8. В большинстве случаев для получения реального выигрыша достаточно выделить «ядро» — набор модулей, используемых на всех (или по крайней мере на часто загружаемых) страницах сайта. Например, в нашем примере достаточно выделить в ядро ресурсы A и B, чтобы получить преимущество:

  • P1 — тратим время на загрузку (A + B) и C
  • P2 — тратим время на загрузку D
  • P3 — тратим время на загрузку (E + F)

Вдумчивый читатель сейчас возмутится и спросит: «А что, если ядра нет? Или ядро получается слишком маленьким?». Спешу заверить — это легко5 решается вручную выделением 23 независимых групп со своими собственными ядрами. При желании задачу разбиения можно формализовать и получить точное машинное решение — но это обычно не нужно; руководствуясь простейшим правилом — чем больше ядро, тем лучше, можно добиться вполне приличного результата.

Как говорят на Хабре: «Каков же месседж этой статьи?» Ограничимся жалкими тремя пунктами:

  • бездумное следование модным веяниям приведет к бесполезной работе;
  • модные веяние часто несут в себе неплохие идеи;
  • если вы любите своих пользователей — объединяйте ресурсы с умом.

1 При правильно настроенном кэшировании (наличии 'Expires') эти запросы будут отправлены только при первой загрузке страницы. Тем не менее, встречают по одёжке.

2 Например, весь Prototype целиком, вместо отдельно взятых функций '$' и '$$', ага.

3 Вы не пользуетесь JS-фреймворком (пусть даже велосипедом собственного изобретения)?

4 На самом деле не дерево, а DAG (Направленный Ациклический Граф) — я просто не знаю правильного русскоязычного термина. Ах да, если у вас в зависимостях получились циклы — что-то где-то было разбито неправильно.

5 с пустым кэшем, конечно.

6 подразумевается, что большая часть запросов — это GET-запросы с разумной длиной URL. Длина такого запроса примерно постоянна, и составляет ~450–600 байт.

7 по крайней мере, пока они находятся на одном хосте.

8 и, что приятно видеть из комментов, используется.

9 «У нас есть такие приборы — но мы вам про них не расскажем». Придется поверить на слово.

Читать дальше

Все комментарии (habrahabr.ru)