Bubble Tea — 用 Elm 架构写终端 UI 的 Go 框架
是什么
Bubble Tea 是 charm 出品的 Go TUI 框架:把”画一个能键盘交互的终端界面”这件事,强制按 Elm 架构(TEA:Model-View-Update) 拆成三块。日常类比:像把”看电视”和”按遥控器”分开——遥控器(Update)只负责”把按键变成新台号”,电视屏幕(View)只负责”按当前台号画画面”,两边互不抢饭碗。
最小可跑程序:
type model struct{ count int }func (m model) Init() tea.Cmd { return nil }func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if k, ok := msg.(tea.KeyMsg); ok && k.String() == "+" { m.count++ } return m, nil}func (m model) View() string { return fmt.Sprintf("count=%d", m.count) }func main() { tea.NewProgram(model{}).Run() }不用循环、不用 select、不用手动重画——按一下 +,框架把 tea.KeyMsg 投进 Update,拿到新 model 后调 View 重画。所有副作用(HTTP / 读文件 / 计时器)打包成 tea.Cmd,在 goroutine 里跑完再以新 msg 回流。
为什么重要
不理解 Bubble Tea 这套 TEA 强约束,下面这些事都解释不清:
- 为什么
gh dash(GitHub CLI 仪表盘)/glow(终端 markdown)/soft-serve(git over SSH)这一批 27k stars 量级的 Go TUI 工具,长得像同一个家族——都用了 charm 全家桶 - 为什么 Go 圈早期写 TUI 都用 [tcell/tview],命令式
AddItem/SetText,但近三年新项目几乎全切到 Bubble Tea——TEA 让”状态变化 = 屏幕变化”变得可推理 - 为什么 React / Redux / Elm / SwiftUI / Compose 这些前端框架的”单向数据流”思想,其实在终端里更纯粹(输入只有键盘,输出只有字符串)
- 为什么”View 是纯函数”听起来抽象,但只要写过一次 Bubble Tea 就忘不掉——重画错乱、闪烁、状态不一致这些 TUI 老 bug 几乎被消灭
核心要点
Bubble Tea 的设计可以拆成 四件套:
-
Model(值类型的状态):一个 struct 装下应用所有状态。值传递不是性能问题——每次 Update 返回新 model,框架内部只比较和替换。状态太大就拆子模型嵌套(list 里嵌 textinput 是常见做法)。
-
Update(消息分派):函数签名
Update(tea.Msg) (tea.Model, tea.Cmd)。所有变化的入口——键盘、窗口 resize、HTTP 回包、计时器 tick——都化成tea.Msg。Update 用switch msg.(type)分派。Update 不能阻塞,长任务必须包进 Cmd。 -
View(纯函数渲染):
View() string把当前 model 翻译成”屏幕上要画的字符串”。配lipgloss加边框、颜色、padding。View 里别做 IO——它每帧都跑,读文件就 60 次/秒地读。 -
Cmd(副作用单元):
tea.Cmd = func() tea.Msg。要发 HTTP?写个返回httpDoneMsg{}的闭包,从 Update 返回它。框架在 goroutine 里跑,结果当成新 msg 回到 Update。tea.Batch(...)并发组合,tea.Sequence(...)串行。
实践案例
案例 1:键盘驱动的计数器
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "+": m.count++ case "-": m.count-- } } return m, nil}tea.Quit 是框架内置的特殊 Cmd,告诉 Program 退出。注意 m.count++ 修改的是值副本,return 时返回出去,框架接住——没有共享可变状态,goroutine race 天生不存在。
案例 2:HTTP 请求作为 Cmd
func fetchUser(id int) tea.Cmd { return func() tea.Msg { resp, err := http.Get(fmt.Sprintf("/users/%d", id)) if err != nil { return errMsg{err} } return userMsg{decode(resp.Body)} }}// Update 里触发:case tea.KeyMsg: if msg.String() == "r" { return m, fetchUser(m.id) }// userMsg 回流时:case userMsg: m.user = msg.user; return m, nil请求在框架管理的 goroutine 里跑,Update 永不阻塞——TUI 不会因为网络慢就卡住键盘。
案例 3:用 bubbles + lipgloss 拼真实界面
import ("github.com/charmbracelet/bubbles/textinput"; "github.com/charmbracelet/lipgloss")
ti := textinput.New(); ti.Placeholder = "搜索..."; ti.Focus()style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1)view := style.Render(ti.View()) // 圆角边框包住一个输入框bubbles 子库提供 textinput / spinner / list / viewport / table / progress 七八个常用组件,每个都自己实现 Model/Update/View——你 wrap 进自己的 model,把它们的 Update 调用代理出去就能组合。
踩过的坑
-
View 里做 IO 让 CPU 飙到 100%:把
os.ReadFile写在 View 里——TUI 每秒至少 30 帧就读 30 次磁盘。改成在 Update 里通过 Cmd 异步读,结果存进 model。 -
Update 阻塞导致 UI 冻住:
http.Get直接写在 Update 分支里,按下键就卡 3 秒。必须包成 Cmd。新人最爱犯。 -
AltScreen 模式忘记关:
tea.WithAltScreen()开了独立缓冲区(vim 那种”进入”和”退出”切屏),程序崩了没退出会让用户终端卡住。用defer配Program.Quit()兜底。 -
truecolor 在 SSH / tmux 下降级:lipgloss 颜色用
#FF6600在本地 iTerm2 完美,登 SSH 就变成 256 色逼近。要用lipgloss.AdaptiveColor{Light:..., Dark:...}或CompleteAdaptiveColor兼容多档色深。 -
窗口 resize 没处理界面错位:
tea.WindowSizeMsg在初始化也会发一次,要在 Update 里存下宽高,View 里按宽度算布局。漏处理 resize 是 bug 大头。 -
测试要用 teatest 而不是直接调 Update:
teatest.NewTestModel能录回放,断言”输入这串键之后屏幕长什么样”,比单测 Update 函数更稳。
适用 vs 不适用场景
适用:
- 中长生命周期的开发者工具(dashboard / log viewer / DB client / git client)
- 需要键盘驱动的交互式 CLI(向导式安装、配置编辑器)
- 想让 TUI 通过 SSH 暴露(搭
wish库直接变远程服务) - 团队偏好 Go + 想要”前端式”心智模型的项目
不适用:
- 一次性脚本输出(
fmt.Println就够,别上框架) - 需要复杂图形(图表、像素艺术)→ 用 ratatui 的 canvas 或直接 ANSI escape
- 极致低延迟(高频交易终端、游戏)→ TEA 的”全量重画”模型有开销,用立即模式
- 不想吃 Go 语法的团队 → JS 圈用 [Ink],Rust 圈用 ratatui,Python 圈用 [Textual]
学到什么
- TEA 在终端里比在浏览器更纯粹——输入只有 KeyMsg,输出只有字符串,没有 DOM diff,没有 CSS 复杂度。这是学单向数据流最好的入门场地
- 副作用包成值(Cmd)再交还给框架 是函数式 + 并发的优雅交叉点——goroutine 没暴露给业务代码
- 小核心 + 周边库(bubbles / lipgloss / wish / glamour) 的”乐高式生态”是 charm 的成功模式,比一个上帝框架更易演化
- Go 也能写出函数式风格的代码——值类型 model + 纯函数 View 把 OOP 习惯的”对象方法改自己”扳过来
延伸阅读
- 官方教程系列:Bubble Tea Tutorials(4 个 example 从计数器到 HTTP)
- charm 全家桶官网:charm.sh(lipgloss / bubbles / wish / glow / soft-serve 一站式)
- 真实项目源码:gh dash(GitHub CLI 扩展,27k stars 的项目自身用 bubbletea 写)
- Elm 架构原文:The Elm Architecture(TEA 思想原产地,比 Bubble Tea 文档更系统)
关联
- ratatui —— Rust 的 TUI 库,立即模式(immediate-mode),不强制 TEA,对照看能理解两种范式
- textual —— Python TUI 框架,吸收了 CSS-like 样式和组件化思路
- gsap —— Web 动画库,和 harmonica(charm 的弹簧库)共享”easing + 时间线”心智
- lipgloss —— Bubble Tea 的样式伙伴,CSS-like 边框 / 颜色 / padding
- glow —— charm 自家终端 markdown 阅读器,用 Bubble Tea 写
- wish —— 把 Bubble Tea 应用通过 SSH 暴露的库