G

[Golang] 一次简单的 BubbleTea 消息循环分析

RoLingG Golang 2026-08-08

一次简单的 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 相连:cmdsmsgs。这就是整个框架的核心回路。注意这里没有第三个人——你的异步代码、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 无需参与QuitMsgInterruptMsgBatchMsgsequenceMsg❌ 内部 switch 直接 return / continue 短路
框架内部事务处理完毕后继续透传,模型需感知终端状态或输入事件WindowSizeMsgMouseMsgKeyPressMsgPasteMsg✅ 处理后 fall-through,进入 model.Update
用户自定义消息,框架无对应分支、原样透传LoadMsgPollMsgTailMsgConfigMsg✅ 内部 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 应用,最难的不是单个组件怎么写,而是所有异步流程怎么在一个单向数据流的框架里活下来。理解了这条消息链,很多 "为什么它没刷新"、"为什么切走就停了" 的疑问,答案都在回路里。

PREV
[每日算法] 二进制矩阵中的最短路径与AStar算法
NEXT
[Golang] 使用泛型和方法表达式简化消息分发

评论(0)

发布评论