Go: с нуля до своего блога Урок 35 из 50
Макет: одна рамка на все страницы
Тридцать пятый урок курса по Go. Семь шаблонов блога держат семь копий одного и того же начала страницы. Сегодня остаётся одна рамка: base с блоками, папки pages и partials, семантическая разметка — и измеренная ловушка, из-за которой у людей все страницы вдруг показывают одну и ту же.
Зачем это нужно
Посчитаем, что накопилось в блоге к этому уроку. Семь шаблонов, и в каждом из них — своё <!doctype html>, свой <meta charset>, своя ссылка на стили и свой логотип. Семь копий одного и того же.
Пока страниц было две, это выглядело как мелочь. Дальше в курсе появятся вход, регистрация, профиль и настройки — и копий станет одиннадцать. Захотите поменять шапку — поменяете в одиннадцати местах, в десяти правильно.
Есть и вторая причина, не про удобство. Страницы блога до сих пор собраны из <h1> и <p>, а браузер, поисковик и программа чтения с экрана ждут разметки, которая говорит, где шапка, где основное, где подвал. Сегодня появится и она.
Сразу целиком
Новая папка, go mod init sabaq32. Файлов теперь несколько, и разложены они не как раньше:
templates/
base.html рамка: одна на всё
partials/
header.html куски, общие для страниц
footer.html
pages/
list.html то, что вставляется в рамку
about.html
main.go
templates/base.html
{{ define "base" -}}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<title>{{ block "title" . }}Мой блог{{ end }}</title>
</head>
<body>
{{ template "header" . }}
<main>
{{- block "main" . }}{{ end }}
</main>
{{ template "footer" . }}
</body>
</html>
{{- end }}
templates/partials/header.html
{{ define "header" -}}
<header>
<a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
<nav>
<a href="/">Статьи</a>
<a href="/search">Поиск</a>
<a href="/about">О блоге</a>
</nav>
</header>
{{- end }}
templates/partials/footer.html
{{ define "footer" -}}
<footer>
<p>© {{ .Year }} Мой блог</p>
</footer>
{{- end }}
templates/pages/list.html
{{ define "title" }}Статьи — Мой блог{{ end }}
{{ define "main" }}
<ul>
{{- range .Articles }}
<li><a href="/read/{{ .Slug }}">{{ .Title }}</a> — {{ .Updated }}</li>
{{- end }}
</ul>
{{- end }}
templates/pages/about.html
{{ define "title" }}О блоге — Мой блог{{ end }}
{{ define "main" }}
<article>
<h1>О блоге</h1>
<p>Здесь я пишу о том, что узнал.</p>
</article>
{{- end }}
main.go
package main
import (
"embed"
"fmt"
"html/template"
"io/fs"
"log"
"os"
"path"
"time"
)
//go:embed templates
var files embed.FS
// Article — статья такая, какой её видит страница.
type Article struct {
Slug string
Title string
UpdatedAt time.Time
}
// months — потому что названий месяцев по-русски в Go нет: пакет time знает
// только английские.
var months = [...]string{"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"}
// Updated возвращает дату так, как её читает человек. Форматирование живёт
// здесь, а не в шаблоне: шаблон показывает, а не считает.
func (a Article) Updated() string {
if a.UpdatedAt.IsZero() {
return "не правилась"
}
return fmt.Sprintf("%d %s %d", a.UpdatedAt.Day(),
months[a.UpdatedAt.Month()-1], a.UpdatedAt.Year())
}
// pages собирает по одному набору шаблонов на страницу: макет, все куски и
// ровно одна страница. Иначе блоки с одинаковыми именами затрут друг друга.
func pages() map[string]*template.Template {
found, err := fs.Glob(files, "templates/pages/*.html")
if err != nil || len(found) == 0 {
log.Fatal("страницы не найдены: ", err)
}
out := make(map[string]*template.Template, len(found))
for _, page := range found {
name := path.Base(page)
t, err := template.New(name).ParseFS(files,
"templates/base.html", "templates/partials/*.html", page)
if err != nil {
log.Fatal(name, ": ", err)
}
out[name] = t
}
return out
}
func main() {
tpl := pages()
data := map[string]any{
"Year": 2026,
"Articles": []Article{
{Slug: "dala", Title: "О степи", UpdatedAt: time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)},
{Slug: "privet", Title: "Привет, мир"},
},
}
for _, name := range []string{"list.html", "about.html"} {
fmt.Printf("== %s\n", name)
if err := tpl[name].ExecuteTemplate(os.Stdout, "base", data); err != nil {
log.Fatal(err)
}
fmt.Println()
}
}
Вывод — две готовые страницы:
== list.html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<title>Статьи — Мой блог</title>
</head>
<body>
<header>
<a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
<nav>
<a href="/">Статьи</a>
<a href="/search">Поиск</a>
<a href="/about">О блоге</a>
</nav>
</header>
<main>
<ul>
<li><a href="/read/dala">О степи</a> — 6 сентября 2026</li>
<li><a href="/read/privet">Привет, мир</a> — не правилась</li>
</ul>
</main>
<footer>
<p>© 2026 Мой блог</p>
</footer>
</body>
</html>
== about.html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/style.css">
<title>О блоге — Мой блог</title>
</head>
<body>
<header>
<a href="/"><img src="/static/logo.svg" width="40" height="40" alt=""></a>
<nav>
<a href="/">Статьи</a>
<a href="/search">Поиск</a>
<a href="/about">О блоге</a>
</nav>
</header>
<main>
<article>
<h1>О блоге</h1>
<p>Здесь я пишу о том, что узнал.</p>
</article>
</main>
<footer>
<p>© 2026 Мой блог</p>
</footer>
</body>
</html>
Разбор
define и block: рамка и то, что в неё вставляют
{{ define "header" }} даёт куску имя. {{ template "header" . }} вставляет кусок по имени, а точка передаёт ему те же данные, что и всей странице, — забудете её, и подвал не узнает про год.
{{ block "main" . }} — это define и template в одной записи: «здесь будет блок с таким именем, а если его никто не задал, то вот содержимое по умолчанию». В base.html таких блоков два: заголовок окна с запасным значением Мой блог и пустой main.
Страница не рисует ничего целиком. Она только говорит: «мой title вот такой, мой main вот такой». Рамку рисует base.
Папки pages и partials
Разделение не для красоты. В partials лежит то, что вставляют в рамку всегда и по имени: шапка, подвал, дальше — карточка статьи, форма поиска. В pages — то, чем рамку наполняют, по одному файлу на страницу.
Разница видна в следующем разделе: наборы шаблонов собираются по одному на страницу, и partials попадают в каждый набор, а из pages в набор берётся ровно один файл.
Ловушка: все шаблоны в одном пространстве имён
Вот из-за чего макеты ломаются у половины тех, кто делает их впервые.
ParseFS складывает все найденные шаблоны в один набор, а имя внутри набора одно на всех. Две страницы, обе объявляют {{ define "main" }} — и вторая молча затирает первую. Проверим:
== один набор на все страницы
-- просим list:
<!doctype html>
<html lang="ru">
<head>
<title>Список</title>
</head>
<body>
<main><p>это список</p></main>
</body>
</html>
-- просим about:
<!doctype html>
<html lang="ru">
<head>
<title>Список</title>
</head>
<body>
<main><p>это список</p></main>
</body>
</html>
Мы просили разные страницы, а получили одну и ту же — дважды. Ошибки нет, потому что с точки зрения Go ничего плохого не случилось: в наборе просто оказался один шаблон main, последний из прочитанных.
Образ. Ящик с папками, где на всех папках одна подпись. Положить можно сколько угодно, достать — только ту, что сверху.
Свой набор шаблонов на каждую страницу
Лечится это тем, что наборов делают столько же, сколько страниц:
t, err := template.New(name).ParseFS(files,
"templates/base.html", "templates/partials/*.html", page)
В каждый набор входят рамка, все общие куски и одна страница. Имена внутри набора больше не сталкиваются: main в наборе ровно один.
Собирается это один раз при старте, в карту «имя страницы → набор». Дальше обработчик берёт нужный набор и выполняет в нём base. Заметьте: выполняется всегда base, а не имя страницы, — рисует-то рамка.
Ошибка в любом шаблоне обнаружится при старте, а не у читателя, — то же правило, что и в уроке про шаблоны: разобрать всё на старте лучше, чем получить сюрприз в середине страницы.
Семантика: header, main, footer, nav, article
В выводе видно, что страница теперь собрана не из «дивов вообще», а из тегов, у которых есть смысл: <header> — шапка, <nav> — навигация, <main> — единственное на странице основное содержимое, <article> — самостоятельный текст, <footer> — подвал.
Это не украшение. Программа чтения с экрана позволяет перейти сразу к <main>, минуя меню. Поисковик отличает содержимое от обвязки. Браузер в режиме чтения выбрасывает всё, кроме <article>.
Правило простое: <main> на странице один, <h1> тоже один, а <div> берут тогда, когда подходящего по смыслу тега нет.
Дата для человека, а не для базы
До сих пор блог показывал время правки так, как его отдала SQLite: 2026-09-06 12:00:00. Читателю это ни о чём.
Форматирование живёт в Go, а не в шаблоне: шаблон показывает, а не считает. Метод у типа — самое подходящее место:
func (a Article) Updated() string {
if a.UpdatedAt.IsZero() {
return "не правилась"
}
return fmt.Sprintf("%d %s %d", a.UpdatedAt.Day(),
months[a.UpdatedAt.Month()-1], a.UpdatedAt.Year())
}
Шаблон зовёт его как поле — {{ .Updated }}, без скобок.
Про месяцы стоит сказать отдельно. В Go нет названий месяцев на русском или казахском: пакет time знает только английские. Список приходится писать самому — как в этой программе. И само форматирование в Go делается не буквами вроде YYYY-MM-DD, а образцом даты: 02.01.2006. Это не случайные числа, а «второе января две тысячи шестого» — способ записать, что где стоит.
Пробелы: {{- и -}}
Шаблон печатает всё, что стоит между действиями, включая переводы строк. Из-за этого range оставляет пустую строку на каждый оборот, а страница выглядит рваной.
Минус внутри скобок съедает пробелы с этой стороны: {{- range .Articles }} уберёт перевод строки перед собой. В выводе выше список идёт ровно, без дыр, — именно из-за минусов в list.html.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему две страницы с блоком
mainв одном наборе шаблонов показывают одно и то же? - Что выполняет обработчик — шаблон страницы или
base, и почему? - Почему дату форматируют в Go, а не в шаблоне?
Задание
Обязательное. Переведите блог на макет. Все семь шаблонов разъезжаются по pages и partials, общее начало остаётся в base.html, шапка с навигацией — в partials/header.html. Наборы собираются один раз при старте в карту. Разметка становится семантической, а дата правки выводится через метод, а не сырой строкой.
Всё это сделано в step-20 — сверьтесь после того, как сделаете сами.
По желанию.
- Подсветите в навигации текущую страницу. Понадобится передать в шаблон её имя.
- Добавьте
partials/article-card.htmlи используйте его и в списке, и на странице тега. - Уберите минусы из
{{- range }}и посмотрите на исходный код страницы в браузере. - Год в подвале здесь приходит с данными. Сделайте его функцией шаблона через
Funcs: тогда его не придётся передавать на каждой странице, а подвал не покажет2026в две тысячи двадцать седьмом. В step-20 сделано именно так.
Куда это встанет в блоге
Это первый урок, после которого блог перестаёт выглядеть учебным примером. Одна рамка, настоящая разметка, человеческая дата.
Долги. Стилей по-прежнему почти нет, и это сознательно: оформлению курс не учит. Навигация не знает, на какой странице читатель. И у страниц нет ни описания для поисковика, ни заголовков для соцсетей — это будет в уроке про чек-лист перед запуском.
Ответы
Показать ответы
- Потому что
ParseFSкладёт все шаблоны в один набор, а имена в нём общие: второйmainзатирает первый. Никакой ошибки при этом нет — в наборе просто остаётся один шаблон с этим именем. base, всегда. Страница задаёт только блокиtitleиmain, а рисует рамка; выполнять имя страницы бессмысленно — в ней нет ни<html>, ни<body>.- Потому что шаблон показывает, а не считает. Метод у типа держит правило в одном месте, его можно проверить тестом, и он одинаково работает на всех страницах.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.