本文永久链接 – 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 两种响应,告诉缓存区分对待
if isHX(r) {
render(w, "plant-list", data)
return
}
render(w, "page", data)
}
// 详情面板(片段)
func (a *App) detail(w http.ResponseWriter, r *http.Request) {
id, _ := plantID(r)
p, ok := a.store.Get(id)
if !ok {
http.NotFound(w, r)
return
}
render(w, "detail", p)
}
// 时间线:模拟一个慢查询,配合 hx-trigger="load" 懒加载
func (a *App) timeline(w http.ResponseWriter, r *http.Request) {
id, _ := plantID(r)
time.Sleep(600 * time.Millisecond)
render(w, "timeline", map[string]any{"Entries": a.store.Entries(id)})
}
// 添加事件:成功返回时间线 + OOB 提示;失败用响应头改道到 #toast
func (a *App) addEvent(w http.ResponseWriter, r *http.Request) {
id, _ := plantID(r)
typ := strings.TrimSpace(r.FormValue("event_type"))
if typ == "" {
w.Header().Set("HX-Retarget", "#toast")
w.Header().Set("HX-Reswap", "innerHTML")
w.WriteHeader(http.StatusUnprocessableEntity)
render(w, "error-text", "事件类型不能为空")
return
}
a.store.AddEntry(id, Entry{Type: typ, Notes: strings.TrimSpace(r.FormValue("notes")), At: time.Now()})
render(w, "timeline", map[string]any{"Entries": a.store.Entries(id)})
render(w, "toast", "已记录:+"+typ)
}
// 更新健康度:一次响应同时更新列表徽标、详情徽标和提示
func (a *App) setHealth(w http.ResponseWriter, r *http.Request) {
id, _ := plantID(r)
p, ok := a.store.SetHealth(id, r.FormValue("level"))
if !ok {
http.NotFound(w, r)
return
}
render(w, "health-badge-oob", p) // 列表里的徽标
render(w, "health-badge-detail-oob", p) // 详情里的徽标
render(w, "toast", p.Name+" 的健康度已更新为 "+p.Health)
}
func (a *App) delete(w http.ResponseWriter, r *http.Request) {
id, _ := plantID(r)
a.store.Delete(id)
// 主体为空:目标 <li> 会被整个移除;同时用 OOB 更新提示
render(w, "toast", "已删除")
}
// ---------- 登录过期演示:HX-Redirect ----------
func redirect(w http.ResponseWriter, r *http.Request, url string) {
if isHX(r) {
w.Header().Set("HX-Redirect", url) // 普通 302 会被 HTMX 当成“要塞进目标里的页面”
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, url, http.StatusSeeOther)
}
func (a *App) secure(w http.ResponseWriter, r *http.Request) {
if _, err := r.Cookie("session"); err != nil {
redirect(w, r, "/login")
return
}
render(w, "toast-inline", "🔓 你已登录,这是受保护的内容")
}
func (a *App) loginPage(w http.ResponseWriter, r *http.Request) { render(w, "login", nil) }
func (a *App) login(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "demo", Path: "/", HttpOnly: true})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Path: "/", MaxAge: -1})
redirect(w, r, "/")
}
func main() {
a := &App{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("GET /plants", a.search)
mux.HandleFunc("GET /plants/{id}", a.detail)
mux.HandleFunc("GET /plants/{id}/timeline", a.timeline)
mux.HandleFunc("POST /plants/{id}/events", a.addEvent)
mux.HandleFunc("POST /plants/{id}/health", a.setHealth)
mux.HandleFunc("DELETE /plants/{id}", a.delete)
mux.HandleFunc("GET /secure", a.secure)
mux.HandleFunc("GET /login", a.loginPage)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("POST /logout", a.logout)
log.Println("listening on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
3. templates/layout.html
{{define "page"}}<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Let's Go 植物记录</title>
<!-- 让 422 响应也参与替换,这样表单校验错误可以直接显示 -->
<meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true}]}'>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script>
<style>
body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;padding:0 1rem;line-height:1.6}
[x-cloak]{display:none!important}
ul{padding-left:1.2rem} li{margin:.4rem 0}
.badge{padding:.1rem .5rem;border-radius:999px;font-size:.85rem;background:#e5e7eb}
.良好{background:#bbf7d0}.一般{background:#fde68a}.枯萎{background:#fecaca}
#toast{min-height:1.5rem;color:#065f46;margin:.5rem 0}
.err{color:#b91c1c}
input,textarea,button{font:inherit;margin:.15rem 0}
.htmx-request{opacity:.5}
</style>
</head>
<body>
<h1>🌱 Let's Go 植物记录</h1>
<div id="toast"></div>
<input type="search" name="q" value="{{.Q}}" placeholder="搜索植物,比如:薄荷"
hx-get="/plants"
hx-trigger="input changed delay:300ms, search"
hx-target="#plant-list"
hx-push-url="true">
<div id="plant-list">{{template "plant-list" .}}</div>
<div id="detail"></div>
<hr>
<button hx-get="/secure" hx-target="#secure-result">模拟:请求受保护内容</button>
<span id="secure-result"></span>
</body>
</html>{{end}}
{{define "plant-list"}}<ul>
{{range .Plants}}{{template "plant-item" .}}
{{else}}<li>没有找到匹配的植物</li>
{{end}}</ul>{{end}}
{{define "plant-item"}}<li id="plant-{{.ID}}">
<strong>{{.Name}}</strong> {{template "health-badge" .}}
<button hx-get="/plants/{{.ID}}" hx-target="#detail">查看</button>
<span x-data="{ open: false }">
<button x-show="!open" @click="open = true">删除</button>
<span x-show="open" x-cloak>
确定删除?
<button hx-delete="/plants/{{.ID}}" hx-target="#plant-{{.ID}}" hx-swap="outerHTML">确认</button>
<button @click="open = false">取消</button>
</span>
</span>
</li>{{end}}
4. templates/partials.html
{{define "health-badge"}}<span id="health-{{.ID}}" class="badge {{.Health}}">{{.Health}}</span>{{end}}
{{define "health-badge-oob"}}<span id="health-{{.ID}}" class="badge {{.Health}}" hx-swap-oob="true">{{.Health}}</span>{{end}}
{{define "health-badge-detail-oob"}}<span id="detail-health-{{.ID}}" class="badge {{.Health}}" hx-swap-oob="true">{{.Health}}</span>{{end}}
{{define "toast"}}<div id="toast" hx-swap-oob="true">{{.}}</div>{{end}}
{{define "error-text"}}<span class="err">{{.}}</span>{{end}}
{{define "toast-inline"}}{{.}}{{end}}
{{define "detail"}}<section>
<h2>{{.Name}} <span id="detail-health-{{.ID}}" class="badge {{.Health}}">{{.Health}}</span></h2>
<div>
<button hx-post="/plants/{{.ID}}/health" hx-vals='{"level":"良好"}' hx-swap="none">标记:良好</button>
<button hx-post="/plants/{{.ID}}/health" hx-vals='{"level":"一般"}' hx-swap="none">标记:一般</button>
<button hx-post="/plants/{{.ID}}/health" hx-vals='{"level":"枯萎"}' hx-swap="none">标记:枯萎</button>
</div>
<form hx-post="/plants/{{.ID}}/events" hx-target="#timeline"
hx-on::after-request="if (event.detail.successful) this.reset()">
<input name="event_type" placeholder="事件类型:浇水 / 换盆 / 施肥">
<br>
<textarea name="notes" placeholder="备注(可选)" rows="2" cols="40"></textarea>
<br>
<button type="submit">记录</button>
</form>
<h3>时间线</h3>
<div id="timeline" hx-get="/plants/{{.ID}}/timeline" hx-trigger="load">加载中……</div>
</section>{{end}}
{{define "timeline"}}<ul>
{{range .Entries}}<li>{{.At.Format "01-02 15:04"}} · <strong>{{.Type}}</strong>{{if .Notes}}:{{.Notes}}{{end}}</li>
{{else}}<li>还没有记录</li>
{{end}}</ul>{{end}}
{{define "login"}}<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>登录</title></head>
<body style="font-family:system-ui;max-width:420px;margin:3rem auto">
<h1>请先登录</h1>
<p>(演示:点击按钮即写入 session Cookie)</p>
<form method="post" action="/login"><button type="submit">登录</button></form>
</body></html>{{end}}
5. 示例使用和体验顺序
建议按下面的顺序操作,每一步对应文章里的一个知识点:
- 搜索:在顶部输入框输入“薄”,停顿约 300 毫秒后列表自动刷新。此时地址栏变成
/plants?q=薄,复制这个链接在新标签页打开,会得到完整页面,这就是同一个地址对 HTMX 请求和普通访问返回不同内容。 - 查看与懒加载:点击某株植物的“查看”,详情面板先出现,时间线显示“加载中……”,约 0.6 秒后才填充,这是
hx-trigger="load"加服务端模拟慢查询的效果。 - 添加事件:填写事件类型和备注后点击“记录”,时间线顶部立即多出一条,顶部提示同步更新,表单自动清空。
- 错误改道:事件类型留空直接提交,错误信息会出现在顶部提示区,而不是时间线里,这是服务端用
HX-Retarget改了更新位置。 - 多区域更新:点击“标记:枯萎”,列表里的徽标、详情里的徽标和顶部提示会同时变化,而服务端只返回了三段 HTML 片段。
- 删除确认:点击“删除”,原地展开确认按钮,点“取消”则收起,这一步没有任何网络请求,是 Alpine.js 管理的局部状态。
- 登录跳转:点击页面底部“模拟:请求受保护内容”,会被带到登录页。点击登录后回到首页,再点一次该按钮,就能看到受保护内容。
6. 想观察请求时怎么做
打开浏览器开发者工具的 Network 面板,可以看到所有 HTMX 请求的响应体都是 HTML 片段而不是 JSON。点开请求,还能在请求头里看到 HX-Request: true,在响应头里看到 HX-Retarget、HX-Redirect 等控制头。
还在为“复制粘贴喂AI”而烦恼?我的新专栏 《AI原生开发工作流实战》 将带你:
- 告别低效,重塑开发范式
- 驾驭AI Agent(Claude Code),实现工作流自动化
- 从“AI使用者”进化为规范驱动开发的“工作流指挥家”
扫描下方二维码,开启你的AI原生开发之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
