25.07.2014

История одного шаблонизатора, от xml к xtpl и data-binding

Задача

Задача

И началось ;]

Синтаксис?

Я знаю Smarty!

Smarty

			<div>
				{{if expr }}
					Вот поэтому Smarty и подобные плохи, {{$username}}.
			</span>
				{{/if}}
		

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

AsyncTpl

AsyncTpl

Синтаксис

Синтаксис: поддрежка XML & Smarty

Что у них общего?

			#!+ <!-- XML -->
			<xtpl:if test="expr"> .. </xtpl:if>
			#!- <xtpl:value> expr </xtpl:value>

			#!+ <!-- Smarty -->
			{{if expr }} .. {{/if}}
			#!- {{ expr }}
		

Ничего!
Но :]

XML Парсер

			new Parser({
				#! left: "<"
				#! right: ">"
				#!+ c_open: "<!--"
				c_close: "-->"
				tags: false
				trim: true
				#!- firstLine: true
			});
		

Smarty Парсер

			new Parser({
				#! left: "{{"
				#! right: "}}"
				#! tags: "foreach include extends block ..."
			});
		

Пример парсинга

			<!-- XML -->
			<div>
				<xtpl:if test="expr">OK</xtpl:if>
			</div>

			<!-- Smarty -->
			<div>
				{{if expr}}OK{{/if}}
			</div>
		

Резльтат парсинга XML

			[{
				#! name: "div", type: 1, __open: true // <div>
			}, {
				#! name: "if", ns: "xtpl", type: 1, __open: true, // <xtpl:if
				#!+ attributes: [{
					#! name: "test", type: 2, value: "expr" // test="expr">
				#!- }]
			}, {
				#! name: "#text", type: 3, value: "OK" // текст в if
			}, {
				#! name: "if", ns: "xtpl", type: 1, __close: true // </xtpl:if>
			}, {
				#! name: "div", type: 1, __close: true // </div>
			}]
		

Резльтат парсинга Smarty

			[{
				#! name: "#text", type: 3, value: "<div>" // <div>
			}, {
				#! name: "if", type: 1, __open: true, // {{if
				#! attributes: "expr" // expr}}
			}, {
				#! name: "#text", type: 3, value: "OK" // текст в if
			}, {
				#! name: "if", type: 1, __close: true // {{/if}}
			}, {
				#! name: "#text", type: 3, value: "</div>" // </div>
			}]
		

Помимо этого

Массив состоял не из простых объектов, а был экземпляром класса Node:

Node

Node

Трансформация

Трансформация

			_trans: function (node, input, stack) {
				var val;
				switch (node.name) {
					#!+ case "if": // if test="expr"
						#! val = node.__open
							#! ? "__XIF=0;" + _try("__XIF=(" + node.attr("test") + ")", node) + "if(__XIF){";
							#! : "}"
					 	#!- break;
					#!+ case "foreach": // foreach data="array" item="val"
						val	= node.__open
								#! ? ("__XFOR=0;" + _try("__XFOR=" + node.attr("data"), node)
								#!+	+ "__buf.each(__XFOR, function (" + (node.attrAny("item", "__v")) + "){"
								#!- )
								#! : "});"
						#!- break;
				}
				// ..
			}
		

Итого v1

AsyncTpl::XML

			var xtpl = require("AsyncTpl").engine("XML");
			xtpl.NS     = "xtpl";   // namespace
			xtpl.ASYNC  = true;     // async include templates
			xtpl.STREAM = false;    // streaming
			xtpl.ESCAPE = true;     // html escape all variables
			xtpl.DEBUG  = true;     // compile & run-time errors (console.log)
			xtpl.ROOT_DIR       = "./tpl/";
			xtpl.COMPILE_DIR    = "./tpl_c/";
		

AsyncTpl::Smarty

			var smarty = require("AsyncTpl").engine("Smarty");
			smarty.LEFT = "{{";
			smarty.RIGHT = "}}";
			#!+ smarty.modifiers({ // {{ctx.expr|lower}}
				lower: function (val) {
					return val.toLowerCase();
				}
			#!- });
		

Основные моменты

Пример

hello.xml

			<div>
				<xtpl:if test="ctx.expr">
					Hello!
				</xtpl:if>
			</div>
		

hello.js

			function (ctx, __buf) {
				var __XIF;
				#! __buf.w("<div>");
				#!+ __XIF = 0;
				try { __XIF = ctx.expr; }  //  <xtpl:if test="ctx.expr">
				#!- catch (e) { xtpl.error(e, 1, "path/to.xml"); }
				#!+ if (__XIF) {
					__buf.w("Hello!");
				#!- }
				#! __buf.w("</div>");
				return __buf.toStr();
			}
		

А потом началось

А потом началось

Я начал пробовать шаблонизатор на реальных задача. Взял Backbone, написал простенький SPA и...

users.xml

			<ul>
				#!+ <x:each data="ctx.users" as="user">
					#!+ <li>
						#! <x:value>user.get("name")</x:value>
					#!- </li>
				#!- </x:each>
			</ul>
		

Список.js

			var UserList = new Backbone.View.extend({
				#! template: xtpl.compile("users.xml"),
				#!+ initialize: function () {
					this.listenTo(this.collection, "change", this.render);
				#!- },
				#!+ render: function () {
					#! var html = this.template({ users: this.collection.models });
					#! this.$el.html(html);
				#!- }
			});
		

App

			// Где-то в приложении
			users.fetch({
				success: function () {
					#!+ var list = new UserList({
						el: "#users",
						collection: users
					#!- });
					#! list.render();
				}
			});
		

:[

Зачем?

			#!+ // это?
			#!- this.listenTo(this.collection, "change", this.render);
		

Я знаю,
что нужно ;]

Версия 2.0

Версия 2.0

Прасер

Парсер

Главное изменение в том, что на выходе терперь получалось дерево (наподобие DOM), c соответсвующими методами для работы с ним.

Data binding + Backbone

			#!+ <x:bind data="ctx.items" as="list">
				<ul>
					#!+ <x:each data="list" as="item">
						#!+ <li>
							<x:value>item.get("name")</x:value>
						#!- </li>
					#!- </x:each>
				</ul>
			#!- </x:bind>
		

Data binding + Backbone

<x:bind/> — создает замыкание (микро шаблон), подписывается на все события коллекции, при изменении которой, заново рендерил связанный блок.

Data binding + Backbone

			#!+ __buf.w("<div id='uniqId'>"); // <x:bind
			#! try { __xbind = ctx.items; } catch (e) {} // data="ctx.items"
			#!+ __buf.bind(__xbind, "uniqId", function (list) { // as="list"
				__buf.w("<ul>"); // <ul>
				#!+ __buf.each(list, function (item) { // <x:each/>
					// вывод элементов
				#!- });
				__buf.w("</ul>"); // </ul>
			#!- });
			#!- __buf.w("</div>"); // </x:bind>
		

А дальше хуже

А дальше хуже

А дальше хуже

Это был очень долгий и сложный путь, как внедрить data binding в обычный js шаблон, чтобы он работал как на сервере так и браузере.

Шаблон

			<ul>
				<x:each bind="ctx.list" as="item">
					#!+ <li>
						<x:value bind>item.name</x:value>
					#!- </li>
				</x:each>
			</ul>
			#! <b>total: <x:value bind>ctx.list.length</x:value></b>
		

Считаем позицую

			<ul>
				<x:each bind/>  // [0]
					<li>
						<x:value bind/> // [0, $idx, 0]
					</li>
				</x:each>
			</ul>
			<b>total: <x:value bind/></b> // [1, 1]
		

Шаблон.js

			__buf.w("<ul>");
			#!+ __buf.bind("each", [0], // <x:each bind/>
					#!+ function () { // getter
						try { return ctx.list; } catch (e) {}
					#!- },
					#!+ function (__xbind0) { // микро шаблон
						__buf.w("<li>");
						#!+ __buf.bind("value", [0], function () { // <x:value bind/>
							#! try { return __xbind0.name; } catch (e) {} // getter
						}, function (__xbind0) { // микро шаблон
							#! __buf.v(__xbind0);
						#!- });
						__buf.w("</li>");
					#!- }
			#!- );
			__buf.w("</ul>");
			#! // и так далее
		

Прошло два года

xtpl (v4.0)

xtpl

Cинтаксис

Cинтаксис

			input.btn.btn-success.xlarge[type="button"] {
			   on-tap: ctx.counter++;
			   value: ctx.counter || "Click me"
			}
		

Cинтаксис: Custom elements

			&btn = button.btn { // Обявляем элемент «btn»
			   #!+ class: "btn-{{ctx.mod || 'default'}}"
			   | {{ctx.text}} // это текстовая нода
			   if ctx.icon {
				  #! &icon { x-name: ctx.icon }
			   #!- }
			}
			#! &btn { x-text: "Fine", x-mod: "success", type: "reset" }
		

Cинтаксис

Если вы решили создать свой синтаксис, то позаботьтесь о поддержки им хоть в одной IDE, иначе оно не имеет смысла. Конечно это только мое мнение, но без этого будет крайне трудно использовать подобную поделку.

Расширяемость

xtpl.tag

xtpl.tag

			xtpl.tag({
				"nodeName": {
					#!+ // Препроцессин
					#!- pre: function (node, attrs) { },
					#!+ // JavaScript код
					code: function (node, attrs) {
						#! return ["begin", "end"];
					#!- }
				}
			});
		

xtpl.tag: Пример

			xtpl.tag({ // loop[from=1][to=3] | {{$i}}
				#!+ "loop": function () {
					return [
						#! "for (var i = $from; i < $to; i++) {",
						#! "}"
					];
				#!- }
			});
		

xtpl.tag и атрибуты

xtpl.decl

xtpl.decl

			xtpl.decl("name", {
				#!+ compile: function (node, expr) {
					// Вызывается в момент компиляции
				#!- },
				#!+ init: function (el, value) {
					// Инициализация элемента (добавление в DOM)
				#!- },
				#!+ update: function (el, newValue, oldValue) {
					// Изменение значения
				#!- },
				#!+ remove: function (el) {
					// Удаления из DOM
				#!- }
			});
		

xtpl.decl: пример #1

			xtpl.decl("x:show", {
				#!+ compile: function (node, expr) {
					var attrs = node.attrs;

					attrs.style = (attrs.style || "")
						+ ";{{" + expr + " ? '' : 'display: none;'}}";
				#!- }
			});
		

xtpl.decl: пример #2

			// init(el, state) & update(el, state)
			xtpl.decl("x:autofocus", function (node, state) {
				#!+ if (state) {
					setTimeout(function () {
						#! el.focus();
						#!+ el.selectionStart =
						#!- el.selectionEnd = (el.value ? el.value.length : 0);
					}, 1);
				#!- }
			});
		

xtpl.mod

xtpl.mod

			xtpl.mod("name", function (value, ...rest) {
				// Модификатор значения
			});
		

xtpl.mod: пример #1

			// Нужно: <b>Всего 2 письма</b>
			#! // Шаблон: b | Всего {{ctx.total | plural:"letters"}}
			#!+ xtpl.mod("plural", function (total, name) {
					#! // total — значение пеменной
					#! // name — название языковой константы
					#! return total + " " + i18n.plural(total, name);
			#!- });
		

xtpl.mod: пример #2

			// Нужно: <pre>{ &quot;foo&quot;: &quot;bar&quot; }</pre>
			// Шаблон: pre | {{ctx.data | pretty }}
			#!+ xtpl.mod("pretty", function (value) {
				return JSON.stringify(value, null, "\t");
			#!- });
		

Binding

Binding

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

Binding: пример шаблона

Рассмотри пример ввода имени его его вывода:
			#! input { x-model: **ctx.name**, placeholder: "Enter your name" }
			#! h2 | Hello **{{ctx.name}}**!
		
			var el = document.getElementById("target");
			xtpl.bind(el, templateStr, { name: "" });
		

Binding: результат работы

			<div id="target">
				#!+ <!--attrs-->
					<input placeholder="Enter your name"/>
				#!- <!--/attrs-->
				#! <h2>Hello <!--x:value--><!--/x:value-->!</h2>
			</div>
		

Binding: js-шаблон

			__buf.bindOpen("attrs"); // <!--attrs-->
			#! __buf.s("<input");
			#!+ __buf.bindEvent("input", // x-model="ctx.name"
				#!+ function (el, evt) { // слушатель
					try { ctx.name = el.value } // получение значения
					catch (e) { __xerr(e, "hello.xtpl", 2); }
				#!- }
			#!- );
			#!+ __buf.bindAttr("value", // x-model="ctx.name"
				#!+ function (__xargs) { // getter
					try { __xargs[0] = ctx.name }
					catch (e) { __xerr(e, "hello.xtpl", 2) }
				#!- },
				#!+ function (__xbind0) {  // setter
					__buf.w(__xbind0)
				#!-}
			#!- );
			#! __buf.s(" placeholder='Enter your name'/>")
			__buf.bindClose("attrs"); // <!--/attrs-->
		

Binding: js-шаблон

			__buf.s("<h2>Hello ")
			#!+ __buf.b("x:value", // {{ctx.name}}
				#!+ function (__xargs) {  // getter
					try { __xargs[0] = ctx.name }
					catch (e) { __xerr(e, "hello.xtpl", 3) }
				#!- },
				#!+ function (__xbind0) { // setter
					try { __xval0 = __xbind0 }
					catch (e) { __xval0 = void 0; __xerr(e, "hello.xtpl", 3) }
					__buf.w(__xval0);
				#!- }
			#!- );
			__buf.s("!</h2>")
		

Benchmark

Benchmark

Performed 3000 iterations
total, ms avg. per loop, ms
Reactive 69 984 23.33
React 52 950 17.65
AngularJS 49 815 16.61
Vue 43 141 14.38
xtpl 33 458 11.15

Эпилог

По-большому счету, это не готовый продукт, а исследование на тему, возможно ли доработать обычную js-функцию-шаблон до поддержки data binding, заставив всё это работать, как на сервере, так и клиенте. Как оказалось такой подход достаточно производительный и не уступает таким проектам как Angular или React, единственно что ему не хватает, это довести до ума :]