Пост в продолжение недели^W месяца JavaScript на Хабре.
После статьи о разработке одностраничного веб-приложения занёс в закладки либу ICanHaz с целью потерзать и чуть допилить её, как руки дойдут. И, как водится, отложил в долгий ящик.
И вот, простудившись аккурат перед прошлыми выходными и, соответственно, нежданно получив два свободных дня за компом, я вернулся к этой затее. В итоге вместо косметического допиливания получился свой скромный велосипед с мотором.
Напомню, что ICanHaz — это простой способ чуть-чуть организовать шаблоны Mustache, используемые javascript'ом в браузере. Рендеринг шаблонов с помощью этой библиотеки сводится к простому вызову функции. Ещё она избавляет от необходимости экранировать половину шаблона, т.к. его текст можно писать прямо в HTML-теге <script>
Собственно, ICanHaz прост: есть шаблонизатор (mustache.js), есть шаблоны (полноценные и partial'ы), записанные прямо в HTML-коде в тегах <script> с именами в атрибутах ID и есть глобальный объект ich, в который при загрузке страницы добавляются методы с именами шаблонов. Дальше, передавая соответствующему методу объект, на выходе получаем отрендеренный текст:
Мне же хотелось несколько более тонкой душевной^W организации шаблонов. Например, в простейшем интернет-магазине может быть несколько типов товаров (пусть это будут книги, журналы, фильмы и музыка). Для каждого из них должен быть определён шаблон-список (list), для каждого списка — item, для item'а — цена с приписанной валютой (price). А потом мы добавляем справа колонку со скидками и там хотим показывать цену чуть по-другому…
В общем, можно, конечно так работать и с плоской коллекцией шаблонов, но неплохо было бы их как-то организовать, например, в иерархию, да ещё и с наследованием partial'ов (например, для всех типов товаров определить формат цены, для всех шаблонов с книгами — способ форматирования имён авторов). Также замечательно было бы иметь возможность добавить один шаблон в несколько мест без необходимости копирования блока <script> (например, сделать для журналов и газет шаблон даты выхода, содержащий номер недели в году, а для книг и фильмов — только год).
Из этих идей и родилось нечто.
Библиотека основана на движке шаблонизации Milk, представляющем собой реализацию Mustache на CoffeeScript. Milk был выбран давно и уже не помню по каким причинам, вроде как из-за лучшей поддержки спецификации Mustache. Название, соответственно, от Milk'а и пошло (плюс намёк на шаблоны, встроенные в HTML).
Собираются версии HotMilk для jQuery, MooTools и браузера без фреймворка, а также базовая версия без подгрузки шаблонов из DOM (её можно хоть на сервере использовать при желании).
Перед началом использования, точно также как в ICanHaz, надо заполнить библиотеку шаблонов. Делается это с помощью метода $addTemplate:
Шаблоны можно добавлять и удалять в любом порядке, так что в случае чего их можно асинхронно подтягивать с сервера. Шаблоны также могут быть автоматически собраны из тегов <script> при окончании загрузки страницы. Для того, чтобы шаблон подгрузился, надо выставить ему тип «text/x-mustache-template» и снабдить его атрибутом data-hotmilk-path (кстати, спецификация HTML5 не запрещает использовать атрибуты data-* для скриптов, так что тут даже валидность соблюдается):
Шаблон из примера — это partial, т.е. он используется как часть при отрисовке шаблона 'books/list'.
В атрибут data-hotmilk-path можно добавить несколько путей, разделённых двоеточием (будет создано несколько независимых копий).
Все принципы организации шаблонов можно уложить в несколько простых тезисов:
Небольшая демка лежит в репозитории HotMilk'а на github'е.
Если кто-нибудь вдруг захочет использовать библиотеку в деле, обратите внимание, что хоть сколько-нибудь вменяемого набора тестов пока нет и возможно не будет ещё долго, так что могут иметь место баги.
Пробегусь по некоторым фрагментам исходников с небольшими комментариями.
Класс коллекции partial'ов. Именно за счёт этой конструкции реализовано наследование шаблонами подшаблонов родителей.
Работает просто: при каждом вызове создаёт новый класс с предыдущим набором partial'ов в качестве прототипа. При первом запуске берёт свой прототип. Соответственно, каждый созданный таким образом объект будет вполне себе экземпляром класса PartialsCollection, обладающим, к тому же, свойствами всех коллекций в цепочке родителей:
Фабрика шаблонизирующих функций. Практически сердце библиотеки. Получает шаблон и экземпляр класса PartialCollection и возвращает функцию, принимающую модель и возвращающую отрендеренную строку.
Функции Milk.render передаются шаблон, модель, и фукнция поиска подшаблонов по имени, которая просто смотрит, есть ли такое поле у коллекции и есть ли у него $value.
А вот так потом с помощью этой фабрики функций создаётся уже полноценный узел нашего дерева:
Для реализации добавления шаблонов в произвольном порядке пришлось реализовать ещё одну хитрость. Например, возможна ситуация, когда добавляется partial «path/to#partial» но ещё не существует ни одного шаблона и непонятно, «to» — это шаблон или ещё одна группа. Чтобы эту ситуацию разрулить, путь всегда строится из групп, а потом, если оказалось, что надо было прикреплять шаблон, узел заменяется с сохранением partial'ов:
С подшаблонами проще:
Если создание шаблона производится через создание группы с последующей заменой, то удаление — это действие обратное: сначала шаблон заменяется на группу с сохранением коллекции подшаблонов, а потом происходит чистка пути с конца (удаляются группы, оставшиеся вообще без шаблонов и partial'ов).
Внимательные читатели заметили кривой способ вызова hasOwnProperty. Сделано это опять же для перестраховки, чтобы не сломаться от шаблона с именем hasOwnProperty. Ситуация, конечно, бредовая, ну да ладно, заодно это должно положительно сказаться на сжатии.
В общем, основные моменты рассмотрел, желающие могут изучить остаток на github'е. Скачать готовые сборки можно там же.
Надеюсь, кому-нибудь пригодится. Спасибо за внимание!
UPD: Стоило написать статью, обнажурил, что сильнейшим образом накосячил при попытке сделать наследование от Function (это вообще возможно???).
В общем, пофиксил. После правки функциональность не пострадала, только внутренности переделал и обновил пост в затронутых местах.
После статьи о разработке одностраничного веб-приложения занёс в закладки либу ICanHaz с целью потерзать и чуть допилить её, как руки дойдут. И, как водится, отложил в долгий ящик.
И вот, простудившись аккурат перед прошлыми выходными и, соответственно, нежданно получив два свободных дня за компом, я вернулся к этой затее. В итоге вместо косметического допиливания получился свой скромный велосипед с мотором.
Напомню, что ICanHaz — это простой способ чуть-чуть организовать шаблоны Mustache, используемые javascript'ом в браузере. Рендеринг шаблонов с помощью этой библиотеки сводится к простому вызову функции. Ещё она избавляет от необходимости экранировать половину шаблона, т.к. его текст можно писать прямо в HTML-теге <script>
Зачем?
Собственно, ICanHaz прост: есть шаблонизатор (mustache.js), есть шаблоны (полноценные и partial'ы), записанные прямо в HTML-коде в тегах <script> с именами в атрибутах ID и есть глобальный объект ich, в который при загрузке страницы добавляются методы с именами шаблонов. Дальше, передавая соответствующему методу объект, на выходе получаем отрендеренный текст:
$('#myDiv').html(ich.myTemplateName(objModel));
Мне же хотелось несколько более тонкой душевной^W организации шаблонов. Например, в простейшем интернет-магазине может быть несколько типов товаров (пусть это будут книги, журналы, фильмы и музыка). Для каждого из них должен быть определён шаблон-список (list), для каждого списка — item, для item'а — цена с приписанной валютой (price). А потом мы добавляем справа колонку со скидками и там хотим показывать цену чуть по-другому…
В общем, можно, конечно так работать и с плоской коллекцией шаблонов, но неплохо было бы их как-то организовать, например, в иерархию, да ещё и с наследованием partial'ов (например, для всех типов товаров определить формат цены, для всех шаблонов с книгами — способ форматирования имён авторов). Также замечательно было бы иметь возможность добавить один шаблон в несколько мест без необходимости копирования блока <script> (например, сделать для журналов и газет шаблон даты выхода, содержащий номер недели в году, а для книг и фильмов — только год).
Из этих идей и родилось нечто.
Описание HotMilk
Библиотека основана на движке шаблонизации Milk, представляющем собой реализацию Mustache на CoffeeScript. Milk был выбран давно и уже не помню по каким причинам, вроде как из-за лучшей поддержки спецификации Mustache. Название, соответственно, от Milk'а и пошло (плюс намёк на шаблоны, встроенные в HTML).
Собираются версии HotMilk для jQuery, MooTools и браузера без фреймворка, а также базовая версия без подгрузки шаблонов из DOM (её можно хоть на сервере использовать при желании).
Перед началом использования, точно также как в ICanHaz, надо заполнить библиотеку шаблонов. Делается это с помощью метода $addTemplate:
HotMilk.$addTemplate('path/to/template', 'template {{text}}');
HotMilk.$addTemplate('path/to#partial', '...');
Шаблоны можно добавлять и удалять в любом порядке, так что в случае чего их можно асинхронно подтягивать с сервера. Шаблоны также могут быть автоматически собраны из тегов <script> при окончании загрузки страницы. Для того, чтобы шаблон подгрузился, надо выставить ему тип «text/x-mustache-template» и снабдить его атрибутом data-hotmilk-path (кстати, спецификация HTML5 не запрещает использовать атрибуты data-* для скриптов, так что тут даже валидность соблюдается):
<script type="text/x-mustache-template" data-hotmilk-path="books/list#item">
<a href="books/{{id}}"><b>{{title}}</b> by {{#author}}{{>author}}{{/author}}</a>
</script>
Шаблон из примера — это partial, т.е. он используется как часть при отрисовке шаблона 'books/list'.
В атрибут data-hotmilk-path можно добавить несколько путей, разделённых двоеточием (будет создано несколько независимых копий).
Все принципы организации шаблонов можно уложить в несколько простых тезисов:
- Шаблоны организуются в иерархическую структуру наподобие файловой системы, где шаблоны — аналог файлов (шаблон в дереве всегда — лист);
- Путь к шаблону записывается через слеши: path/to/template. Потом такой шаблон можно вызвать как
HotMilk.path.to.template(objData);
- К любому узлу (шаблону или группе) можно привязать partial: path/to/node#partialName. Он будет доступен этому узлу и всем его детям;
- Родительские partial'ы можно «перегружать» одноимёнными своими;
- Partial'ы также могут быть отрисованы отдельно:
При таком вызове шаблону будут доступны partial'ы, доступные его родительскому узлу (в примере — узлу «template»);HotMilk.path.to.template.$.partial(objData);
- Имена шаблонов должны быть корректными идентификаторами javascript: состоять из букв, цифр, подчёркивания и знака доллара, но не должны начинаться с цифр или доллара.
Небольшая демка лежит в репозитории HotMilk'а на github'е.
Если кто-нибудь вдруг захочет использовать библиотеку в деле, обратите внимание, что хоть сколько-нибудь вменяемого набора тестов пока нет и возможно не будет ещё долго, так что могут иметь место баги.
Под капотом: особенности реализации
Пробегусь по некоторым фрагментам исходников с небольшими комментариями.
Класс коллекции partial'ов. Именно за счёт этой конструкции реализовано наследование шаблонами подшаблонов родителей.
PartialsCollection = function(parentPartialsCollection) {
var ctor = function() {};
ctor.prototype = parentPartialsCollection || PartialsCollection.prototype;
return new ctor();
};
Работает просто: при каждом вызове создаёт новый класс с предыдущим набором partial'ов в качестве прототипа. При первом запуске берёт свой прототип. Соответственно, каждый созданный таким образом объект будет вполне себе экземпляром класса PartialsCollection, обладающим, к тому же, свойствами всех коллекций в цепочке родителей:
var a = new PartialsCollection();
a.t1 = "template 1" // a.t1 === 'template 1'; a.hasOwnProperty('t1') === true;
var b = new PartialsCollection(a); // b.t1 === 'template 1'; b.hasOwnProperty('t1') === false;
Фабрика шаблонизирующих функций. Практически сердце библиотеки. Получает шаблон и экземпляр класса PartialCollection и возвращает функцию, принимающую модель и возвращающую отрендеренную строку.
var createTemplatingFunction = function(template, partialsCollection) {
return function(data) {
return Milk.render(template, data, function(partialName) {
if(partialsCollection[partialName] && partialsCollection[partialName].$value != null) {
return partialsCollection[partialName].$value;
} else {
throw new Error("Unknown partial: " + partialName);
}
});
};
};
Функции Milk.render передаются шаблон, модель, и фукнция поиска подшаблонов по имени, которая просто смотрит, есть ли такое поле у коллекции и есть ли у него $value.
А вот так потом с помощью этой фабрики функций создаётся уже полноценный узел нашего дерева:
var createTemplateNode = function(template, partialsCollection) {
partialsCollection = partialsCollection || new PartialsCollection();
var templatingFunction = createTemplatingFunction(template, partialsCollection);
templatingFunction.$ = partialsCollection;
// Чтобы не засорять Function.prototype приходится добавлять методы в каждый экземпляр...
templatingFunction.$addTemplate = addTemplate;
templatingFunction.$removeTemplate = removeTemplate;
return templatingFunction;
};
Для реализации добавления шаблонов в произвольном порядке пришлось реализовать ещё одну хитрость. Например, возможна ситуация, когда добавляется partial «path/to#partial» но ещё не существует ни одного шаблона и непонятно, «to» — это шаблон или ещё одна группа. Чтобы эту ситуацию разрулить, путь всегда строится из групп, а потом, если оказалось, что надо было прикреплять шаблон, узел заменяется с сохранением partial'ов:
var addNormalTemplate = function(root, path, template) {
if(path.length === 0) {
throw new Error("Couldn't create template: name must not be empty");
}
var node = nodeBuildPath(root, path.slice(0,-1)),
name = path[path.length - 1];
if(hasOwnProperty(node, name)) {
// Если узел существует, но создан был только для прикрепления подшаблонов,
// заменяем узел, сохраняя коллекцию partial'ов
if(node[name] instanceof GroupNode && nodeIsEmpty(node[name])) {
node[name] = createTemplateNode(template, node[name].$);
} else {
// Иначе - ошибка (заменить непустую группу нельзя)
throw new Error("Couldn't add template: node " + path.join('/') + " already exists");
}
} else {
// Если узла не существует, создаётся новый с новой коллекцией подшаблонов,
// производной от коллекции родительского узла
node[name] = createTemplateNode(template, new PartialsCollection(node.$));
}
};
С подшаблонами проще:
var addPartialTemplate = function(root, path, partialName, template) {
// если узла нет, создаём его
var node = nodeNavigatePath(root, path) || nodeBuildPath(root, path);
if(hasOwnProperty(node.$, partialName)) {
throw new Error("Couldn't add partial: node " + path.join('/') + "#" + partialName + " already exists");
}
// Кстати, да: т.к. partial тоже может быть отдельно вызван,
// передаём ему в качестве коллекции подшаблонов коллекцию,
// в которую тут же включаем его самого
node.$[partialName] = createPartialTemplate(template, node.$);
};
Если создание шаблона производится через создание группы с последующей заменой, то удаление — это действие обратное: сначала шаблон заменяется на группу с сохранением коллекции подшаблонов, а потом происходит чистка пути с конца (удаляются группы, оставшиеся вообще без шаблонов и partial'ов).
Внимательные читатели заметили кривой способ вызова hasOwnProperty. Сделано это опять же для перестраховки, чтобы не сломаться от шаблона с именем hasOwnProperty. Ситуация, конечно, бредовая, ну да ладно, заодно это должно положительно сказаться на сжатии.
var hasOwnProperty = function(obj, propName) {
return Object.prototype.hasOwnProperty.call(obj, propName);
};
В общем, основные моменты рассмотрел, желающие могут изучить остаток на github'е. Скачать готовые сборки можно там же.
Надеюсь, кому-нибудь пригодится. Спасибо за внимание!
Ссылки
- Репозиторий HotMilk на GitHub
- ICanHaz — Прототип и вдохновитель
- Mustache — формат logic-less шаблонов
- CoffeeScript — язык, компилируемый в JavaScript
- Milk — реализация Mustache на CoffeeScript
UPD: Стоило написать статью, обнажурил, что сильнейшим образом накосячил при попытке сделать наследование от Function (это вообще возможно???).
В общем, пофиксил. После правки функциональность не пострадала, только внутренности переделал и обновил пост в затронутых местах.