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

  • Tutorial
На тему шаблонизаторов статей написано великое множество, в том числе и здесь, на хабре.
Раньше мне казалось, что сделать что-нибудь своё — «на коленке» — будет очень сложно.
Но, случилось так, что прислали мне тестовое задание.
Напиши, мол, 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 к самооценке.

UPD 2: Сразу скажу, чтобы «чётко понять» что там и зачем, нужно начать это делать, желательно вместе с debugger'ом.
UPD 3: Оно разобрано «на пальцах». Там есть ещё где «оптимизнуть». Но будет гораздо менее наглядно.

Приступим.

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

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

	
	// 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 .

UPD 1: спасибо lynx1983 за Issue #2.

Итак, разделим строку на части 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];

			// если определён текущий obj для блока
			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 текущий элемент массива и его строку
							stars.push ( [ block.be4e, current[k] ] );
							// парсим вложенные блоки
							parseBlocks( block.nest, stars );
						}
						break;
					
					// если у нас объект
					case '[object Object]':
						for (var k in current) {
							if (current.hasOwnProperty(k)) {
								// кладём в stars текущий элемент объекта и его строку
								stars.push ( [ block.be4e, current[k] ] );
								// парсим вложенные блоки
								parseBlocks( block.nest, stars );
							}
						}
						break;
					
					// у нас не массив и не объект, просто выведем его
					default:
						stars.push ( [ block.be4e, current ] );
						parseBlocks( block.nest, stars );
				}

				// кладём в 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];
				// убираем лишнее и делаем trim
				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;
	}




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

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

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

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

:)
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 18

    0
    Я как-то не интересовался джаваскриптовыми шаблонизаторами, поэтому хочется спросить у автора (а заодно и у читательского сообщества джаваскриптовикóв): существовали ли до этого шаблонизаторы с аналогичным (подстановки, итерации, фильтры, комментарии) или с бóльшим набором возможностей? Какие можете порекомендовать?

    Самостоятельно пытаясь ответить на этот вопрос, сейчас полез в проект Node.js почитать вики-страницу «Modules», нашёл сообщение «DEPRECATED» и удаление ссылки с заглавной страницы вики. Совершённое не прохожим каким-нибудь вики-редактором, а одним из центральных участников проекта.

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

    Однако вот вспомнил, возвращаюсь спросить.

    Знает ли кто ответ?
      0
      Да полно. Для меня самые популярные:
      Handlebarsjs
      Dot.js
      EJS

      Первая ссылка гугла по javascript template engine.
      stackoverflow.com/questions/7788611/what-javascript-template-engines-you-recommend

      Вторая:
      garann.github.io/template-chooser/
        0
        Есть очень продвинутый BEMHTML, но у него немного другая концепция и довольно сложно вкурить, как его прикрутить, к динамически изменяющимся данным, но результат получается неплохой.
          0
          Страница удалена потому что модулей стало слишком много, чтобы их отслеживать. Большое количество модулей из списка устарело, либо не поддерживается.
            +2
            Некоторое время назад начал потихоньку дописывать doT, так как из всех существовавших он больше всех привлек. Основной репозиторий был заброшен долгое время, и PR они не принимали.

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

            Тут можно посмотреть github.com/printercu/doT
              0
              Спасибо! Использовал doT в своем недопроекте. Непременно просмотрю ваш код — может удалю пару велосипедов.
              0
              MaskJs потом еще появился. Выделяется из всех: jsperf.com/javascript-template-engine-compare/77
                +2
                Там тест стал неправильно работать. Движок MaskJs, как видно, сменил язык парсинга, поэтому он ничего по факту не делал, поэтому на тесте был быстрее всех.

                jsperf.com/javascript-template-engine-compare/79 — исправленный тест (посмотрел доки MaskJs и написал правильно).

                (Проблема этого теста в том, что он ведёт на актуальные версии шаблонизаторов. Еслли что-то в них изменилось, есть вероятность, что работать не будет или будет показывать неправильно, как в этом случае.)

                И в лидерах в этом тесте — doT и underscore:
                  0
                  Класс! doT всё-таки самый быстрый))
                    0
                    Вот jsperf хороший сервис, но никогда не уверен в актуальности теста — не хватает удаления и редактирования. А Маска уже давно перешла немного в другую область — mvc движок — todomvc, поэтому на данный момент чистое сравнение с шаблонизаторами также не совсем верно. Одним главным отличием всегда было то, что генерировался DOM вместо HTML, и такой подход себя оправдывает в общей производительности приложения.
                0
                Это же лишняя работа — сначала push(), потом join(''). Быстрее будет просто собирать в строку.

                … Ещё одну статью с самописным шаблонизатором к этой habrahabr.ru/post/201592/ (про doT.js) — и неделя шаблонизаторов состоится!
                  +1
                  Не уверен. ИМХО как раз конкатенация строк — это очень затратная операция.
                  К тому же если строка будет очень большой, то это будет дополнительно тормозить.
                  Но, безусловно, возможно, что я ошибаюсь.
                  Последний раз когда я разбирался с этим вопросом на дворе стоял 2007 год, с тех пор многое могло измениться.

                  jsperf.com/array-join-vs-string-connect
                    0
                    В общем, видимо да, для современных браузеров Вы скорее правы, чем я.
                      0
                      Есть подозрение, что трудно написать корректный тест. Умный интерпретатор просто разворачивает цикл, а затем сразу всё складывает, получая очень быструю функцию, что не прокатывает с массивами. То есть в результатах мы видим не чистое выполнение кода, а степень оптимизации движка, причём время компиляции в тест не входит. Лучше проверять на реальном исполнении и реальных данных.
                        0
                        Вот, тоже об этом подумал.
                        У меня на каждом шаге цикла строка вычисляется же.
                        Предсказать её невозможно никак.
                        И я тут не знаю, что будет лучше, join или +=
                  +1
                  Для разнообразия:

                  Вот был написал мной в свое время «шаблонизатор»: code.google.com/p/kite/
                  Объем (в LOC) примерно такой же как у автора, но
                  1. Компилируемый — идея.
                  2. {{mustache}} compatible + conditionals
                    +1
                    Подключил к Вашему тесту dot().

                    Получилось, что jQuery.tmpl() вообще отдыхает — за 10 секунд не может быть сравним с остальными с одинаковым числом циклов. Поэтому число циклов пришлось увеличить, чтобы результаты точнее были, а jQuery.tmp — убрать. Выходит:

                    <script type="text/javascript" src="doT.js"></script>
                    <script type="text/doT" id="d-template">
                      <ul>
                        {{~it:val}}
                          <li><b>{{=val.firstName}}</b> <i>{{=val.lastName}}</i></li>
                        {{~}}
                      </ul>
                    </script>
                    

                    var NUM_REPETITIONS = 1500;
                    ...
                           function test_doT()
                           {
                             var template = $("#d-template" ).html();
                             var html;
                             var t1 = (new Date()).getTime();
                             var compiled = doT.compile(template);
                             for(var n = 0; n < NUM_REPETITIONS; ++n)
                               html = compiled(data.contacts);
                             var t2 = (new Date()).getTime();
                             $("#out").html(html);
                             
                             return (t2 - t1) / NUM_REPETITIONS;
                           }
                    ...
                    res.push( { name:"doT", time: (test_doT()*1000), ratio:0, sloc:140 } );
                    

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

                        html = doT.compile(template)(data.contacts);
                    
                      +1
                      Картина в Хроме:

                      (измерения не очень точны для коротких интервалов из-за малого общего интервала измерения, хотя было взято 6000 циклов; поэтому надо предполагать погрешность примерно в 10%).

                  Only users with full accounts can post comments. Log in, please.