---
title: "HTML-шаблоны"
description: "Урок fiber шаблоны: интерфейс fiber.Views и c.Render в v3, html/template, layout и {{embed}}, движки gofiber/template, Reload для dev и экранирование XSS."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/13-shablony/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# HTML-шаблоны

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 13 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/13-shablony/*

Тема «fiber шаблоны» закрывает пробел, который копился с самого первого сервера: двенадцать уроков все ответы выдавал хендлер — строкой, JSON-ом, редиректом, — и ни разу полноценной HTML-страницей. Сегодня разбираем рендер страниц. Первый слой — стандартная библиотека html/template: действия {{.Field}}, range, if, функции и главное свойство — автоматическое экранирование по контексту. Второй слой — механизм рендера Fiber v3: интерфейс fiber.Views с двумя методами, метод c.Render на контексте, поля конфига Views, ViewsLayout и PassLocalsToViews, и девять адаптеров движков в модуле github.com/gofiber/template — от html до pug и mustache. Минимальный стек html template fiber — это стандартная библиотека плюс один движок-адаптер, и всё сверено с исходниками тега v3.5.0: res.go, ctx.go, app.go. Канон продолжается: API на :8080, слои helmet, cors, logger, сессии из двенадцатого урока — обещанный в нём тизер исполнится здесь же: страница задач наконец поприветствует пользователя по имени из sess.Get("user").

### fiber шаблоны: страницы вместо JSON

До сегодняшнего дня ваш инстанс Fiber был чистым API: он отдавал данные — c.JSON в девятом уроке, статусы, редиректы, — а рисовал интерфейс кто-то другой: фронтенд на Vite из восьмого урока или index.html из пятого. Серверные шаблоны переворачивают распределение труда: HTML собирает сам сервер, прямо в хендлере, подставляя данные в заранее приготовленные файлы-заготовки. Зачем это нужно, если есть SPA? Во-первых, простые сайты — лендинги, блоги, админки, страницы документов — не заслуживают сборщика и фреймворка: шаблон на сервере дешевле и быстрее. Во-вторых, SEO и первый рендер: браузер получает готовый HTML без второго этапа «скачал JS, подождал, отрисовал». В-третьих, старый добрый POST формы из одиннадцатого урока красивее всего работает с серверной отрисовкой: форма уходит, сервер рисует страницу снова, уже с флеш-сообщением от сессии.

Тема «fiber шаблоны» стоит на двух слоях, и их важно не путать. Нижний слой — язык шаблонов: как в файле записать «сюда подставь поле Title», «здесь пройди циклом по задачам», «если пользователь залогинен — покажи кнопку выхода». В Go за это отвечает стандартная библиотека, и знакомство с html template fiber начинается именно с неё. Верхний слой — транспорт: как Fiber находит движок, отдаёт ему данные, забирает готовый HTML и кладёт его в ответ с правильным Content-Type. Это исходники Fiber v3: интерфейс fiber.Views в ctx.go, метод Render в res.go, поля конфига в app.go. Пройдём снизу вверх: сначала язык, потом транспорт, потом layout-каркасы, перезагрузка в dev и защита от XSS. Канон курса продолжается: тот же инстанс на :8080, те же слои helmet, cors и logger, сессии из [двенадцатого урока](/courses/fiber/lessons/12-cookie-sessii/) — и страница задач, которая знает, кто её открыл.

### html/template: действия, поля и Parse

Шаблон — это текст с действиями: фрагментами в двойных фигурных скобках {{ }}, которые при исполнении заменяются данными. Всё остальное — HTML, который уйдёт в ответ как есть. Действие {{.Title}} означает «поле Title от точки»: точка — текущие данные, переданные шаблону, и для структуры это доступ к полям, а для карты — к ключам. Фигурные скобки называются разделителями, или delims, и в стандартной библиотеке они фиксированы; движки gofiber/template умеют их менять, но это редкая экзотика.

Два пакета стандартной библиотеки различаются одним словом и целой философией. text/template — про произвольный текст: письма, конфиги, логи; он ничего не знает про HTML и вставляет данные как есть. html/template — надстройка над ним с тем же синтаксисом, но с автоматическим экранированием: перед вставкой данные чистятся с учётом HTML-контекста, в который они попали. Для веб-страниц всегда берите html/template — это правило безопасности, к которому вернёмся в середине урока; Fiber даже в запасном механизме рендера парсит файлы именно html/template-ом.

Жизненный цикл шаблона — три вызова. template.New("имя") создаёт пустой набор; Parse(s string) разбирает строку и возвращает готовый к исполнению набор или ошибку — опечатка в действии ловится здесь, до первого запроса; Execute(w, data) исполняет шаблон, пишет результат в writer и подставляет данные. Удобная обёртка template.Must(p, err) паникует при ошибке разбора — она для шаблонов, вшитых в программу на этапе инициализации: упало при старте — честнее, чем молча рисовать сломанные страницы. В Fiber этот цикл сохраняется один в один, только Execute вызовет за вас движок, а writer-ом будет буфер ответа. И ещё одна привычка, которую стоит завести сразу: шаблон компилируют один раз, а исполняют на каждый запрос — Parse в хендлере на каждый приход запроса это лишняя работа и мусор для сборщика.

### Минимальный шаблон: список задач

```go
package main

// Task — модель канона курса: рисуем список задач на HTML
type Task struct {
        ID    int
        Title string
        Done  bool
}

// Page — данные, которые уйдут в шаблон целиком (это «точка»)
type Page struct {
        Title string
        Tasks []Task
}

func main() {
        // html/template: разбор один раз при старте, template.Must — паника при ошибке
        tmpl := template.Must(template.New("list").Parse(`
<h1>{{.Title}}</h1>
<ul>
  {{range .Tasks}}<li>#{{.ID}} {{.Title}}{{if .Done}} — готово{{end}}</li>{{else}}<li>пока пусто</li>{{end}}
</ul>
`))

        data := Page{Title: "Задачи", Tasks: []Task{
                {ID: 1, Title: "Купить кофе", Done: true},
                {ID: 2, Title: "Написать отчёт", Done: false},
        }}

        // Execute пишет результат в writer: здесь stdout, в Fiber — буфер ответа
        if err := tmpl.Execute(os.Stdout, data); err != nil {
                log.Fatal(err)
        }
}
```

Здесь собран весь базовый словарь шаблонов. {{.Title}} — поле данных. {{range .Tasks}}...{{end}} — цикл по слайсу: внутри точка смещается на текущий элемент, поэтому {{.Title}} в теле цикла — это уже поле Task, а не Page. {{if .Done}}...{{end}} — ветвление по любому значению: ноль, пустая строка, nil и false трактуются как ложь. {{else}} после range — приятная мелочь стандартной библиотеки: ветка для пустого слайса, когда итерировать нечего. Разбор через Parse выполнен один раз в main, а не в хендлере: шаблон — неизменяемая программа, данные меняются, код — нет.

### Что напечатает Execute?

```go
// html/template: что уйдёт в stdout?
tmpl := template.Must(template.New("t").Parse("<p>{{.}}</p>"))
_ = tmpl.Execute(os.Stdout, "<b>\"Важно\"</b>")
```

**Что выведет программа?**

- ✅ <p>&lt;b&gt;&#34;Важно&#34;&lt;/b&gt;</p> — символы заменены HTML-сущностями
- <p><b>"Важно"</b></p> — html/template не трогает данные, только подставляет
- <p>&lt;b&gt;&quot;Важно&quot;&lt;/b&gt;</p> — как вариант 1, но кавычки через &quot;
- Ошибка Execute: строки с тегами в шаблон передавать запрещено

Это html/template в действии: перед вставкой значения в текст страницы сработала таблица замен htmlReplacementTable из исходников стандартной библиотеки — комментарий к ней честен: runes that need to be escaped inside a quoted attribute value or in a text node. Меньше становится &lt;, больше — &gt;, амперсанд — &amp;, двойная кавычка — &#34; (не &quot;!), одинарная — &#39;. Обратите внимание и на деталь для дотошных: плюс превращается в &#43;. Именно поэтому вариант с &quot; — ловушка: эта сущность привычна по другим языкам, но Go-экранировщик использует числовые коды. Ни ошибки, ни передачи тегов в чистом виде нет и быть не может: умышленно вставить сырой HTML можно, но только явным типом template.HTML, о котором поговорим в блоке про XSS.

### range, if и функции: арсенал шаблонов

Второй уровень языка — управление потоком и вычисления. Ветвление {{if .User}}...{{else}}...{{end}} уже встречалось; у if есть цепочки {{else if}}, а {{with}} — это if плюс переопределение точки: внутри {{with .User}} значение поля становится точкой, и пишут коротко {{.}}, не повторяя путь. Цикл range умеет декомпозицию с двумя переменными: {{range $i, $t := .Tasks}} даёт индекс и элемент, а $$-переменные живут до конца шаблона и не путаются с точкой, смещающейся в циклах.

Функции в шаблонах вызываются по имени, аргументы — через пробел, а вертикальная черта превращает вызов в конвейер: {{.Title | printf "%s"}} то же самое, что {{printf "%s" .Title}}. Встроенных функций хватает на повседневное: len для длин, index для слайсов и карт, сравнения eq, ne, lt, le, gt, ge, логика and, or, not и знакомый по fmt printf. Конвейеры комбинируются свободно: {{if gt (len .Tasks) 0}} — «если задач больше нуля», скобки группируют вызовы, как в shell.

Свои функции подключаются через FuncMap — карту «имя-строка: Go-функция» — и вот тут появляется мостик к движкам Fiber: template.New("t").Funcs(template.FuncMap{"короткаяДата": formatDate}) до Parse. В gofiber/template то же самое делает engine.AddFunc(name, fn) и массово AddFuncMap, а комментарий в исходнике разрешает переопределение: It is legal to overwrite elements of the default actions. Наконец, переиспользование кусков: {{define "имя"}}...{{end}} объявляет именованный фрагмент, {{template "имя" .}} вставляет его с текущей точкой, {{block "имя" .}} — объявляет и сразу вставляет. Именно на этих трёх действиях строятся партиалы — переиспользуемые шапки, футеры и карточки, — а layout-паттерн Fiber, к которому мы придём через несколько абзацев, реализован через функцию с именем embed.

### Проверьте себя: {{else}} внутри range

**Вопрос:** В шаблоне строка: {{range .Tasks}}<li>{{.Title}}</li>{{else}}<li>пока пусто</li>{{end}}. Хендлер передал Page с полем Tasks, равным nil. Что увидит клиент?

- ✅ Строку «пока пусто»: {{else}} в range срабатывает, когда итерировать нечего
- Пустое место: range по nil-слайсу пропускает и тело, и ветку else
- Ошибку разбора: {{else}} допустим только внутри {{if}}
- Список с одной пустой строкой: range выполнит тело один раз для nil

В стандартной библиотеке у всех трёх управляющих действий — if, with и range — есть ветка else, и семантика у range самая практичная: else выполняется, когда слайс пуст, nil или отсутствует вовсе, то есть ровно в тот момент, когда страница без неё рисовала бы пустой <ul>. Ошибки разбора нет — Parser различает else по контексту действия; тело по nil не выполняется ни разу. Тот же приём экономит отдельный {{if}} на «список пуст, создайте первую задачу»: одну семантическую проверку делает сам range. Единственное, за чем стоит следить, — точка: в теле цикла она смещена на элемент, в ветке else она остаётся прежней, и {{.Title}} в else читал бы поле корневой структуры, а не задачи.

### Рендер v3: интерфейс fiber.Views и c.Render

Теперь транспорт. В исходниках Fiber v3.5.0 весь механизм держится на одном интерфейсе, объявленном в ctx.go, и комментарий к нему лаконичен: Views is the interface that wraps the Render function. Два метода: Load() error — один раз разобрать шаблоны и приготовиться — и Render(out io.Writer, name string, binding any, layout ...string) error — отрисовать шаблон с именем name, подставив данные binding, и, если переданы имена layout, обернуть результат в каркас. Всё, что удовлетворяет этому интерфейсу, — движок Fiber; всё, что не удовлетворяет, — не движок, как ни называй себя в документации.

Метод c.Render(name string, bind any, layouts ...string) error живёт в res.go на DefaultRes — Ctx подмешивает его через встраивание, как и c.Cookie из прошлого урока. Док-комментарий обещает: Render a template with data and sends a text/html response. We support the following engines: https://github.com/gofiber/template. Внутри — четыре шага. Первый: буфер из пула bytebufferpool — тот же пул, что экономит аллокации во всём Fiber. Второй: nil в bind превращается в пустую fiber.Map, а затем renderExtensions досыпает данные: сначала ключи из c.ViewBind (о нём — в блоке про fiber.Map), затем, если взведён PassLocalsToViews, — значения из Locals; свои ключи вызов Render перекрывает всех. Третий шаг — поиск движка: Fiber идёт по смонтированным приложениям сзади наперёд, и для каждого совпавшего префикса берёт конфиг этого приложения — так каждое смонтированное sub-приложение может нести свой движок; сам вызов обёрнут в RLock глобальной по движку блокировки viewsLock, а перезагрузка шаблонов берёт Lock — читатели не мешают друг другу, писатель ждёт всех.

Четвёртый шаг — и самый неожиданный: если движка в конфиге нет вовсе, c.Render не падает с ошибкой «движок не настроен», а честно рендерит запасным механизмом. Комментарий в исходнике: Render raw template using 'name' as filepath if no engine is set — файл по пути name открывается с диска, парсится html/template-ом и исполняется с вашими данными. Ошибки на этом пути называются по шагам: failed to open, failed to parse, failed to execute; путь от движка оборачивается как failed to render. В конце Content-Type принудительно ставится в text/html; charset=utf-8, и буфер уезжает в тело ответа.

### Подключаем движок: Views в конфиге

```go
package main

import (
        "log"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/session"
        "github.com/gofiber/template/html/v3"
)

func main() {
        // движок: папка ./views, расширение .html
        engine := html.New("./views", ".html")
        // перечитывать шаблоны на каждом рендере — режим разработки
        engine.Reload(true)

        app := fiber.New(fiber.Config{
                // поле Views принимает любой интерфейс fiber.Views
                Views: engine,
        })

        // сессии из двенадцатого урока: страница приветствует по имени
        app.Use(session.New())

        tasks := []fiber.Map{
                {"ID": 1, "Title": "Купить кофе", "Done": true},
                {"ID": 2, "Title": "Написать отчёт", "Done": false},
        }

        app.Get("/tasks", func(c fiber.Ctx) error {
                // достаём пользователя из сессии: тизер прошлого урока сбылся
                user, _ := session.FromContext(c).Get("user").(string)
                // c.Render: имя шаблона, данные, layouts... — здесь без layout
                return c.Render("index", fiber.Map{
                        "Title": "Задачи",
                        "User":  user,
                        "Tasks": tasks,
                })
        })

        log.Fatal(app.Listen(":8080"))
}
```

Вся настройка — три строки. html.New("./views", ".html") создаёт движок: папка, где лежат шаблоны, и расширение, по которому их отличают от остального мусора. engine.Reload(true) включает перечитывание на каждом рендере — режим разработки, о нём отдельный блок. fiber.Config{Views: engine} — и движок встроен: при старте app.init() вызовет engine.Load() один раз, а при ошибке лишь предупредит в лог: failed to load views: %v — приложение всё равно поднимется, и первая же страница честно скажет render: template ... does not exist. В хендлере — ровно один вызов: c.Render("index", fiber.Map{...}). Рядом в ./views/index.html лежит шаблон с тем же словарём, что в минимальном примере выше, плюс приветствие: {{if .User}}<p>Привет, {{.User}}!</p>{{end}}. Сессия из двенадцатого урока отдаёт имя, шаблон вставляет его с автоматическим экранированием — связка работает от куку session_id до готового HTML.

### Девять движков gofiber/template и имена v2

Синтаксис {{.Title}} — не единственный в экосистеме Go, и Fiber не заставляет его любить. Док-комментарий c.Render ссылается на модуль github.com/gofiber/template, и в нём девять адаптеров движков: ace, amber, django, handlebars, html, jet, mustache, pug, slim. Каждый адаптер — отдельный модуль с путём вида github.com/gofiber/template/html/v3: импортируете пакет — получаете конструктор New(directory, extension string) и тот же набор методов настройки. Движок html — это обёртка над стандартным html/template, то есть именно тот синтаксис, который мы разбираем в этом уроке; остальные — над сторонними библиотеками: mustache приносит лаконичный синтаксис без логики {{name}}, pug — питоноподобные отступы вместо закрывающих тегов, django — {% if %} и {% for %}, handlebars — {{#each}} и {{#if}}, jet — быстрые шаблоны с наследованием, amber и slim — компактные языки разметки, ace — максимально близкий к Go-шаблонам. Забавная деталь из исходников: даже pug-движок внутри себя компилирует разметку в html/template — Go-экранирование работает всюду.

Теперь про версии — здесь сюрприз приятный. Сверка с веткой v2 (master репозитория Fiber /v2) показывает: имена переехали без изменений. В v2 те же Config.Views, Config.ViewsLayout и PassLocalsToViews, тот же интерфейс с Load и Render, тот же вызов c.Render(name, bind, layouts...). После уроков про куки и сессии, где v2-имена CookieName, Expiration и глобальная session.Store приходилось выпалывать из чужих примеров, это передышка: старые сниппеты конфига движка переносятся на v3 как есть, достаточно поменять путь импорта. Отдельно стоит NewFileSystem(fs http.FileSystem, extension string) — вариант конструктора, принимающий http.FileSystem: с ним шаблоны читаются не с диска, а из файловой системы, в которую можно подсунуть embed.FS из пятого урока — все шаблоны вшиты в бинарник, и деплой не тащит за собой папку views.

### Адаптеры gofiber/template: девять движков

| Движок | Синтаксис | Пакет |
| --- | --- | --- |
| html | html/template: {{.Field}}, range, if | github.com/gofiber/template/html/v3 |
| mustache | без логики: {{name}}, {{#tasks}} | github.com/gofiber/template/mustache/v3 |
| pug | отступы вместо тегов: h1= Title | github.com/gofiber/template/pug/v3 |
| handlebars | {{#each}}, {{#if}}, хелперы | github.com/gofiber/template/handlebars/v3 |
| django | {% if %}, {% for %}, фильтры | github.com/gofiber/template/django/v3 |
| jet | наследование, компиляция в байткод | github.com/gofiber/template/jet/v3 |
| amber | отступы, компилируется в html/template | github.com/gofiber/template/amber/v3 |
| slim | компактные отступы, как в Ruby | github.com/gofiber/template/slim/v3 |
| ace | почти Go-шаблоны, без директив | github.com/gofiber/template/ace/v3 |

### Данные в шаблон: fiber.Map, ViewBind и Locals

Что передать вторым аргументом c.Render? Что угодно — binding any, — но на практике царит fiber.Map: тип из app.go, буквально map[string]any. Это готовая структура данных без объявлений: собрал карту — отдал в шаблон. В примере выше задачи лежат как []fiber.Map — каждая задача карта с ключами ID, Title, Done, и в шаблоне {{.Title}} по-прежнему работает, потому что html/template читает и поля структур, и ключи карт одинаково. Типизированные структуры — не запрет, а выбор: они дают компилятору шанс поймать опечатку в имени поля, а fiber.Map — скорость прототипирования. Для урока канона хватит карт; в большом проекте структуры выгоднее.

Второй аргумент — не единственный источник данных, и вот точный порядок из res.go. Сначала c.ViewBind(vars Map) — метод с док-строкой: ViewBind Add vars to default view var map binding to template engine. Variables are read by the Render method and may be overwritten. Он копит карту на запрос: middleware или общий хендлер кладёт туда то, что нужно всем страницам сразу — имя пользователя, CSRF-токен, версию приложения. Затем, если в конфиге взведён PassLocalsToViews, все значения c.Locals — та же карта пользовательских значений fasthttp — тоже попадают в шаблон; поле конфига честно предупреждает: default выключен, включайте осознанно, чтобы внутренние локалы не утекали в разметку. Порядок приоритетов прост и линеен: то, что вы передали прямо в c.Render, перекрывает ViewBind; ViewBind перекрывает Locals; ни один из них не затирает ключи, уже пришедшие сверху. Практический вывод: в Render — данные конкретной страницы, в ViewBind — общий для всех страниц контекст, Locals — только если вы сознательно включили проход и держите локалы в порядке.

### Проверьте себя: чей ключ сильнее

**Вопрос:** Взведён PassLocalsToViews. Middleware положил c.Locals("user", "anna"). Другой middleware вызвал c.ViewBind(fiber.Map{"user": "boris"}). Хендлер рендерит c.Render("index", fiber.Map{"user": "vera"}). Что подставит {{.User}}?

- ✅ vera: свои ключи вызов Render перекрывает ViewBind и Locals
- boris: ViewBind перезаписывает всё, что было в Locals
- anna: Locals всегда старше — они стоят раньше в цепочке
- Ошибку: ключ user определён трижды, движок откажется рендерить

Внутри c.Render в res.go работает renderExtensions, и его логика — досыпать, а не затирать: ключи из viewBindMap копируются в карту bind только если такого ключа там ещё нет, значения из Locals — только если нет и после этого. То, что передано вторым аргументом c.Render, уже стоит в карте — и потому непобедимо. Ключ, пришедший из ViewBind, побеждает Locals просто потому, что копируется раньше. И никакой ошибки о повторе не будет: словарь молча хранит одно значение на ключ. Именно поэтому дисциплина распределения важна: хотите гарантировать значение — передавайте в Render; хотите фон по умолчанию — ViewBind; Locals с PassLocalsToViews — удобный, но самый слабый канал, и включать его стоит, только если вы уверены, что в Locals нет ничего, что не должно попасть в HTML.

### Layout: общий каркас страницы

У любой страницы есть всё одинаковое: DOCTYPE, head с мета-тегами, шапка с навигацией, футер. Дублировать это в каждом шаблоне — значит править пять файлов при добавлении одного пункта меню. Layout — это каркас страницы с дыркой посередине, в которую вставляется отрисованный шаблон. В Fiber у layout-паттерна два уровня включения. Локальный: третьим аргументом c.Render — c.Render("index", data, "layouts/main") — variadic layouts ...string, и README модуля показывает именно такой вызов. Глобальный: поле конфига ViewsLayout с док-строкой Views Layout is the global layout for all template render until override on Render function — каркас по умолчанию для всех рендеров, пока конкретный вызов не передал свой layout; в res.go это буквально: если layouts пуст, а ViewsLayout задан, глобальное имя подставляется автоматически.

Внутри каркаса дырку рисует действие {{embed}}. Имя настраивается: метод движка engine.Layout("yield") меняет его, а дефолт — embed, константа из исходников html-движка LayoutName: "embed". Механика в Render красивая: движок строит цепочку вложенных функций — внешний layout рендерится первым, его {{embed}} вызывает рендер следующего, последний embed рендерит сам шаблон страницы. Вариадичность layouts не декоративна: передав список имён, вы получите вложенные каркасы — базовый HTML-скелет, внутри него каркас раздела с сайдбаром, внутри него страница. И две ошибки, которые стоит знать заранее: если переданного имени layout нет среди загруженных шаблонов, движок отвечает render: LayoutName %s does not exist; если же {{embed}} случайно написать в обычной странице — не в каркасе, — срабатывает функция-страж из исходников с сообщением layoutName called unexpectedly.

### Каркас layouts/main.html и вызов с layout

```go
<!-- views/layouts/main.html — общий каркас всех страниц -->
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>{{.Title}}</title>
</head>
<body>
  <nav><a href="/tasks">Задачи</a> <a href="/about">О проекте</a></nav>
  <!-- {{embed}} вставляет отрисованный шаблон страницы -->
  {{embed}}
  <footer>Самоучитель Go — курс Fiber</footer>
</body>
</html>

// ---- хендлер: третий аргумент Render — имя каркаса ----
app.Get("/tasks", func(c fiber.Ctx) error {
        return c.Render("index", fiber.Map{"Title": "Задачи"}, "layouts/main")
})
```

Каркас и страница живут в одном движке, поэтому layout — не отдельный механизм, а просто ещё один загруженный шаблон с именем layouts/main: вложенная папка layouts входит в имя. Данные доступны и каркасу, и странице — одна карта binding отдаётся обоим, поэтому {{.Title}} в head работает. Вызов без третьего аргумента отрисует index без каркаса — так удобно отлаживать фрагменты; глобальный ViewsLayout в конфиге закрывает обратный случай, когда каркас нужен всем рендерам сразу. Помните про две ошибки из блока выше: отсутствие файла-каркаса даст render: LayoutName %s does not exist, а случайный {{embed}} в обычной странице — layoutName called unexpectedly.

### Каркаса нет: что ответит сервер?

```go
// файл views/layouts/missing.html НЕ создан,
// при этом views/index.html существует
return c.Render("index", fiber.Map{"Title": "Задачи"},
        "layouts/missing")
```

**Что выведет программа?**

- ✅ 500 с ошибкой failed to render: render: LayoutName layouts/missing does not exist
- Страница без каркаса: движок молча пропустит отсутствующий layout
- 404: Render вернёт fiber.ErrNotFound за несуществующий шаблон
- Паника процесса: Lookup по отсутствующему имени валит приложение

В html-движке Render сначала ищет шаблон страницы через Lookup — и только затем в цикле по переданным именам каркасов: lay := set.Lookup(layName); if lay == nil { return fmt.Errorf("render: LayoutName %s does not exist", layName) }. Ошибка всплывает из движка в c.Render, где оборачивается в failed to render: %w, возвращается хендлером в Fiber и попадает в обработчик ошибок — по умолчанию это 500 с текстом ошибки в ответе. Никакого молчаливого деградирования до страницы без каркаса движок не делает: лучше громкая ошибка, чем тихо сломанная вёрстка. И это не паника — процесс жив, остальные маршруты работают. А что делать с 500-й, кроме как чинить имя, — хороший вопрос к следующей теме: четырнадцатый урок «Обработка ошибок» покажет, как перехватить любую ошибку хендлера и нарисовать вместо голого текста красивую страницу «что-то сломалось» в том же каркасе.

### Автоперезагрузка в dev: Reload и ReloadViews

Шаблоны парсятся при старте, но вы правите их каждые пять минут — перезапускать сервер на каждую правку вёрстки значит терять темп. В gofiber/template для этого есть метод engine.Reload(true), и комментарий в исходниках формулирует назначение честно: reload on each render, use it when you're in development and you don't want to restart the application when you edit a template file. Дефолт — false: в проде шаблоны разбираются один раз при Load, и каждый рендер — чистое исполнение без чтения диска. Механика включена в исходниках как ShouldReload, а проверка — в методе PreRenderCheck: если набор уже загружен и перезагрузка выключена, рендер идёт по быстрому пути под shared-блокировкой; если ShouldReload взведён, перед каждым рендером файлы читаются заново. Приятная деталь для отладки: неудавшийся Load не выставляет флаг Loaded, поэтому следующий рендер сам повторит попытку — починили опечатку в шаблоне и просто обновили страницу.

Второй инструмент — на стороне Fiber: app.ReloadViews(). Док-комментарий: ReloadViews reloads the configured view engine by invoking its Load method. Метод обходит и корневое приложение, и все смонтированные, у каждого движка берёт write-блокировку — рендеры на время перезагрузки вежливо ждут, — а при неудаче возвращает ошибку fiber: failed to reload views: %w. Сценарий — файловый вотчер: fsnotify заметил изменение в ./views, вызвал ReloadViews, ни один запрос не потерян. Третий уровень — Debug(true) у движка: при каждой загрузке печатает в лог строку вида views: parsed template: имя — незаменимо, когда сомневаетесь, какие имена вообще попали в набор. И последнее из исходников, о чём редко пишут: Load при старте вызывается в app.init(), и его ошибка не валит процесс — только log.Warnf "failed to load views: %v". Сервер поднимется без шаблонов, и каждая страница будет отвечать render: template ... does not exist, пока вы не перезагрузите движок или не перезапустите приложение с починенными файлами.

### Соберите подключение движка и рендер

Вставьте пропуски: конструктор html-движка, включение перезагрузки, поле конфига Views и вызов рендера с layout.

```go
// Каркас страницы задач: движок + рендер
func setup() *fiber.App {
        // конструктор html-движка: папка и расширение
        engine := html.___("./views", ".html")
        // в dev перечитываем шаблоны при каждом рендере
        engine.___(true)

        app := fiber.New(fiber.Config{
                // интерфейс fiber.Views принимает движок
                ___: engine,
        })

        app.Get("/tasks", func(c fiber.Ctx) error {
                // имя шаблона, данные, имя каркаса
                return c.___("index", fiber.Map{
                        "Title": "Задачи",
                        "Tasks": tasks,
                }, "layouts/main")
        })

        return app
}
```

Подсказка: html.New(directory, extension) создаёт движок; engine.Reload(true) включает перечитывание; в fiber.Config поле Views: engine; страница отдаётся вызовом c.Render("index", fiber.Map{...}, "layouts/main").

Правильные ответы в пропусках: `New`, `Reload`, `Views`, `Render`

### Соберите путь запроса через рендер

Расставьте шаги GET /tasks от curl до готового HTML в ответе.

Правильный порядок строк:

1. `curl localhost:8080/tasks`
2. `helmet ставит защитные заголовки ответа и пропускает дальше`
3. `cors добавляет CORS-заголовки и тоже пропускает`
4. `logger пишет строку запроса в консоль`
5. `session middleware находит куку session_id и грузит сессию: имя пользователя доступно FromContext`
6. `хендлер собирает fiber.Map{Title, User, Tasks} и зовёт c.Render("index", bind, "layouts/main")`
7. `Render берёт буфер из bytebufferpool, досыпает ViewBind и Locals в отсутствующие ключи`
8. `под RLock движка Views.Render ищет шаблон index и каркас layouts/main через Lookup`
9. `{{embed}} вставляет страницу внутрь каркаса, html/template экранирует данные по контексту`
10. `Content-Type: text/html; charset=utf-8, буфер уходит в тело ответа`

### Экранирование XSS: контексты html/template

Вернёмся к обещанному XSS. Опасность серверных шаблонов — подстановка данных: если пользователь контролирует поле, которое уезжает в HTML, то без экранирования <script>alert(1)</script> в названии задачи станет исполняемым скриптом на странице каждого, кто её увидит. html/template защищает автоматически, и защита умнее простой замены символов: пакет разбирает сам шаблон и понимает контекст каждого действия — текст страницы, значение атрибута, URL в href, тело <script>, CSS. Для каждого контекста свой экранировщик из исходников: attrEscaper для атрибутов, urlFilter для адресов, jsValEscaper для JavaScript. В тексте страницы (htmlEscaper) применяется таблица htmlReplacementTable: <, >, &, " и ' — в сущности, плюс и нулевой байт заодно.

Самая поучительная защита — URL. Схема javascript: опасна не символами, а смыслом, поэтому замена символов не поможет — и в исходниках стоит фильтр urlFilter: безопасные схемы http, https и mailto проходят, всё остальное заменяется словом-заглушкой filterFailsafe = "ZgotmplZ". Комментарий к нему рассказывает историю: innocuous word, not a keyword in any programming language, contains no special characters... when it appears in output it is distinct enough that a developer can find the source of the problem via a search engine — авторы специально выбрали слово, которое невозможно встретить случайно и которое гуглится за секунду. Если увидели #ZgotmplZ в href — html/template заблокировал опасную схему.

И честное предупреждение о люке: стандартная библиотека даёт типы «доверенного» контента — template.HTML, template.JS, template.URL. Значение такого типа экранировщик пропускает как есть — в исходниках это буквально if t == contentTypeHTML { return s }. Люк для разметки, которую вы построили сами из проверенных частей; люк в стене для всего остального. template.HTML(строка от пользователя) — это рукопожатие с XSS. Свяжем с восьмым уроком: helmet-заголовки — Content-Security-Policy, ограничения на инлайн-скрипты — это второй рубеж обороны: экранирование не пустило скрипт в разметку, CSP не даст исполниться и тому, что просочилось. Один механизм не отменяет другой — слоёная защита строится именно так.

### Проверьте себя: javascript: в href

**Вопрос:** Шаблон <a href="{{.URL}}">открыть</a>, хендлер передал URL: "javascript:alert(document.cookie)". Что окажется в HTML?

- ✅ <a href="#ZgotmplZ">открыть</a>: urlFilter заблокировал небезопасную схему
- <a href="javascript:alert(document.cookie)">открыть</a>: экранирование не трогает URL
- <a href="javascript%3Aalert(document.cookie)">открыть</a>: схема закодирована, ссылка безопасна
- Execute вернёт ошибку: опасные URL запрещены стандартом шаблонов

Контекст URL обрабатывается в два слоя: сначала фильтр схем, потом URL-экранирование. Фильтр сверяет протокол со списком безопасных — http, https, mailto — и в противном случае подставляет заглушку filterFailsafe = "ZgotmplZ" из исходников escape.go: innocuous word... distinct enough that a developer can find the source of the problem via a search engine. Никакой ошибки исполнения нет и быть не может: защита встроена в сам рендер, а не в проверку с исключением. Вариант с процентным кодированием — тонкая ловушка: некоторые браузеры декодируют %3A обратно, и фильтр через кодирование схем не строится в принципе. Практический вывод: пользовательские ссылки принимайте и нормализуйте на входе (только http/https), а ZgotmplZ в выводе воспринимайте как красный флаг — данные по этому ключу пришли с недоверенного источника.

### Статика и шаблоны: когда что выбирать

Пятый урок научил отдавать готовые файлы: app.Static монтирует каталог, и каждый запрос к /assets/style.css выливается в чтение файла с диска и отправку байт в ответ — файл на сервере и файл в ответе совпадают попиксельно, Fiber даже кэширует обработчики fasthttp на CacheDuration вперёд. Шаблоны — противоположный полюс: HTML не существует до запроса, он собирается заново из каркаса и данных для каждого клиента. Граница выбора проходит по вопросу «зависит ли ответ от данных?». Логотип, стили, собранный бандл SPA, robots.txt, картинки — статика из пятого урока, быстрый путь без Go-кода на каждый байт. Страница задач текущего пользователя, список заказов, форма с flash-сообщением из сессии — шаблоны: данные меняются, и готового файла просто нет.

Гибрид — норма индустрии, и у него два типовых вида. Первый вы уже собирали в восьмом уроке: статический SPA-каркас из app.Static плюс JSON API — шаблонам тут делать нечего, интерфейс рисует JavaScript. Второй — серверный рендер: страницы целиком собирает Fiber, а статика обслуживает ассеты под префиксом /assets; в одном приложении спокойно живут app.Static("/assets", "./public") для файлов и Config{Views: engine} для страниц — они не конфликтуют, потому что/static отдаёт существующие файлы, а маршруты страниц обрабатывают остальное. Отдельный мостик — embed: пятый урок вшивал статику в бинарник через embed.FS, а здесь NewFileSystem(fs, extension) делает то же с шаблонами; в обоих случаях деплой — один файл, без папок views и public рядом с бинарником. Разница в сложности поддержки: статике всё равно, шаблоны — это код в данных, и за их синтаксис отвечает тот же code review, что и за Go.

### Ловушки рендера: симптом, причина, лечение

| Симптом | Причина | Лечение |
| --- | --- | --- |
| c.Render читает файл с диска и парсит на каждый запрос | Views в конфиге не задан: работает запасной рендер 'name' как пути файла | настроить fiber.Config{Views: engine} — запасной путь только для экспресс-задач |
| render: template index does not exist | имя не совпало: расширение файла != Extension движка или файл вне Directory | сверить папку и расширение; Debug(true) покажет список загруженных имён |
| render: LayoutName layouts/main does not exist | файл каркаса не загружен или имя передано с расширением .html | имена — без расширения: layouts/main, не layouts/main.html |
| layoutName called unexpectedly | {{embed}} написан в обычной странице, а не в каркасе | {{embed}} живёт только в layout-шаблонах, в страницы — {{template}} и {{block}} |
| правки шаблона не видны без перезапуска | Reload(false) — дефолт: рендер идёт по скомпилированному набору | dev: engine.Reload(true) или app.ReloadViews() из вотчера |
| приложение поднялось, все страницы дают failed to render | Load при старте упал, но только log.Warnf failed to load views: %v | чинить шаблоны; при Reload(true) следующий рендер повторит загрузку сам |
| в href вместо адреса — #ZgotmplZ | urlFilter html/template заблокировал небезопасную схему | принимать только http/https/mailto; схему валидировать на входе |

> **Имя шаблона — это путь без папки-корня и без расширения.** Load обходит папку движка рекурсивно — filepath.Walk в исходниках html-движка, — и комментарий там же фиксирует правило именования: ./views/html/index.tmpl -> index. Имя — относительный путь от Directory минус расширение, поэтому вложенные папки входят в него целиком: каркас зовётся layouts/main, страница раздела — admin/users. Отсюда три гигиенических правила: не передавать расширение в c.Render (index.html — не имя), не класть в папку шаблонов файлы с другим расширением (движок их молча пропустит — HasExtension в обходе), и не использовать кириллицу с пробелами в именах файлов — они станут частью имени в шаблонах и URL-логах, где пробелы живут плохо.

### Словарь урока

- **fiber.Views** — Интерфейс из ctx.go: Views is the interface that wraps the Render function. Два метода: Load() error — разбор шаблонов — и Render(out io.Writer, name string, binding any, layout ...string) error. Всё, что умеет эти два метода, — движок Fiber; интерфейс реализуют все девять адаптеров github.com/gofiber/template.
- **c.Render(name, bind, layouts...)** — Метод из res.go: Render a template with data and sends a text/html response. Буфер из bytebufferpool; nil в bind превращается в fiber.Map; ViewBind и Locals (при PassLocalsToViews) досыпают только отсутствующие ключи; движок ищется по смонтированным приложениям; без движка — запасной рендер файла по пути name через html/template; Content-Type: text/html; charset=utf-8.
- **fiber.Map / ViewBind / PassLocalsToViews** — fiber.Map = map[string]any — стандартная форма данных для шаблона. c.ViewBind(vars Map) копит общий для страниц контекст: Variables are read by the Render method and may be overwritten. PassLocalsToViews (default false) пропускает c.Locals в шаблон. Приоритет: Render > ViewBind > Locals; верхние не затирают нижние.
- **Layout и {{embed}}** — Третий аргумент c.Render — variadic layouts; глобальный аналог — Config.ViewsLayout (global layout for all template render until override). Внутри каркаса {{embed}} вставляет отрисованный шаблон; имя настраивается engine.Layout("yield"), дефолт embed. Ошибки: render: LayoutName %s does not exist и layoutName called unexpectedly.
- **Экранирование html/template** — Контекстное: текст, атрибуты, URL, JS и CSS — у каждого свой экранировщик. В тексте <, >, &, кавычки — в сущности (&lt;, &gt;, &amp;, &#34;, &#39;). Схема javascript: в href заменяется на ZgotmplZ (filterFailsafe, безопасные схемы — http, https, mailto). template.HTML пропускает значение без экранирования — только для своей проверенной разметки. Второй рубеж — helmet-заголовки из восьмого урока.
- **Reload и ReloadViews** — engine.Reload(true) — перечитывать шаблоны при каждом рендере (для dev; дефолт false). app.ReloadViews() — перезагрузить движок по требованию, обходя смонтированные приложения, под write-блокировкой; ошибка: fiber: failed to reload views: %w. Load при старте в app.init(): ошибка — только лог failed to load views: %v, процесс живёт; Debug(true) печатает views: parsed template: имя.

### Запуск, четыре сценария curl и что дальше

Соберите каркас: движок html.New("./views", ".html") с Reload(true), Config{Views: engine}, сессии из [двенадцатого урока](/courses/fiber/lessons/12-cookie-sessii/), шаблоны index.html и layouts/main.html, маршрут /tasks — при желании под стражем requireAPIKey("s3cret") из седьмого урока, как в каноне API. Прогоните четыре сценария. Первый: curl localhost:8080/tasks — в ответе HTML с каркасом: навигация, заголовок «Задачи», список из двух пунктов и подпись «Гость», потому что сессии нет. Второй: войдите формой и повторите curl -b jar.txt localhost:8080/tasks — вместо «Гость» появится «Привет, anna!»: имя приехало из sess.Get("user") и проэкранировалось на пути в разметку. Третий: переименуйте шаблон в views/index.html.bak и обновите страницу — 500 с render: template index does not exist; верните имя, и при Reload(true) следующая же загрузка починит страницу без перезапуска сервера. Четвёртый: подставьте в Title строку со скриптом и посмотрите в исходнике страницы сущности вместо тегов — XSS закрыт экранированием, а в href с javascript:-схемой вы найдёте ZgotmplZ.

Материал этого урока держится на четырёх опорах: статики из [пятого урока](/courses/fiber/lessons/05-staticheskie-fajly/) — там app.Static и embed.FS, с которыми шаблоны делят приложение, полный план — на странице [курса Fiber](/courses/fiber/). Песочница сайта сетевые сценарии не запускает — runner-ов в уроках Fiber нет, интерактивы здесь quiz, predict, fill, order и флешкарты; каркас с шаблонами собирайте локально в терминале.

Дальше — четырнадцатый урок «Обработка ошибок»: ErrorHandler, свои типы ошибок и единый формат ответа. Сегодняшняя тема тянет его за собой уже сейчас: c.Render вернул ошибку failed to render — и по умолчанию это голый текст на 500; в следующем уроке вы научитесь рисовать вместо него страницу «что-то сломалось» в том же каркасе layouts/main и отдавать API честные JSON-ошибки — по одной схеме для страниц и для API.

## Коротко: что запомнить

- Два слоя темы: язык шаблонов — стандартный html/template ({{.Field}}, range, if, функции, конвейеры) и транспорт Fiber v3 — интерфейс fiber.Views (Load + Render) и метод c.Render(name, bind, layouts...) в res.go. Поля конфига Views, ViewsLayout и PassLocalsToViews переехали из v2 без переименований.
- Механика c.Render: буфер из bytebufferpool, nil в bind становится fiber.Map, ViewBind и Locals досыпают только отсутствующие ключи (Render > ViewBind > Locals), движок ищется среди смонтированных приложений, Content-Type принудительно text/html; charset=utf-8. Без движка — запасной рендер файла по пути name через html/template (failed to open/parse/execute).
- Модуль github.com/gofiber/template несёт девять адаптеров движков — ace, amber, django, handlebars, html, jet, mustache, pug, slim — каждый своим модулем вида github.com/gofiber/template/html/v3. html — обёртка над стандартной библиотекой; NewFileSystem принимает http.FileSystem для embed.FS.
- Layout: третий аргумент c.Render или глобальный ViewsLayout; внутри каркаса {{embed}} (дефолт LayoutName, меняется engine.Layout). Вложенные каркасы — через список имён. Ошибки: render: template %s does not exist, render: LayoutName %s does not exist, layoutName called unexpectedly.
- Разработка: engine.Reload(true) перечитывает шаблоны на каждом рендере, app.ReloadViews() — по требованию (fiber: failed to reload views: %w), Debug(true) печатает загруженные имена. Load при старте не валит процесс — только log.Warnf failed to load views: %v; неудавшийся Load повторится на следующем рендере.
- Экранирование контекстное: текст и атрибуты — сущности, URL — фильтр схем (http/https/mailto, остальное — #ZgotmplZ), JS и CSS — свои экранировщики. template.HTML отключает экранирование — только для своей разметки. Helmet из восьмого урока — второй рубеж.
- Статика из пятого урока — файлы как есть (app.Static), шаблоны — HTML собирается на каждый запрос из данных; гибрид норма: /assets статикой, страницы — через Views. Имя шаблона — путь от Directory без расширения: layouts/main, index.

## Домашнее задание

Дособерите страницу задач до продакшн-видов: вынесите каркас в layouts/main.html, добавьте партиал карточки задачи через {{define "task"}} и {{template "task" .}} в цикле, а флеш-сообщения из двенадцатого урока отрисуйте в каркасе — {{if .Flash}} с полем, которое страж кладёт в ViewBind до хендлера. Добавьте функцию движка engine.AddFunc("короткаяДата", ...) и форматируйте CreatedAt из канона в человекочитаемый вид прямо в шаблоне. Сломайте нарочно один шаблон, убедитесь, что приложение живо и рендер ругается, затем почините при Reload(true) без перезапуска. Подставьте в поле заголовка строку со скриптом и найдите сущности в исходнике страницы; соберите ссылку с javascript:-схемой и объясните себе ZgotmplZ. Наконец, вшейте шаблоны в бинарник через embed.FS и NewFileSystem из этого урока и статические ассеты из пятого — один бинарник должен нести и разметку, и стили.

## Вопросы и ответы по уроку

### c.Render отдаёт render: template index does not exist — что проверить первым делом?

Три вещи в порядке частоты: совпадает ли расширение файлов с тем, что передано конструктору html.New("./views", ".html") — файлы .tmpl при Extension ".html" молча пропускаются при обходе; попадает ли файл в Directory — обход идёт по папке движка, а имя в c.Render — путь от этой папки без расширения; и вызывался ли Load — при старте он выполняется сам, но ошибка только логируется (failed to load views: %v), а не валит процесс. Включите engine.Debug(true): при каждой загрузке в лог печатается views: parsed template: имя — сразу видно, какие имена реально есть в наборе.

### Обязателен ли gofiber/template, если есть html/template в стандартной библиотеке?

Нет. c.Render без настроенного Views умеет запасной путь: читает файл по переданному имени с диска и парсит его html/template-ом — для пары страниц этого хватает. Движок даёт главное: разбор всех шаблонов один раз при старте, layout-механику с {{embed}}, переиспользование через {{define}}, FuncMap-функции и перезагрузку без перезапуска. Другие движки из девяти адаптеров берут ради синтаксиса — mustache для лаконичности, pug для отступов; их Fiber встраивает тем же интерфейсом fiber.Views, и хендлеры не меняются.

### Как вывести в шаблон готовый HTML без экранирования — и когда это допустимо?

Только через типы доверенного контента: template.HTML для разметки, template.JS для скриптов, template.URL для адресов — значение такого типа экранировщик пропускает как есть. Допустимо, когда разметку построил ваш код из проверенных частей: например, подсветка синтаксиса или иконки из собственной библиотеки. Недопустимо — для любых данных пользователя: template.HTML(строка от клиента) — это прямое рукопожатие с XSS, экранирование для того и встроено, чтобы не доверять данным. Правило: разметку строит шаблон из данных, а не данные несут разметку; если без HTML в данных никак — прогоняйте их через санитайзер до помещения в template.HTML.
