В продолжение статьи о оптимизации DOM операций я бы хотел написать свой мини фреймворк. В ходе написания мы реализуем
- Свой шаблонизатор
- Паттерн subscriber
- Свой фреймворк с методами жизненного цикла
- Свой стейт менеджер
- Возможно сделаем роутер самописный
Все это поможет нам лучше понять устройство этих вещей под капотом. В ходе написания будем широко использовать ООП паттерн. А в конце мы сделаем небольшой замер скорости работы нашего фреймворка и React.
Зачем?
Зачем писать свой шаблонизатор - очевидный вопрос который вытекает из темы данного поста
Наше приложение будет - обычный список задач без замудренного функционала, просто чтобы показать как работает фреймворк под капотом. Хотя на том "фреймворке" что мы будем писать можно делать и более сложные вещи
Мы конечно можем рендерить наш список задач в формате
<div class="todo__wrapper">
<div class="todo__button">
<input type="text" class="todo__input" oninput="changeHandler()">
<button class="button" onclick="clickHandler()" type="button">
<span>Добавить задачу</span>
</button>
</div>
<ul class="todo__list">
<li class="todo__item">Задача 1</li>
<li class="todo__item">Задача 2</li>
<li class="todo__item">Задача 3</li>
</ul>
</div>Все классно работает, нет никаких проблем, кроме того что это статика, а если мы хотим рендерить список задач динамически, так же как классы - это уже нужно будет придумывать какую-то функцию, вешать слушатель и так далее. А так же между этим не забывать об оптимизации рендеринга и общего перфоманса.
В jsx, который по сути своей тоже шаблонизатор, мы могли бы сделать так
<ul class="todo__list">
{todos.map(todo => (<li className={'todo__item'}>{todo.text}</li>))}
</ul>И в таком случае наши тудушки будут рендериться динамически реактивно, я хочу сделать что-то похожее.
Имплементация
Я буду хранить шаблон в файле todoList.tmpl.ts, выглядить он будет так
export const template = `
<div class="{{ wrapperClassName }}">
<div class="todo__button">
<input type="text" class="{{ inputClassName }}" oninput="{{ changeHandler }}">
<button class="button" onclick="{{ handleClick }}" type="button">
<span>{{ buttonText }}</span>
</button>
</div>
<ul class="{{ todoListClassName }}">
{{ todoListItems }}
</ul>
</div>
`;Теперь нам нужно сделать класс шаблонизатора, который будет
- Принимать строку
- Распознавать все элементы которые заключены в {{}}
- Создавать массив этих элементов
- Иметь метод compile, который будет принимать обьект, где будут ключи идентичные элементам массива созданного в п3
- Метод compile будет брать обьект который передан в него (назовем обьект ctx от слова context)
- И обращаться по ключу, забирая ключ у элемента массива
- Далее полученное значение методом replace мы будем вставлять в полученную на вход в класс шаблонную строку
- В итоге метод отдает шаблонную строку где значения заменены на динамические
Реализация в коде
type funcType = () => void;
type keyType = string | any[] | funcType
class Templator {
private _template: string;
constructor(template: string) {
this._template = template;
}
compile(ctx: Record<string, keyType>) {
const templateVariableRe = /\{\{(.*?)\}\}/g;
let match = null;
let result = this._template;
while (match = templateVariableRe.exec(this._template)) {
const variableName = match[1] && match[1].trim();
if (!variableName) {
continue;
}
const data = ctx[variableName];
result = result.replace(new RegExp(match[0], 'gi'), data as string);
}
return result;
}
}Вот так выглядит начальная версия нашего шаблонизатора, мы просто регулярным выражением фильтруем строку которую отправили, потом находим в обьекте ctx такую переменную и заменяем в итоговой строке значением из обьекта шаблон
Допишем еще пару моментов в реализации
Сначала сделаем внешний удобный api
export const compile = (template: string, props: Record<string, keyType>) => {
const templator = new Templator(template);
return templator.compile(props);
};С использованием этой функции можно удобно компилировать строку Далее
export const todoTemplate = `
<div class="{{ wrapperClassName }}">
<div class="todo__button">
<input type="text" class="{{ inputClassName }}" oninput="{{ changeHandler }}">
<button class="button" onclick="{{ handleClick }}" type="button">
<span>{{ buttonText }}</span>
</button>
</div>
<ul class="{{ todoListClassName }}">
{{ todoListItems }}
</ul>
</div>
`;const todos = [
{
text: 'Задача 1',
isDone: false
}, {
text: 'Задача 2',
isDone: false
}, {
text: 'Задача 3',
isDone: false
}, {
text: 'Задача 4',
isDone: false
}, {
text: 'Задача 5',
isDone: false
},
]
const app = document.querySelector('#app');
const todoList = compile(todoTemplate, {
wrapperClassName: 'todo__wrapper',
buttonText: 'Добавить задачу',
todoListClassName: 'todo__list',
inputClassName: 'todo__input',
todoListItems: todos.map(item => (
`<li class="todo__item">${item.text}</li>`
))
})
if (app) {
app.innerHTML = todoList;
}Чтобы было понятнее что тут происходит, я покажу консоль логи выполнения метода compile класса Templator, а потом итоговый результат


В итоге получается вот так

Предлагаю сделать небольшую доработку чтобы не было запятых, появляются они потому что мы рендерим элементы массива, а там они идут через запятую
После того как мы получаем переменную data в классе Templator, добавим такой код
if (Array.isArray(data)) {
result = result.replace(new RegExp(match[0], 'gi'), join(data));
continue
}Теперь имплементируем функцию join где нибудь в папке utils
function join(templates: string[]) {
if (!Array.isArray(templates)) {
throw new Error(`Функция join ожидает массив, был передан ${typeof templates}`);
}
return templates.join('');
}TypeGuard в данном случае конечно же не обязателен Теперь запятых не будет, но я хочу добавить слушатель события на кнопку
Делать мы это будем инлайново, типо
<button class="button" onclick="{{ handleClick }}" type="button">В таком случае handleClick должна именно записываться с круглыми скобками, типо onclick='handleClick()'
Мы не можем в html файл импортировать функцию, и если мы просто так ее напишем - не будет понятно откуда ее вызывать.
Нам нужно "эмулировать" глобальную функцию, сначала записать ее в window по ключу [funcName] А потом оттуда достать и сделать вот так
onclick='window.funcName()';
Добавим такую проверку
if (typeof data === 'function') {
window[variableName] = data;
result = result.replace(new RegExp(match[0], 'gi'), `window.${variableName}()`);
continue
}И обязательно расширим тип window чтобы не было ts ошибок, в отдельном файле
export {}
declare global {
interface Window {
[key: string]: () => void;
}
}В обьект который вторым параметром передаем в compile добавим
handleClick: () => {
const todos = app && app.querySelector('.todo__list');
if (todos) {
todos.innerHTML += `<li class="todo__item">Задача</li>`;
}
}После всего этого у нас получается отрендеренный полностью список с рабочей кнопкой handleClick по клику на которую добавляется элемент с названием "Задача"
Итог
В этой статьей я показал по сути первый шаг к тому чтобы реализовать свой фреймворк - написание удобного шаблонизатора для рендеринга динамического контента в DOM Конечно, в нем еще есть над чем работать, но для нашей задачи он подойдет идеально. Если кому интересно - можете доработать, в конце написания цикла статей я дам ссылку на репозиторий с готовым фреймворком, можно будет поиграться и доработать
В следующей статье разберем паттерн subscriber на котором будем работать наш фреймворк.