illumium.org

Главная › Блоги › Блог kayo

Разработка простого шаблонизатора на JavaScript

kayo — Сб, 15/10/2011 - 10:43

Есть несколько случаев, когда вам может понадобиться шаблонизатор на JavaScript, среди них как необходимость формирования содержимого на клиенте, так и на сервере, если используется JavaScript среды, такие как NodeJS или Rhino. Сразу скажу, что для себя рассматривал многие имеющиеся шаблонизаторы, от простых до экзотических. Более всего интересовали простые, однако позволяющие использовать сложную логику в шаблонах, и одним из таковых оказался EJS. Однако, штука эта была написана несколько не так, как мне бы хотелось, с одной стороны было много лишнего, с другой основная функциональность уж слишком усложнена. Я увидел возможность реализовать компиляцию и рендеринг его шаблонов гораздо проще.

Применение EJS

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

В случае EJS суть проста: в составе шаблона мы можем совмещать выводимое как есть содержимое с кусочками встроенного JavaScript, аналогично тому, как в файлах сценариев PHP объединяется HTML с кодом. В коде видны как глобальные переменные, так и те, что мы позаботимся передать рендереру при вызове. Ещё там видны так называемые функции-помощники, которые упрощают формирование часто используемых конструкций, например, тегов ссылок или картинок.

Код обрамляется в тег <% ваш код для исполнения %> или в тег <%= ваша переменная для вывода %>.

Вот как это может выглядеть:

<%if(content){%>
  <% if (title) { %>
    <h2><%=title%></h2>
  <% } %>
  <div><%= content %></div>
<%}%>

Я намеренно написал конструкции в разных стилях, чтобы показать, что наличие пробелов значения не имеет. Формируя вывод с использованием шаблона, мы передаём объект со свойствами, выступающими в качестве переменных, с которыми оперирует код внутри нашего шаблона. Смысл в том, что с помощью логических конструкций на самом же JavaScript можно гибко менять вид формируемого в результате содержимого. Как вариант возможно использование тегов вида [% %], если шаблон предполагается рендерить уже после того, как он включён в дерево DOM. В свете моих паттернов использования, оно мне показалось не очень полезным.

Улучшаем EJS

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

Разбор исходных шаблонов

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

/(?:\n\s*)?(<%[=]?)((?:[^%]|[%][^>])+)%>/gm

Итак, что мы здесь делаем:

  • (?:\n\s*)? — убираем перевод строки и все пробелы от него до тега кода, если таковое вообще присутствует. Это, конечно, делать было вовсе не обязательно, но мы же эстеты, так предотвратим появление множества пустых строк в формируемом содержимом. Спецификатор ?: в начале подпаттерна означает, что его содержимое не захватывается, а просто пропускается.
  • (<%[=]?) — захватываем собственно открывающий тег. На этот раз захватываем целиком подпаттерн открывающего тега, что нам потребуется потом при формировании кода.
  • [^%]|[%][^>] — наш код — это любые символы, не являющиеся %, или символ %, за которым не следует символ >.
  • (?:[^%]|[%][^>])+ — наш код — эти последовательности могут присутствовать более одного раза, но не должны захватываться.
  • ((?:[^%]|[%][^>])+) — наш код — а захватываем мы всю последовательность целиком.
  • %> — закрывающий тег не захватываем.
  • gm — включаем глобальную полнотекстовую (многострочную) обработку.

Как можно видеть, всё достаточно тривиально, осталось разобраться, что делать с этим регулярным выражением. А здесь нам поможет очень полезный метод String.split, который разбивает строку с использованием указанного разделителя, возвращая массив получившихся подстрок. Особенность данного метода в том, что в качестве разделителя он может принимать регулярное выражение, причём, если в нём присутствуют захватываемые подпаттерны, подстроки, которые им соответствуют, также попадут в результирующий массив в обычном порядке. Это и даёт нам идею нативной оптимизации компилятора шаблонов: разбиваем строку регулярным выражением, получаем последовательность из кусочков строчного контента и спецификаторов кода со следующими за ними кусочками этого кода, далее достаточно просто обработать получившийся массив.

Формирование исполняемого кода

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

Организуем это следующим нехитрым образом:

  • функцию будем формировать в виде строки, пригодной для вызова eval
  • в теле функции организуем последовательное присоединение кусочков содержимого к некой переменной результата
  • обычный код на JS вставим как есть

Однако нам потребуется решить следующие проблемы:

  • передача переменных и функций-помощников в тело исполняемой функции шаблона
  • организация переменной результата так, чтобы это не пересекалось с переменными, используемыми в шаблоне

Решение первой проблемы

Для того, чтобы решить первую проблему, проще всего использовать конструкцию with. Да, это именно та вещь, которая является извечной темой бурных дискуссий разработчиков и головной болью стандартизаторов w3c, именно её так ненавидят всякие валидаторы типа JSLINT и компиляторы вроде Closure Compiler. То, что делает данная конструкция, проще всего представить как предоставление объекта в качестве уровня видимости локальных переменных, то есть вы сможете обратиться к свойствам объекта по имени как к обычным переменным, то есть без префикса имени объекта с точкой, вот, как это выглядит:

var obj = {
  foo: 'It is foo.',
  bar: 42
};
with(obj){
  foo += ' And bar is ' + bar;
}

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

Решение второй проблемы

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

Посмотрим, что у нас есть, а вариантов в общем то не много: как-то использовать объект this или объект arguments, которые, ввиду особенностей языка, пользователь всё равно не должен использовать в шаблонах, а если и может, то лишь неким особым образом. Рассмотрев все за и против, я решил использовать arguments, массив неименованных аргументов, переданных функции, в один из них и будем класть всё, что нужно для корректного формирования содержимого.

Особенности реализации

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

Самое время перейти непосредственно к коду, рассмотрим всё по частям, начиная с главного и основного, а именно, с объекта шаблона:

  // Конструктор
  var EJS = function(src){
    if(typeof src == 'string'){ // Если передан шаблон
      this.compile(src); // Сразу компилируем его
    }
  };

  // Прототип
  EJS.prototype = {
    regexp: /(?:\n\s*)?(<%[=]?)((?:[^%]|[%][^>])+)%>/gm,
    helper: {} // Функции-помощники
  };

Далее реализуем функцию компилирования шаблона:

    EJS.prototype.compile = function(src){
      delete this.method;
      delete this.error; // удаляем следы предыдущего вызова компилятора
      var p = src.split(this.regexp), // Результат разбора
          r = [], // Результат сборки
          i, o;
      this.parsed = p; // Сразу же сохраняем результат разбора
      // Выполняем сборку функции генерации содержимого
      for(i = 0; i < p.length; i++){
	if(p[i] == '<%'){ // Если встречаем просто код
	  o = p[++i]; // Вставляем, как есть
	}else{ // В противном случае, имеем дело с содержимым
	  if(p[i] == '<%='){ // Если это переменная шаблона,
	    o = p[++i]; // просто выводим её
	  }else{ // Если это кусочек самого шаблона,
	    o = 'arguments[1][' + i + ']'; // будем брать его из результата разбора
	  }
	  o = 'arguments[0]+=' + o + ';'; // Формируем конструкцию для вывода
	}
	r.push(o); // Добавляем полученную часть кода к результату
      }
      // Вставляем начало и конец функции генерации содержимого
      r.unshift('this.method=function(){'+
                'with(arguments[2]){'+
                'with(arguments[3]){');
      r.push('};};return arguments[0];};');
      try{ // Пробуем вычислить полученный код, собрав в строку
	eval(r.join('\n'));
	return true; // Возвращаем истину
      }catch(e){ // Если что-то пошло не так, формируем ошибку
        if(typeof this.check == 'function'){ // Если доступно,
          // выполняем продвинутое обнаружение ошибок
          // (например, с помощью JSLINT)
	  e = this.check(r);
	}
	this.error = new EJS.CompileError(e.message);
	return this.error; // Возвращаем ошибку
      }
    };

Осталось добавить функцию рендеринга шаблона, но она более чем тривиальна:

    EJS.prototype.render = function(data){
    // Принимаем единственный аргумент, объект с переменными шаблона
      if(this.method instanceof Function){ // Если есть скомпилированный шаблон
	try{ // Пробуем применить шаблон
	  return this.method('', this.parsed, this.helper, data);
          // Если получилось, возвращаем сформированное содержимое
	}catch(e){
          // Если ничего не получилось, формируем ошибку рендеринга
	  this.error = new EJS.RenderError(e.message);
	}
      }
      return this.error; // Возвращаем ошибку
    };

Конечно, не забываем добавить функции ошибок:

  EJS.CompileError = function(message){
    if(typeof message == 'string'){
      this.message = message;
    }
  };
  EJS.CompileError.prototype = new Error();
  EJS.CompileError.prototype.name = 'EJS.CompileError';
  
  EJS.RenderError = function(message){
    if(typeof message == 'string'){
      this.message = message;
    }
  };
  EJS.RenderError.prototype = new Error();
  EJS.RenderError.prototype.name = 'EJS.RenderError';

Ну и если хотим, добавим функции-помощники:

  // Функция для красивой работы с функциями-помощниками
  EJS.Helper = function(name, func){
  // Не трудно догадаться, как её использовать для 
  // получения, добавления и удаления функций-помощников
    if(arguments.length < 2){
      return EJS.prototype.helper[name];
    }
    if(func === null){
      delete EJS.prototype.helper[name];
      return;
    }
    if(typeof func == 'function'){
      EJS.prototype.helper[name] = func;
      return true;
    }
  };
  
  EJS.Helper('img_tag', function(src, alt){ // Тэг картинки
    return src ? '<img src="' + src + '"' +
           (alt ? ' alt="' + alt + '"' : '') +
           '/>' : '';
  });
  EJS.Helper('link_to', function(title, href){ // Тэг ссылки
    return title && href ? '<a href="' + href + '">' + title + '</a>' : '';
  });
  // Далее добавьте свои

На этом почти всё, ну разве только что могу предложить сделать более высокоуровневую обёртку вокруг всего этого.

Что дальше?

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

В моих проектах применяется такая штука как RequireJS, которая реализует единый механизм связывания кода ваших приложений, да и не только кода, а вообще в принципе любых типов ресурсов, причём независимо от среды, в которой выполняется ваш код на JavaScript. В статье JavaScript: Node.JS, RequireJS, NodeLint и другие я уже писал о том, как это работает, однако одна очень полезная особенность так и не была рассмотрена. Это использование и разработка так называемых плагинов RequireJS. Но данный вопрос будет темой следующей статьи, эта итак получилась весьма не маленькая.

Здесь выкладываю библиотеку с некоторыми тестами, для запуска которых на стороне сервера вам понадобится NodeJS с установленным RequireJS, а на клиентской стороне web-сервер nginx. Тестируется командой make из директории проекта.

ВложениеРазмер
ejs.tar_.xz75.68 КБ
  • Разработка для WEB
  • ecmascript
  • ejs
  • javascript
  • nodejs
  • requirejs
  • template
  • Бортовой журнал Иллюмиума

Отправить комментарий

Содержимое этого поля является приватным и не будет отображаться публично.
  • Доступные HTML теги: <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Syntax highlight code surrounded by the {syntaxhighlighter SPEC}...{/syntaxhighlighter} tags, where SPEC is a Syntaxhighlighter options string or "class="OPTIONS" title="the title".

Подробнее о форматировании

CAPTCHA
Этот вопрос задается для того, чтобы выяснить, являетесь ли Вы человеком или представляете из себя автоматическую спам-рассылку.
          _  __  _____  __        __     _     __        __
__ __ | |/ / |___ | \ \ / / / \ \ \ / /
\ \ / / | ' / / / \ \ /\ / / / _ \ \ \ /\ / /
\ V / | . \ / / \ V V / / ___ \ \ V V /
\_/ |_|\_\ /_/ \_/\_/ /_/ \_\ \_/\_/
Введите код, изображенный в стиле ASCII-арт.
RSS-материал

Навигация

  • Подшивки
  • Фотоальбомы

«Иллюмиум» на якоре.

Работает на Drupal, система с открытым исходным кодом.

(L) 2010, Illumium.Org. All rights reversed ^_~