Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 8 из 25 ~20 мин +50 XP

Крупные модели: композиция подмоделей

Композиция подмоделей — способ держать крупный интерфейс в bubbletea в голове: экран делится на панели, каждая живёт собственной структурой со своим Update и View, а корневая модель только маршрутизирует сообщения. Ни одна панель не знает о существовании другой — именно это делает изменения локальными. Разберём подмодели, пересылку сообщений, передачу фокуса и соберём экран из хедера, списка и футера.

Содержание урока · 11 разделов

Приложение растёт, и однажды модель перестаёт помещаться в экран редактора: три сотни строк полей, switch на тридцать веток, где клавиши, тики и ответы сети сплетены в один клубок. Править такое страшно: любое изменение в ветке для списка аукается в ветке для формы. Выход не в новом фреймворке, а в композиции: Bubble Tea с самого начала устроена как elm архитектура, а elm-программы собираются из вложенных моделей. Экран делится на части — шапка со статусом, список задач, строка помощи внизу, — и каждая часть становится подмоделью: обычной структурой со своим состоянием, своим Update и своим View. Корневая модель хранит их как поля и занимается только routing: какое сообщение какому адресату достанется. Сегодня соберём такую программу руками — и правило, которое на ней закрепится, спасёт вам недели рефакторинга.

Подмодель: та же архитектура в миниатюре#

Подмодель — это не особый тип из библиотеки, а соглашение: структура с полями состояния и методами Update и View. Реализовывать интерфейс tea.Model формально не обязательно — его обязан соблюдать только корень программы, потому что именно его получает tea.NewProgram. Подмодель может иметь собственную сигнатуру Update, удобную ей: возвращать свой конкретный тип вместо tea.Model — и это даже удобнее, потому что после вызова не нужен type assertion. Состояние подмодели живёт в её полях, команды она тоже может возвращать свои. Родитель не знает устройства своих детей: он держит их как поля и вызывает методы. Граница простая: подмодель отвечает за свою часть экрана целиком — и за то, как она реагирует на сообщения, и за то, как рисуется. Если для ответа на сообщение панели нужно лезть в чужие поля — сигнал, что граница проведена неправильно. Проверка простая: описание панели должно умещаться в одно предложение — «список задач с курсором и загрузкой». Если предложение разрастается, панель уже не панель.

Панель списка как подмодель

GO
type 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 как диспетчер

GO
type 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 именно для этого: поле ввода без фокуса даже курсор не показывает. Правило передачи эстафеты простое: смену фокуса делает только корень, панели сообщают о желании сообщениями — «передай фокус дальше», — но не меняют чужие поля сами. Обработчики остаются честными, а состояние фокуса единственно.

Передача фокуса между панелями

GO
type 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, а панели возвращают свои конкретные типы.

Маршруты по умолчанию#

СообщениеКому уходитПочему
tea.WindowSizeMsgвсем панелямкаждая пересчитывает свою ширину и высоту
tea.KeyMsgактивной панелииначе клавиши срабатывают несколько раз
spinner.TickMsgвладельцу спиннератик нужен только одной панели
itemsLoadedMsgспискуданные принадлежат ему
сообщение о выходекорнюtea.Quit возвращает только верхний уровень

Композиция — это ещё и верстка: корневой 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.

Проверка: что такое подмодель

Что такое подмодель в терминах 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 подмодели в маршрутизации по фокусу.

func (m mainModel) routeToFocused(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.focused { case pane: var cmd tea.Cmd m.list, cmd = m.list.(msg) return m, cmd case paneFooter: var cmd tea.Cmd m.footer, cmd = m.footer.Update(msg) return m, cmd } return m, nil }

Соберите маршрутизацию клавиш

Расставьте строки ветки корневого Update: клавиши уходят только сфокусированной панели списка, остальное — нейтральный возврат.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: словарь композиции

Термин

Подмодель

нажмите, чтобы перевернуть
Определение

Структура с собственными Update и View: состояние панели. Корень хранит её полем и делегирует сообщения.

1 / 5

Что дальше#

Дальше композиция начнёт работать на вас: девятый урок про bubbles/list подкинет готовую панель списка с фильтрацией — вставьте её как подмодель, и половина ручного кода этого урока станет не нужна. Поля ввода из урока про textinput тоже встают подмоделями, а тонкости переключения фокуса между ними разобраны в уроке про фокус и формы. Полная программа курса — на странице курса Bubble Tea. Практика: доведите трёхпанельный экран из урока до запуска локально, добавьте четвёртую панель со статусом и убедитесь, что добавление — это одна структура и три ветки маршрутизации, а не правка клубка. Если так и вышло, композиция у вас получилась.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
ХЕДЕР — общий каркас корня
▸ 1) собрать каркас
2) написать футер
ФУТЕР: всё спокойно

Клавиатура — обработано в 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. Если для третьей панели потребовалось править логику первых двух — граница проведена неправильно: перепишите так, чтобы панели не знали друг о друге, и запустите локально.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Крупные модели: композиция подмоделей)

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

Подмодель должна реализовывать интерфейс tea.Model?

Нет. Интерфейс обязан соблюдать только корневая модель, которую получает tea.NewProgram. Подмодели свободны в сигнатурах: их Update может возвращать их собственный тип, что даже удобнее — не нужен type assertion после вызова.

Как подмодель попросить корень о чём-то?

Командой: панель возвращает свою команду, которая производит сообщение — например, requestFocusMsg или saveFinishedMsg, — а корень обрабатывает его в своём Update и решает, что делать. Прямых ссылок на родителя и чужие поля панели не получают.

Не громоздко ли делить даже маленькие программы на подмодели?

Да, на одной панели это церемония ради церемонии. Практический компромисс — держать модель плоской, пока не появились два экрана или две панели; затем делить по самой конфликтной границе. Ориентир — вторая панель: с её появлением композиция окупается, потому что каждая следующая часть экрана становится изолированной структурой, а не новой веткой в общем клубке.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Zap25 уроков~9 чпрактика25 уроков уже доступны

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler