Pull to refresh

HotMilk — библиотека для удобной организации шаблонов Mustache

Reading time7 min
Views3.3K
Пост в продолжение недели^W месяца JavaScript на Хабре.

После статьи о разработке одностраничного веб-приложения занёс в закладки либу 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'ы также могут быть отрисованы отдельно:
    HotMilk.path.to.template.$.partial(objData);
    При таком вызове шаблону будут доступны partial'ы, доступные его родительскому узлу (в примере — узлу «template»);
  • Имена шаблонов должны быть корректными идентификаторами 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'е. Скачать готовые сборки можно там же.

Надеюсь, кому-нибудь пригодится. Спасибо за внимание!

Ссылки




UPD: Стоило написать статью, обнажурил, что сильнейшим образом накосячил при попытке сделать наследование от Function (это вообще возможно???).
В общем, пофиксил. После правки функциональность не пострадала, только внутренности переделал и обновил пост в затронутых местах.
Tags:
Hubs:
Total votes 18: ↑17 and ↓1+16
Comments2

Articles