...

четверг, 14 ноября 2013 г.

Пишем свой JavaScript шаблонизатор

На тему шаблонизаторов статей написано великое множество, в том числе и здесь, на хабре.

Раньше мне казалось, что сделать что-нибудь своё — «на коленке» — будет очень сложно.

Но, случилось так, что прислали мне тестовое задание.

Напиши, мол, JavaScript шаблонизатор, вот по такому сценарию, тогда придёшь на собеседование.

Требование, конечно, было чрезмерным, и поначалу я решил просто игнорить.

Но из спортивного интереса решил попробовать.

Оказалось, что не всё так сложно.

Собственно, если интересно, то под катом некоторые заметки и выводы по процессу создания.


Для тех, кому только глянуть: the result, the cat.


Дано:


Исходный шаблон — это JS String(), а данные это JS Object().

Блоки вида {% name %} body {% / %} , возможна неограниченная вложенность.

Если значение name является списком, то выводятся все элементы, иначе если не undefined, выводится один элемент.

Подстановки вида: {{ name }} .

В блоках и подстановках возможно использование точек в качестве имени, например {{.}} или {%.%} , где точка будет текущим элементом объекта верхнего уровня.

Есть ещё комментарии — это {# any comment w\wo multiline #} .

Для самих значений возможны фильтры, задаются через двоеточие: {{ .:trim:capitalize… }} .


Работать оно должно как:



var str = render (tpl, obj);


Доказать:

+1 к самооценке.


Приступим.


Т.к. исходный шаблон — это строка, то можно пользоваться преимуществами регулярок.


Для начала можно убрать комментарии, чтобы не отсвечивали:




// to cut the comments
tpl = tpl.replace ( /\{#[^]*#\}/g, '' );



Hint: [^] означает любой символ, * — сколько угодно раз.


Теперь можно подумать над тем, как будем парсить «чистый» результат.

Так как блоки возможны вложенные, предлагаю хранить всё в виде дерева.

На каждом уровне дерева будет JS Array (), элементы которого могут содержать аналогичную структуру.


Чтобы создать этот массив нужно отделить мух от котлет.

Для этого я воспользовался String.split() и String.match().


Ещё нам понадобится глубокий поиск по строковому val имени внутри объекта obj.


Применённый вариант getObjDeep:



var deeps = function (obj, val) {
var hs = val.split('.');
var len = hs.length;
var deep;
var num = 0;
for (var i = 0; i < len; i++) {
var el = hs[i];
if (deep) {
if (deep[el]) {
deep = deep[el];
num++;
}
} else {
if (obj[el]) {
deep = obj[el];
num++;
}
}
}
if (num == len) {
return deep;
} else {
return undefined;
}
};






И сразу тут скажу СПАСИБО, subzey за greedy quantificator fix .

Итак, разделим строку на части parts и элементы matches:




// регулярка для парсинга, цифробуквы,
// точка, подчеркивание,
// двоеточие слеш и минус, сколько угодно раз
var ptn = /\{\%\s*[a-zA-Z0-9._/:-]+?\s*\%\}/g;

// строковые куски
var parts = tpl.split (ptn);

// сами спички
var matches = tpl.match (ptn);




Для разбора полётов нам понадобятся два массива.

В одном мы будем хранить блоки, в другом будет текущий элемент из цикла по спичкам.




// все блоки
var blocks = [];

// вложенности
var curnt = [];

if( matches ){ // т.к. м.б. null

var len = matches.length;
for ( var i = 0; i < len; i++ ) {

// выкидываем {% и %}, и попутно делаем trim
var str = matches[i].replace (/^\{\%\s*|\s*\%\}$/g, '');

if (str === '/') {

// finalise block
// ...

} else {

// make block
// ...

}

// ...



Тут blocks — итоговый массив с выделенынми блоками, а curnt — массив с текущей вложенностью.


На каждом шаге цикла мы определяем, что сейчас в str, начало блока или завершение.

Если начало блока, т.е. str !== '/' , то создаём новый элемент и push его в массив.

И ещё push его в curnt, т.к. нам необходимо понимать на каком мы уровне.

Попутно заносим в блок сами строки.

Соотвественно, если у нас пустой curnt, то мы на нулевом уровне дерева.

Если curnt не пустой, то нужно заносить в nested элемент последнего curnt.




// длина текущей вложенности
var cln = curnt.length;

if (cln == 0) {

// т.к. это верхний уровень, то просто в него и кладём текущий элемент
blocks.push ( struct );

// пишем текущую вложенность, она же нулевая
curnt.push ( struct );

} else {

// нужно положить в nested текущего вложенного блока
curnt[cln - 1].nest.push ( struct );

// теперь взять этот "последний" элемент и добавить его в curnt
var last = curnt[cln - 1].nest.length - 1;
curnt.push ( curnt[cln - 1].nest [ last ] );

}



Соотвественно, каждый элемент массива это, минимум:




var struct = {

// текущий obj для блока
cnt: deeps( obj, str ),
// вложенные блоки
nest: [],
// строка перед всеми вложенными блоками
be4e: parts[ i + 1 ],

// str -- строка, идущая после завершения данного
// cnt -- блок-родитель, парсить строку будем в его рамках
af3e: {
cnt: null,
str: ''
}

};



Т.к. у нас может быть ситуация, когда после блока есть что-нибудь ещё, то здесь af3e.str и должно быть строкой, идущей сразу после {% / %} текущего блока. Все необходимые ссылки мы проставим в момент завершения блока, так наглядней.

В этот же момент мы удаляем последний элемент элемент curnt.




if (str === '/') {

// предыдущий элемент curnt
// является родителем
// завершившегося сейчас блока
curnt [cln - 1].af3e = {
cnt: ( curnt [ cln - 2 ] ? curnt [ cln - 2 ].cnt : obj ),
str: parts[ i + 1 ]
};
curnt.pop();



Теперь мы можем собрать одномерный массив, в котором будут все нужные подстроки с их текущими obj.

Для этого нужно «разобрать» получившийся blocks, учитывая что могут быть списки.

Понадобится немного рекурсии, но в целом это будет уже не так сложно.




// массив строк для парсинга элементарных частей блоков
var stars = [ [ parts[0], obj ] ];
parseBlocks( blocks, stars );



Примерный вид parseBlocks()



var parseBlocks = function ( blocks, stars ) {

var len = blocks.length;
for (var i = 0; i < len; i++) {

var block = blocks [i];

if (block.cnt) {
var current = block.cnt;

switch ( Object.prototype.toString.call( current ) ) {
case '[object Array]':
var len1 = current.length;
for ( var k = 0; k < len1; k++ ) {
stars.push ( [ block.be4e, current[k] ] );
parseBlocks( block.nest, stars );
}
break;
case '[object Object]':
for (var k in current) {
if (current.hasOwnProperty(k)) {
stars.push ( [ block.be4e, current[k] ] );
parseBlocks( block.nest, stars );
}
}
break;
default:
stars.push ( [ block.be4e, current ] );
parseBlocks( block.nest, stars );
}

stars.push ( [ block.af3e.str, block.af3e.cnt ] );
}

}

};






Далее мы поэлементно его распарсим получившийся stars и, собрав результат в строку, получим итоговый результат:




var pstr = [];

var len = stars.length;
for ( var i = 0; i < len; i++ ) {
pstr.push( parseStar ( stars[i][0], stars[i][1] ) );
}

// Результат:
return pstr.join ('');



Примерный вид parseStar()



var parseStar = function ( part, current ) {

var str = '';
var ptn = /\{\{\s*.+?\s*\}\}/g;

var parts = part.split (ptn);
var matches = part.match (ptn);

str += parts[0];

if (matches) {

var len = matches.length;
for (var i = 0; i < len; i++) {

var match = matches [i];
var el = match.replace(/^\{\{\s*|\s*\}\}$/g, '');
var strel = '';
var deep = deeps( current, el );
deep && ( strel += deep );
str += strel;

}

if (len > 0) {
str += parts[ len ];
}
}

return str;
}






Приведённый код немного меньше финального результата.

Так, например, я не показал что делать с текущим элементом, если он задан ка точка.

Так же я не привёл обработку фильтров.

Кроме того в итоговом варианте, я «от себя» добавил в обработку ситуаций, когда «текущий элемент» или «значение для» являются функциями.


Но моей целью было показать саму концепцию…


А результат, как уже было сказано в начале статьи, можно найти здесь.

Итоговый пример, после я пошёл на собеседование лежит тут.


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

Спасибо за внимание!


:)


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at fivefilters.org/content-only/faq.php#publishers.


Комментариев нет:

Отправить комментарий