Что такое Handlebars
Handlebars — это так называемый logic-less шаблонизатор. Ваши шаблоны не содержат бизнес-логики: не лезут в базу данных, не вычисляют скидки и не принимают решений. Его задача — взять готовые данные и красиво их отобразить.
Изначально Handlebars появился в мире JavaScript как развитие Mustache — еще более минималистичного шаблонизатора. Со временем его портировали на разные языки, и для .NET появилась библиотека Handlebars.NET (пакет Handlebars.Net на NuGet).
Ключевая идея проста: вы пишете шаблон с плейсхолдерами вида {{name}}, передаете в него объект с данными и на выходе получаете готовую строку. Вот минимальный пример:
Здравствуйте, {{clientName}}!
Ваш заказ №{{orderNumber}} на сумму {{totalAmount}} оформлен.
Почему Handlebars?
Для нашей задачи — дать аналитикам возможность самостоятельно править шаблоны документов — Handlebars выигрывает по нескольким причинам:
- Простой синтаксис:
{{name}}понятен даже не-разработчику. - Logic-less с возможностью расширения: шаблон сам по себе не может выполнить произвольный код. Но если нужна дополнительная логика (форматирование даты, склонение слов), ее можно добавить через хелперы.
- Переносимость: тот же синтаксис Handlebars работает и в JavaScript. Если у вас есть фронтенд, который тоже генерирует документы, — шаблоны можно переиспользовать.
- Безопасность: шаблон не может выполнить произвольный C#-код.
Синтаксис шаблонов
Самая базовая конструкция — подстановка значения:
Имя клиента: {{clientName}}
Номер договора: {{contract.number}}
Вложенные свойства доступны через точку — {{contract.number}} достанет свойство Number из объекта Contract.
Блок {{#each}} — перебор коллекций
Список товаров:
{{#each items}}
- {{this.name}}: {{this.price}} руб.
{{/each}}
Внутри {{#each}} контекст переключается на текущий элемент коллекции. Через {{this}} можно обратиться к самому элементу, а через {{@index}} — к его индексу.
Блоки {{#if}} и {{#unless}}
{{#if}} в Handlebars проверяет только «правдивость» значения (не null, не пустая строка, не false, не 0, не пустая коллекция).
{{#if discount}}
Скидка: {{discount}}%
{{else}}
Скидка не предоставляется
{{/if}}
{{#unless paid}}
Счет не оплачен
{{/unless}}
{{#unless}} — это просто инвертированный {{#if}}. Удобно для случаев, когда нужно показать что-то при отсутствии значения.
Быстрый старт
С теорией разобрались, теперь перейдем к практике. Для начала сделаем консольный проект и подключим библиотеку.
dotnet new console -n HandlebarsDemo
cd HandlebarsDemo
dotnet add package Handlebars.Net
Теперь напишем минимальный пример: скомпилируем шаблон, передадим в него данные и получим результат:
using HandlebarsDotNet;
// Компилируем шаблон — это делается один раз
var template = Handlebars.Compile(
"Здравствуйте, {{{name}}}! Ваш заказ №{{{orderNumber}}} оформлен.");
// Передаем данные и получаем результат
var result = template(new
{
name = "Иван Петров",
orderNumber = "12345"
});
Console.WriteLine(result);
// Вывод: Здравствуйте, Иван Петров! Ваш заказ №12345 оформлен.
Шаблон компилируется один раз, а вызывается многократно с разными данными. Метод Handlebars.Compile() возвращает делегат HandlebarsTemplate<object, object>, который можно сохранить и переиспользовать. Не стоит компилировать один и тот же шаблон на каждый вызов — это лишняя нагрузка.
В реальном приложении скомпилированные шаблоны стоит кешировать — например, в Dictionary<string, HandlebarsTemplate> или через DI-контейнер как синглтон. Об этом мы еще поговорим в разделе про best practices.
Хелперы
Хелпер — это функция, которую можно вызвать прямо из шаблона. Встроенных конструкций Handlebars хватает для базовых вещей: показать значение, перебрать список, проверить наличие. Но как только нужно отформатировать дату, сравнить два значения, вывести сумму прописью — без хелперов не обойтись.
В Handlebars.NET есть два вида хелперов:
Inline-хелперы вызываются как функции и возвращают строку:
Дата: {{formatDate date "dd.MM.yyyy"}}
Сумма: {{formatCurrency amount "RUB"}}
Block-хелперы оборачивают блок контента и решают, показывать его или нет:
{{#ifEquals status "paid"}}
✅ Оплачено
{{else}}
❌ Не оплачено
{{/ifEquals}}
Пример: пишем inline-хелпер formatDate
При генерации документов форматирование дат — одна из самых частых задач. Давайте напишем хелпер, который принимает дату и формат, а возвращает отформатированную строку.
using HandlebarsDotNet;
var handlebars = Handlebars.Create();
// Регистрируем хелпер formatDate
handlebars.RegisterHelper("formatDate", (output, context, arguments) =>
{
if (arguments.Length < 2)
{
output.Write("(формат не указан)");
return;
}
var dateValue = arguments[0];
var format = arguments[1]?.ToString() ?? "dd.MM.yyyy";
if (dateValue is DateTime date)
{
output.Write(date.ToString(format));
}
else if (DateTime.TryParse(dateValue?.ToString(), out var parsedDate))
{
output.Write(parsedDate.ToString(format));
}
else
{
output.Write("(дата не указана)");
}
});
Теперь используем его в шаблоне:
Дата договора: {{formatDate contractDate "dd MMMM yyyy"}}
Срок оплаты: {{formatDate dueDate "dd.MM.yyyy"}}
Попробуем скомпилировать и проверить:
var template = handlebars.Compile(
"Дата договора: {{formatDate contractDate "dd MMMM yyyy"}}n" +
"Срок оплаты: {{formatDate dueDate "dd.MM.yyyy"}}"
);
var result = template(new
{
contractDate = new DateTime(2025, 3, 15),
dueDate = new DateTime(2025, 4, 1)
});
Console.WriteLine(result);
// Вывод:
// Дата договора: 15 марта 2025
// Срок оплаты: 01.04.2025
Пример: пишем block-хелпер ifEquals
Помните, мы говорили, что {{#if}} не умеет сравнивать значения? Вот хелпер, который это исправляет:
handlebars.RegisterHelper("ifEquals", (output, options, context, arguments) =>
{
if (arguments.Length < 2)
{
options.Inverse(output, context);
return;
}
var left = arguments[0]?.ToString();
var right = arguments[1]?.ToString();
if (string.Equals(left, right, StringComparison.OrdinalIgnoreCase))
{
options.Template(output, context);
}
else
{
options.Inverse(output, context);
}
});
Здесь появляются два важных метода: options.Template() — отрисовывает содержимое между {{#ifEquals}} и {{else}}, а options.Inverse() — содержимое после {{else}}. Это и есть главное отличие block-хелпера от inline: он управляет рендерингом целого блока контента.
Использование:
{{#ifEquals documentType "invoice"}}
СЧЕТ НА ОПЛАТУ
{{else}}
ДОКУМЕНТ
{{/ifEquals}}
Best practices в работе с Handlebars
Мы написали несколько хелперов, и теперь самое время поговорить о том, как делать это правильно. Вот практики, к которым я пришел на реальных проектах.
Хелпер делает одну вещь
Принцип единственной ответственности работает и здесь. formatDate форматирует дату — и больше ничего. Он не проверяет, просрочена ли дата, не логирует вызовы и не лезет в настройки локализации.
Плохой пример — хелпер-комбайн:
// Так делать не стоит
handlebars.RegisterHelper("smartDate", (output, context, arguments) =>
{
var date = (DateTime)arguments[0];
Logger.Log($"Formatting date: {date}"); // логирование
if (date < DateTime.Now) // бизнес-логика
{
output.Write("ПРОСРОЧЕНО: " + date.ToString("dd.MM.yyyy"));
}
else
{
output.Write(date.ToString("dd.MM.yyyy"));
}
});
Лучше разделить: formatDate форматирует, а данные можно подготовить заранее и передать в модель.
Обработка входных данных
Всегда проверяйте входные аргументы.
// Хороший подход — всегда проверять аргументы
handlebars.RegisterHelper("formatDate", (output, context, arguments) =>
{
if (arguments.Length == 0 || arguments[0] == null
|| arguments[0] is UndefinedBindingResult)
{
output.Write("(дата не указана)");
return;
}
// ... форматирование
});
Без такой проверки вы получите NullReferenceException в рантайме и вместо красивого документа — стектрейс в логах.
Организация работы с шаблонизатором
- Camel case для имен хелперов:
formatDate,formatCurrency,ifEquals,pluralize. - Группировка по назначению: хелперы форматирования отдельно, условные — отдельно, работа с коллекциями — отдельно.
- Регистрация в одном месте: соберите все хелперы в метод расширения (extension method).
public static class HandlebarsHelpers
{
public static IHandlebars RegisterAllHelpers(this IHandlebars handlebars)
{
RegisterFormattingHelpers(handlebars);
RegisterConditionalHelpers(handlebars);
RegisterCollectionHelpers(handlebars);
return handlebars;
}
private static void RegisterFormattingHelpers(IHandlebars handlebars)
{
handlebars.RegisterHelper("formatDate", /* ... */);
handlebars.RegisterHelper("formatCurrency", /* ... */);
}
private static void RegisterConditionalHelpers(IHandlebars handlebars)
{
handlebars.RegisterHelper("ifEquals", /* ... */);
}
}
Теперь регистрация выглядит чисто:
var handlebars = Handlebars.Create();
handlebars.RegisterAllHelpers();
Производительность
Несколько вещей, о которых стоит помнить:
- Компилируйте шаблон один раз:
Handlebars.Compile()парсит шаблон и строит AST. Делать это на каждый запрос — расточительно. - Не создавайте
IHandlebarsна каждый вызов: создайте один экземпляр, зарегистрируйте все хелперы и переиспользуйте его. - Количество хелперов почти не влияет на производительность: они хранятся в словаре, поиск по имени — O(1).
Заключение
Handlebars.NET — отличный выбор, когда шаблоны должны быть понятны не только разработчикам. Простой синтаксис с двойными фигурными скобками осваивается за считаные минуты, а система хелперов позволяет расширять функциональность без усложнения самих шаблонов.
Рекомендую несколько ресурсов для более глубокого изучения темы:
- Handlebars.NET — GitHub
- Handlebars.NET — NuGet
- Handlebars.Net helpers — готовые хелперы
- Документация Handlebars.js (синтаксис)
Спасибо за прочтение статьи! Кроме того, я веду телеграм-канал Flexible Coding, где рассказываю о своем опыте в программировании.