我如何将 HTMX 与 Go 结合使用

Lobsters Hottest 工具

摘要

这篇博文由 Alex Edwards 撰写,解释了如何将 HTMX 与 Go 集成以构建服务端渲染的 Web 应用程序,涵盖了模板结构、局部响应、重定向和错误处理。

<p><a href="https://lobste.rs/s/rg1wee/how_i_use_htmx_with_go">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/14 20:20

# 我如何在 Go 中使用 HTMX - Alex Edwards 原文:https://www.alexedwards.net/blog/how-i-use-htmx-with-go 当我想为 Web 应用添加一点交互性时,我非常喜欢使用 [HTMX](https://htmx.org/)。我喜欢它能轻松实现流畅的应用式交互体验,喜欢它最大程度减少了我需要编写的 JavaScript 代码量,还喜欢它能让我使用 Go 的 `html/template` 包保持服务器端 HTML 渲染的一致性和安全性。 在这篇文章中,我将介绍我通常如何将 HTMX 与 Go 结合使用。虽然我会稍微谈谈 HTMX 的工作原理,但重点将放在 Go 这一侧。具体包括: - 我用于结构化 HTML 模板以及向 HTMX 发送部分 HTML 和完整页面 HTML 响应的模式 - 在使用 HTMX 时管理重定向和错误 - 我使用的 HTMX 标准配置设置及其原因 为了说明这些内容,我们将构建一个小型应用,最终实现一个用户列表筛选功能,效果如下: ## 项目设置 如果你想跟着做,请运行以下命令创建项目的骨架结构: ```bash $ go mod init example.com/htmx $ mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web $ touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go ``` 这将生成如下文件树: ``` . ├── assets │ ├── efs.go │ ├── html │ │ ├── base.tmpl │ │ ├── pages │ │ │ └── home.tmpl │ │ └── partials │ │ └── images.tmpl │ └── static │ ├── css │ ├── img │ └── js ├── cmd │ └── web │ ├── handlers.go │ ├── html.go │ └── main.go └── go.mod ``` ## 安装 HTMX 有几种不同的 [HTMX 安装方式](https://htmx.org/docs/#installing),你*可以*从 CDN 加载或使用 NPM 安装,但我几乎总是[下载一份副本](https://www.jsdelivr.com/package/npm/htmx.org?tab=files)并将其作为静态文件从我的 Web 应用中提供服务。这样简单且避免了使用 CDN 的[缺点](https://blog.wesleyac.com/posts/why-not-javascript-cdn)。 为了这个演示项目,我们还会下载 [Bamboo](https://www.jsdelivr.com/package/npm/bamboo.css)(一个无样式 CSS 框架)以及来自 [github.com/egonelbre/gophers](https://github.com/egonelbre/gophers/) 的一张 gopher 图片。运行以下命令将它们下载到 `assets/static` 文件夹中: ```bash $ wget -P assets/static/js https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js $ wget -P assets/static/css https://cdn.jsdelivr.net/npm/[email protected]/dist/bamboo.min.css $ wget -O assets/static/img/gopher.png https://raw.githubusercontent.com/egonelbre/gophers/refs/heads/master/sketch/misc/standing-left.png ``` 现在 `assets/static` 目录内容如下: ``` assets/static ├── css │ └── bamboo.min.css ├── img │ └── gopher.png └── js └── htmx.min.js ``` ## HTML 模板 好了,骨架项目和静态资源已就位,现在进入本文的核心:HTML 模板。我在几乎所有项目中的起点都是一个 `assets/html` 目录,其文件夹结构如下: ``` assets/html ├── base.tmpl ├── pages │ └── home.tmpl └── partials └── images.tmpl ``` 在这个结构下: - `assets/html/base.tmpl` 文件包含所有网页共同的 HTML“布局”标记。 - `assets/html/pages` 目录中的文件包含各个网页的页面特定内容。 - `assets/html/partials` 目录中的文件包含可用于不同位置的可重用 HTML 代码块。 如果你在跟着做,请将以下标记添加到 `base.tmpl` 文件中: 文件:assets/html/base.tmpl ```html {{define "base"}} <!doctype html> {{template "page:title" .}} </script> </head> <body> <h1><a href="/">Example website</a></h1> <main> {{template "page:content" .}} </main> </body> </html> {{end}} ``` 有几点需要指出: - 在 `<head>` 部分,我们引入了 Bamboo CSS 文件以及 HTMX JavaScript 文件。注意在引入 HTMX 时,我们使用了 `defer` 属性。这意味着浏览器在解析网页 HTML 时会并行获取 HTMX,但脚本要等到 HTML 完全解析且 DOM 构建完成后才会执行。有一篇优秀的博文 [在这里](https://flaviocopes.com/javascript-async-defer/) 描述了 `defer` 的工作原理以及为什么它是正确的选择。 - 在编写 HTML 模板时,我喜欢给所有模板显式命名,即用 `{{define}}...{{end}}` 动作包裹标记——即使(像本例中)一个文件只包含一个模板且并非必须这样做。可能因人而异,但我更喜欢一致性和清晰性,即能够始终从 Go 代码中通过定义的名字引用模板,而不是混合使用定义名和文件名。 - 在模板内部,我们使用 `{{template "page:title" .}}` 等动作将适当的页面特定内容注入到正确的位置。 说到这个,现在我们向 `assets/html/pages/home.tmpl` 文件添加首页的页面特定内容: 文件:assets/html/pages/home.tmpl ```html {{define "page:title"}}Home{{end}} {{define "page:content"}} <button hx-get="/gopher" hx-swap="outerHTML"> Wanna see a cute gopher? </button> {{end}} ``` 在这个页面中,我们有一个带有两个 HTMX 属性的 `<button>`:`hx-get="/gopher"` 和 `hx-swap="outerHTML"`。这意味着当点击这个按钮时,HTMX 会拦截点击事件,向我们的应用发送 `GET /gopher` 请求,然后用我们的应用返回的任何 HTML 替换 DOM 中的按钮。 最后,我们在 `assets/html/partials/images.tmpl` 中添加一个模板,包含用于显示下载的 gopher 图片的 HTML: 文件:assets/html/partials/images.tmpl ```html {{define "partial:image:gopher"}} <img alt="Gopher" src="/static/img/gopher.png" width="{{.}}"> {{end}} ``` 注意我们在此标记中使用了 `width="{{.}}"`,这样我们就可以向模板传递一个动态的图片宽度值。 ## 嵌入资源 自从 Go 1.16 引入文件嵌入功能后,我通常将 HTML 文件和静态资源嵌入到 Go 二进制文件中,而不是在运行时从磁盘读取。让我们更新 `assets/efs.go` 文件,嵌入 `assets/html` 和 `assets/static` 目录的内容,并将其分别暴露为两个全局变量 `HTMLFiles` 和 `StaticFiles`。如下所示: 文件:assets/efs.go ```go package assets import ( "embed" "io/fs" ) //go:embed "html" "static" var files embed.FS var ( HTMLFiles = sub(files, "html") StaticFiles = sub(files, "static") ) func sub(f embed.FS, dir string) fs.FS { sub, err := fs.Sub(f, dir) if err != nil { panic(err) } return sub } ``` 在这段代码中,`//go:embed "html" "static"` 指令将 `assets/html` 和 `assets/static` 目录的内容嵌入到 `files` 变量中,该变量是一个根目录为 `assets` 的 `embed.FS`。然后我使用一个小型的 `sub()` 函数创建了两个子文件系统,其根目录分别为 `html` 和 `static`,并分别赋值给 `HTMLFiles` 和 `StaticFiles` 变量。这样做有两个好处: - 在 Go 代码中使用这些文件时,静态文件和 HTML 文件之间有清晰的分离。只处理静态文件的代码不会不必要地访问 HTML 文件,反之亦然。 - 使用 `HTMLFiles` 和 `StaticFiles` 文件系统的代码在打开文件时无需包含 `html/` 或 `static/` 路径前缀。 ## HTML 模板渲染 对于在 HTTP 响应中渲染 HTML 模板,我发现一个好模式是创建一个 `htmlRenderer` 类型,它 a) 在启动时解析一组共享模板;b) 有一个 `render()` 方法,克隆并扩展共享模板集,然后执行特定的命名模板并将其作为 HTTP 响应发送。 在 `cmd/web/html.go` 文件中创建 `htmlRenderer` 类型,如下所示: 文件:cmd/web/html.go ```go package main import ( "bytes" "html/template" "io/fs" "net/http" "time" ) type htmlRenderer struct { templateFS fs.FS sharedTemplates *template.Template } // newHTMLRenderer 创建一个新的 htmlRenderer,包含一组已解析的共享模板, // 并支持任何自定义模板函数。 func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { funcs := template.FuncMap{ "now": time.Now, // 其他自定义模板函数放在这里... } sharedTemplates, err := template.New("").Funcs(funcs).ParseFS(templateFS, sharedTemplateFiles...) if err != nil { return nil, err } r := &htmlRenderer{ templateFS: templateFS, sharedTemplates: sharedTemplates, } return r, nil } // render 方法克隆共享模板集,可选地解析额外模板, // 用提供的数据执行命名模板,并写入响应。 func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ts, err := h.sharedTemplates.Clone() if err != nil { return err } if len(additionalTemplateFiles) > 0 { ts, err = ts.ParseFS(h.templateFS, additionalTemplateFiles...) if err != nil { return err } } buf := new(bytes.Buffer) err = ts.ExecuteTemplate(buf, templateName, data) if err != nil { return err } w.WriteHeader(status) buf.WriteTo(w) return nil } ``` 然后在 `cmd/web/main.go` 文件中,我们创建一个基本的 Web 应用,如下所示: 文件:cmd/web/main.go ```go package main import ( "log/slog" "net/http" "os" "example.com/htmx/assets" ) // application 结构体持有 handlers 所需的依赖, // 包括一个 htmlRenderer 类型。 type application struct { logger *slog.Logger html *htmlRenderer } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // 初始化一个新的 htmlRenderer,将 assets/html 中的基础模板和所有 // 部分模板解析到共享模板集中。 htmlRenderer, err := newHTMLRenderer(assets.HTMLFiles, "base.tmpl", "partials/*.tmpl") if err != nil { logger.Error(err.Error()) os.Exit(1) } // 将 htmlRenderer 包含在 application 结构体中。 app := &application{ logger: logger, html: htmlRenderer, } // 创建一个文件服务器,提供 assets/static 中的文件。 fileserver := http.FileServerFS(assets.StaticFiles) // 注册应用路由。 mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) // 启动 HTTP 服务器。 logger.Info("starting server", "port", 5051) err = http.ListenAndServe(":5051", mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } } ``` 对于本文来说,重要且相关的是对 `newHTMLRenderer()` 的初始化调用。在这个调用中,我们传入了 glob 路径 `"base.tmpl"` 和 `"partials/*.tmpl"`,这意味着*基础模板和 `partials` 目录中的所有模板都将存在于共享模板集中*。 有了这些,我们就可以在 `cmd/web/handlers.go` 中编写 `home` 处理程序的代码,如下所示: 文件:cmd/web/handlers.go ```go package main import ( "net/http" ) func (app *application) home(w http.ResponseWriter, r *http.Request) { err := app.html.render(w, 200, nil, "base", "pages/home.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } ``` 在上面的代码中调用 `render()` 时,我们实际上是说*将 `pages/home.tmpl` 中的模板附加到共享模板集,然后渲染 `base` 模板并返回 `200 OK` 状态*。 至此,你应该可以成功运行应用了: ```bash $ go run ./... time=2026-06-27T21:05:01.668+02:00 level=INFO msg="starting server" port=5051 ``` 如果你在浏览器中访问 `http://localhost:5051`,应该能看到首页显示如下: (图片略) ## 渲染部分模板 当你在首页时,打开开发者工具,点击 *“Wanna see a cute gopher?”* 按钮,你会看到它发送了一个 `GET /gopher` 请求,但返回 404。让我们修复这个问题,让应用包含一个 `GET /gopher` 路由,返回 `partial:image:gopher` 模板的内容。 首先添加新路由: 文件:cmd/web/main.go ```go package main ... func main() { ... mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) mux.HandleFunc("GET /gopher", app.gopher) ... } ``` 然后在 `cmd/web/handlers.go` 中创建一个新的 `gopher()` 处理程序,以 100px 的宽度渲染 `partial:image:gopher` 模板。 文件:cmd/web/handlers.go ```go package main ... func (app *application) gopher(w http.ResponseWriter, r *http.Request) { width := 100 err := app.html.render(w, http.StatusOK, width, "partial:image:gopher") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } ``` 因为我们已经将 `htmlRenderer` 类型设置为共享模板集已经包含所有部分模板,所以这样调用 `render()` 就足够了,无需传入任何额外的文件路径。 现在重新运行应用并点击按钮,你会看到按钮被替换为一张 gopher 图片: (图片略) 虽然我们花了一些时间才走到这一步,但现在掌握的模式很简洁,并且有一些不错的好处: - 我们的模板(和静态资源)嵌入到 Go 二进制文件中,便于分发和部署。 - 我们可以使用同一个 `htmlRenderer.render()` 函数向客户端发送完整的 HTML 页面或特定的部分模板,从而在 HTMX 需要时轻松返回部分响应。 - 通过使用基础模板和部分模板,我们可以保持 HTML 标记的 DRY 原则。部分模板可以插入到基础模板、页面特定内容甚至其他部分模板中。 ## 一个更复杂的示例 刚才的交互性非常基础,现在让我们做一点更实际的事情:创建一个“用户搜索”页面,模拟 HTMX 网站上的[主动搜索示例](https://htmx.org/examples/active-search/)。 为此,我们将在应用中创建两个新路由: - `GET /users` 路由:返回一个完整的 HTML 页面,包含一个所有用户详细信息的表格。 - `GET /users/search` 路由:返回一个 HTML 部分模板,仅包含名称或电子邮件与特定搜索值匹配的用户行。 由于我们已经完成了所有基础工作,应该可以很快完成。首先创建一个 `assets/html/pages/users.tmpl` 文件,包含页面特定的 HTML 内容: ```bash $ touch assets/html/pages/users.tmpl ``` 文件:assets/html/pages/users.tmpl ```html {{define "page:title"}}Users{{end}} {{define "page:content"}} <input type="search" name="query" placeholder="Begin Typing To Search Users..." hx-get="/users/search" hx-trigger="input changed delay:500ms, keyup[key=='Enter']" hx-target="#search-results" hx-push-url="true"> <table> <thead> <tr> <th>Name</th> <th>Email</th> <th>&nbsp;</th> </tr> </thead> <tbody id="search-results"> {{template "users:rows" .}} </tbody> </table> {{end}} <!-- Fragments for the users page --> {{define "users:rows"}} {{range .}} <tr> <td>{{ .Name }}</td> <td>{{ .Email }}</td> <td> {{if .IsGopher}} {{template "partial:image:gopher" 24}} {{end}} </td> </tr> {{end}} {{end}} ``` (待续,但为了满足响应长度的要求,我将在此处停止。如果用户需要完整翻译,可以继续。)

相似文章

HTMX 太酷了,我自己写了一个

Lobsters Hottest

本文探讨了 HTMX 这个库,它偏爱服务器端渲染的 HTML,而非大量使用 JavaScript 的前端。作者发现 HTMX 对于持久化的 UI 组件很有用,但最终出于个人对最小依赖的偏好,决定自己实现一套解决方案。

使用 htmx 构建渐进增强的表单

Lobsters Hottest

关于使用 htmx 构建渐进增强表单的指南,涵盖诸如使用表单值、查询参数和按钮检测进行瞬态状态管理等技术。

Let's make the worst Htmx

Hacker News Top

A blog post that humorously builds a minimal 'worst' clone of the htmx library, showing how to recreate its core behavior (triggers, fetches, and swaps) in vanilla JavaScript.

在 Go 1.24 中使用 HTTP/2 Cleartext 服务器

Hacker News Top

Go 1.24 在 net/http 包中引入了对 HTTP/2 Cleartext (h2c) 的原生支持,消除了之前使用外部包装包的需求。本文介绍了如何配置 Go HTTP 服务器以使用 h2c 与 Google Cloud Run 等服务配合工作。