本文永久链接 – 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/

相关文章

我国第二艘国产大型邮轮“爱达 · 花城号”将于 11 月 6 日在上海完成交付

我国第二艘国产大型邮轮“爱达 · 花城号”将于 11 月 6 日在上海完成交付

IT之家
上汽大众 ID.ERA 5X 将于 11 月 12 日上市,家族首款纯电 SUV

上汽大众 ID.ERA 5X 将于 11 月 12 日上市,家族首款纯电 SUV

IT之家
广汽埃安 Ray7 车型 11 月 12 日上市:搭载华为 DriveONE 多合一电驱,预售价 12.88 万元起

广汽埃安 Ray7 车型 11 月 12 日上市:搭载华为 DriveONE 多合一电驱,预售价 12.88 万元起

IT之家
“女生罹癌离世前攒下医药费,买张 RTX5090 留给男友”温情故事引发关注,网友感叹“宁愿这是编的”

“女生罹癌离世前攒下医药费,买张 RTX5090 留给男友”温情故事引发关注,网友感叹“宁愿这是编的”

IT之家
用 WorkBuddy 做个人名片网页,挂上开源设计技能后效果完全不同了

用 WorkBuddy 做个人名片网页,挂上开源设计技能后效果完全不同了

掘金
我测了 6 种防请求竞态的写法:只有 1 种真能打

我测了 6 种防请求竞态的写法:只有 1 种真能打

掘金