一次简单的 BubbleTea 消息循环分析
Bubble Tea 是 Go 生态里流行的终端 UI 框架,类属 Elm 架构(Init / Update / View 三件套)。框架的核心是一条消息循环:消息从输入进来,交给 Update 处理,再渲染成一帧。这条循环到底是怎么转的?一条消息从终端进来,到界面重新渲染一帧,中间经过了几层处理?
这篇文章把这条链从源码层面拆开,代码基于 charm.land/bubbletea v2。框架部分完全通用,应用部分的例子都换成了抽象表述。
核心契约:一句可以循环的话
类 Elm 架构的核心是单向数据流,浓缩成一句话:
Update 一条 Msg,返回一个新 Model 和一条 Cmd整个应用的状态变化,都浓缩在这一句话里。框架做的事,其实就是把这句话放进一个循环里反复执行。这反复执行的话中流程,即后面讲的一切都是它的展开。
两条永远在转的 goroutine
程序跑起来之后,Bubble Tea 内部有两条永远在转的 goroutine:
┌─────────────┐ cmds ┌────────────────┐
│ eventLoop │ ──── cmd ────────────► │ handleCommands │
│ (主循环) │ ◄──── msg ──────────── │ (命令执行者) │
└─────────────┘ msgs └────────────────┘eventLoop是主循环,它在等消息。等到了就喂给model.Update,拿到新 model 和新 cmd。handleCommands是命令执行者,它在等 cmd。等到了就cmd()跑一遍,把返回的 Msg 再发回给主循环。
两者之间用两个 channel 相连:cmds 和 msgs。这就是整个框架的核心回路。注意这里没有第三个人——你的异步代码、HTTP 请求、文件读取,全是通过 "发一条 Msg 回来" 来跟主循环对话的。
第一条消息是怎么转起来的
事件循环不会自己启动,得先有启动代码把第一条消息送进去。这个启动代码是 Run()。
Run()
├─ model.Init() ──► 得到第一条 cmd (initCmd)
├─ initCmd 送进 cmds channel
├─ handleCommands 拿到 initCmd,go func() { msg := cmd(); p.Send(msg) }()
├─ Send 把 msg 送进 msgs channel
└─ 进入 eventLoop,主循环开始转注意 Init 返回的那条 cmd,并不是主循环直接执行的,而是走了一遍 cmds → handleCommands → Send → msgs 这条路,最后以一条 Msg 的形式回到主循环。Bubble Tea 不区分 "初始化命令" 和 "运行中命令" ——所有命令最终都会变成消息。
主循环的每一轮
eventLoop 的骨架是一条 select,同时等三样东西:
select {
case <-p.ctx.Done(): // 程序退出 / 被取消
return model, nil
case err := <-p.errs: // 渲染器出错
return model, err
case msg := <-p.msgs: // 正常消息:键盘、异步结果、tick、窗口变化
...
}前两条是退出通道,运行期间一直在跑的其实是第三条。但这条 msg 并不是直接进 model.Update 的,在喂给我们之前,eventLoop 先按类型过一遍它自己的内部 switch:
switch msg := msg.(type) {
case QuitMsg: // 用户按 q
return model, nil // 直接退出,不进 model.Update
case InterruptMsg: // ctrl+c
return model, ErrInterrupted
case BatchMsg: // tea.Batch 产生的批
go p.execBatchMsg(msg) // 起 goroutine 并发执行子命令
continue // 拆完就走,不进 model.Update
case sequenceMsg: // tea.Sequence 产生的串行批
go p.execSequenceMsg(msg)
continue
case WindowSizeMsg: // 终端尺寸变化
p.renderer.resize(...) // 先通知 renderer 重新测量
// do not continue,做完后厨的事继续漏给 model.Update,
// 所以我们的 Update 也能收到 tea.WindowSizeMsg
case clearScreenMsg:
p.renderer.clearScreen()
// ... 还有剪贴板、RawMsg、颜色 profile 等十几类框架内部消息
}
var cmd Cmd
model, cmd = model.Update(msg) // 能走到这的,都是内部 switch 漏下来的
select {
case <-p.ctx.Done():
return model, nil
case cmds <- cmd: // 把 cmd 塞进 cmds channel,交给 handleCommands
}
p.render(model) // 用新 model 渲染一帧所以 eventLoop 里其实有两个 switch:第一个是框架内部消息专用,负责退出、拆批、维护 renderer 这些 "后厨" 事务;第二个才是我们写在 model.Update 里的分发逻辑。这个分层值得专门强调——它是理解 "哪条消息能到我手里" 的关键。
从消息来源的角度概览一下到不到 Update:
| 消息来源 | 类型 | 例子 | 到不到 model.Update |
|---|---|---|---|
| 框架 | 生命周期与内部事务:程序退出、中断、批命令拆解,model.Update 无需参与 | QuitMsg、InterruptMsg、BatchMsg、sequenceMsg | ❌ 内部 switch 直接 return / continue 短路 |
| 框架 | 内部事务处理完毕后继续透传,模型需感知终端状态或输入事件 | WindowSizeMsg、MouseMsg、KeyPressMsg、PasteMsg | ✅ 处理后 fall-through,进入 model.Update |
| 用户 | 自定义消息,框架无对应分支、原样透传 | LoadMsg、PollMsg、TailMsg、ConfigMsg | ✅ 内部 switch 无法匹配,直接漏给 model.Update |
这里有个实践上很反直觉的点:大多数 "到我手里" 的消息,其实是自定义消息,而不是框架消息。按键消息 KeyPressMsg 甚至根本不在框架的内部 switch 里——它原样漏给 model.Update,由你自己的代码决定怎么处理。框架消息能到 Update 的反而是少数:要么是 WindowSizeMsg 这种 "模型必须感知终端状态" 的,要么是按键/鼠标/粘贴这类 "模型必须感知输入" 的。
handleCommands 拿到 cmd 后,是另起一个 goroutine 去跑 cmd() 的,主循环不会被卡住:
handleCommands:
for {
select {
case <-p.ctx.Done():
return
case cmd := <-cmds:
if cmd == nil { continue } // 空命令直接跳过
go func() { // 不阻塞主循环
msg := cmd() // 执行命令,比如发起一个 HTTP 请求
p.Send(msg) // 结果变成消息,送回主循环
}()
}
}这也顺带回答了 "为什么 tea.Batch 能同时做多件事"。顶层 Init 把各个子模块的初始化命令打包成一个 tea.Batch 返回,这一整包在 handleCommands 里跑完,产生一条 BatchMsg;它进 eventLoop 时被内部 switch 拦住,go p.execBatchMsg(msg) 拆成一批 goroutine 并行执行,每个子命令的结果各自 Send 回来,重新排进 p.msgs。
消息进了顶层 Update 之后,怎么决定给谁
框架的回路在 tea.go 里转,但我们自己的代码在顶层 model.Update 里。一个多模块应用,Update 收到一条 Msg 后要决定 "这条消息该给谁"。这个决定通常分几段,按优先级从上往下:
Update(msg)
├─ ① 配置变更消息 → 更新全局配置并保存,直接 return
├─ ② 全局消息 (窗口大小/按键) → 自己处理,也直接 return
├─ ③ 跨模块消息 (异步加载结果) → 按消息类型路由给对应模块
└─ ④ 其余消息 → 交给当前激活的模块①和②都是 "框架级" 消息,处理完就 return,不会往下漏。
③和④才是真正的模块分发。③拦截的是异步加载的结果。这类消息可能在用户切到别的模块之后才从 goroutine 里回来,如果直接交给 "当前激活的模块",就会被丢掉,所以要在顶层按消息类型拦下来,路由给对应的模块。④则是常规按键、Tick 这类消息,交给当前激活的那个模块。
这里有个小细节:③处理过的消息,④就不该再碰了,否则同一条消息会被处理两次。所以用一个标记:
crossHandled := false
switch msg := msg.(type) {
case LoadMsg, ...:
crossHandled = true
m.modA, cmds = updateForward(m.modA, msg, cmds, (*ModA).Update)
...
}
if !crossHandled {
switch m.state { ... } // 当前模块分发
}异步消息怎么在这条回路里转
前面讲的都是框架侧的回路。落到应用里,这套回路正是所有异步代码赖以生存的管道——异步结果最终都以 Msg 的形式排进 p.msgs,由 eventLoop 派发。常见的异步来源有两种,可以把这条回路完整走一遍。
模式一:tea.Tick 链式自旋
tea.Tick 是单次触发的,不会自己重复。要持续轮询,就得在每次收到 tick 消息时再返回一个新的 tea.Tick,一环扣一环:
Init: return tea.Batch(fetchCmd(), PollTickCmd(2*time.Second))
Update: case PollMsg:
return m, tea.Batch(fetchCmd(), PollTickCmd(2*time.Second))每一轮都走同一条回路:
PollTickCmd → handleCommands 起 goroutine 等 2 秒
→ Send 回 PollMsg → eventLoop → 模块 Update
→ 处理数据 + 返回下一批命令(含新的 PollTickCmd)→ 再来一轮这个自旋有个前提:该模块得是当前激活的模块。PollMsg 不在跨模块路由表里,切到别的模块后,这条消息就被当前激活的模块吞掉了,轮询模块的 Update 不会被调用,自然也没有下一轮 PollTickCmd——所以是 "活跃时自旋,切走即停"。
模式二:goroutine + channel 推送
轮询的替代方案是推模式:直接起 goroutine 阻塞读数据源,往 channel 里喂,另一条命令阻塞等 channel。
watch(ch, done) // goroutine 里阻塞读,往 channel 喂新内容
receiveCmd(ch) // 阻塞等 channel,返回一条携带数据的 Msg模块把 receiveCmd 当 Cmd 返回(有数据就再返回下一个,收到结束信号才收尾),handleCommands 就在 goroutine 里跑它,一直阻塞到有数据;watch 的 goroutine 一有动静就往 channel 里送,receiveCmd 拿到后把 Msg Send 回主循环。这是 "推" 模式,比 tick 轮询延迟低得多,毫秒级就能看到新数据。
这两类异步消息的差别,正好落在跨模块路由上:如果 TailMsg 在路由表里,就算切到别的模块,goroutine 照常跑、新数据照常送回原模块;而 PollMsg 不在路由表里,切走自旋就断了。往跨模块路由表里加哪条消息,本质上就是在决定 "这个异步流程要不要跟着全局活下来"。
状态共享:值拷贝 + 指针字段
有个问题值得单独说:Go 的方法默认是值接收者,model.Update(msg) 拿到的其实是 model 的一个拷贝。那模块的状态是怎么共享的?
关键在字段类型。顶层 model 的子模块字段都是指针:
type model struct {
state State
modA *ModA
modB *ModB
...
}model.Update(msg) 拷贝的是这个 struct,但 struct 里的指针字段拷贝的是"指向同一块内存的地址"。所以模块内部对指针指向的数据做的修改,在下一次拷贝时依然可见。
再看框架这边。model 只在启动时种下一次,之后每轮都是:
model, cmd = model.Update(msg)循环变量 model 被覆盖成新值,下一轮继续用。这条链把「旧状态 → 新状态」一路接力下去,单向数据流就是这样形成的。
一个用不上的返回值
Run() 会把最终 model 作为返回值交出来,看起来像是 "程序结束时取回最后状态"。但实际应用里它基本用不上:一是程序退出通常就是用户按了 q,此时界面往往开着 AltScreen,退出那一帧已经不存在了,就算取回来也没法展示;二是所有模块的状态都在指针里,程序运行期一直是"活的",退出时已经没有需要善后的状态了。
模块之间没有"回调"耦合
多模块应用里容易担心一件事:模块和顶层之间,会不会有互相调用的环形依赖?
正常的做法下没有。依赖是单向的 DAG:
main
├─► modA ──► component ──► ui
├─► modB ──► component ──► ui
...所有模块都只依赖共享的组件层,没有任何模块相互依赖,自然没有回环。
模块怎么跟顶层通信?不是回调,是消息。模块想改全局配置,就发一条 ConfigMsg,顶层在 Update 的第一段拦下来更新全局配置再保存。这是 "把变更告诉中央",而不是 "调用一个顶层提供的函数"。全局只有一个方向:模块 → 消息 → 顶层。
严格说,整个应用里真正的 "回调" 只有一处,在组件层:某个输入组件把一个 onSubmit 函数当参数传给模块。但那是组件和模块之间的约定,跟顶层的模块分发没关系。
总结
把消息循环从黑盒变成白盒之后,最值钱的几个结论:
- 框架只有一条主循环,消息从
msgs channel进来,Update吃掉,cmd 从cmds出去,命令执行完又变成消息回来。自旋自转,不需要外部驱动。 - 你自己的
Update只负责 "这条消息给谁",把框架级、跨模块、当前模块分得清清楚楚。而且大多数到手的消息其实是自定义消息,不是框架消息。 - 状态共享靠的是值拷贝 + 指针字段,模块内部改的是指针指向的数据,不是拷贝本身。
- 顶层和各模块之间是纯粹的消息传递,依赖图是 DAG,没有回调环。
写 TUI 应用,最难的不是单个组件怎么写,而是所有异步流程怎么在一个单向数据流的框架里活下来。理解了这条消息链,很多 "为什么它没刷新"、"为什么切走就停了" 的疑问,答案都在回路里。
RoLingG | 博客
评论(0)