本文永久链接 – https://tonybai.com/2026/10/21/go-htmx-templ-sqlc-fullstack-gophercon-uk-2026
大家好,我是Tony Bai。
【导读】
一个后端工程师,被前端的状态管理折磨到烂尾了一个副业项目。直到他换上 Go + HTMX + templ + sqlc,终于把它做完了。这场 GopherCon UK 2026 的演讲,没有堆砌概念,只讲真实踩坑:HTMX 怎么用、HTML 为什么比 JSON 更适合做 Web 接口、什么场景千万别用它。
【文章要点】
- 痛点:后端工程师做全栈,最难受的是前端状态与后端状态不同步。
- 核心思路:HTMX 让浏览器发请求、服务端返回 HTML 片段,状态只留在后端。
- 技术栈:Go 标准库 + HTMX + templ + sqlc,全程强类型,几乎没有“魔法”。
- 进阶技巧:
hx-trigger防抖搜索、页面加载后懒加载、OOB 多区域更新、响应头控制、HX-Redirect。 - API 设计:移动端 API、Web 应用 API、数据 API 是三件事,不要强行合并。
- 边界:重交互、前后端分离团队、需要完善设计系统的场景,别硬上。
- 开发体验:Task、Air、Nix、Playwright for Go,把“懒”做到极致。

开篇:一个烂尾项目引发的反思
2021 年,Haseeb Majid 想做一个多人派对游戏 Banter Bus。玩法类似 Jackbox:大家输入房间码进入同一个房间,一起玩。
后端用 WebSocket 维护状态、防作弊,他驾轻就熟。前端用 Svelte,一开始还行,后来越写越乱:store、全局状态、各种同步,最后彻底失控。
他是后端工程师,对前端没有太多热情,也就没有动力专门去学“最佳实践”。结果就是:项目搁浅,成了副业项目坟场里的又一个名字。
转机出现在 2023 至 2024 年。他读到 HTMX 团队的文章,决定再试一次。这一次,项目真的做完了。
在 GopherCon UK 2026 上,他把这套方案拆开讲了一遍。演讲的案例是他自己的园艺记录应用 Let’s Go(用来记录那些被他养死的植物)。
HTMX 到底是什么?
一句话:HTMX 是一个约 50KB(压缩后)的小库,让你用 HTML 属性直接向后端发请求。
流程只有四步:

没有 JSON 序列化,没有前端状态管理,也没有手写 fetch。
最小示例:植物时间线的“添加事件”表单,只需要两个属性。
<form hx-post="/plants/1/events" hx-target="#timeline"> <input name="event_type" placeholder="浇水 / 换盆 / 施肥" /> <textarea name="notes"></textarea> <button type="submit">记录</button> </form> <div id="timeline"><!-- 服务端返回的 HTML 会替换这里 --></div> hx-post:提交时向该地址发 POST。hx-target:把返回的 HTML 放到哪里。
不写 hx-target 时,默认更新的是发起请求的元素本身。
凭什么值得一试?
1. 状态只留在后端,一整类 Bug 直接消失
传统 SPA 的老毛病:前端存一份状态,后端存一份状态,两边不同步,你开始排查“到底谁错了”。
HTMX 的做法是:前端只是一个“哑”界面,告诉后端“该显示什么”,后端说了算。
对 Banter Bus 这类多人游戏尤其关键:状态必须在服务端,否则玩家改个请求就能给自己加一百万分。
2. 零构建链
HTMX 就是一个 JS 文件,你可以:
- 直接用 CDN;
- 下载到本地;
- 通过
go:embed打进 Go 二进制。
不需要 npm,不需要 package.json。想追踪更新,也可以交给 Renovate 这类依赖机器人提 PR。
3. 对 LLM 也更友好
演讲者回应了最常见的质疑:“LLM 写 React 很强,为什么还要折腾 HTMX?”
他的答案很朴素:
LLM 写出的 Go 代码,我能判断好不好;LLM 写出的 React 代码,我只能看它“能跑就行”。
再加上 HTMX 的数据流非常直白:这个请求发到哪个端点,返回什么 HTML,一眼看完,没有分散在前端各处的状态。人容易审查,LLM 也容易理解。
进阶玩法:几个属性,拼出强大交互
1. 搜索:防抖 + 可分享链接
<input type="search" name="q" hx-get="/plants" hx-trigger="input changed delay:300ms" hx-target="#plant-list" hx-push-url="true" /> hx-trigger:输入变化后延迟 300 毫秒再请求,相当于防抖;hx-push-url:把查询参数写进浏览器地址栏,搜索结果可以直接分享。
对比同样功能的 Svelte 实现,要自己声明变量、监听参数变化、写 fetch、维护列表。当然,这不是严格的同口径比较,但复杂度差异一目了然。
2. 懒加载:先出页面,再补重数据
时间线要跑一堆昂贵的 SQL?让页面先出来:
<div hx-get="/plants/1/timeline" hx-trigger="load"> 加载中... </div> 页面加载完成后,HTMX 自动请求,并用返回结果替换占位内容。
3. OOB:一次响应更新多个区域
更新植物健康度,同时要刷新多个组件,还要弹出提示。难道要返回整页?
用 hx-swap-oob(Out of Band)即可:服务端返回几段带 ID 的 HTML,HTMX 按 ID 找到页面上的对应位置分别替换。
<!-- 服务端返回的片段 --> <span id="health-badge" hx-swap-oob="true">健康度:良好</span> <div id="toast" hx-swap-oob="true">已更新</div> 4. 用响应头反向控制前端
后端也能“指挥”HTMX:
HX-Retarget:忽略前端设置的目标,改去更新别处,比如错误提示;HX-Reswap:改写替换策略。
注意一个细节:HTMX 的默认替换策略是 innerHTML,只替换目标元素的内部内容;想连元素本身一起替换,使用 outerHTML。另外,HTMX 默认不会替换 4xx、5xx 响应的内容,要展示错误提示,需要返回 200 或调整响应处理配置。
5. 登录过期怎么重定向?
这是演讲者踩过的坑:普通 302 重定向在 HTMX 请求里不会跳转,登录页会被整个塞进原来那个小表单里。
解决办法:识别 HX-Request 请求头,改用 HX-Redirect。
func redirect(w http.ResponseWriter, r *http.Request, url string) { if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", url) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, url, http.StatusSeeOther) } 6. 局部交互交给 Alpine.js
删除前的二次确认,没必要请求服务器。演讲者推荐用 Alpine.js 处理这类局部状态:
<div x-data="{ open: false }"> <button @click="open = true">删除植物</button> <div x-show="open"> <p>确定删除吗?</p> <button hx-delete="/plants/1">确认</button> <button @click="open = false">取消</button> </div> </div> 他也给出了警告:别把 JavaScript 写成字符串塞进属性里。编辑器不识别,高亮失效,可读性更差,“两头的坏处都占了”。判断标准很简单:当你自己都看不懂这些属性时,就老老实实写几行 JS。
Go 端落地:Handler + templ + sqlc
整体架构仍然是 Go 圈熟悉的三层:

1. templ:类型安全的 HTML 模板
templ 是专为 Go 设计的模板工具,特别适合 HTMX 这种“返回局部 HTML”的场景。它的杀手锏是:完全类型化,并自带语言服务器(LSP),编辑器里有补全、跳转、类型报错。
package components templ Timeline(entries []db.TimelineEntry) { <div id="timeline"> for _, e := range entries { @EntryCard(e) } </div> } 写完 .templ 文件后,执行 templ generate 生成 Go 代码(文件头带有“请勿编辑”提示)。类型不对,生成时就报错,而不是等到运行时。
Handler 里使用它非常自然:
func (h *Handler) AddEvent(w http.ResponseWriter, r *http.Request) { entries, err := h.svc.AddEvent(r.Context(), r.PathValue("id"), r.FormValue("event_type"), r.FormValue("notes")) if err != nil { http.Error(w, "服务异常", http.StatusInternalServerError) return } components.Timeline(entries).Render(r.Context(), w) } templ 还支持布局:定义带 { children... } 的基础模板,页头页脚只改一处。
有观众追问:标准库
html/template不行吗?演讲者的回答很坦诚:标准库也可以,可以先从简单做起,嫌烦再引入 templ,毕竟它是新增的一个依赖。他选 templ,主要因为类型检查和 LSP 体验。
2. sqlc:手写 SQL,自动生成 Go 代码
-- name: ListTimeline :many SELECT id, plant_id, event_type, notes, created_at FROM timeline_entries WHERE plant_id = $1 ORDER BY created_at DESC; 执行 sqlc generate,就得到强类型的结构体和查询方法。
它的价值在于没有魔法:SQL 是你自己写(或让 LLM 写)的,一眼能审查,不会冒出一百行莫名其妙的 ORM 拼接 JOIN。
API 到底返回 HTML 还是 JSON?
这是 HTMX 最常被追问的问题:“我还有移动端,不想维护两套接口。”
演讲者引用了 HTMX 官方关于拆分数据 API 与应用 API的文章,把 API 分成三类:

关键观点:
- Web 和移动端的形态不同,未必需要一模一样的数据,强行复用只是“过度 DRY”。
- 数据 API 要稳定。如果让 Web 前端复用它,一方面会限制页面迭代,另一方面容易出现多次请求和 N+1 问题。
- 拆分的代价是样板代码更多、测试更多,要自己权衡。
他的结论是:对他的场景,Web 返回 HTML 完全够用,数据 API 单独维护,一事一议。
什么时候别用 HTMX?
演讲者反复强调:HTMX,这不是银弹。
- 前端交互很重(复杂的客户端响应式逻辑):用 React、Svelte 更合适;
- 团队前后端分离:别抢前端同事的工具选择;
- 需要成熟的设计系统:Storybook 等生态在 React、Svelte 一侧更完善;
- 反过来,适合的场景是:后端工程师主导、缺少前端人力,比如内部管理后台、客服工具、个人副业项目。
关于性能,他也很坦诚:他的应用没有真正意义上的规模,首屏因为 HTMX 体积小会更快,但“体感上没有明显差别”;骨架屏之类的优化仍然需要自己投入精力。
开发体验:把“懒”做到极致
演讲最后一部分,他分享了让开发顺手的工具:
| 工具 | 作用 |
|---|---|
| Task(go-task) | 一条 task dev 拉起整套本地环境 |
| Docker Compose | 启动 Postgres 等隔离依赖 |
| Air | Go 代码变更自动热重载 |
| templ / Tailwind watch | 模板与样式变更自动生成 |
| Nix 开发环境 | 所有人使用完全一致的工具版本,免去全局安装 |
| Playwright(Go 版) | 用 Go 写端到端测试,保持“全栈 Go” |
现场有观众补充:Air 本身也能执行 templ、sqlc 等构建前置任务,可能让单独的 watch 任务不再必要。演讲者表示此前并不知道这一点,感兴趣的话可以自行验证。
另外,他提到 LLM 配合 Playwright CLI 排查 Bug,“效果不错”。
小结
回到那个烂尾的 Banter Bus:它没有被更强大的框架拯救,而是被更简单的架构拯救。
- 状态集中在后端,Bug 少一整类;
- HTML 局部更新,数据流一目了然;
- templ 与 sqlc 提供强类型,让人和 LLM 都更容易审查。
如果你是被前端折磨的后端工程师,不妨挑一个内部小工具,用 Go + HTMX 走一遍。简单,往往就是最大的生产力。
参考资料
- 演讲视频:https://www.youtube.com/watch?v=lZ4EPkIngRc
- HTMX 官方文档:https://htmx.org
- HTMX 文章《Splitting Your Data & Application APIs: Going Further》:https://four.htmx.org/essays/splitting-your-apis
- templ:https://templ.guide
- sqlc:https://sqlc.dev
- Alpine.js:https://alpinejs.dev
附录:一个完整可运行示例
下面是一个“植物记录”应用,只依赖 Go 标准库,数据放内存,不需要数据库。完整示例代码可以在这里下载。
**环境要求:**Go 1.22 及以上(用到了标准库路由的方法匹配和 r.PathValue)。浏览器需要能访问 unpkg 与 jsDelivr 两个 CDN。
项目结构:
letsgo/ ├── go.mod ├── main.go └── templates/ ├── layout.html └── partials.html 运行:
mkdir -p letsgo/templates && cd letsgo # 把下面四个文件按路径保存后: go run . # 浏览器打开 http://localhost:8080 1. go.mod
module letsgo go 1.22.0 2. main.go
package main import ( "embed" "html/template" "log" "net/http" "strconv" "strings" "sync" "time" ) //go:embed templates/*.html var tplFS embed.FS var tpl = template.Must(template.ParseFS(tplFS, "templates/*.html")) // ---------- 数据层:内存存储,换成 sqlc + PostgreSQL 时只需替换这里 ---------- type Plant struct { ID int Name string Health string } type Entry struct { Type string Notes string At time.Time } type Store struct { mu sync.RWMutex plants []*Plant entries map[int][]Entry } func NewStore() *Store { return &Store{ plants: []*Plant{ {1, "薄荷", "良好"}, {2, "罗勒", "一般"}, {3, "多肉", "良好"}, }, entries: map[int][]Entry{ 1: {{"浇水", "早上浇了一杯水", time.Now().Add(-48 * time.Hour)}}, }, } } func (s *Store) Search(q string) []*Plant { s.mu.RLock() defer s.mu.RUnlock() var out []*Plant for _, p := range s.plants { if q == "" || strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) { c := *p out = append(out, &c) } } return out } func (s *Store) Get(id int) (*Plant, bool) { s.mu.RLock() defer s.mu.RUnlock() for _, p := range s.plants { if p.ID == id { c := *p return &c, true } } return nil, false } func (s *Store) SetHealth(id int, level string) (*Plant, bool) { s.mu.Lock() defer s.mu.Unlock() for _, p := range s.plants { if p.ID == id { p.Health = level c := *p return &c, true } } return nil, false } func (s *Store) Delete(id int) { s.mu.Lock() defer s.mu.Unlock() for i, p := range s.plants { if p.ID == id { s.plants = append(s.plants[:i], s.plants[i+1:]...) return } } } func (s *Store) AddEntry(id int, e Entry) { s.mu.Lock() defer s.mu.Unlock() s.entries[id] = append([]Entry{e}, s.entries[id]...) // 新记录放最前面 } func (s *Store) Entries(id int) []Entry { s.mu.RLock() defer s.mu.RUnlock() return append([]Entry(nil), s.entries[id]...) } // ---------- 传输层:HTTP handler ---------- type App struct{ store *Store } func isHX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } func render(w http.ResponseWriter, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("render %s: %v", name, err) } } func plantID(r *http.Request) (int, bool) { id, err := strconv.Atoi(r.PathValue("id")) return id, err == nil } // 首页:完整页面 func (a *App) index(w http.ResponseWriter, r *http.Request) { render(w, "page", map[string]any{"Q": "", "Plants": a.store.Search("")}) } // 搜索:同一个 URL,HTMX 请求返回片段,直接访问(刷新、分享链接)返回完整页面 func (a *App) search(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) data := map[string]any{"Q": q, "Plants": a.store.Search(q)} w.Header().Add("Vary", "HX-Request") // 同一 URL 两种响应,告诉缓存区分对待 原文:https://tonybai.com/2026/10/21/go-htmx-templ-sqlc-fullstack-gophercon-uk-2026/



