Обложка: Как и зачем создавать собственный шаблонизатор для DOM

Как и зачем создавать собственный шаблонизатор для DOM

Владимир Санников
Владимир Санников

Фронтенд-разработчик в Miro и автор курса «Мидл фронтенд-разработчик» в Яндекс.Практикуме

Посмотрим в код

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

Перед нами два отрывка кода, которые отвечают за одинаковую разметку для списка чатов: декларативный и императивный. Так это выглядит в HTML:

<div class="chat__wrapper">
  <div class="chat__button">
    <button class="button">
      <span>Добавить чат</span>
    </button>
  </div>

  <ul class="chat__list">
    <li class="chat__item">Название чата 1</li>
    <li class="chat__item">Название чата 2</li>
    <li class="chat__item">Название чата 3</li>
  </ul>
</div>

Теперь создаём такое же дерево, но через DOM API в JavaScript:

const CHAT_NAMES = [
  'Название чата 1',
  'Название чата 2',
  'Название чата 3',
];
function renderChat() {
  const ul = document.createElement('ul');
  ul.className = 'chat__list';

  CHAT_NAMES.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item;
    li.className = 'chat__item';
    ul.appendChild(li);
  });

  return ul;
}

function renderButton() {
  const button = document.createElement('button');
  button.className = 'button';

  const text = document.createElement('span');
  text.textContent = 'Добавить чат';
  button.appendChild(text);
		
  const wrapper = document.createElement('div');
  wrapper.className = 'chat__button';
		
  wrapper.appendChild(button);
  return wrapper;
}

const wrapper = document.createElement('div');
wrapper.className = 'chat__wrapper';

wrapper.appendChild(renderButton());
wrapper.appendChild(renderChat());

document.body.appendChild(wrapper);

HTML выглядит понятнее, чем множество однотипных вызовов DOM API. Однако есть и минус: HTML статичен — его прописывают один раз и пользуются, а если нужно описать десятки или сотни однотипных элементов на странице, придётся написать всю разметку.

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

Где-то на стыке этих двух подходов работы с DOM появляется шаблонизация. С одной стороны, она позволяет описать разметку декларативно, а с другой стороны — описать динамические данные прямо в этой разметке.

Представьте, если бы вы могли создать DOM-дерево с динамическими данными, не используя createElement и описание свойств, а например, вот так:

<div class="{{ wrapperClassName }}"> 
  <div class="chat__button"> 
    <button class="button"> 
      <span>{{ buttonText }}</span> 
    </button> 
  </div> 

  <ul class="{{ chatListClassName }}"> 
    {{ chatListItems }} 
  </ul> 
</div> 

Такая запись читается проще, чем манипуляции с document.createElement. Здесь можно:

  • ясно увидеть, где должны быть имена классов и в каких местах они динамические;
  • представить, как выглядит DOM-дерево сразу, не прикидывая в голове цепочку append (добавлений) элементов друг в друга;
  • рендерить куски вёрстки быстро и наглядно.

К подобному синтаксису шаблона мы и будем стремиться.

Зачем это всё?

Действительно, зачем в век React, Angular и Vue заниматься такой мелочью, как шаблонизация? Все современные фреймворки умеют динамически подставлять данные в DOM и даже больше: циклы, условные операторы, компоненты, жизненный цикл.

В конце концов, если не пользоваться фреймворками — есть уже готовые шаблонизаторы, можно же взять их?

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

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

Именно поэтому написана эта статья — чтобы показать один из возможных подходов к динамическому формированию разметки.

Идея и синтаксис шаблона

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

Чтобы шаблонизатор мог работать, нужно придумать соглашение о синтаксисе — как описать в шаблоне места, куда должны подставляться данные из контекста. Обычно используют какие-нибудь скобки, в которые помещают идентификаторы переменных.

<div class="chat {{wrapperClassName}}">
<!-- или -->
<div class="chat [[wrapperClassName]]">
<!-- или -->
<div class="chat ((wrapperClassName))">

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

<div class="chat"> 
  Всего активно(на данный момент) (chatCount) чатов 
</div>

Если выбрать круглые скобки в качестве маркера динамических данных и не удвоить их, непонятно, где переменная, а где просто текст в скобках.

Примем за обозначение переменных в шаблоне удвоенные фигурные скобки — такое решение достаточно распространённое. Таким образом шаблон для примера выше будет таким:

<div class="chat"> 
  Всего активно(на данный момент) {{chatCount}} чатов 
</div>

Хранение шаблона

Так как шаблон — это строка, то его можно хранить просто в строке 🙂

const template = ` 
<div class="chat"> 
  Всего активно(на данный момент) {{chatCount}} чатов 
</div> 
`; 

Если пойти дальше и развивать идею, окажется, что необязательно хранить строку с шаблоном именно в файле с кодом. Хранение шаблона превращается в отдельную задачу в духе «получить откуда-то шаблон», например, из тега <script>:

<script type="template" id="chatTemplate"> 
  <div class="chat"> 
    Всего активно(на данный момент) {{chatCount}} чатов 
  </div> 
</script>

Значение у скрипта type отличается от text/javascript, поэтому браузер не будет пытаться обработать его как JavaScript.

Получить содержимое шаблона очень просто:

const template = document.getElementById('chatTemplate').innerHTML;

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

Можно создать и использовать шаблоны следующим образом:

// chats.tmpl

<div class="chat">
  Всего активно(на данный момент) {{chatCount}} чатов
</div>
// Использование шаблона 

const tmpl = require('./chats.tmpl'); 

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

Желаемый синтаксис использования

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

Что нам предстоит сделать:

  1. Подготовить шаблон (написать его в переменной, импортировать или достать из template-тега).
  2. Подготовить данные для шаблона.
  3. Поместить данные и шаблон в шаблонизатор.
  4. Поместить в DOM разметку, полученную из шаблонизатора.

Представим, как может выглядеть третий пункт:

const template = ...;
const data = ...;

// может быть вот так:
const markup = templator(template, data);

// или так:
const markup = templator(template)(data)

// или даже так:
const markup = new Templator(template).compile(data);

// или как-нибудь ещё

На самом деле API шаблонизатора может выглядеть так, как вам нравится — всё зависит от задач.

Мы остановимся на третьем варианте — const markup = new Templator(template); markup.compile(data). Это позволит единожды создать экземпляр с шаблоном и в дальнейшем вызывать его метод compile с нужными данными для получения разметки.

Возможные способы реализации

Есть много способов реализовать шаблонизатора для DOM под капотом — от самых простых до сложнейших. Рассмотрим два основных:

  1. Заменить по регулярному выражению все переменные, встречающиеся в шаблоне, на данные и с помощью innerHTML поместить получившуюся разметку в DOM.
  2. Написать полноценный интерпретатор шаблонов, который будет символ за символом читать шаблон, разбивать его на токены, строить синтаксическое дерево и по этому дереву собирать разметку.

Первый способ — базовый и немного «костыльный». Его опасность — в innerHTML, который нежелательно использовать в более-менее серьёзных приложениях. Второй способ — достаточно мощный: он позволяет вводить различные операторы и операции в шаблон, превращая шаблоны в язык программирования.

Сейчас мы осуществим первый способ реализации, чтобы обрести базовое понимание шаблонизации.

Реализуем шаблонизатор для DOM

Попробуем заставить работать следующий код:

const template = `
<div class="{{ wrapperClassName }}">
  <div class="chat__button">
    <button class="button">
      <span>{{ buttonText }}</span>
    </button>
  </div>

  <ul class="{{ chatListClassName }}">
    {{ chatListItems }}
  </ul>
</div>
`;

const CHAT_NAMES = [
  'Название чата 1',
  'Название чата 2',
  'Название чата 3',
];

const chatsTemplate = new Templator(tmpl)

const chatsMarkup = chatsTemplate.compile({
  wrapperClassName: 'chat__wrapper',
  buttonText: 'Добавить чат',
  chatListClassName: 'chat__list',
  chatListItems: CHAT_NAMES.map(item => (
    `<li class="chat__item">${item}</li>`
  )),
});

document.body.innerHTML = chatsMarkup;

Раз мы приняли решение делать шаблонизатор для DOM на классе, напишем заготовку такого класса:

class Templator {
  constructor(template) {
    this._template = template;
  }

  compile(ctx) {
     // здесь будет происходить вся работа
  }
}

Конструктор принимает шаблон в виде обычной строки, а его превращение в разметку будет скрыто в методе compile.

Proof of concept — регулярное выражение

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

Мы договорились о способе записи переменных в шаблоне — {{ someVariable }}. Найти такие фрагменты в строке нам поможет простое регулярное выражение /\{\{(.*?)\}\}/.

Попробуем применить регулярное выражение к строке с помощью метода exec:

/\{\{(.*?)\}\}/.exec('some string') // получим null 
/\{\{(.*?)\}\}/.exec('some template with {{ variable }}') // получим массив 
/\{\{(.*?)\}\}/.exec('some template with {{ variable }}')[0] // {{ variable }} 
/\{\{(.*?)\}\}/.exec('some template with {{ variable }}')[1] // variable — обратите внимание: вокруг есть пробелы 

Итак, если совпадение в строке есть, возвращается массив, у которого во втором элементе лежит то, что нам нужно — то, что внутри фигурных скобок. Это значение мы и будем использовать, чтобы обратиться к ключу объекта-контекста: «Если exec вернул не null, заменим в шаблоне встреченную шаблонную переменную на значение из контекста».

Пробуем дальше — у нас в шаблоне наверняка может встретиться не одна переменная, а несколько.

/\{\{(.*?)\}\}/.exec('some template with {{ variable }} with {{ anotherVariable }}')
// массив, но такой же, как в первый раз — не этого мы хотели

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

  1. Нужно добавить ему флаг g , что значит global — искать все совпадения. Регулярное выражение с флагом g будет хранить и изменять своё состояние.
  2. Регулярное выражение надо применить к строке несколько раз. Сколько точно, заранее неизвестно. В этом случае отлично подходит цикл while, а условием для него будет служить знание того, что exec возвращает null, когда совпадение не найдено.

Посмотрим на примере:

/\{\{(.*?)\}\}/g.exec('some template with {{ variable }} with {{ anotherVariable }}')
// Даже с флагом массив такой же, как и в первый раз

// Попробуем применить ещё раз регулярное выражение
/\{\{(.*?)\}\}/g.exec('some template with {{ variable }} with {{ anotherVariable }}')
// Опять то же самое :(

// На самом деле строки выше каждый раз создают новое регулярное выражение — вот почему результаты одинаковые
// Чтобы всё было ок, нужно один раз создать регулярное выражение и использовать его:
const templateVariableRe = /\{\{(.*?)\}\}/g;

// Надоело копировать и вставлять строку — давайте поместим её в переменную
const template = 'some template with {{ variable }} with {{ anotherVariable }}';

templateVariableRe.exec(template)[1] // variable
templateVariableRe.exec(template)[1] // anotherVariable
templateVariableRe.exec(template) // null

// Вот теперь то, что нужно!

Чтобы не писать exec вручную, завернём его в цикл. Но нам нужно не просто выполнить exec, а ещё и поработать с его результатом. Для этого воспользуемся особенностью JS, когда операция присваивания возвращает присваиваемое значение:

let b;
const result = b = 1; // или const result = (b = 1) для большей читабельности
console.log(result); // 1
let match = null;
while (match = templateVariableRe.exec()) {
  console.log(match);
}

Теперь этот цикл пройдётся по всей строке и найдёт все совпадения — то что нужно!

Пишем метод compile

Теперь, когда у нас достаточно навыков работы с регулярным выражением, нужно его применить. Алгоритм следующий:

  1. находим совпадение в строке шаблона,
  2. очищаем от пробелов название переменной,
  3. заменяем в шаблоне все вхождения этой переменной на значение из контекста,
  4. повторяем.
class Templator {
  constructor(template) {
    this._template = template;
    }

    compile(ctx) {
      const templateVariableRe = /\{\{(.*?)\}\}/g;
      let match = null;
      et result = this._template;

      while (match = templateVariableRe.exec(this._template)) {
        const variableName = match[1].trim(); // почистили строку от пробелов
        if (!variableName) {  // Вдруг там просто пустые скобки...
          continue;
        }
        const data = ctx[variableName];
        result = result.replace(new RegExp(match[0], 'gi'), data);
      }

      return result;
    }
}

Интересный момент в выражении new RegExp(match[0], ‘gi’). Вы помните, что exec первым элементом массива возвращает полное совпадение из строки регулярному выражению. Здесь мы создаём из этого совпадения новое регулярное выражение, чтобы заменить все вхождения в шаблон подобной строки на настоящее значение.

Такая реализация кажется достаточной, но на самом деле нужно разобраться с парой моментов:

  • Что делать, если в контексте не передано нужной переменной?
  • Что делать, если в нужном ключе объекта лежит не примитив, а объект или массив?
  • Что делать, если в нужном ключе объекта лежит функция?

Эти вопросы не имеют однозначного ответа и выходят за рамки статьи: у нас нет цели написать полноценный шаблонизатор — мы хотим познакомиться с принципами его построения. Однако остановимся на последнем вопросе, чтобы в будущем не наделать ошибок.

Добавляем «лёгкие» обработчики событий

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

Пусть в шаблоне переменные значения с функциями помещаются только в атрибуты on*:

const template = '<div onclick="{{ handleClick }}">Click</div>';

Вот почему обработчик называется «лёгким» — это не настоящий addEventListener, а использование DOM API. Этот шаблон должен превратиться в следующий HTML:

<div onclick="handleClick()">Click</div>

Обратите внимание: в HTML это именно вызов функции со скобками в конце.

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

Чтобы добавить такие обработчики, нужно проверить в методе компиляции, что значение из контекста по ключу является функцией. Если это так, то требуется подставлять не просто значение, но и добавлять в конец вызов функции. Например, мы передали в контексте {handleClick: () => {}} для шаблона <button onclick='{{handleClick}}’>, а на выходе должны получить:

<button onclick='handleClick()'>

Но откуда возьмётся handleClick в области видимости, где его будет вызывать Browser API? Чтобы этот HTML-элемент работал корректно, функция handleClick должна находиться в window — именно там её будет искать браузер.

Помимо добавления скобок, ещё придётся присвоить функцию из контекста в какое-то поле window. Сделаем это:

class Templator {
  constructor(template) {
    this._template = template;
  }

  compile(ctx) {
    const templateVariableRe = /\{\{(.*?)\}\}/g;
    let match = null;
    let result = this._template;

    while (match = templateVariableRe.exec(this._template)) {
      const variableName = match[1].trim(); // Почистили строку от пробелов
      if (!variableName) {  // Вдруг там просто пустые скобки
        continue;
      }

      const data = ctx[variableName];
				
      if (typeof data === 'function') {
        window[variableName] = data; // Сохранили функцию в window
        result = result.replace(new RegExp(match[0], 'gi'), `window.${variableName}()`); // Использовали
        continue // Потому что мы уже обработали функцию — идём дальше
      }

      result = result.replace(new RegExp(match[0], 'gi'), data);
    }

    return result;
  }
}

Заключение

Итак, мы изучили, что такое шаблонизация, какие вопросы стоит задавать при создании шаблонизатора и как на них можно отвечать. Реализация получилась, что называется, на коленке, но она вполне отражает ход размышлений и при этом даже работает 🙂

Несомненно, эту реализацию можно улучшить, но стоит ли? В приведённом случае мы всё равно подвержены уязвимости в виде innerHTML, потому что не создаём в шаблонизаторе настоящий DOM, а только оперируем строкой.

Для создания настоящего DOM надо как минимум написать настоящий парсер для языка разметки, выстроить работу с абстрактным синтаксическим деревом (AST) и создать компилятор из AST в DOM API, чтобы на выходе из шаблонизатора был честный DOM-элемент с наполнением. А ещё чтобы это всё было создано с помощью document.createElement. Но эта тема тянет на несколько статей, и если вам интересно — мы с командой курса «Мидл фронтенд-разработчик» их напишем.

Немного практики

Сделайте так, чтобы в синтаксисе шаблона можно было размещать обращения к полям объекта:

const template = ` 
<div class="{{ className }}"> 
  <span>{{ user.info.firstName }}</span> 
</div> 
`; 

const markup = templator(template)({ 
  className: 'users__list', 
  user: { 
    info: { 
      firstName: 'Anonymous' 
    } 
  } 
})