Введение в современные шаблонизаторы и проблему интерполяции
Современная фронтенд-разработка и экосистема JavaScript/TypeScript движутся в сторону максимального упрощения синтаксиса и отказа от тяжеловесных компиляторов в пользу нативных языковых конструкций. Одной из таких конструкций стали шаблонные литералы (template literals) и их кастомные модификации — так называемые tagged templates (тегированные шаблонные строки). Они открывают огромные возможности для создания легковесных библиотек разметки, CSS-in-JS решений и производительных HTML-рендереров. Однако на практике разработчики часто сталкиваются с неочевидными подводными камнями, когда стандартные ожидания от форматирования данных и композиции компонентов разбиваются о внутреннюю логику работы таких парсеров.
Одной из наиболее показательных и часто обсуждаемых проблем в сообществе является ситуация, когда самописный или легковесный t-string HTML renderer полностью игнорирует спецификаторы формата вроде :.2f (пришедшие из мира Python или аналогичных языковых традиций) и при этом агрессивно экранирует любые вложенные шаблоны, превращая их в простой текстовый мусор на экране. Эта проблема затрагивает не только новичков, но и опытных инженеров, пытающихся перенести привычные паттерны интерполяции строк в динамическую среду браузера или серверного рендеринга на Node.js.
В этой статье мы подробно разберем анатомию этого явления, изучим причины возникновения подобных багов, посмотрим на примеры уязвимого кода и, самое главное, напишем надежное архитектурное решение, способное корректно обрабатывать числовые форматы, сохранять структуру вложенных шаблонов и обеспечивать безопасность приложения.
Анатомия шаблонных литералов и тегов в JavaScript
Прежде чем погружаться в проблему некорректной обработки модификаторов формата и вложенности, необходимо вспомнить, как именно устроен механизм тегированных шаблонных строк в спецификации ECMAScript. Когда мы пишем функцию-тег перед строкой, движок JavaScript разделяет эту конструкцию на два основных компонента:
- Массив строк (strings), который содержит статичные текстовые фрагменты, разделенные выражениями. Важно отметить, что у этого массива всегда есть свойство
raw, содержащее неэкранированные исходные последовательности символов. - Массив выражений (values), который представляет собой результаты вычисления каждого интерполированного выражения внутри конструкции
${...}.
Рассмотрим базовый пример простого HTML-рендерера:
function html(strings, ...values) {
return strings.reduce((result, str, i) => {
const value = values[i - 1] !== undefined ? values[i - 1] : '';
return result + escapeHtml(value) + str;
});
}
Этот наивный подход кажется работоспособным до тех пор, пока мы оперируем простыми строками и числами. Но как только в дело вступают специфические требования к отображению данных — например, строгая финансовая или научная точность чисел (тот самый синтаксис :.2f) — или потребность в композиции компонентов путем передачи одного шаблона внутрь другого, этот хрупкий карточный домик начинает рушиться. Функция escapeHtml слепо экранирует всё подряд, а интерпретатор строк понятия не имеет, что делать с двоеточием и флагами форматирования.
Почему синтаксис :.2f чужд JavaScript и как с ним борются
Выражение вроде {price:.2f} выглядит абсолютно естественно для разработчиков, пришедших из Python, где форматные строки (f-strings) поддерживают мощные встроенные спецификаторы. Там двоеточие служит четким разделителем между самой переменной и правилами ее визуализации (округление до двух знаков после запятой, выравнивание, добавление нулей). Однако в чистом JavaScript и TypeScript синтаксис шаблонных литералов устроен иначе. Конструкция внутри фигурных скобок ${...} — это полноценное выражение JavaScript, а не мини-язык форматирования.
Когда разработчик пытается написать что-то вроде:
const template = html`<span>Цена: ${price:.2f}</span>`;
он неизбежно получает синтаксическую ошибку на этапе парсинга самого скрипта (SyntaxError: Unexpected token), так как парсер JS не ожидает двоеточие в таком контексте. Если же этот синтаксис эмулируется внутри строкового шаблона или передается как строка для кастомного интерпретатора:
// Попытка передать строку с модификатором
const template = html`<span>Цена: ${'price:.2f'}</span>`;
то наш t-string HTML renderer видит обычную строку или число. У него нет встроенной логики разбора суффиксов формата, потому что стандартная распаковка тегированного литерала передает в массив values уже готовое, вычисленное значение переменной price (например, число 45.6789), а исходный текст выражения (включая :.2f) безвозвратно теряется.
В результате рендер выводит длинное «сырое» число 45.678954, начисто игнорируя попытку применить маску форматирования. Попытки решить это в лоб приводят к загромождению разметки громоздкими вызовами методов прямо в шаблоне:
// Работающий, но уродливый и загромождающий шаблон подход
const template = html`<span>Цена: ${price.toFixed(2)}</span>`;
Проблема экранирования вложенных шаблонов как «сырого текста»
Вторая половина исследуемой проблемы касается композиции интерфейсов. Архитектура современных веб-приложений строится на переиспользовании компонентов и вложенности шаблонов разметки друг в друга. Мы хотим иметь возможность объявить подкомпонент:
const badge = html`<span class="badge">Активно</span>`;
а затем внедрить его внутрь главного макета страницы:
const page = html`
<div class="card">
<h2>Статус задачи</h2>
${badge}
</div>
`;
И здесь мы сталкиваемся с критическим архитектурным сбоем. Большинство наивных HTML-рендереров на базе шаблонных строк реализуют универсальную защиту от XSS-уязвимостей, прогоняя каждое входящее значение через функцию экранирования HTML-сущностей:
- Заменяются символы
<на<. - Заменяются символы
>на>. - Кавычки и амперсанды также подвергаются кодированию.
Когда в качестве значения ${badge} передается результат работы другой функции html``, этот результат обычно представляет собой либо готовую строку разметки (например, <span class="badge">Активно</span>), либо массив строк. Наивный рендерер воспринимает этот сложный HTML-объект просто как скалярное строковое значение и применяет к нему функцию экранирования.
В результате на экран выводится ужасная картина: вместо красивого синего бейджа пользователь видит в браузере буквально текст код разметки:
<span class="badge">Активно</span>
Шаблонизатор полностью проглотил вложенный шаблон, превратив его в «сырой текст» (raw text) и заэкранировав все теги. Рендерер не смог распознать, что перед ним не вредоносная строка пользовательского ввода, а доверенный фрагмент разметки, который должен быть встроен в DOM как живые HTML-ноды, а не как экранированная текстовая строка.
Проектирование надежного движка: как подружить форматтеры и вложенность
Чтобы преодолеть описанные ограничения, нам необходимо выйти за рамки примитивного соединения строк через reduce и спроектировать более интеллектуальный механизм обработки тегированных шаблонов. Полноценное решение должно уметь делать три вещи:
- Безопасно различать обычный текст, числа и доверенные HTML-фрагменты (компоненты).
- Предоставлять гибкий механизм форматирования данных (включая имитацию синтаксиса для чисел).
- Корректно обрабатывать массивы элементов и вложенные структуры без потери тегов.
Давайте рассмотрим паттерн создания безопасного обертчика (Wrapper) для доверенного HTML. Мы можем ввести специальный класс или символ-флаг, который будет указывать на то, что строка уже была обработана нашим шаблонизатором и не нуждается в повторном экранировании:
class SafeHTML {
constructor(content) {
this.content = content;
}
toString() {
return this.content;
}
}
Теперь наша функция-тег html сможет проверять тип передаваемых значений. Если значение является экземпляром SafeHTML или массивом таких экземпляров, мы вставляем его в итоговую разметку «как есть», минуя функцию экранирования:
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function html(strings, ...values) {
let result = strings[0];
for (let i = 0; i < values.length; i++) {
let val = values[i];
// Обработка вложенных шаблонов
if (val instanceof SafeHTML) {
result += val.content;
} else if (Array.isArray(val)) {
result += val.map(item =>
item instanceof SafeHTML ? item.content : escapeHtml(item)
).join('');
} else {
result += escapeHtml(val);
}
result += strings[i + 1];
}
return new SafeHTML(result);
}
Такой подход мгновенно решает проблему «сырого текста» для вложенных шаблонов. Когда мы вызываем html`...` внутри другого шаблона, внутренний вызов возвращает объект SafeHTML, который внешний рендерер распознает и корректно интегрирует в общую структуру DOM.
Решение проблемы кастомного форматирования (включая псевдо-:.2f)
Что же касается проблемы кастомного форматирования чисел наподобие :.2f, то поскольку прямой синтаксис ${val:.2f} недопустим в стандартном JavaScript, нам нужно предложить разработчикам элегантную альтернативу в рамках существующих языковых стандартов, либо реализовать вспомогательные функции-модификаторы.
Отличным решением является создание утилит форматирования (хелперов), которые интегрируются в поток данных шаблонной строки:
const fmt = {
decimal: (num, digits = 2) => Number(num).toFixed(digits),
currency: (num) => `$${Number(num).toFixed(2)}`
};
// Использование в шаблоне
const price = 45.6789;
const template = html`<нижняя-панель>Цена: ${fmt.decimal(price, 2)}</нижняя-панель>`;
Если же архитектурные требования проекта требуют именно компактной строковой записи спецификаторов формата, некоторые продвинутые библиотеки перехватывают исходные строки шаблона через свойство strings.raw, анализируют их с помощью регулярных выражений на предмет наличия паттернов вроде {name:format} и динамически подставляют значения. Однако нативная реализация через функции-хелперы является более производительной, безопасной и не приводит к усложнению парсера.
Заключение
Создание собственных легковесных HTML-рендереров на основе тегированных шаблонных строк в JavaScript — это увлекательная и мощная задача, позволяющая создавать быстрые и чистые пользовательские интерфейсы без тяжелых зависимостей. Тем не менее, разработчики неизбежно сталкиваются с архитектурными ограничениями языка, такими как игнорирование непривычных спецификаторов формата (например, :.2f) и агрессивное превращение вложенных шаблонов в экранированный «сырой текст».
Как мы выяснили, корень этих проблем кроется в наивном подходе к обработке значений внутри интерполяций и отсутствии разграничения между обычным пользовательским вводом и доверенными компонентами разметки. Внедрение специализированных классов-оберток (вроде SafeHTML), грамотная обработка массивов и использование функциональных хелперов для форматирования чисел позволяют полностью нивелировать эти недостатки.
Понимание внутренних механизмов работы JavaScript с шаблонными литералами дает вам полный контроль над процессом рендеринга, обеспечивая идеальный баланс между лаконичностью кода, безопасностью приложения (защитой от XSS) и гибкостью отображения сложных числовых и компонентных данных.