Как и зачем создавать собственный шаблонизатор для DOM
Фронтенд-разработчик в Miro рассказал, что такое шаблонизация, и показал один из возможных подходов к динамическому формированию разметки.
Посмотрим в код
Прежде чем начинать изучать шаблонизацию, давайте вспомним два базовых подхода к созданию разметки.
Перед нами два отрывка кода, которые отвечают за одинаковую разметку для списка чатов: декларативный и императивный. Так это выглядит в HTML:
Теперь создаём такое же дерево, но через DOM API в JavaScript:
HTML выглядит понятнее, чем множество однотипных вызовов DOM API. Однако есть и минус: HTML статичен — его прописывают один раз и пользуются, а если нужно описать десятки или сотни однотипных элементов на странице, придётся написать всю разметку.
Есть огромный соблазн отдать эту рутинную работу коду. Или, например, когда десятки или сотни элементов приходят с сервера в процессе работы приложения, нужно создавать dom-элементы динамически, наполняя их данными, как в примере кода выше.
Где-то на стыке этих двух подходов работы с DOM появляется шаблонизация. С одной стороны, она позволяет описать разметку декларативно, а с другой стороны — описать динамические данные прямо в этой разметке.
Представьте, если бы вы могли создать DOM-дерево с динамическими данными, не используя createElement и описание свойств, а например, вот так:
Такая запись читается проще, чем манипуляции с document.createElement. Здесь можно:
- ясно увидеть, где должны быть имена классов и в каких местах они динамические;
- представить, как выглядит DOM-дерево сразу, не прикидывая в голове цепочку append (добавлений) элементов друг в друга;
- рендерить куски вёрстки быстро и наглядно.
К подобному синтаксису шаблона мы и будем стремиться.
Зачем это всё?
Действительно, зачем в век React, Angular и Vue заниматься такой мелочью, как шаблонизация? Все современные фреймворки умеют динамически подставлять данные в DOM и даже больше: циклы, условные операторы, компоненты, жизненный цикл.
В конце концов, если не пользоваться фреймворками — есть уже готовые шаблонизаторы, можно же взять их?
Сколько бы ни было на рынке инструментов, какие бы возможности они ни предоставляли, фактически всё сводится к 26 буквам английского алфавита, десятку символов и идеям: паттернам программирования и проектирования, принципам построения чистой архитектуры и чистого кода, шаблонам решения типовых задач.
Понимая идеи и принципы решения тех или иных задач, вам будет проще разобраться в любых существующих инструментах — вы будете понимать, какую задачу и с помощью каких принципов они решают. Такой подход намного продуктивнее, нежели простое заучивание API-фреймворков.
Именно поэтому написана эта статья — чтобы показать один из возможных подходов к динамическому формированию разметки.
Идея и синтаксис шаблона
Шаблонизатор — это функция, которая на вход получает некоторую разметку в виде чистого текста и контекст, а на выход даёт готовый DOM-элемент или текст, готовый к использованию в innerHTML. Под контекстом подразумевается набор информации, который подставляется вместо переменных в шаблонной строке.
Чтобы шаблонизатор мог работать, нужно придумать соглашение о синтаксисе — как описать в шаблоне места, куда должны подставляться данные из контекста. Обычно используют какие-нибудь скобки, в которые помещают идентификаторы переменных.
Скобки удваиваются, чтобы не спутать динамические данные со статическим текстом, который может быть в шаблоне. Они достаточно часто употребляются в текстах, поэтому без удвоения их легко перепутать:
Если выбрать круглые скобки в качестве маркера динамических данных и не удвоить их, непонятно, где переменная, а где просто текст в скобках.
Примем за обозначение переменных в шаблоне удвоенные фигурные скобки — такое решение достаточно распространённое. Таким образом шаблон для примера выше будет таким:
Хранение шаблона
Так как шаблон — это строка, то его можно хранить просто в строке ?
Если пойти дальше и развивать идею, окажется, что необязательно хранить строку с шаблоном именно в файле с кодом. Хранение шаблона превращается в отдельную задачу в духе «получить откуда-то шаблон», например, из тега :
Значение у скрипта type отличается от text/javascript, поэтому браузер не будет пытаться обработать его как JavaScript.
Получить содержимое шаблона очень просто:
Также организовать хранение шаблона можно в файле с любым расширением и импортировать его. У популярных шаблонизаторов зафиксированы свои расширения, чтобы редакторы кода могли легко их распознавать.
Можно создать и использовать шаблоны следующим образом:
Однако для такого решения, возможно, придётся настроить ваш сборщик, чтобы он корректно импортировал подобные файлы.
Желаемый синтаксис использования
Теперь, когда мы понимаем синтаксис и метод хранения шаблона, пора подумать о том, как оживлять этот шаблон и превращать его в HTML.
Что нам предстоит сделать:
- Подготовить шаблон (написать его в переменной, импортировать или достать из template-тега).
- Подготовить данные для шаблона.
- Поместить данные и шаблон в шаблонизатор.
- Поместить в DOM разметку, полученную из шаблонизатора.
Представим, как может выглядеть третий пункт:
На самом деле API шаблонизатора может выглядеть так, как вам нравится — всё зависит от задач.
Мы остановимся на третьем варианте — const markup = new Templator(template); markup.compile(data). Это позволит единожды создать экземпляр с шаблоном и в дальнейшем вызывать его метод compile с нужными данными для получения разметки.
Возможные способы реализации
Есть много способов реализовать шаблонизатора для DOM под капотом — от самых простых до сложнейших. Рассмотрим два основных:
- Заменить по регулярному выражению все переменные, встречающиеся в шаблоне, на данные и с помощью innerHTML поместить получившуюся разметку в DOM.
- Написать полноценный интерпретатор шаблонов, который будет символ за символом читать шаблон, разбивать его на токены, строить синтаксическое дерево и по этому дереву собирать разметку.
Первый способ — базовый и немного «костыльный». Его опасность — в innerHTML, который нежелательно использовать в более-менее серьёзных приложениях. Второй способ — достаточно мощный: он позволяет вводить различные операторы и операции в шаблон, превращая шаблоны в язык программирования.
Сейчас мы осуществим первый способ реализации, чтобы обрести базовое понимание шаблонизации.
Реализуем шаблонизатор для DOM
Попробуем заставить работать следующий код:
Раз мы приняли решение делать шаблонизатор для DOM на классе, напишем заготовку такого класса:
Конструктор принимает шаблон в виде обычной строки, а его превращение в разметку будет скрыто в методе compile.
Proof of concept — регулярное выражение
Прежде чем писать реальный код в заготовке класса, давайте потренируемся на тестовых данных. В первую очередь нам надо научиться находить в строке шаблонную переменную и на что-то её заменять.
Мы договорились о способе записи переменных в шаблоне — {{ someVariable }}. Найти такие фрагменты в строке нам поможет простое регулярное выражение /\{\{(.*?)\}\}/.
Попробуем применить регулярное выражение к строке с помощью метода exec:
Итак, если совпадение в строке есть, возвращается массив, у которого во втором элементе лежит то, что нам нужно — то, что внутри фигурных скобок. Это значение мы и будем использовать, чтобы обратиться к ключу объекта-контекста: «Если exec вернул не null, заменим в шаблоне встреченную шаблонную переменную на значение из контекста».
Пробуем дальше — у нас в шаблоне наверняка может встретиться не одна переменная, а несколько.
Дело в том, что у регулярных выражений есть несколько хитростей. В нашем случае регулярное выражение находит первое совпадение и останавливает работу метода exec. Требуется два шага, чтобы заставить регулярное выражение работать со всеми совпадениями.
- Нужно добавить ему флаг g , что значит global — искать все совпадения. Регулярное выражение с флагом g будет хранить и изменять своё состояние.
- Регулярное выражение надо применить к строке несколько раз. Сколько точно, заранее неизвестно. В этом случае отлично подходит цикл while, а условием для него будет служить знание того, что exec возвращает null, когда совпадение не найдено.
Посмотрим на примере:
Чтобы не писать exec вручную, завернём его в цикл. Но нам нужно не просто выполнить exec, а ещё и поработать с его результатом. Для этого воспользуемся особенностью JS, когда операция присваивания возвращает присваиваемое значение:
Теперь этот цикл пройдётся по всей строке и найдёт все совпадения — то что нужно!
Пишем метод compile
Теперь, когда у нас достаточно навыков работы с регулярным выражением, нужно его применить. Алгоритм следующий:
- находим совпадение в строке шаблона,
- очищаем от пробелов название переменной,
- заменяем в шаблоне все вхождения этой переменной на значение из контекста,
- повторяем.
Интересный момент в выражении new RegExp(match[0], ‘gi’). Вы помните, что exec первым элементом массива возвращает полное совпадение из строки регулярному выражению. Здесь мы создаём из этого совпадения новое регулярное выражение, чтобы заменить все вхождения в шаблон подобной строки на настоящее значение.
Такая реализация кажется достаточной, но на самом деле нужно разобраться с парой моментов:
- Что делать, если в контексте не передано нужной переменной?
- Что делать, если в нужном ключе объекта лежит не примитив, а объект или массив?
- Что делать, если в нужном ключе объекта лежит функция?
Эти вопросы не имеют однозначного ответа и выходят за рамки статьи: у нас нет цели написать полноценный шаблонизатор — мы хотим познакомиться с принципами его построения. Однако остановимся на последнем вопросе, чтобы в будущем не наделать ошибок.
Добавляем «лёгкие» обработчики событий
Итак, если в ключе контекста встречается функция, нужно корректно её обработать. Прежде всего нужно договориться о правиле применения функций в шаблоне, на соблюдение которого мы будем полагаться.
Пусть в шаблоне переменные значения с функциями помещаются только в атрибуты on*:
Вот почему обработчик называется «лёгким» — это не настоящий addEventListener, а использование DOM API. Этот шаблон должен превратиться в следующий HTML:
Обратите внимание: в HTML это именно вызов функции со скобками в конце.
Такой способ можно довольно просто добавить в текущий шаблонизатор, чтобы не перегружать его ненужной логикой. Всё остальное будет лишним и слишком сложным в ситуации простого знакомства с шаблонизацией. А ещё этот механизм не позволяет передавать в функцию атрибут event. Минусов хватает, но начинать с чего-то надо ?
Чтобы добавить такие обработчики, нужно проверить в методе компиляции, что значение из контекста по ключу является функцией. Если это так, то требуется подставлять не просто значение, но и добавлять в конец вызов функции. Например, мы передали в контексте {handleClick: () => {}} для шаблона