Содержание урока · 11 разделов
- Подмодель: та же архитектура в миниатюре
- Маршрутизация сообщений: кто получит что
- Фокус: клавиши — только активной панели
- Маршруты по умолчанию
- Собираем экран: header, list, footer
- Насколько глубоко вкладывать
- Уровнем выше: экраны как подмодели
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Приложение растёт, и однажды модель перестаёт помещаться в экран редактора: три сотни строк полей, switch на тридцать веток, где клавиши, тики и ответы сети сплетены в один клубок. Править такое страшно: любое изменение в ветке для списка аукается в ветке для формы. Выход не в новом фреймворке, а в композиции: Bubble Tea с самого начала устроена как elm архитектура, а elm-программы собираются из вложенных моделей. Экран делится на части — шапка со статусом, список задач, строка помощи внизу, — и каждая часть становится подмоделью: обычной структурой со своим состоянием, своим Update и своим View. Корневая модель хранит их как поля и занимается только routing: какое сообщение какому адресату достанется. Сегодня соберём такую программу руками — и правило, которое на ней закрепится, спасёт вам недели рефакторинга.
Подмодель: та же архитектура в миниатюре#
Подмодель — это не особый тип из библиотеки, а соглашение: структура с полями состояния и методами Update и View. Реализовывать интерфейс tea.Model формально не обязательно — его обязан соблюдать только корень программы, потому что именно его получает tea.NewProgram. Подмодель может иметь собственную сигнатуру Update, удобную ей: возвращать свой конкретный тип вместо tea.Model — и это даже удобнее, потому что после вызова не нужен type assertion. Состояние подмодели живёт в её полях, команды она тоже может возвращать свои. Родитель не знает устройства своих детей: он держит их как поля и вызывает методы. Граница простая: подмодель отвечает за свою часть экрана целиком — и за то, как она реагирует на сообщения, и за то, как рисуется. Если для ответа на сообщение панели нужно лезть в чужие поля — сигнал, что граница проведена неправильно. Проверка простая: описание панели должно умещаться в одно предложение — «список задач с курсором и загрузкой». Если предложение разрастается, панель уже не панель.
Панель списка как подмодель
GOtype listPane struct {
cursor int
items []string
}
func (p listPane) Update(msg tea.Msg) (listPane, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up":
if p.cursor > 0 {
p.cursor--
}
case "down":
if p.cursor < len(p.items)-1 {
p.cursor++
}
}
case itemsLoadedMsg:
p.items = msg.items
}
return p, nil
}
func (p listPane) View() string {
return fmt.Sprintf("Задачи (%d)\n", len(p.items))
}listPane знает только про свой курсор и свои элементы. Сигнатура Update возвращает listPane, а не tea.Model: панель — не корень, ей не обязательно соответствовать интерфейсу программы. Сообщение itemsLoadedMsg она обрабатывает сама — данные загружаются снаружи, а что с ними делать, решает владелец.
Маршрутизация сообщений: кто получит что#
Корневой Update становится диспетчером: тип сообщения плюс текущий контекст решают, чей Update вызвать. Первое правило — широковещательные сообщения рассылаются всем. tea.WindowSizeMsg нужен каждой панели: список пересчитывает видимую высоту, футер — ширину строки помощи. Собираете возвращённые команды через tea.Batch из прошлого урока — и это первый настоящий случай, когда Batch нужен именно вам. Второе правило — адресные сообщения идут владельцу: spinner.TickMsg уходит панели со спиннером, itemsLoadedMsg — списку. Третье — решения о жизни программы принимает только корень: ни одна панель не возвращает tea.Quit сама; панель может попросить выход собственным сообщением, а корень решит. Такой диспетчер пишется один раз и почти не меняется — при добавлении панели добавляется ветка, а не правится клубок логики. Это и есть цена композиции: чуть больше методов — зато каждое сообщение имеет ровно одного адресата.
Корневой Update как диспетчер
GOtype mainModel struct {
list listPane
header headerPane
footer footerPane
}
func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
var cmds []tea.Cmd
var cmd tea.Cmd
m.header, cmd = m.header.Update(msg)
cmds = append(cmds, cmd)
m.list, cmd = m.list.Update(msg)
cmds = append(cmds, cmd)
m.footer, cmd = m.footer.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case itemsLoadedMsg:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
case tea.KeyMsg:
return m.routeToFocused(msg)
}
return m, nil
}WindowSizeMsg уходит всем трём панелям, их команды собираются в пачку. Адресное itemsLoadedMsg уходит только списку. Клавиши — особый случай: их маршрутизацией занимается routeToFocused, потому что решает не тип сообщения, а текущий фокус. Последний return ловит всё, что осталось: корень обязан вернуть модель и команду на любое сообщение.
Фокус: клавиши — только активной панели#
Клавиши — самые конфликтные сообщения: стрелки нужны и списку, и полю ввода, и кому угодно. Отдать нажатие всем — получить двойную реакцию: курсор шагнёт в списке и что-то сделает в футере. Поэтому у корневой модели живёт поле фокуса — обычно отдельный тип и константы на панель: paneList, paneFooter. routeToFocused смотрит на фокус и пересылает KeyMsg только активной панели; Tab обрабатывается в корне и переключает фокус, попутно сообщая панелям о смене — если они знают про свои состояния активности, переключите их явно. В bubbles у компонентов есть методы Focus и Blur именно для этого: поле ввода без фокуса даже курсор не показывает. Правило передачи эстафеты простое: смену фокуса делает только корень, панели сообщают о желании сообщениями — «передай фокус дальше», — но не меняют чужие поля сами. Обработчики остаются честными, а состояние фокуса единственно.
Передача фокуса между панелями
GOtype pane int
const (
paneList pane = iota
paneFooter
)
func (m mainModel) routeToFocused(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if msg.String() == "tab" {
if m.focused == paneList {
m.focused = paneFooter
} else {
m.focused = paneList
}
return m, nil
}
var cmd tea.Cmd
if m.focused == paneList {
m.list, cmd = m.list.Update(msg)
} else {
m.footer, cmd = m.footer.Update(msg)
}
return m, cmd
}Tab перехвачен корнем: он меняет фокус и ничего не пересылает. Остальные клавиши уходят строго активной панели — вторая панель нажатий не видит. Обратите внимание на форму: routeToFocused возвращает (tea.Model, tea.Cmd), потому что вызывается из корневого Update, а панели возвращают свои конкретные типы.
Классическая ошибка композиции — пересылать KeyMsg всем подмоделям сразу: проще написать, и неделю работает. Потом пользователь нажимает Enter, форма отправляется дважды, а вы ищете, кто вторично обработал событие. Клавиши — только по фокусу; широковещание оставьте WindowSizeMsg.
Маршруты по умолчанию#
| Сообщение | Кому уходит | Почему |
|---|---|---|
| tea.WindowSizeMsg | всем панелям | каждая пересчитывает свою ширину и высоту |
| tea.KeyMsg | активной панели | иначе клавиши срабатывают несколько раз |
| spinner.TickMsg | владельцу спиннера | тик нужен только одной панели |
| itemsLoadedMsg | списку | данные принадлежат ему |
| сообщение о выходе | корню | tea.Quit возвращает только верхний уровень |
Собираем экран: header, list, footer#
Композиция — это ещё и верстка: корневой View склеивает панельные View в один кадр. Для классической трёхчастной схемы — шапка, содержимое, строка помощи — корень пишет что-то вроде m.header.View() + "\n" + m.list.View() + "\n" + m.footer.View(): конкатенация строк, никаких особых API. Честные размеры приходят с WindowSizeMsg: каждая панель запоминает свои ширину и высоту и рисует ровно в них — вьюпорты и таблицы из bubbles умеют обрезать содержимое сами. Настройку размеров даёт альтернативный экран, о котором был третий урок: полноэкранной программе окно терминала — её холст. Порядок строк жёсткий: панель не должна рисовать соседей — если футеру нужен статус из шапки, корень передаёт ему данные сообщением, а не даёт ссылку на чужую подмодель. Данные текут вниз через поля и сообщения, события — вверх через команды: так поток остаётся читаемым даже на пятом экране. Заметка для верстальщика: пустые строки между панелями — тоже чей-то View. Пусть их рисует корень или панели — лишь бы источник был один: два места, добавляющих отступ, гарантируют пульсирующую вёрстку.
Насколько глубоко вкладывать#
Иерархия композиции растёт легко: корень, экраны, панели, виджеты — и легко же превращается в ад пробрасывания сообщений через четыре уровня. Рабочая глубина — два: корень держит экраны или крупные панели, те — свои компоненты. Мой ориентир по размеру: если подмодель перевалила за полторы-двести строк, в неё, скорее всего, протекла доменная логика, которой там не место — вынесите её в обычные функции и пакеты без всякого UI. Про разложение по файлам и папкам в проекте говорит отдельный урок о структуре: панель — свой файл, сообщения — общий, стили — свой. И ещё одна честная оговорка: композиция требует церемонии — методы, маршруты, фокус. На счётчике это выглядит громоздко. Цена окупается на второй панели: дальше каждая новая часть экрана — это новая структура и две ветки, а не ещё один клубок в общей модели.
Уровнем выше: экраны как подмодели#
Композиция масштабируется и вверх: следующий уровень — экраны. Корень хранит не панели, а текущий экран: type screen int с константами screenDashboard, screenEditor; поле currentScreen и ветка в Update маршрутизирует по нему сообщение целиком. Внутри экрана — свои панели, и получается дерево: корень, экран, панели. Переключение — тоже сообщение: панель возвращает команду, производящую showEditorMsg, корень меняет currentScreen, новый экран получает стартовое сообщение или команду. Пару нюансов практики. Состояние уходящего экрана можно сохранить в корне, если возврат на него нужен с тем же видом — привычка из мобильных интерфейсов, в терминале работает так же. Общие сообщения — WindowSizeMsg, ошибки, выход — оставьте корню, у каждого экрана пусть будут только свои. А как разложить экраны по файлам и пакетам — тема урока о структуре проекта: там же про styles.go и слой домена без UI.
Примеры этого урока — библиотечная композиция: им нужен локальный Go и настоящий терминал, в песочнице TUI не запускается. Соберите проект по шаблону из первых уроков и походите по панелям Tab'ом: двойная реакция клавиш, если она появится, видна сразу.
Проверка: что такое подмодель
Что такое подмодель в терминах Bubble Tea?
Проверка: фокус
Как отдать клавиши только активной панели?
Сколько насчитают панели?
Прочитайте код и выберите, что он выведет:
type counterPane struct{ hits int }
func (p counterPane) Update(msg tea.Msg) (counterPane, tea.Cmd) {
if _, ok := msg.(tea.KeyMsg); ok {
p.hits++
}
return p, nil
}
// В корневом Update клавиши пересылаются ВСЕМ панелям:
case tea.KeyMsg:
var c1, c2 tea.Cmd
m.list, c1 = m.list.Update(msg)
m.footer, c2 = m.footer.Update(msg)
return m, tea.Batch(c1, c2)
// Пользователь нажал Enter три разаЗаполните маршрутизатор
Восстановите константу панели и вызов Update подмодели в маршрутизации по фокусу.
Соберите маршрутизацию клавиш
Расставьте строки ветки корневого Update: клавиши уходят только сфокусированной панели списка, остальное — нейтральный возврат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь композиции
Что дальше#
Дальше композиция начнёт работать на вас: девятый урок про bubbles/list подкинет готовую панель списка с фильтрацией — вставьте её как подмодель, и половина ручного кода этого урока станет не нужна. Поля ввода из урока про textinput тоже встают подмоделями, а тонкости переключения фокуса между ними разобраны в уроке про фокус и формы. Полная программа курса — на странице курса Bubble Tea. Практика: доведите трёхпанельный экран из урока до запуска локально, добавьте четвёртую панель со статусом и убедитесь, что добавление — это одна структура и три ветки маршрутизации, а не правка клубка. Если так и вышло, композиция у вас получилась.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: items []string (2) · cursor int = 0 · status string = "всё спокойно" · focused int = 0
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- Подмодель — структура со своими Update и View; интерфейс tea.Model обязан соблюдать только корень.
- Корневой Update — диспетчер: тип сообщения и фокус решают, кому уходит событие.
- WindowSizeMsg рассылают всем и собирают команды через tea.Batch; клавиши уходят только активной панели.
- Фокус — поле корня с константами панелей; Tab обрабатывает корень.
- Корневой View склеивает панельные View; данные текут вниз, события — вверх.
- Рабочая глубина композиции — два уровня; рост подмодели за 150-200 строк — сигнал вынести доменную логику.
Домашнее задание
Разбейте любую свою модель на две панели и добавьте фокус с переключением по Tab. Затем добавьте третью панель и посчитайте, сколько веток пришлось дописать в корневой Update. Если для третьей панели потребовалось править логику первых двух — граница проведена неправильно: перепишите так, чтобы панели не знали друг о друге, и запустите локально.
Личные заметкиПусто
Вопросы и ответы по уроку
Подмодель должна реализовывать интерфейс tea.Model?
Нет. Интерфейс обязан соблюдать только корневая модель, которую получает tea.NewProgram. Подмодели свободны в сигнатурах: их Update может возвращать их собственный тип, что даже удобнее — не нужен type assertion после вызова.
Как подмодель попросить корень о чём-то?
Командой: панель возвращает свою команду, которая производит сообщение — например, requestFocusMsg или saveFinishedMsg, — а корень обрабатывает его в своём Update и решает, что делать. Прямых ссылок на родителя и чужие поля панели не получают.
Не громоздко ли делить даже маленькие программы на подмодели?
Да, на одной панели это церемония ради церемонии. Практический компромисс — держать модель плоской, пока не появились два экрана или две панели; затем делить по самой конфликтной границе. Ориентир — вторая панель: с её появлением композиция окупается, потому что каждая следующая часть экрана становится изолированной структурой, а не новой веткой в общем клубке.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler