Front-end шаблонизатор
Прошлую свою статью я посветил описанию «велосипеда» (загрузчика и шаблонизатора в рамках «легкого» framework«а). Волею судеб, для пары проектов я был вынужден выделить шаблонизатор и сделать его standalone версию, обогатив при этом рядом новых возможностей. Именно об front-end шаблонизаторе и пойдет речь.
Но чтобы сэкономить ваше время, прежде я обозначу тех, кому эта статья может быть интересной (ибо букв будет много):
- Вы front-end разработчик, и вам интересно использование шаблонов.
- Вы back-end разработчик, и вам интересно использование шаблонов на front-end«е.
- Вы давно ищете какой-нибудь инструмент для систематизации своей коллекции UI-control’ов, накопившуюся за несколько лет.
- Вы интересуетесь разработкой web-компонентов.
- Вам хочется высказать критические замечания и порекомендовать angularJS.
- У вас есть свободное время и вам интересно почитать об очередном велосипеде.
- У вас нет свободного времени, но вам интересно.
- Вы хороший и любознательный человек.
Проект называется Flex.Patterns, но для простоты я буду его называть просто patterns. Ниже будет несколько примеров, которые вы легко сможете воспроизвести и сами. В отличие от Flex, описанного в прошлой статье, patterns не требует никаких настроек и танцев с бубном — подцепил и пользуйся. Patterns вообще довольно простой, что было моей главной целью.
Например, шаблон в patterns — это просто HTML страница и ничего больше. Никакого специфичного синтаксиса вроде того, что используется в EJS и многих других шаблонизаторах.
<% for(var i=0; i
- <%= supplies[i] %>
<% } %>
Весь синтаксис patterns ограничивается тремя определениями:
- Hook. {{name_of_hook}}. Зацепка, с помощью которой вы можете помечать места в шаблоне для вставки контента.
- Model. {{:: name_of_ reference }}. Так вы можете указать на свойство узла или его атрибут, который должен быть связан с объектом модели для дальнейшего манипулирования.
- DOM. {{$name_of_reference}}. Указав с помощью этой метки на узел, вы получите возможность очень быстро обращаться к данному узлу, изменять его, прикреплять события и делать прочие рутинные вещи.
Создание шаблона
Ну давайте на примере. Создадим popup для авторизации пользователя. Нам понадобятся четыре шаблона:
- Всплывающее окно — html.
- Разметка для окна авторизации — html.
- Поле для текста — html.
- Кнопка — html.
Ниже шаблон для всплывающего окна (popup).
Flex.Template
{{title}}
{{content}}
{{bottom}}
Как вы уже заметили — это самый обыкновенный HTML файл. В HEAD вы можете подключать CSS и JS файлы, которые будут автоматически подключены вместе с шаблоном и закэшированы.
Кэширование — это важная часть patterns. И сами шаблоны (HTML) и ресурсы (CSS и JS) сохраняются в localStorage, а это значит, что при повторном использовании шаблона, все данные будут взяты не с сервера, а с клиента, что самым благоприятным образом сказывается на скорости отрисовки. Кроме того patterns сам следит за актуальностью кэша: всякий раз patterns запрашивает HEADERs всех шаблонов и их ресурсов; и если что-то изменилось, patterns самостоятельно обновит кэш, чтобы поддерживать всю систему в актуальном состоянии. Но, вернемся к нашему окну авторизации.
Шаблон разметки (далее буду приводить только содержание тега BODY, для экономии места)
Login
{{login}}
Password
{{password}}
{{controls}}
Поле для ввода текста (в нашем случае это будет логин и пароль)
{{::value}}
Обратите внимание на то, что мы связали INPUT.value и P.innerHTML через переменную названную value, используя приведенную выше метку {{:: value}}. Таким образом, если какой-то текст будет введен в INPUT, то он будет отображен и в связанном параграфе. Кроме того, созданная переменная value будет помещена в модель.
Ну и последний шаблон, необходимый для окна авторизации — кнопка.
{{title}}
Прежде чем пойти дальше, стоит оговориться. Тот факт, что patterns в качестве шаблонов использует полноценные HTML файлы дает вам возможность открывать их отдельно от страницы, где они используются, а это дает возможность быстрой отладки стилей и логики, если таковая предусмотрена.
Присоединение шаблона
Шаблон может быть присоединен к странице (то есть отрисован) двумя способами:
- Через вызов JavaScript метода
- Через HTML разметку.
Какой использовать — зависит исключительно от поставленной задачи. Например, если шаблон должен быть отрисован сразу после загрузки страницы, то лучше присоединять его через разметку. Если же мы говорим о чем-то вроде нашего тестового окна авторизации, то здесь более уместен вызов через JavaScript. Давайте рассмотрим оба метода.
Отрисовка через JavaScript
За рендеринг шаблона отвечает метод get — _patterns.get (), который вернет экземпляр класса шаблона, который вы можете смонтировать (прикрепить к указанному узлу), через метод — render. Взгляните на пример ниже и все станет ясно.
var id = flex.unique();
_patterns.get({
url : '/patterns/popup/pattern.html',
node : document.body,
hooks : {
id : id,
title : 'Test dialog window',
content : _patterns.get({
url : '/patterns/patterns/login/pattern.html',
hooks : {
login : _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'text',
}
}),
password: _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'password',
}
}),
controls: _patterns.get({
url : '/patterns/buttons/flat/pattern.html',
hooks : [{ title: 'login', id: 'login_button' }, { title: 'cancel', id: 'cancel_button' }]
}),
}
})
}
}).render();
Самый важный параметр — это url, где мы указываем место, откуда брать шаблон. Не менее важный параметр — это hooks. Помните в шаблонах мы указывали места для контента через метку — {{name}}. В параметре hooks, мы определяем контент для каждой такой метки.
Полное описание всех параметров, которые принимает метод _patterns.get (), вы можете найти здесь. А на результат этого примера можно посмотреть тут.
Но идем дальше.
Отрисовка через HMTL разметку
В ситуации, когда сформированный шаблон нам нужен немедленно после загрузки страницы, мы можем, используя тег PATTERN, поместить шаблон непосредственно в разметку.
В данном случае для определения hook«ов мы используем одноименные теги. То есть приведенные ниже две конструкции по своему смыслу идентичные.
_patterns.get({
url : '/patterns/popup/pattern.html',
hooks : {
id : 0,
title : 'Test dialog window',
...
}
}).render();
Обратите внимание, что тег PATTERN мы используем только для корневого узла, а дальше лишь добавляем свойство SCR, чтобы обозначить, что в качестве контента hook (зацепки) будет использоваться вложенный шаблон.
То есть следующая разметка означает, что patterns должен найти шаблон по адресу, указанному в SRC и применить его с hook«ом type в значении «text».
text
Здесь вы можете посмотреть на работающий пример. Откройте sources of page, чтобы убедиться, что никаких вызовов JavaScript нет и в исходной разметке страницы присутствует тег PATTERN с необходимыми для отрисовки данными.
Повторение шаблона
Очень часто нам бывает необходимо повторить шаблон многократно. Самым ярким примером этого служит таблица. Чтобы ее создать нам понадобится два шаблона.
Шаблон таблицы.
{{titles.column_0}}
{{titles.column_1}}
{{titles.column_2}}
{{titles.column_3}}
{{rows}}
И шаблон строки в таблице.
{{column_0}}
{{column_1}}
{{column_2}}
{{column_3}}
Имея эти два шаблона и данные, мы можем отрисовать нашу таблицу.
var data_source = [];
for (var i = 0; i < 100; i += 1) {
data_source.push({
column_0: (Math.random() * 1000).toFixed(4),
column_1: (Math.random() * 1000).toFixed(4),
column_2: (Math.random() * 1000).toFixed(4),
column_3: (Math.random() * 1000).toFixed(4),
});
}
_patterns.get({
url: '/patterns/table/container/pattern.html',
node: document.body,
hooks: {
titles: {
column_0: 'Column #0',
column_1: 'Column #1',
column_2: 'Column #2',
column_3: 'Column #3',
},
rows: _patterns.get({
url: '/patterns/table/row/pattern.html',
hooks: data_source,
})
}
}).render();
Здесь вы можете найти работающий пример.
Итак, чтобы повторить некоторый шаблон несколько раз нам достаточно передать значение hook (зацепки) в виде массива данных. И, как вы могли заметить, для повторения шаблона при его определении в HTML мы повторяем значения hook столько раз, сколько нам нужно, как это было ранее продемонстрировано с кнопками к окну авторизации.
login_button login
cancel_button cancel
Так же обратите внимание на то, что имена hook«ов в заголовках определены через точку {{titles.column_0}}, что позволяет нам в функции рендеринга использовать более осмысленное определение их значений. Так, все заголовки определяются в объекте titles.
Контролеры и функции обратного вызова
По сути в patterns контролер и функция обратного вызова — это одно и тоже. Отличие лишь в месте хранения.
Как вы могли догадаться, функция обратного вызова определяется в момент рендеринга шаблона.
_patterns.get({
url : 'some_url',
callbacks: {
//Callback-function definition
success: function (results) {
var instance = this,
dom = results.dom,
model = results.model,
binds = results.binds,
map = results.map,
resources = results.resources;
...
}
},
}).render();
А вот чтобы создать контролер, нужно создать JS файл следующего содержания
_controller(function (results) {
var instance = this,
dom = results.dom,
model = results.model,
binds = results.binds,
map = results.map,
resources = results.resources;
...
});
Затем вам достаточно прикрепить его к вашему шаблону.
Flex.Template
{{title}}
{{content}}
{{bottom}}
И все, контролер готов. Теперь все что определено внутри вашего контролера будет запускаться всякий раз после того, как шаблон отрисован.
Однако наибольший интерес представляет объект results, который передается и в контролер, и в функцию обратного вызова.
Модель и связи
Два важных объекта, которые вы получаете — это model и binds
var model = results.model;
var binds = results.binds;
Что бы продемонстрировать, что есть что, давайте изменим шаблон для строки таблицы следующим образом:
{{column_0}}{{::column_0}}
{{column_1}}{{::column_1}}
{{column_2}}{{::column_2}}
{{column_3}}{{::column_3}}
Как вы видите мы добавили пару связей. Во-первых, мы связали свойство background каждой ячейки с переменной background_n. То же самое мы сделали и со значениями самих ячеек, связав их с переменной column_n.
Теперь в контролере (или функции обратного вызова) мы можем получить доступ к связанным свойствам узлов.
_patterns.get({
...
callbacks : {
success: function (results) {
(function (model) {
var fun = function () {
var r = Math.round(19 * Math.random()),
c = Math.round(3 * Math.random());
model.__rows__[r]['column_' + c] = (Math.random() * 1000).toFixed(4);
model.__rows__[r]['background_' + c] = 'rgb(' + Math.round(255 * Math.random()) + ', ' + Math.round(255 * Math.random()) + ', ' + Math.round(255 * Math.random()) + ')';
setTimeout(fun, Math.ceil(50 * Math.random()));
};
fun();
}(results.model));
}
}
}).render();
Посмотреть на слетевшую с катушек таблицу можно здесь.
Итак, объект model содержит ссылки на связанные значения. Обратите внимание на свойство __rows__. Через данную конструкцию __hook__, обозначаются уровни вложенности hook«ов. Так как данные содержатся не в корневом шаблоне (шаблоне таблице), а вложены в hook rows, то и доступ к ним возможен через model.__rows__. Двойное же подчеркивание введено как превентивная мера от конфликтов имен.
Если вы помните, то в шаблоне окна авторизации мы связывали INPUT.value с P.innerHTML. В функции обратного вызова мы так же получаем и ссылку на value.
_patterns.get({
url : '/patterns/popup/pattern.html',
node : document.body,
hooks : {
id : id,
title : 'Test dialog window',
content : _patterns.get({
url : '/patterns/patterns/login/pattern.html',
hooks : {
login : _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'text',
}
}),
password: _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'password',
}
}),
controls: _patterns.get({
url : '/patterns/buttons/flat/pattern.html',
hooks : [{ title: 'login', id: 'login_button' }, { title: 'cancel', id: 'cancel_button' }]
}),
}
})
},
callbacks: {
success: function (results) {
var instance = this,
model = results.model;
model.__content__.__login__.value = 'this new login';
}
},
}).render();
С model разобрались, но что же такое binds? А binds по своей структуре тоже самое что и model, за одним лишь исключением — «на конце» не значение, а методы.
success: function (results) {
var instance = this,
dom = results.dom,
binds = results.binds,
id = null;
//Add handle
id = binds.__content__.__login__.value.addHandle(function (name, value) {
var obj = this;
});
//Remove handle
binds.__content__.__login__.value.removeHandle(id);
}
И их (методов) всего два:
- addHandle
- removeHandle
Как вы уже догадались, первый прикрепляет обработчик событий, а второй его удаляет. Таким образом, вы можете «повесить» свою функцию к какому-либо свойству модели, которая будет срабатывать всякий раз, когда это свойство меняется.
DOM и карта
Еще два интересных объекта — это DOM и map.
var dom = results.dom;
var map = results.map;
Изменим немного шаблон кнопки для нашего окна авторизации, чтобы продемонстрировать возможности объекта dom.
{{title}}
Итак, мы добавили ссылку {{$button}} к узлу кнопки. Таким образом, мы отметили узел, в отношении которого patterns создаст коллекцию методов для работы с данным узлом.
success: function (results) {
var instance = this,
dom = results.dom;
dom.listed.__content__.__controls__[0].button.on('click', function () {
alert('You cannot login. It\'s just test. Login is "' + model.__content__.__login__.value + '", and password is "' + model.__content__.__password__.value + '"');
});
dom.listed.__content__.__controls__[1].button.on('click', function () {
alert('Do not close me, please.');
});
dom.grouped.__content__.__controls__.button.on('click', function () {
alert('This is common handle for both buttons');
});
}
Как вы видите, мы получили возможность прикрепить обработчики событий к кнопкам формы. Полный перечень всех методов, идущих «из коробки» вы найдете здесь. Там же есть и описание того, как добавить свои собственные методы.
Здесь же я лишь обращу ваше внимание на то, что объект dom имеет два свойства:
- grouped
- listed
Первое свойство содержит сгруппированные методы. То есть, так как на форме у нас две кнопки, то при обращении, например, к методу on (прикрепление событий), мы прикрепим событие сразу к двум кнопкам. Если же нам нужен доступ к каждой отдельной кнопке, то нам необходимо использовать свойство listed.
В свою очередь объект map дает нам возможность быстрого поиска узлов, так как ограничивает поиск контекстом шаблона или его частей.
success: function (results) {
var instance = this,
map = results.map,
nodes = null;
//Will find all P in whole popup
nodes = map.__context.select('p');
//Will find all P inside popup in content area
nodes = map.content.__context.select('p');
//Will find all P in textbox-control of login
nodes = map.content.login.__context.select('p');
}
То есть map.content.login.__context.select ('p') будет искать все параграфы только внутри части шаблона, относящейся к шаблону текстового поля, определенному для указания логина.
Вы можете использовать объект map для быстрого поиска узлов и получения ссылок на них.
Обмен данными
Ну и наконец последний объект, передаваемый в функцию обратного вызова — это resources. Все просто — это механизм обмена данными. Так, при отрисовке шаблона вы можете определить свойство resources.
_patterns.get({
url : '/patterns/popup/pattern.html',
node : document.body,
hooks : {
id : id,
title : 'Test dialog window',
content : _patterns.get({
url : '/patterns/login/pattern.html',
hooks : {
login : _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'text',
}
}),
password: _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'password',
}
}),
controls: _patterns.get({
url : '/patterns/buttons/flat/pattern.html',
hooks : [{ title: 'login', id: 'login_button' }, { title: 'cancel', id: 'cancel_button' }]
}),
},
})
},
resources: {
field1 : 'one',
field2 : 'two'
},
callbacks: {
success: function (results) {
var instance = this,
resources = results.resources;
window.console.log(resources.field1);
window.console.log(resources.field2);
//Result in console:
//one
//two
}
},
}).render();
Именно оно и будет передано в функцию обратного вызова, как это продемонстрировано в примере. Таким образом, вы получаете возможность обмена данными между моментами: до отрисовки и после.
Условия или меняющиеся шаблоны
На самом деле это наиболее интересная часть patterns (с моей точки зрения конечно), потому что предлагаемые здесь подходы могут вас немного озадачить. Но, обо всем по порядку.
Итак, хороший шаблон не может быть абсолютно статичным и должен как-то меняться в зависимости от данных с которыми он будет отрисован. Подавляющее число шаблонизаторов для этой цели использует смешение синтаксисов, вставляя непосредственно в разметку логику. Так это делает, например, упомянутый в начале статьи EJS.
<% for(var i=0; i
- <%= supplies[i] %>
<% } %>
Чтобы продемонстрировать то, как подобные задачи решает patterns давайте вернемся к нашему примеру окна авторизации и доработаем шаблон текстового поля таким образом, чтобы на случай его использования для пароля, пользователю отображалась подсказка с допустимыми символами.
Flex.Template
{{::value}}
You can use in password only letters, number and _
Итак, как вы видите, мы добавили немного новой разметки, а именно:
You can use in password only letters, number and _
Таким образом мы можем определять условия, обрамляя нужную часть разметки в HTML-комментарии.
Так же вы не могли не заметить и прикрепленного JS файла — conditions.js. Вот его содержание:
_conditions({
type: function (data) {
return data.type;
}
});
Как вы можете видеть, там определена функция (type) соответствующая названию условия в разметке .
Так что же произойдет после отрисовки обновленного шаблона окна авторизации? Логика действий patterns будет довольно простой: обнаружив условия в шаблоне текстового поля, patterns попытается найти функцию type (по имени условия). Найдя эту функцию, patterns передаст ей значения hook’ов (аргумент функции — data). Если эта функция вернет определенное в условии значение password, то дополнительная часть разметки будет включена в шаблон.
Здесь работающий пример нашего обновленного окна авторизации.
Кроме того, мы можем определять условия не только в отдельном файле, прикрепляемом к шаблону, но и вовремя его отрисовки.
_patterns.get({
url : '/patterns/popup/pattern.html',
node : document.body,
hooks : {
id : id,
title : 'Test dialog window',
content : _patterns.get({
url : '/patterns/login/pattern.html',
hooks : {
login : _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'text',
},
conditions : {
type: function (data) {
return data.type;
}
},
}),
password: _patterns.get({
url : '/patterns/controls/textinput/pattern.html',
hooks : {
type: 'password',
},
conditions : {
type: function (data) {
return data.type;
}
},
}),
controls: _patterns.get({
url : '/patterns/buttons/flat/pattern.html',
hooks : [{ title: 'login', id: 'login_button' }, { title: 'cancel', id: 'cancel_button' }]
}),
},
})
},
}).render();
Вы, наверное, сейчас хотели бы сказать: условия в шаблоне реализованы через жопу как-то странно. Не спешите. Есть два серьезных мотива.
- Во-первых, имея условия, определенные по умолчанию (это те что прикреплены к шаблону в виде отдельного JS), мы получаем возможность переопределить их, не влезая в шаблон — через функцию рендеринга (как это показано выше). Таким образом нам не нужно плодить кучу компонентов, отличающихся совсем немного, так как мы всегда можем немного «поправить» логику под текущие нужды.
- Во-вторых, и это для меня главное — у нас есть возможность быстро пересобрать шаблон, если вводные данные изменились. Что бы было яснее: обычный шаблонизатор, анализирует условия и делает итоговый шаблон, который и монтируется в разметку; если данные изменились, нужно удалять отрисованный экземпляр и собирать шаблон заново. Подход с функциями-условиями позволяет обойтись без этой дорогой операции и пересобрать лишь небольшой кусок шаблона, к которому относится условие.
Чтобы лучше понять «во-вторых» давайте изменим шаблон строки для нашей таблицы.
{{column_0}}{{::column_0}}
{{column_1}}{{::column_1}}
{{column_2}}{{::column_2}}
{{column_3}}{{::column_3}}
This value is less than 111
This value is more than 111 and less than 222
This value is more than 222 and less than 333
This value is more than 333 and less than 666
This value is more than 666 and less than 1000
Выглядит все мудрено, правда? Но взглянув на функции-условия, станет все ясно.
var conditions = {
value_sets: function (data) {
if (data.column_3 <= 333 ) { return '0'; }
if (data.column_3 > 333 && data.column_3 <= 666 ) { return '0.5'; }
if (data.column_3 > 666 ) { return '1'; }
},
sub_value_sets: function (data) {
if (data.column_3 <= 111 ) { return '0'; }
if (data.column_3 > 111 && data.column_3 <= 222 ) { return '0.5'; }
if (data.column_3 > 222 ) { return '1'; }
},
};
conditions.value_sets. tracking = ['column_3'];
conditions.sub_value_sets. tracking = ['column_0'];
_conditions(conditions);
На самом деле все просто: в зависимости от числа, которое будет попадать в четвертую ячейку каждой строки, будет меняться и подпись под этой строкой.
Через свойство tracking мы показываем patterns при изменении каких данных нужно обновлять шаблон. В данном случае, мы привязали наши условия к значениям первой и последней ячеек каждой строки.
Давайте запустим рендеринг, добавив немного динамики.
var data_source = [];
for (var i = 0; i < 100; i += 1) {
data_source.push({
column_0: (Math.random() * 1000).toFixed(4),
column_1: (Math.random() * 1000).toFixed(4),
column_2: (Math.random() * 1000).toFixed(4),
column_3: (Math.random() * 1000).toFixed(4),
});
}
_patterns.get({
url : '/patterns/table/container/pattern.html',
node : document.body,
hooks : {
titles : {
column_0: 'Column #0',
column_1: 'Column #1',
column_2: 'Column #2',
column_3: 'Column #3',
},
rows : _patterns.get({
url: '/patterns/table/row_con/pattern.html',
hooks: data_source,
})
},
callbacks : {
success: function (results) {
(function (model) {
var fun = function () {
var r = Math.round(99 * Math.random()),
c = Math.round(3 * Math.random());
model.__rows__[r]['column_' + c] = (Math.random() * 1000).toFixed(4);
setTimeout(fun, Math.ceil(50 * Math.random()));
};
fun();
}(results.model));
}
}
}).render();
Итак, как вы видите, через каждые 50 мс. мы меняем значение ячеек. И если изменится первая или последняя шаблон будет перерисован в той части в которой необходимо, а не полностью, как это делают многие другие шаблонизаторы (если вообще делают). Рабочий пример этого безобразия можно посмотреть тут.
Вместо завершения
На самом деле есть еще много разных моментов, о которых можно было бы рассказать, но я боюсь, что уже вышел далеко за лимит вашего терпения, посему буду закругляться.
К главным преимуществам patterns я бы отнес следующее:
- Благодаря тому, что шаблон — это всего лишь HTML и никакого нестандартного синтаксиса не используется, шаблоны можно запускать отдельно от страницы и отлаживать.
- Благодаря нестандартному подходу к условиям шаблон можно частично «пересобрать» без перезагрузки.
- Благодаря встроенной системе кэширования весь шаблон (включая его ресурсы) будет храниться на стороне клиента, что снижает нагрузку на трафик.
Но это главные преимущества лишь для меня, для вас они могут быть другие, либо вообще отсутствовать.
Здесь вы сможете найти довольно подробное описание всего того что относится к patterns.
Это страница проекта на github«е.
Спасибо больше за ваше внимание.