Содержание урока · 12 разделов
- Команда — это func() tea.Msg
- Возврат команды из Init и Update
- Горутины нужны фреймворку, не вам
- Правила команды: что можно и что нельзя
- Делегирование: Update не превращается в свалку
- Команда против горутины с p.Send
- Ошибки из асинхронных операций
- Контекст: как отменять затянувшуюся работу
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Счётчик из второго урока отзывался мгновенно — и не потому, что он маленький. Update в Bubble Tea обрабатывает сообщения по одному: пришло нажатие, инкремент, перерисовка — всё за микросекунды. Теперь представьте, что по нажатию r модель должна сходить в API за списком задач. Запрос занимает полсекунды в хорошем случае и десять секунд в плохом. Если эта работа случится прямо внутри Update, весь терминальный интерфейс замрёт: клавиши не обрабатываются, q не работает, картинка стоит. Пользователь такое не прощает. Хуже того: очередь событий продолжит копить нажатия, и после разморозки они обрушатся на Update лавиной — «залипшая» клавиатура вам обеспечена. Bubble Tea решает проблему архитектурно: долгую работу объявляют командой — значением типа tea.Cmd, — фреймворк уводит её в сторону, а в Update приходит только сообщение-результат, когда работа закончена. Сегодня пройдём механизм целиком: от сигнатуры до правил и ошибок.
Команда — это func() tea.Msg#
Определение умещается в одну строку: tea.Cmd — это тип func() tea.Msg. Обычная функция без аргументов, которая возвращает сообщение — значение любого типа. Никаких интерфейсов, колбэков и промисов: асинхронная команда в bubbletea — замыкание, которое выполнит работу и вернёт результат. Что важно понимать сразу. Команду не вызывают сами — её возвращают из Init или Update, а цикл событий запускает её в собственной горутине. Пока команда работает, Update свободен и продолжает ловить клавиши. Когда функция завершается, её возвращаемое значение попадает в очередь сообщений и обрабатывается как обычное событие — тем же тип-свитчем, что и нажатия клавиш. Тип сообщения вы придумываете сами: resultMsg, tasksLoadedMsg, fileSavedMsg — соглашение простое, имя должно отвечать на вопрос «что случилось». Одно событие — одно сообщение: не пытайтесь упаковать в него три разных факта, дробите на отдельные типы. В elm архитектуре такие значения зовутся сообщениями, и это не украшение: сообщение — факт, который уже произошёл, а не просьба что-то сделать.
Свой тип сообщения и команда загрузки
GOtype tasksLoadedMsg struct {
tasks []Task
err error
}
func fetchTasksCmd() tea.Cmd {
return func() tea.Msg {
tasks, err := store.LoadTasks()
if err != nil {
return tasksLoadedMsg{err: err}
}
return tasksLoadedMsg{tasks: tasks}
}
}tasksLoadedMsg — тип-результат: успех и ошибка едут одним сообщением, просто в разных полях. fetchTasksCmd возвращает не результат, а замыкание: фреймворк вызовет его позже, в своей горутине. Внутри — обычный Go-код: поход в хранилище, разбор ошибки, упаковка в сообщение. Ошибку не печатаем и не паникуем — её увидит Update и решит, что показывать.
Возврат команды из Init и Update#
Команда живёт в двух местах, оба вы уже видели, просто там стоял nil. Init возвращает команду, которая должна выполниться при старте: загрузить настройки, проверить соединение, запустить первые часы. Update возвращает команду вслед за моделью — обычно как реакцию на конкретное событие: пользователь нажал r — верните fetchTasksCmd, пришло сообщение загрузки — верните команду сохранения. Ноль означает «асинхронной работы не заказано»: nil — штатное значение, а не ошибка. Отсюда ясная картина потока: событие → Update → (модель, команда) → горутина → сообщение → Update. Цикл может закручиваться сколько угодно: каждая команда способна породить следующую. И помните про потолок: один Update обрабатывает одно сообщение и возвращает максимум одну команду. Как упаковать несколько — тема седьмого урока про tea.Batch и tea.Sequence. И практический совет по чтению чужого кода: команды ищутся по возвращаемому типу — grep по tea.Cmd показывает все асинхронные точки программы за секунду, это удобнее, чем вылавливать голые go func().
Горутины нужны фреймворку, не вам#
Внутри Bubble Tea каждый вызов команды уходит в отдельную горутину: фреймворк запускает её, ждёт tea.Msg и проталкивает результат в очередь событий. Вам писать go func() не нужно — и в типичном приложении не нужно вообще. Это принципиальное отличие от окружающего мира: в обычном Go вы сами следите, чтобы каналы из урока про горутины закрывались, а гонки — разруливались. Здесь ответственность разделена: команда делает одну вещь и возвращает одно сообщение, остальное — забота цикла событий. Когда горутина всё-таки нужна? Для долгоживущего фона: слушатель очереди, подписка на WebSocket, чтение лога из потока — то, что не заканчивается одним результатом. Такие воркеры живут вне команд и общаются с программой через p.Send из третьего урока. Промежуточные случаи — пачки одноразовых задач — тоже решаются командами, просто упакованными вместе: об этом седьмой урок. Правило короткое: одноразовая работа — команда, бесконечный поток — горутина с Send.
time.Sleep, запрос сети, чтение файла внутри Update замораживают весь интерфейс: сообщения копятся в очереди, но не обрабатываются. Кажется очевидным — пока не сделаешь сам. Долгая работа живёт в команде; Update только решает, что делать с результатом.
Программа целиком: загрузка с состоянием
GOtype model struct {
loading bool
tasks []Task
err error
}
func (m model) Init() tea.Cmd {
return fetchTasksCmd()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tasksLoadedMsg:
m.loading = false
m.err = msg.err
m.tasks = msg.tasks
return m, nil
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
return m, nil
}
func (m model) View() string {
if m.err != nil {
return "Ошибка: " + m.err.Error()
}
if m.loading {
return "Загружаю задачи..."
}
return fmt.Sprintf("Задач: %d, q — выход\n", len(m.tasks))
}
func main() {
m := model{loading: true}
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Ошибка:", err)
os.Exit(1)
}
}Проследите флаг loading: Init вернул команду, фреймворк запустил её, и до прихода tasksLoadedMsg View показывает «Загружаю...». Сообщение загрузки обрабатывается тем же switch, что и клавиши, — отдельного потока, очереди или колбэка в вашей программе нет. В реальном приложении вместо статичной строки здесь будет спиннер из шестого урока.
Правила команды: что можно и что нельзя#
Внутри команды действуют три запрета. Первый: не трогайте модель. Команда получает данные при создании — через параметры фабрики — и возвращает результат сообщением; прямой доступ к состоянию ломает elm архитектуру и ведёт к гонкам данных. Второй: не печатайте в терминал. Пока программа работает, вывод принадлежит рендереру — fmt.Println из команды вклинит строку в кадр, ровно как из любой горутины. Третий: не тяните глобальное состояние. Команда, которая пишет в пакетную переменную, при повторном запуске удивит вас значением из прошлого раза — я однажды ловил именно такой баг полдня. Что можно: обычный Go — клиенты HTTP, запросы к базе, файлы, контексты с таймаутами. Аргументы передавайте фабрикой: func(id int) tea.Cmd { return func() tea.Msg { ... } } — внешняя функция принимает параметр и возвращает готовую команду с захваченным значением.
Делегирование: Update не превращается в свалку#
С появлением собственных сообщений switch в Update растёт: клавиши, тики, результаты загрузок, ответы сохранений. К пятому-шестому типу в одном switch начинаются потери. Первое правило гигиены — проверку сообщения, checkingMsg, всегда делайте type-свитчем: case tasksLoadedMsg, case tea.KeyMsg — компилятор не даст перепутать типы, и это одна из главных выгод elm архитектуры на Go. Второе — делегирование: сообщение обрабатывает тот, кому оно адресовано. В маленькой модели достаточно приватных методов: m.handleKeys(msg), m.handleResult(msg) — Update превращается в диспетчер на пять строк. Отдельный признак беды — события-строки: если вы различаете сообщения по полю внутри общей структуры, остановитесь и заведите отдельный тип. Тип — это одновременно маршрутизация и документация. В больших моделях сообщениями распоряжаются подмодели — этому посвящён восьмой урок. Третье: не плодите поля модели под каждый случай; сообщение несёт данные, Update раскладывает их по существующим полям.
Команда против горутины с p.Send#
| Ситуация | Инструмент | Почему |
|---|---|---|
| Один запрос к API или базе | tea.Cmd | один результат — одно сообщение, горутину запустит фреймворк |
| Таймеры и тики | tea.Cmd через tea.Tick | команда планирует следующее событие сама |
| Чтение файла при старте | tea.Cmd из Init | результат нужен один раз, до первого кадра |
| Поток событий: WebSocket, tail лога | горутина + p.Send | команда заканчивается, а поток живёт бесконечно |
Ошибки из асинхронных операций#
Ошибка в команде — не повод паниковать и не повод молчать. Паника внутри горутины уронит программу целиком, а проигнорированный err превратится в интерфейс, который «просто ничего не загрузил». Рабочая схема старая, как сам Go: ошибка едет в сообщении. Либо отдельный тип errMsg с полем err, либо — как в примере выше — поле err в сообщении-результате. Update складывает её в модель, View решает, что показывать: строку об ошибке, тост, кнопку повтора. Бонус подхода в том, что ошибку можно обрабатывать как событие: показать сообщение и через пару секунд стереть, предложить повтор той же командой. В примерах Charm встречаются оба варианта — и отдельный errMsg, и поле в resultMsg; оба каноничны. Ещё антипаттерн — возвращать nil при ошибке: Update не узнает, что что-то случилось, и покажет вечную загрузку. Если ошибка возможна, она обязана доехать до модели. Выберите одну схему на проект и не смешивайте.
Контекст: как отменять затянувшуюся работу#
Команда ушла и висит: запрос к API не отвечает три минуты, пользователь смотрит на надпись «Загружаю» и жмёт Esc. Отмена в Go делается контекстом, и в командах он работает как обычно: фабрика принимает ctx context.Context, замыкание передаёт его в запрос — http.NewRequestWithContext, операции с базой, файловое чтение. Остаётся вопрос: кто вызовет cancel()? Честных схемы две. Первая: контекст с таймаутом создаётся внутри самой команды — работа гарантированно закончится и вернёт сообщение с ctx.Err(), никаких висячих запросов. Вторая: cancel хранится в модели, Update по Esc его вызывает, команда возвращается раньше срока. Второй вариант отзывчивее, но требует дисциплины — поле cancel в модели живёт ровно до следующего запуска команды, иначе отменит не то. Для старта берите первую схему: таймаут внутри команды закрывает большую часть случаев, а живую отмену добавите, когда она действительно понадобится пользователю.
Код этого урока — настоящий bubbletea-код, ему нужен локальный терминал: go mod init, go get github.com/charmbracelet/bubbletea и go run main.go. Песочница сайта показывает логику на виртуальном терминале, но задержки сети и клавиши честно живут только в go run.
Проверка: анатомия команды
Какой тип у tea.Cmd?
Проверка: nil как ответ
Пользователь нажал клавишу, асинхронная работа не нужна. Что возвращает Update?
Команда, которая планирует сама себя
Прочитайте код и выберите, что он выведет:
type countMsg int
func bumpCmd() tea.Cmd {
return func() tea.Msg { return countMsg(1) }
}
func (m model) Init() tea.Cmd {
return bumpCmd()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case countMsg:
m.n += int(msg)
return m, bumpCmd()
case tea.KeyMsg:
if msg.String() == "q" {
return m, tea.Quit
}
}
return m, nil
}Заполните пропуски в команде
Восстановите сигнатуру команды, имя сообщения и вызов фабрики из Init.
Соберите команду fetchData
Расставьте строки команды в правильном порядке: тип сообщения, фабрика, замыкание и результат.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь асинхронности
Что дальше#
Теперь ваши программы умеют ждать, не замирая. Дальше — показывать ожидание: шестой урок про спиннеры подключает компонент bubbles, который тикает как раз командами этого урока. Когда понадобится запустить несколько команд разом — спиннер плюс загрузка, — открывайте седьмой урок про tea.Batch и tea.Sequence. А настоящий веб-запрос с состояниями загрузки и ошибки ждёт в уроке про HTTP-данные. Полная программа — на странице курса Bubble Tea. Домашняя практика простая: напишите команду сохранения, подложите в неё time.Sleep на три секунды и убедитесь, что q работает даже во время «долгой» записи — локальный терминал покажет правду о задержках быстрее любых слов.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный терминал
модель Bubble Tea · Model → Update → ViewОтредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.
Клавиатура — обработано в Update
модель собрана: status string = "готов к проверке" · checks int = 0 · loading bool = false
программа работает — нажмите клавишу ниже
Коротко: что запомнить
- tea.Cmd — это func() tea.Msg: команду возвращают из Init и Update, а фреймворк запускает её в горутине.
- Результат команды — сообщение-значение; Update обрабатывает его тем же type-свитчем, что и клавиши.
- nil в качестве команды — штатное «работы не заказано», а не ошибка.
- Update не блокируется: долгая работа живёт в команде, а не в обработчике события.
- Ошибка едет в сообщении — errMsg или поле err; паника в команде роняет программу целиком.
- Одноразовая работа — команда; бесконечный поток событий — горутина с p.Send.
Домашнее задание
Добавьте к программе урока команду saveTasksCmd, которая «сохраняет» список и возвращает savedMsg. Пусть нажатие s запускает сохранение, а View показывает статус. Затем подложите в команду time.Sleep на три секунды и запустите локально: убедитесь, что q по-прежнему работает, пока идёт запись. Если интерфейс замер — перечитайте callout и найдите, куда просочилась работа.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Update не блокируется при асинхронной операции?
Потому что операция выполняется не в Update: команда уходит в горутину фреймворка, а в Update приходит готовый результат-сообщение. Update обрабатывает сообщения по одному и всегда быстро возвращается.
Нужны ли свои горутины в Bubble Tea приложении?
Для одноразовых операций — нет: их запускает фреймворк под каждую команду. Свои горутины нужны для долгоживущих потоков событий, и общаются они с программой через p.Send, не трогая модель напрямую.
Как вернуть из Init несколько команд сразу?
Init возвращает одну команду, но её можно собрать из нескольких: tea.Batch запускает их параллельно, tea.Sequence — по очереди. Механика и ловушки — в седьмом уроке курса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
- Спиннеры: индикаторы загрузки
- tea.Batch и tea.Sequence: комбинации команд
- Крупные модели: композиция подмоделей
- bubbles/list: готовые списки
- Клавиатура: KeyMsg и обработка нажатий
- tea.Program: запуск и управление программой
- Первая программа: Model, Update, View
- Знакомство с Bubble Tea: интерфейсы в терминале
- Все курсы по Go
Следующая ступень учебного пути
Zap: структурное логирование на Go
Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.
- Писать структурные логи с полями вместо строк
- Выбирать между Logger и SugaredLogger осознанно
- Настраивать энкодеры, выводы и ротацию файлов
- Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler