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



    Вот, наконец дошли руки поделиться с людьми одним из множества велосипедов (как сейчас называют личные наработки). До хабраката пару плюсов и минусов этого решения:
    Из плюсов:
    • скорость jsperf (тест с прекомпилированными шаблонами: 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, но со своей кучей «вкусняшек». Если будет интерес к подобным вещам (это ведь не зарелизеные библиотеки для продакшн) тоже выложу на гитхаб, и напишу статью.

    Удачи!
    • +27
    • 3,9k
    • 8
    Поделиться публикацией

    Похожие публикации

    Комментарии 8

      +4
      Поправьте пожалуйста пример на Гите — он кажется не работает.
        0
        Ух, точно. Сейчас поправлю. Спасибо. Сам поломал скрипт, а из кэша грузилось…
          0
          Теперь должно работать.
        0
        Я уже привык к шаблонам нокаута (декларативный html стиль), но ваш вариант интересный, напоминает селекторы и ZenCoding — Сначала непонятный, но потом привыкаешь. +)
          0
          Навеяло beebole.com/pure/
            0
            Интересно.
            Можете рассказать в чем основное отличие от underscore'овского _.template?
              0
              Ну всмысле, кроме zen-coding style, конечно.
                0
                Эх, ну что ж вы делаете — я как раз хотел написать — zen-coding style! Ну а если серьёзно:
                • всё же стиль, сравните:
                  var l = "<% _.each(people, function(name) { %> <li><%= name %></li> <% }); %>";
                  _.template(l, { people: ['moe', 'curly', 'larry'] });
                  

                  var l = "list value='people' > li > '#{.}'";
                  _.template(l, { people: ['moe', 'curly', 'larry'] });
                  

                • скорость — возможно даже теплейты компелировать в (json)Dom и хранить в localStorage или на сервере, или в случае мобильных приложений компилировать их перед релизом — так скорость еще увеличивается
                • учитывая, что «джаваскрипта» в тэмплейте нету, они не меньше функциональны за счет кастомных контролов и утилит. И поэтому замечательно вписывается в mvc/mvvc структуру. Вот есть замечательная библиотека Enjo, так хотелось бы зделать подобное на основе MaskJS,Class и IncludeJS.
                • посмотрел, в underscore нету биндингов — после вставки в дом, надо все вручную обновлять.

                Может ещё что упустил…

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое