Пишем свой 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; 	}  

Приведённый код немного меньше финального результата.
Так, например, я не показал что делать с текущим элементом, если он задан ка точка. Так же я не привёл обработку фильтров.
Кроме того в итоговом варианте, я «от себя» добавил в обработку ситуаций, когда текущий элемент или значение для являются функциями.

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

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

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

🙂

ссылка на оригинал статьи http://habrahabr.ru/post/201684/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *