本文永久链接 – 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. 示例使用和体验顺序

建议按下面的顺序操作,每一步对应文章里的一个知识点:

  1. 搜索:在顶部输入框输入“薄”,停顿约 300 毫秒后列表自动刷新。此时地址栏变成 /plants?q=薄,复制这个链接在新标签页打开,会得到完整页面,这就是同一个地址对 HTMX 请求和普通访问返回不同内容。
  2. 查看与懒加载:点击某株植物的“查看”,详情面板先出现,时间线显示“加载中……”,约 0.6 秒后才填充,这是 hx-trigger="load" 加服务端模拟慢查询的效果。
  3. 添加事件:填写事件类型和备注后点击“记录”,时间线顶部立即多出一条,顶部提示同步更新,表单自动清空。
  4. 错误改道:事件类型留空直接提交,错误信息会出现在顶部提示区,而不是时间线里,这是服务端用 HX-Retarget 改了更新位置。
  5. 多区域更新:点击“标记:枯萎”,列表里的徽标、详情里的徽标和顶部提示会同时变化,而服务端只返回了三段 HTML 片段。
  6. 删除确认:点击“删除”,原地展开确认按钮,点“取消”则收起,这一步没有任何网络请求,是 Alpine.js 管理的局部状态。
  7. 登录跳转:点击页面底部“模拟:请求受保护内容”,会被带到登录页。点击登录后回到首页,再点一次该按钮,就能看到受保护内容。

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技能再上一个新台阶!


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