Содержание урока · 11 разделов
- Интерфейс tea.Model: контракт в три метода
- Model: состояние без магии
- Init(): что происходит при старте
- Update: один switch вместо колбэков
- View: строка, которую перерисуют
- Цикл: сообщение → Update → View
- Три привычки, которые окупятся на больших программах
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Задача на сегодня звучит скучно, а ощущается отлично: программа, где ↑ увеличивает число, ↓ уменьшает, q — выходит. На первый взгляд это страница кода, на второй — весь курс в миниатюре: счётчик использует те же приёмы, что панели задач и дашборды, только деталей меньше. Мы соберём его с нуля, по строкам, и попутно зафиксируем три привычки, которые окупятся на больших программах: состояние живёт в одной структуре, все события проходят через один Update, отрисовка — чистая функция от состояния. Код урока нужно запускать локально: Bubble Tea работает только в настоящем терминале, песочница сайта показывает логику на виртуальном, а ощущение живого интерфейса даст только go run на вашей машине. Начнём с контракта, который придётся подписать фреймворку.
Интерфейс tea.Model: контракт в три метода#
Чтобы Bubble Tea мог управлять вашей программой, он должен знать три вещи: что сделать при старте, как отреагировать на событие и что нарисовать. За это отвечает интерфейс tea.Model — единственный интерфейс фреймворка, который вы реализуете напрямую. Внутри библиотеки он объявлен так:
Код
GOtype Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() string
}Три метода — и ни одного лишнего. Init возвращает стартовую команду Cmd (или nil), Update получает любое событие Msg и возвращает новую модель вместе с командой, View — строку для отрисовки. Заметьте: интерфейс ссылается на типы Cmd и Msg без пакетного префикса, потому что объявлен внутри самого пакета bubbletea. В вашем коде это будет tea.Cmd и tea.Msg.
Model: состояние без магии#
Модель — обычная структура, никакой магии наследования. Поля — ровно то, что нужно интерфейсу: то, что рисуется, и то, что влияет на логику. Счётчику хватает одного поля count; у панели задач будут срез задач, индекс курсора и режим экрана. Реализация интерфейса неявная: достаточно объявить методы с нужными сигнатурами на своём типе — плюс в копилку Elm-архитектуры на Go, никакой церемонии регистрации. Отдельное слово о приёмниках: мы объявляем методы на значении, не на указателе, — и это осознанный выбор. Каждый Update возвращает модель как значение, поэтому состояние явно пересобирается на каждое событие, а не мутирует в глубине. Модель счётчика копировать дёшево; когда дойдёте до тяжёлых моделей, приёмник на указателе оправдан — но начните со значения, оно прощает ошибки. Отдельный бонус такого подхода — тесты: не зависящий от фреймворка Update можно звать в обычном unit-тесте с готовым сообщением и сравнивать модель с ожидаемой, без терминала и моков.
Init(): что происходит при старте#
Init вызывает один раз, до первого кадра. Его работа — объявить, какие действия нужны программе сразу: вернуть nil, если ничего, или команду tea.Cmd, если что-то. Команда — это отложенное действие: функция, которую фреймворк запустит в отдельной горутине, а её результат потом придёт в Update сообщением. Примеры: tea.SetWindowTitle("Счётчик") — попросить терминал сменить заголовок окна; загрузка начальных данных с сервера; старт тикания спиннера. Пока мы возвращаем nil: клавиши сами придут, когда пользователь нажмёт. Полноценное знакомство с командами — тема пятого урока курса, там же объясняется, почему Update никогда не блокируется. Сейчас важен порядок старта: Init → первый View → цикл событий. Если Init возвращает nil, программа просто рисует модель и замолкает до первого нажатия.
Модель, Init и Update: разбор по строкам
GOpackage main
import (
"github.com/charmbracelet/bubbletea"
)
type model struct {
count int
}
func initialModel() model {
return model{count: 0}
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up":
m.count++
case "down":
m.count--
case "q", "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}model — состояние: одно числовое поле. initialModel — конструктор со стартовыми значениями: он пригодится при запуске. Init возвращает nil — стартовых действий нет. Update — сердце программы: тип-свитч перехватывает все сообщения, case tea.KeyMsg отфильтровывает нажатия клавиш, вложенный свитч по msg.String() разбирает, какая именно клавиша. up увеличивает счётчик, down уменьшает, q и ctrl+c возвращают команду tea.Quit — сигнал фреймворку завершить цикл. Последняя строка возвращает модель без изменений для всех прочих событий.
Типичная ошибка новичка: по невнимательности вернуть nil вместо модели — например, написать return nil, nil в какой-то ветке. Компилятор это пропустит (nil — допустимое значение интерфейса), а программа упадёт при первом событии с паникой «Model returned nil from Update!». Сообщение точное и понятное — не пугайтесь, просто найдите ветку, которая забывает вернуть m.
Update: один switch вместо колбэков#
Update — единственное место, где состояние меняется. Это и есть elm архитектура go-фреймворка: никаких регистрируемых обработчиков, никаких замыканий над чужими полями. Каждое событие — клавиша, таймер, ответ сети, resize окна — превращается в значение tea.Msg и попадает в один и тот же switch. Конструкция switch msg := msg.(type) — тип-свитч: в каждой ветке переменная msg имеет конкретный тип, поэтому к её полям можно обращаться без приведения. Событие, которое вы не обработали, просто пролетает в финальный return m, nil — программа не падает, а молча игнорирует его. Это одновременно фича и ловушка: опечатка в строке клавиши не вызовет ни ошибки компиляции, ни паники, — ветка просто не сработает. В четвёртом уроке разберём клавиши так, чтобы опечаток не осталось. Пока держите в голове: любое поведение программы прослеживается от одного switch, и это делает отладку предсказуемой. Здесь же живёт важный контракт: сообщение, которое никто не перехватил, — не исключение и не ошибка, а штатная ситуация, и знать это нужно прежде, чем добавлять в Update собственные типы сообщений.
View: строка, которую перерисуют#
View — самая необязательная и самая благодарная часть. Фреймворк вызывает её после каждого Update и печатает возвращённую строку вместо предыдущей. Внутри — никакого состояния, никакой логики: только форматирование полей модели. Строку собирают fmt.Sprintf, многострочность — обычные \n; на большой программе этой суммы хватать перестанет и в дело пойдёт Lip Gloss с рамками и цветами, но сейчас достаточно чистого Sprintf. Важно понимать, когда View не вызывается: если Update вернул модель без изменений, фреймворк всё равно перерисует строку — она может зависеть от чего угодно, поэтому перерисовка после каждого события честнее и проще, чем отслеживание «а изменилось ли». Дорого? На практике дешевле, чем кажется: обновляются только изменившиеся строки. Если View начнёт менять модель или читать глобальные переменные — вы сломали elm архитектуру, и отладка потеряет опору. Держите View чистым. Последняя деталь: возвращать пустую строку тоже можно — фреймворк нарисует пустой кадр, чем иногда пользуются при переходах между экранами, но для счётчика пустой View — баг, а не фича.
View и main: сборка и запуск
GOfunc (m model) View() string {
return fmt.Sprintf(
"Счётчик: %d\n\n"+
"↑ больше, ↓ меньше, q — выход\n",
m.count,
)
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Println("Программа упала:", err)
os.Exit(1)
}
}View складывает значение и подсказку в одну строку через Sprintf — пустые строки между ними делают вывод читаемым. В main две строки по делу: tea.NewProgram принимает начальную модель и возвращает программу, Run запускает цикл событий и блокирует main до выхода. Run возвращает финальную модель и ошибку; ошибку обязательно проверяем — если терминал недоступен, именно здесь вы узнаете об этом. Финальную модель пока отбрасываем подчёркиванием: что с ней делать — тема третьего урока.
Цикл: сообщение → Update → View#
Сложите три метода в одну картинку. Программа стартует, Init объявляет стартовые действия, View рисует первый кадр. Дальше — вечный цикл: фреймворк читает события терминала, заворачивает их в сообщения и передаёт в Update; Update возвращает новую модель; View рисует её; повторить. Это ровно та схема, по которой в Go гоняют данные между горутинами: получение значения из канала, обработка, отдача результата — если вы писали воркера на каналах из урока про каналы, этот цикл покажется старым знакомым. Разница в том, что фреймворк уже написал весь скучный код: чтение ввода, разбор клавиш, отрисовку и восстановление терминала при выходе. Программа завершается, когда Update возвращает команду tea.Quit либо когда Run падает с ошибкой — после выхода терминал возвращается в исходное состояние, и main продолжает работать как обычная программа. Скорость цикла на глаз неуловима: между нажатием и перерисовкой проходят миллисекунды, поэтому интерфейс ощущается живым, хотя внутри каждый кадр проходит через те же три метода, что и самый первый.
Три привычки, которые окупятся на больших программах#
- Держите модель маленькой: поле попадает в структуру, только если оно рисуется или влияет на логику — остальное живёт вне модели
- Не меняйте состояние вне Update: побочные эффекты — в команды tea.Cmd, о которых подробно расскажет пятый урок
- Держите View чистым: только поля модели и форматирование, без мутаций и глобальных переменных — иначе потеряете главную опору отладки
- Проверяйте каждую ветку Update на return — компилятор ловит не все забытые возвраты, а паника с nil приходит в самый неподходящий момент
- Называйте сообщения по смыслу события, а не по источнику: taskDoneMsg понятнее, чем channelMsg
- Один тип-свитч на модель: когда модель распухнет, вы разложите её на подмодели со своими свитчами — этому посвящён восьмой урок
Мы объявили методы на значении — и я советую начинать так всегда: возврат новой модели делает изменение состояния явным и спасает от скрытых мутаций. Приёмник на указателе беру, только когда модель тяжёлая и профилирование показало, что копирование заметно. Пока не измерили — копируйте.
Проверка: сигнатура Update
Что возвращает метод Update и зачем второй элемент?
Проверка: Init
Что означает Init(), возвращающий nil?
Прогоните счётчик в голове
Прочитайте код и выберите, что он выведет:
type model struct{ count int }
func initialModel() model { return model{count: 5} }
// Update: "up" → m.count++, "down" → m.count--,
// "q" → return m, tea.Quit
func (m model) View() string {
return fmt.Sprintf("Счётчик: %d\n", m.count)
}
// Пользователь нажал: ↑, ↑, ↓, ↑Заполните пропуски в Update
Восстановите разбор клавиш: тип сообщения, метод получения строки, инкремент и команду выхода.
Соберите View из строк
Расставьте строки метода View в правильном порядке — от сигнатуры до закрывающей скобки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: триада и её окружение
Что дальше#
Счётчик собран — и уже полезен, но запускается голым: без полного экрана, без мыши, без связи с внешним миром. Третий урок про tea.Program закрывает именно это: опции вроде WithAltScreen и WithMouseCellMotion, отправка сообщений снаружи через p.Send и полный жизненный цикл от NewProgram до выхода из Run. Потом — команды tea.Cmd: как не блокировать Update и ходить в сеть, пока интерфейс живёт. Если хотите закрепить счётчик руками, запустите его локально и добавьте ветку case "r": сброс на ноль — одна строка, а цикл «поменял Update → увидел поведение» прогоните целиком. Напомню на всякий случай: полный код курса выполняется локально, нужен Go и настоящий терминал — и этого достаточно.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: count int = 0
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- tea.Model — интерфейс из трёх методов: Init, Update, View; реализация неявная, достаточно сигнатур.
- Model — обычная структура: поля только те, что рисуются или влияют на логику.
- Update — единственное место изменения состояния: тип-свитч по сообщениям, каждая ветка возвращает (модель, команду).
- Init с nil означает «стартовых действий нет»; ненулевой Cmd начинает фоновую работу сразу.
- View — чистая функция от модели: строка на Sprintf, перерисовывается после каждого Update.
- Вернуть nil из Update — паника «Model returned nil from Update!»: проверяйте каждый return.
- Цикл «сообщение → Update → View» — тот же приём, что воркер на каналах; фреймворк уже написал скучную часть.
Домашнее задание
Соберите счётчик локально и расширьте его: добавьте клавишу r для сброса на ноль и клавишу s, которая записывает в модель строку статуса. Затем сломайте программу двумя способами: верните nil из одной ветки Update и уберите финальный return m, nil. Запишите, как именно ведёт себя программа в каждом случае — компилятор, паника или молчание.
Личные заметкиПусто
Вопросы и ответы по уроку
Обязательно ли называть тип модели model?
Нет, имя произвольное — model просто традиция туториалов Bubble Tea. В реальных проектах типы называют по экрану или назначению: taskListModel, authModel. Важно не имя, а набор методов Init, Update, View с правильными сигнатурами.
Почему Update принимает и возвращает модель, а не мутирует её?
Так работает Elm-архитектура: возврат нового значения делает каждое изменение состояния явным и проверяемым. Мутации из глубины колбэков — главная источник багов в классических GUI, здесь этот путь закрыт конструктивно.
Можно ли вызывать View самому?
Можно, но незачем: фреймворк вызывает View после каждого Update и сам решает, когда перерисовать терминал. Ручной вызов имеет смысл в тестах — сравнить строку с ожидаемой; в живой программе трогать вывод руками не нужно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler