Поработав на некотором количестве веб-проектов в роли frontend/backend-разработчика/верстальщика в разных компаниях, я постоянно сталкивался с неэффективным и некрасивым подходом к задаче подключения необходимых статических ресурсов (будем пока считать это .css и .js файлы) для отображения на странице.
Основная проблема всех повстречавшихся мне подходов — это тесная связь между структурой frontend кода, логикой деплоя и backend кода (в основном шаблонов), а также отсутствие семантики. Далее под термином frontend-код будет подразумеваться вся совокупность .js, .css и каких-либо других файлов или ресурсов, которые отдаются браузеру. Как правило этими файлами занимаются frontend-разработчики (sick!).
Сначала я приведу пару реальных примеров (на псевдокоде, так как везде использовались разные фреймворки и языки, и реальный код будет только сбивать нас с толку), рассмотрю недостатки и проблемы, связанные с используемыми подходами, а в конце опишу своё видение данной проблемы.
Первый пример
На одном обширном проекте (базировавшимся на Zend Framework) файлы статики подключались примерно следующим образом:
// someWidget.tpl
// PROJECT_STATIC_VERSION — некоторая версия статических файлов проекта (об этом ниже)
ViewHelpers.appendStylesheet("css/some-path/sub-path/some-widget.css?" + PROJECT_STATIC_VERSION);
ViewHelpers.appendJsFile ("js/some-path/sub-path/some-widget.js?" + PROJECT_STATIC_VERSION);
// подключаем ещё файлы
Layout:
<div class="some-widget">
<!-- a lot of cool html -->
</div>
Будем считать что методы ViewHelpers.appendStylesheet и ViewHelpers.appendJsFile гарантируют нам подключение переданных им файлов в соответствующем теге на финальной странице. Строка PROJECT_STATIC_VERSION использовалось для добавления к url'у некоторого ключа, обновление которого заставило бы браузер загрузить новую версию данного файла.
В добавок к этому, файлы часто подключались вне шаблонов, например в коде контроллера или в коде декоратора элемента (Zend_Form_Decorator). Особенно частым было подключение файлов js-фреймворка ExtJS в случае, если js-код, подключаемый из шаблона опирался на ExtJS. К несчастью в 95% случаях это делалось копипастой вида:
ViewHelpers.appendJsFile("js/libs/ext-js/ext-js.js?" + PROJECT_STATIC_VERSION);
ViewHelpers.appendCss ("css/libs/ext-js/ext-js.css?" + PROJECT_STATIC_VERSION);
ViewHelpers.appendJsFile("js/libs/ext-js/locale-ru.js?" + PROJECT_STATIC_VERSION);
Итак, минусы данного подхода (большинство конечно очевидно):
- Backend-код и код шаблонов знают про структуру frontend-кода. Изменение (добавление, перемещение, слияние, дробление) frontend-кода приведёт к необходимости менять backend-код. Что может быть довольно длительным и мучительным процессом, если некоторые файлы подключались в немалом количестве шаблонов (а зачастую так и есть). Т.е. frontend разработчик по сути зависит от backend собрата. Что не есть айс! (В вышеописанном примере спасало то, что на проекте не было разделения frontend/backend разработчиков, поэтому человек реализующий тот или иной компонент, писал как backend так и frontend код.)
- Нет единой точки подключения статических ресурсов. Т.к. файлы могут подключаться из разных мест: код контроллера, код декоратора элемента, код view-helper'а и сам код шаблона, то уже нельзя просто определить в каких файлах нуждается тот или иной компонент.
- Отсутствие явных зависимостей. Так как просто подключался список файлов, нельзя было выявить зависимость между ними. Например, разработчик делающий новый компонент на основе чужого, мог скопировать часть подключений ресурсов (по его мнению отвечающию за какую-ту обособленную часть), а потом ломать голову почему JavaScript не работает. А дело в том что он забыл подключить файл /lib/some-cool-plugin.js
- Примешивание deploy-логики в шаблоны. Я считаю это самым неправильным ходом. (В след. примере будет ещё печальней!). Конкатенация версии статики к url'у ресурса есть одна из техник деплоя приложения для того или иного окружения, и никак не связана с логикой шаблона и frontend-кода. Плюс, это ещё одна возможность ошибится, забыв дописать этот ключ (лень же!) или забыв поменять этот ключ (такое часто бывало).
- Дублирование. Как непосредственно кода подключения (ViewHelpers.blaBlaBla()), так и одних и тех же файлов в разных шаблонах. В общем DRY.
- Отсутствие семантики. Просто перечисленный список ресурсов мало о чём говорит. Мы не можем выделить зависимостей, определить характер данных ресурсов, понять где еще используется данный код и т.д.
- Банальная возможность опечатки. Длинные пути к файлам и имена часто подвержены опечаткам. На несвежую голову часто тратил лишнее время на определение того, что указанный файл не подключался (404 Not Found). Конечно можно было написать код, проверяющий наличие тех или иных файлов, но это не всегда было возможно, т.к. часто в раутинг примешивались правила с nginx'а. Да и вообще, этим никто и не занимался.
Второй пример из проекта на Symfony
// SomeConroller.SomeAction
If (Config.Env == "Production")
{
includeCss("styles/feature.min.css");
includeJs("js/feature.min.js");
}
Else If(Config.Env == "Dev")
{
includeCss("styles/feature/global.css");
includeCss("styles/feature/sub-feature.css");
includeJs("js/classes/Core.js");
includeJs("js/classes/Event.js");
includeJs("js/classes/CoolPlugin.js");
includeJs("js/classes/Feature.js");
includeJs("js/classes/FeatureSubFeature.js");
}
Layout:
<div class="feature">
<!-- feature's code is here -->
</div>
Плюс на уровне проекта был конфиг для описания файлов, необходимых для слияния и минификации js и css кода вида:
styles/feature.min.css:
styles/feature/main.css
styles/feature/sub-feature.css
js/feature.min.js:
js/classes/Core.js
js/classes/Event.js
js/classes/CoolPlugin.js
js/classes/Feature.js
js/classes/FeatureSubFeature.js
Данный пример обладает всеми минусами предыдущего, только в более ужасающей форме:
- Логика деплоя в шаблоне. И так теперь frontend программист полностью знает про все (аж целых 2!) окружения, на которые может деплоится приложение. Плюс к этому добавляется ответственность за поддержание конфига по слиянию и минификации файлов, который можно потестить только на production окружении. Страшно представить, что будет, если добавится новое окружение, например stage или testing. По факту никакая статика не подключится (if-else-if). Считаю это самым кошмарным вариантом подключением статических ресурсов.
- Куча дублирования. Изменение структуры фронтенд-кода превращается в кошмар. Надо поменять конфиг, все места включения под разные окружение.
- Отсутствие зависимостей. В моменты когда какие-то компоненты начинали использовать общий код, приходилось шаманить. Все минифицированные версии разбивались на две части (при этом просто список для Dev не менялся), конфиг становился длиннее, и что самое плохое, теперь чтобы проверить, что мы правильно подключили все файлы в min версию приходилось складывать списки от двух секций в уме.
При этом также появлялись ошибки из мест где еще использовалась не дроблённая часть, и некоторый код срабатывал дважды в разных местах. Для упрощения представьте пример: Блок A использует файлы 1.js и 2.js. Блок B использует файлы 2.js и 3.js. Мы уже не может подключить оба этих блока, т.к. файл 2.js будет обработан 2 раза.
Задача
В итоге проанализировав недостатки этих и других подходов я собрал ряд требований для системы подключения статических ресурсов:
- Единое место подключения ресурсов
- Независимость структуры и легкость модификации фронтэнд кода
- Никакой deploy логики в шаблонах
- Легкое управление зависимостями, минимизация дупликатов
- Явное сообщение об ошибке в случае опечаток
- Наличие семантики
Решение
- Единое место подключения ресурсов. Надо строго определить в проекте место где можно подключать статические ресурсы. Считаю единственным достойным местом это шаблон. Почему? Как правило тот или иной блок разметки связан с соответствующими стилями и ява-скриптом. Логично будет определить эту связь в этом же шаблоне. В итоге предлагаю запретить подключение файлов вне кода шаблонов.
- Наличие семантики. Человеку легче оперировать некоторыми сущностями, нежели списком файлов или ресурсов. Поэтому единицей подключения будет название некоторого блока, определённого вне шаблона. Это название должно отражать суть подключения, а не его состав или физическое расположение. Пример имён: lib/jquery, lib/twitter-bootstrap, reset, blog-module/main, blog-module/photos, plugin/cool-one и т.п.
- Описание зависимостей и минимизация дупликатов. Т.к. мы ссылаемся на имена блоков, нам нужно место, где мы будем описывать эти блоки. Я предлагаю использовать легко-читаемый формат конфигурации (например на языке YAML) для описания так называемой «карты статических ресурсов»:
reset: - fw/css/reset.css lib/underscore: - libs/underscore/underscore.js options: - useCdn lib/jquery: - libs/jquery/jquery-1.7.2.min.js options: - useCdn lib/twitter-bootstrap: - libs/bootstrap/css/bootstrap.css - libs/bootstrap/js/bootstrap.js - css/bootstrap-override.css depends: - lib/jquery framework/core: - fw/js/Tiks.js - fw/js/Classes.js - fw/js/EventsManager.js - fw/js/Core.js - fw/js/CorePublic.js - fw/js/ModulesManager.js - fw/js/Module.js - fw/js/ModuleSandbox.js depends: - lib/underscore - lib/jquery options: - merge module/blog: - js/modules/blog.js - css/modules/blog.css depends: - framework/core - lib/twitter-bootstrap
Теперь в нашем шаблоне блога надо всего лишь подключить:
StaticInclude("module/blog")
Все зависимости подтянутся сами и в правильном порядке. Дубликаты подключаться только один раз (например lib/jquery).
- Никакой deploy логики. То как статические ресурсы будут деплоится должен решать backend код приложения/фреймворка. Там можно применять любые стратегии (слияние, минификация, отдача с CDN и т.д.). Для управления этим можно расширять формат конфига.
- В одном шаблоне — один «инклуд». Если шаблону требуется подключить статический ресурс желательно сделать это одним инклудом с говорящим за себя названием. Не ленитесь заводить блок для подключений вида «библиотека+мой файлик» или «общий_модуль+модификация». При использовании 2-х или более подключений в одном шаблоне, мы вносим описание зависимостей в сам шаблон, тем самым возвращаемся к проблемам первых примеров.
- Независимость и легкость модификации frontend кода. Теперь можно легко добавлять новые файлы в блоки, дробить, перемещать и т.д. При этом никаких изменений в шаблоны вносить не надо.
- Ошибка в случае опечатки. Да. Если в шаблоне подключается блок или блок использует в качестве зависимости другой блок, который не был определён в карте статических ресурсов, то выводим явное сообщение об ошибке. Чтобы мы всегда были уверены в корректности того или иного подключения.
Полезные практики
- Не обязательно всю «карту» хранить в одном файле. Когда блоков становится много, есть смысл дробить карту на сущности вроде libs.yaml, framework,yaml, my-module.yaml, my-component.yaml и т.п.
- Расширяйте формат конфига «карты». Добавляйте разные фичи вроде .less файлов, подгрузку каких-то генерируемых ресурсов (например дескрипторы JS модулей, файлы локализаций через JSONP) в возможности карты. Весьма удобно.
В заключение хочу сказать, что успешно использую данный подход в личных проектах и постепенно внедряю его в текущий проект на работе.
Спасибо всем, кто дочитал. Буду рад любым замечаниям и предложениям!
Автор: aveic