MaskJS, поговорим о шаблонном движке, или новом велосипеде

в 15:59, , рубрики: javascript, template parser, метки: ,

MaskJS, поговорим о шаблонном движке, или новом велосипеде

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

  • скорость jsperf
  • расширяемость := кастомные контролы, трансформация шаблонных данных
  • data bindings
  • компиляция в json для дальнейшего кэширования
  • приятный синтаксис (без мешанины логики и структуры)

Из недостатков (хотя это реализуемо, просто мне это без надобности):

  • шаблонные данные могут находиться только в атрибутах и литералах

Если тема интересная —

Вступление

Давайте я сразу принесу свои извинения за грамматику/стилистику. Хочется написать все очень кратко, но в тоже время, чтобы все меня поняли, и к тому же правильно поняли. Вы же не компиляторы, которые всё «понимают» буквально — а каждый со своим опыт, суждением и прочим. А так как опыта в написании статей у меня мало, как и опыта в писании на русском — то это в корни перечёркивает надежду написать всё так, как я это представляю. Но я попробую, а вы не ругайтесь.

Немного истории

Пару лет назад мне понадобилась функция вида String.format('N: #{a} : #{b}',{a:1,b:2}).
Вскоре я понял, что используя её для форматирования html, у меня, по большей части, связаны руки. Ведь так хочется форматирование по условию, условной видимости и списков. Посмотрев на разные шаблонные движки, ощутил дикое отвращение к смеси html и javascript, плюс использование with(){} и eval/new Function('') тоже не радовало. Подумав, что «мне то совсем чуть-чуть надо» и решил написать для себя сам. Так родились два тэга list и visible и формат вида #{a==1?1:-1}. Mне достаточно было лишь найти рэгэкспом эти тэги, ну а дальше String.format. И вот полтора года этот движок отлично нёс свою службу — был шустрее шустрых и надёжнее надёжных.

«А нам всегда мало...»

Как не прискорбно, но мы так устроены — ну, по крайней мере, я. Захотелось ещё быстрее, ещё более расширяемо и чтобы дальше без месива html/javascript. На этот момент я знал точно: хочу кастомные тэги — что бы по десять раз не писать одну и ту же html структуру и без placeholder-ов(что бы после вставки в дом туда рэндерить). И вот как только я сел допиливать парсер для обработки дополнительных тэгов, тут как назло проснулось стремление к искусству, которое начало мусолить — "Перепиши. Перепиши код. Это же Ford Mondeo 93-го. А сейчас уже даже миллениум давно был. Да вот ещё и синтаксис шаблонов другой надо. Ты что не видел CoffeeScript Sass/Less ZenCoding? Перепиши, кому говорят, иначе уснуть не дам — так и знай". И под этим давлением я не устоял. Но все же главным приоритетом, это была скорость движка — никому не нужен красивый, но со 100 л.с. болид на старте.

Переходим к делу: Дерево

Так как используем свой синтаксис, его надо преобразовывать в дерево. Имеем два типа узлов: Тэг = {tagName:'someTagName', attr: { key:'value'}, nodes:[] } и Литерал = {content:'Some String'}. А так как за 1.5 года использования старого шаблонизатора, я ни разу не помню, что бы подставлял шаблонные данные в название тэга, или названия атрибута, то для простоты шаблона и анализатора, сделаем возможным вставки данных только в них. Поэтому узлы будут следующего вида: Тэг := {tagName:'name', attr: { key:('value'||function)}, nodes:[] } и Литерал := {content:('Some String'||function)}. Где function — это функция которая подставляет шаблонные данные и она только у тех значениях, которые их требуют. Вот мы и посадили дерево, пока что ничего сложного( дальше сложнее тоже не будет).

Анализатор/Парсер

Интересные моменты:

  • анализируем линейно, по возможности даже перепрыгнем участки, для дальнейшего «substring»
  • минимально используем вызовы функций, рекурсию совсем не используем
  • для анализа используем объект: var T = {index:0 /* currentIndex */, length:template.length, template:template}. При вызове дополнительных функций, передаём его(вернее передаётся ссылка на него). Таким образом template string не копируется
  • charCodeAt — не особо быстрее charAt или [], но дальнейшая робота с number быстрее

Сам парсинг до боли прост — составляет 40 строк. (парсинг атрибутов надо было бы не выносить в отдельную функцию, но потерялась бы наглядность)

Кусок кода

var current = T;
for (; T.index < T.length; T.index++) {
	var c = T.template.charCodeAt(T.index);
	switch (c) {
	case 32: //" "
		continue;
	case 39: // "'" парсим литерал
		T.index++;			
		var content = T.sliceToChar("'");  
		// в sliceToChar используется indexOf с проверкой на 'escape character'
		// поэтому след. indexOf имеет смысл, так как, это быстрее чем линейно проверять charCodeAt/charAt/[]
		if (~content.indexOf('#{')) content = T.serialize == null ? this.toFunction(content) : {
			template: content
		};
		current.nodes.push({
			content: content
		});

		if (current.__single) {
			//если это одинарный тэг, переходим к предку, который не одинарный; пример div > ul > li > span > 'Some'			
			if (current == null) continue;
			do (current = current.parent)
			while (current != null && current.__single != null);
		}		
		continue;
	case 62: /* '>' */
		current.__single = true;
		continue;
	case 123: /* '{' */
		continue;
	case 59: /* ';' */
	case 125: /* '}' */
		if (current == null) continue;
		// тэг закрыт ; , или закончился } - переходим к предку
		do(current = current.parent)
		while (current != null && current.__single != null);
		continue;
	}


	//знакомые символы не встретились - парсим tag с атрибутами
	var start = T.index;
	do(c = T.template.charCodeAt(++T.index))
	while (c !== 32 && c !== 35 && c !== 46 && c !== 59 && c !== 123); /** while !: ' ', # , . , ; , { */

	var tag = {
		tagName: T.template.substring(start, T.index),
		parent: current
	};
	current.nodes.push(tag);
	current = tag;
	this.parseAttributes(T, current);
	//парсинг атрибута закончится на ; > {, чуть чуть назад отступим
	T.index--;
}
	

Конструктор

Имея дерево, негоже строить html string для вставки в документ, надо строить сразу documentFragment (хотя function renderHtml тоже оставил, на всякий случай). Этим мы сильно компенсируем затраченное время на парсинг.
Сам процесс снова тривиальный:

Часть кода

function buildDom(node, values, container) {
	if (container == null)  container = document.createDocumentFragment();
	
	if (node instanceof Array) {
		for (var i = 0, length = node.length; i < length; i++) buildDom(node[i], values, container);
		return container;
	}

	if (CustomTags.all[node.tagName] != null) {
		var custom = new CustomTags.all[node.tagName]();
		for (var key in node) custom[key] = node[key];
		custom.render(values, container);
		return container;
	}
	
	if (node.content != null) {
		//это литерал
		container.appendChild(document.createTextNode(typeof node.content === 'function' ? node.content(values) : node.content));
		return container;
	}

	var tag = document.createElement(node.tagName);
	for (var key in node.attr) {
		var value = typeof node.attr[key] == 'function' ? node.attr[key](values) : node.attr[key];
		if (value) tag.setAttribute(key, value);
	}

	if (node.nodes != null) {
		buildDom(node.nodes, values, tag);
	}
	container.appendChild(tag);
	return container;
}

Кастомные контролы

Как видно выше из кода, здесь на сцену выходят кастомные контролы. Если конструктор встретит зарегистрированный обработчик тэга, он создаст объект обработчика, сделает shallow copy значений attr и nodes и передаст контекст сборки в функцию render. То есть наш контрол должен реализовать в прототипе функцию .render(currentValues, container)

toFunction(templateString)

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

  • или ключи к свойствам входных данных, возможна «рекурсия» #{obj.other.value}
  • или обращение к функции трансформаторе #{fnName:line}. Если fnName пустая строка считается что line это условие и будет выполнено функцией ValueUtilities.condition(line, values)

, обрабатываем их и вставляем в template.

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


Примеры
Исходники


оффтоп:
В арсенале много ещё каких «великов», например, IncludeJS — похоже на Require, но со своей кучей «вкусняшек». Если будет интерес к подобным вещам (это ведь не зарелизеные библиотеки для продакшн) тоже выложу на гитхаб, и напишу статью.

Удачи!

Автор: tenbits

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js