Snakeskin — шаблонный движок с поддержкой наследования

в 10:04, , рубрики: javascript, шаблонизатор, метки: ,

Введение

Наверно каждый опытный JavaScript разработчик так или иначе писал свой шаблонный движок. Иногда это бывает по незнанию, что уже существует огромное количество схожих поделок, а иногда разработчик стремится реализовать все свои «хотелки» в своей собственной реализации. У меня был и первый и второй случай, причём сейчас я хочу рассказать именно о втором.

За всё время, что я работаю с JS, я перепробовал немало сторонних шаблонизаторов, таких как Micro-Templating, Mustache/Handlebars, Dust, Google Closure Templates, различные XSLT подобные движки и т.д. и у меня скопился целый список того, что мне не нравится в них:

Micro-Templating и все остальные схожие движки имеют большой минус – они тянут синтаксис JavaScript в шаблон, что делает очень не удобным использование условных операций или циклов, а написание нормального парсера для другого языка может вызывать затруднение. Также как правило, такие шаблоны компилируется уже в конечном JavaScript приложении, т.е. на компьютере у клиента – это удобно при отладке, но для продакшена это подходит только для маленьких проектов.
Handlebars – уже более серьёзная поделка, т.к. имеет свой синтаксис и реализацию для многих языков, а также поддержку прекомпиляции. Минусом на мой взгляд является не самый удачный синтаксис: нужно держать в голове всякие {{, {{{, {{#, отсутствие простой системы фильтров и отсутствие системы наследования шаблонов.
Dust – очень мощный движок, поддерживает прекомпиляцию, фильтры, а также в некотором виде систему наследования. Минусом является опять такие сложный синтаксис, но в целом это очень достойный кандидат для использования в проекте.
Google Closure Templates – ещё один тяжеловес, умеет кучу всего и обладает довольно приятным синтаксисом. Главный минус – это отсутствие live компиляции (придётся при любом изменении каждый раз пересобирать шаблон) и то, что транслятор написан на Java, т.е. при необходимости добавить новый фильтр нам придётся править Java файлик.
XSLT и компания – декларативные шаблоны как правило дают очень высокую гибкость в разработке, но требует специальной подготовки разработчика, т.е. просто прочитав доку вы мало вероятно напишите XSLT шаблон, если никогда не делали это до этого, также проблемы могут возникать при отладке, но в остальном это очень мощный подход.

После подведения итогов о минусах и плюсах, мне захотелось попробовать сделать своё видение, которое отвечало бы следующим требованиям:

  • Максимально простой синтаксис и минимализация возможностей;
  • Поддержка прекомпиляции;
  • Очень гибкая система наследования шаблонов.

Руководствуясь этими требованиями я начал придумывать свой велосипед.

Синтаксис шаблонов Snakeskin

Как и во многих других шаблонизаторах, в Snakeskin управляющие конструкции заключены в фигурные скобки, а декларация шаблона очень напоминает синтаксис Closure Templates:

{template myTemplate()}
    // Тело шаблона
{end}

Внутри блока template не может быть другого блока template, после трансляции данный шаблон превратится в:

function myTemplate() {
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    return __SNAKESKIN_RESULT__;
}
Snakeskin.cache['myTemplate'] = myTemplate;

Как видите, полученная функция шаблона экспортируется в глобальную область видимости, а это может быть не совсем желаемым результатом, поэтому при декларации шаблона можно указать пространство имён:

{template tpl.myTemplate()}
    // Тело шаблона
{end}

=>

if (typeof tpl === 'undefined') {
    window.tpl = {};
}
tpl.myTemplate = function () {
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    return __SNAKESKIN_RESULT__;
};
Snakeskin.cache['tpl.myTemplate'] = tpl.myTemplate;

Следует отметить, что при компилировании для использования в node.js результат будет немного отличаться (функции будут свойствами exports, а не window).
Любому шаблону можно указать неограниченное количество входных параметров:

{template tpl.myTemplate(data, mobile = false)}
    // Тело шаблона
{end}

=>

if (typeof tpl === 'undefined') {
    window.tpl = {};
}
tpl.myTemplate = function (data, mobile) {
    mobile = typeof mobile !== 'undefined' && mobile !== null ? mobile : false;
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    return __SNAKESKIN_RESULT__;
};
Snakeskin.cache['tpl.myTemplate'] = tpl.myTemplate;

Шаблоны можно декларировать в отдельных файлах с расширением .ss или в областях <script type='text/x-snakeskin-template'></script>, логично что в одном файле/области (далее «область декларации») может быть декларировано множество шаблонов. В области декларации шаблонов можно использовать комментарии: однострочные (//) и многострочные (/**/).

Hello World

Простейший пример шаблона Hello World может быть записан так:

{template helloWorld(name = 'world')}
    Hello {name}!
{end}

А вызов из JS:

helloWorld(); // Hello world!
helloWorld('Вася'); // Hello Вася!

В данном примере мы познакомились с синтаксисом вывода параметров: просто заключаем в фигурные скобки необходимый идентификатор. Следует заметить, что при выводе параметров мы можем использовать любой допустимый JS синтаксис, например тернарные операторы или вызов функции. При выводе параметра к нему можно применить фильтры. Фильтры указываются через пайп (|) и применяются последовательно слева-направо, фильтрам можно передавать параметры, например:

{template helloWorld(name = 'world')}
    Hello {name|ucfirst|truncate 10, true}!
{end}

=>

function helloWorld(name) {
    name = typeof name !== 'undefined' && name !== null ? name : 'world';
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    __SNAKESKIN_RESULT__ += 'Hello ';
    __SNAKESKIN_TMP_RESULT__ = Snakeskin.Filters.html(Snakeskin.Filters['truncate'](Snakeskin.Filters['ucfirst'](name), 10, true));
    __SNAKESKIN_RESULT__ += typeof __SNAKESKIN_TMP_RESULT__ === 'undefined' ? '' : __SNAKESKIN_TMP_RESULT__;
    __SNAKESKIN_RESULT__ += '!';
    return __SNAKESKIN_RESULT__;
};
Snakeskin.cache['helloWorld'] = helloWorld;

Обратите внимание, что на все выводимые параметры применяется фильтр html (экранирование html сущностей) по умолчанию, чтобы отменить это необходимо явно задать фильтр !html. Список доступных «из коробки» фильтров будет в конце статьи. Логично, что для того, чтобы добавить свой фильтр, нужно просто расширить Snakeskin.Filters новым методом.
Как уже говорилось выше, что при выводе параметра мы можем использовать любую валидную JavaScript конструкцию, например:

{a == 1 ? 'ok' : false}

// К результату применим фильтр
{(a + b)|trim}

Константы и параметры

Мы уже выяснили, что шаблон может принимать параметры (оно и логично), но следует добавить, что значение параметров не может быть переопределено в теле шаблона, т.е. все параметры являются не изменяемые. Однако, помимо указания параметров шаблона мы можем объявлять новые константы (их значение также не может быть изменено после объявления), чтобы затем их использовать, например:

{a = 1}
{b = [1, 2, 3]}
{e = {a: 2}}

Легко предсказать, в какой код на JS будут преобразованы данные инструкции, поэтому совершенно справедливо, что при операции объявлении константы после знака равно мы можем писать любую валидную JavaScript конструкцию (но я рекомендую не уходить дальше примитивов). При попытке повторного объявления константы компилятор бросит исключение. Константы могут быть объявлены в любом месте внутри {template}, за исключением итераторов, но об этом позже.

Директива with

По умолчанию шаблон имеет доступ ко внешнему scope, для того чтобы вызвать ту или иную переменную или функцию, нам достаточно просто написать её имя в фигурных скобках (при условии что в шаблоне не объявлено константы с таким же именем). Директива with задаёт явно откуда брать те или иные константы, например:

{template test(obj)}
    {with obj}
        {a}// obj.a
        {with a.child}
            {b}// obj.a.child.b
        {end}
    {end}
{end}

=>

function test (obj) {
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    __SNAKESKIN_TMP_RESULT__ = Snakeskin.Filters.html(obj.a);
    __SNAKESKIN_RESULT__ += typeof __SNAKESKIN_TMP_RESULT__ === 'undefined' ? '' : __SNAKESKIN_TMP_RESULT__;
    __SNAKESKIN_TMP_RESULT__ = Snakeskin.Filters.html(obj.a.child.b);
    __SNAKESKIN_RESULT__ += typeof __SNAKESKIN_TMP_RESULT__ === 'undefined' ? '' : __SNAKESKIN_TMP_RESULT__;
    return __SNAKESKIN_RESULT__;
};
Snakeskin.cache['test'] = test;

Как видите в скомпилированном варианте никаких with нет, но константы берутся из указанного контекста.

Директива if

Тут всё просто:

{if a > 3}
    {if (b < 0 || e > 4) && a % 2}
    {elseIf a %% 3}
        ...
    {else}
        ...
    {end}
{end}

Директива forEach

Для итерациям по объектам или массивам используется одна единственная директива forEach, причём в скомпилированном варианте используется реализация именно такого итератора. Внутри декларации forEach можно объявлять свои локальные переменные (внутри тела итератора нельзя), как параметры callback функции, следовательно они видны только для содержимого forEach и вложенных итераторов.

{forEach a => el, i}
    {forEach el => el2}
        {forEach el2}
            {el}
        {end}
    {end}
{end}

Для объявление параметров callback функции используется последовательность =>, после которой они перечисляются через запятую.
Для массивов параметры идут так:

  • элемент массива;
  • номер итерации;
  • яляется ли элемент первым;
  • является ли элемент последним;
  • длина массива.

Для объектов:

  • элемент;
  • ключ;
  • номер итерации;
  • яляется ли элемент первым;
  • является ли элемент последним;
  • длина.
Директивы proto и apply

Директива proto (далее прототип) позволяет выделить область шаблона, которую в дальнейшем можно применять, например:

{template test()}
    {proto a}
        Какойто текст
    {end}
    
    {apply a}
    {apply a}
{end}

Легко догадаться, как будет обработан данный код: в месте декларации прототипа вся область будет вырезана, а затем применена 2 раза по указанию директивы apply. Внутри области прототипа могут быть любые директивы (кроме template), включая вложенные proto и apply. Названия прототипов лежат в отдельном месте от названия констант, поэтому можно не боятся, что у константы такое же имя как и у прототипа. Логично, что не может быть двух прототипов с одинаковым названием. Следует также добавить, что в теле прототипа нельзя вызвать его через apply, это защита от бесконечной рекурсии на этапе трансляции.

{template test()}
    {proto a}
        Какойто текст
        // Не будет работать
        {apply a}
    {end}
{end}

Порядок декларации прототипа и его вызов не имеют значения, т.к. все прототипы компилируется предварительно, т.е. следующая запись будет прекрасно работать:

{template test()}
    {apply a}
    {proto a}
        Какойто текст
    {end}
{end}

Директива block

Директива block немного похоже на proto, но её отличие в том, что выделенная область выводится сразу же в момент декларации и её нельзя затем вызывать, как мы это делали с proto.

{template test(obj)}
    {block a}Вася{end}
{end}

=>

function test(obj) {
    var __SNAKESKIN_RESULT__ = '',
        __SNAKESKIN_TMP_RESULT__;
    __SNAKESKIN_RESULT__ += 'Вася';
    return __SNAKESKIN_RESULT__;
};
Snakeskin.cache['test'] = test;

Как видите, на конечный вид шаблона директива block никак не повлияла, однако она играет очень важную роль при наследовании. Пространство имён блоков также как и proto лежит в отдельном месте, поэтому к ним применяются такие же правила как и к proto блокам. Внутри block могут быть другие директивы (кроме template).

Директива cdata

Всё, что входит блок cdata не обрабатывается парсером и вставляется «как есть», например:

{template test(obj)}
    {cdata}
        {template test(obj)}
            {block a}Вася{end}
        {end}
    {end cdata}
{end}

Обратите внимание, что блок cdata оканчивается на {end cdata}, а не на {end}.

Директива console

Директива console просто немного оптимизирует вызов console api в конечном шаблоне.

Наследование

Вот мы и подошли к самому главному, ради чего я затеял этот велосипед – к наследованию. Наследование в Snakeskin устроено на переопределении/расширении констант/блоков/прототипов родительского шаблона дочерним.
Схема работы такова: дочерний шаблон полностью копирует структуру родительского, а затем переопределяет и дополняет своим содержимым.

Наследование входных параметров

Допустим у нас есть 2 шаблона, причём второй наследуется от первого:

{template base(a = 1, b = 2, c = 1)}
    ...
{end}

// Переопределяем параметр a на новый параметр по умолчанию,
// параметр b наследует значение по умолчанию родителя,
// параметр c удалился из входных параметров, при этом став простой локальной константой со значением 1,
// добавился новый параметр j со значением по умолчанию 1
{template child(a = 2, b, j = 1) extends base}
    ...
{end}

Наследование констант

Тут принцип схож с входными параметрами:

{template base(a = 1, b = 2, c = 1)}
    {q = 1}
    {q2 = 2}
{end}

{template child(a = 2, b, j = 1) extends base}
    // Константа q1 осталась без изменений,
    // константа q2 была переопределена,
    // после всех унаследованных констант добавилась новая
    {q2 = 4}
    {q3 = 3}
{end}

Наследование блоков

Наследование блоков очень сильно похоже на наследование констант, однако отличие в том, что новые глобальные блоки всегда добавляются в конец шаблона.

{template base(a = 1, b = 2, c = 1)}
    {block head}
        {block title}Приветствие{end}
    {end}
{end}

{template child(a = 2, b, j = 1) extends base}
    // Переопределим блок title,
    // причём новый блок содержит вложенный блок
    {block title}
        Прощание
        {block userName}
            ...
        {end}
    {end}
   
    // Добавим в конец шаблона новый блок
    {block footer}Пока!{end}
{end}

Наследование прототипов аналогично наследованию блоков, но не стоит забывать, что прототипы выводятся только по команде apply.

После компиляции связь между дочерним и родительским шаблоном рвётся, т.к. дочерний шаблон полностью копирует всю необходимую структуру родителя, поэтому мы можем не боятся, что при копировании одного скомпилированного шаблона забудем скопировать другой. Альтернативой наследованию является композиция, т.е.шаблон состоит из вызовов других шаблонов, однако следует помнить, что при композиции мы образуем неразрывную связь между шаблонами, также вызов шаблона внутри шаблона можно использовать для создания рекурсии, например:

    {template go(a = 0)}
        {if a < 5} {go(++a)} {end}
    {end}

Обработка ошибок

На этапе компиляции шаблона Snakeskin может кидать различные исключение, например что указанный шаблон для наследования не существует и т.д…

Компиляция

Для компиляции файла шаблонов, нужно просто запустить compiler.js: node compiler myTemplates.ss Скомпилированный файл сохранится в папке с myTemplates.ss, как myTemplates.ss.js, однако можно вручную указать имя: node compiler myTemplates.ss ../result.js

Флаг --commonjs указывает, что скомпилированные функции должны быть декларированы, как свойства exports node compiler myTemplates.ss --commonjs ../result.js

Для работы скомпилированного шаблона, необходимо также подключить snakeskin.live.js (или snakeskin.live.min.js). При подключении шаблонов через require (на сервере) можно воспользоваться методом liveInit, для подгрузки snakeskin.live.

var tpl = require('./helloworld.commonjs.ss.js').liveInit('../snakeskin.live.min.js');

Или же можно просто объявить глобальную переменную Snakeskin.

global.Snakeskin = require('../snakeskin.live.min.js');

Для live в компиляции в браузере необходимо подключать snakeskin.js (или snakeskin.min.js) и пользоваться методом compile, который принимает ссылку на DOM узел или текст шаблонов.

Скомпилированные шаблоны вызываются как простые JavaScript функции и принимают указанные в шаблоне параметры.
Примеры использования можно посмотреть здесь: github.com/kobezzza/Snakeskin/tree/master/demo

Список стандартный фильтров

  • html – экранирование html сущностей, применяется по умолчанию на все выводимые параметры;
  • !html – отменяет выполнение по умолчанию html;
  • uhtml – обратная операция html (& => & и т.д.);
  • stripTags – удаляет знаки тегов (< и >);
  • uri – кодирует URL;
  • upper – переводит в верхний регистр;
  • ucfirst – переводит в верхний регистр первую букву строки;
  • lower – переводит в нижний регистр;
  • lcfirst – переводит в нижний регистр первую букву строки;
  • trim – срезает крайние пробелы;
  • collapse – сворачивает пробелы в 1 и срезает крайние;
  • truncate – обрезает строку до указанный длины и в конце подставляет троеточие, имеет 1 обязательный параметр (максимальная длина) и 1 необязательный (true, если обрезается с учётом целостности слов);
  • repeat – создаёт строку из повторений входной строки, имеет 1 необязательный параметр (количество повторений, по умолчанию 2);
  • remove – удаляет указанную подстроку из входной строки, подстрока указывается как строка или как регулярное выражение;
  • replace – заменяет указанную подстроку из входной строки, подстрока указывается как строка или как регулярное выражение, строка замены указывается как простая строка.

Заключение

Вот и всё, что я хотел рассказать про свой новый велосипед, буду рад услышать критику!
Проект на github: github.com/kobezzza/Snakeskin.

PS: Название Snakeskin (Змеиная шкура) было взято не спроста, т.к. идею о реализации наследования на блоках я подсмотрел в Django Templates для Python.
PSPS: Большое спасибо JS команде Яндекс.Метрики (особенно Денису Селезнёву) за идею :)

Автор: kobezzza

Источник

Поделиться

* - обязательные к заполнению поля