核心原则
htmx 与后端集成没有任何特殊要求,只需遵循三个原则:
- 返回 HTML,不是 JSON:htmx 请求期望服务器返回 HTML 片段,而非数据
- 正确的 Content-Type:响应头必须是
Content-Type: text/html - 检查 HX-Request 头:区分全页请求和 htmx 局部请求,返回不同内容
FastAPI + Jinja2(推荐方案)
pip install fastapi jinja2 python-multipart uvicorn
# app/main.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 辅助函数:判断是否为 htmx 请求
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
@app.get("/users")
async def users_page(request: Request):
users = await db.get_users()
template = "partials/user_list.html" if is_htmx(request) else "pages/users.html"
return templates.TemplateResponse(template, {"request": request, "users": users})
@app.post("/users")
async def create_user(request: Request, name: str = Form(...), email: str = Form(...)):
user = await db.create_user(name=name, email=email)
return templates.TemplateResponse(
"partials/user_row.html",
{"request": request, "user": user},
headers={"HX-Trigger": '{"userCreated": "用户创建成功"}'}
)
<!-- templates/pages/users.html(完整页面) -->
{% extends "base.html" %}
{% block content %}
<div id="user-list">
{% include "partials/user_list.html" %}
</div>
{% endblock %}
<!-- templates/partials/user_list.html(片段) -->
{% for user in users %}
<div class="user-row">
<span>{{ user.name }}</span>
<span>{{ user.email }}</span>
<button hx-delete="/users/{{ user.id }}"
hx-target="closest .user-row"
hx-swap="outerHTML"
hx-confirm="确认删除?">删除</button>
</div>
{% endfor %}
Flask + Jinja2
# app.py
from flask import Flask, render_template, request, make_response
app = Flask(__name__)
def is_htmx():
return request.headers.get("HX-Request") == "true"
@app.route("/posts", methods=["GET"])
def posts():
items = Post.query.all()
if is_htmx():
return render_template("partials/post_list.html", posts=items)
return render_template("pages/posts.html", posts=items)
@app.route("/posts", methods=["POST"])
def create_post():
post = Post(title=request.form["title"])
db.session.add(post)
db.session.commit()
resp = make_response(render_template("partials/post_item.html", post=post))
resp.headers["HX-Trigger"] = "postCreated"
return resp
Go + html/template
// main.go
package main
import (
"html/template"
"net/http"
)
var tmpl = template.Must(template.ParseGlob("templates/**/*.html"))
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
users := db.GetUsers()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if isHTMX(r) {
tmpl.ExecuteTemplate(w, "user-list", users)
return
}
tmpl.ExecuteTemplate(w, "users-page", users)
}
func main() {
http.HandleFunc("/users", usersHandler)
http.ListenAndServe(":8080", nil)
}
<!-- templates/user-list.html -->
{{define "user-list"}}
{{range .}}
<div class="user">
<span>{{.Name}}</span>
<button
hx-delete="/users/{{.ID}}"
hx-target="closest .user"
hx-swap="outerHTML"
>删除</button>
</div>
{{end}}
{{end}}
Django + django-htmx
pip install django-htmx
# settings.py
MIDDLEWARE = [
...
"django_htmx.middleware.HtmxMiddleware",
]
# views.py
from django.shortcuts import render
from django_htmx.http import HttpResponseClientRedirect
def item_list(request):
items = Item.objects.all()
# django-htmx 提供 request.htmx 属性
if request.htmx:
return render(request, "partials/item_list.html", {"items": items})
return render(request, "pages/items.html", {"items": items})
def delete_item(request, pk):
Item.objects.filter(pk=pk).delete()
# 使用 django-htmx 提供的响应类
return HttpResponseClientRedirect("/items")
Node.js + Express + EJS
npm install express ejs
// server.js
const express = require('express');
const app = express();
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: true }));
// 辅助函数:判断是否为 htmx 请求
const isHtmx = (req) => req.headers['hx-request'] === 'true';
app.get('/items', async (req, res) => {
const items = await db.getItems();
if (isHtmx(req)) {
// htmx 请求:只渲染列表片段
return res.render('partials/item_list', { items });
}
// 普通请求:渲染完整页面
res.render('pages/items', { items });
});
app.post('/items', async (req, res) => {
const item = await db.createItem(req.body.name);
// 返回新建的条目 HTML 片段
res.render('partials/item_row', { item });
});
app.delete('/items/:id', async (req, res) => {
await db.deleteItem(req.params.id);
// 返回空响应,htmx 会移除 DOM 元素
res.send('');
});
<!-- views/partials/item_row.ejs -->
<li id="item-<%= item.id %>" class="item">
<span><%= item.name %></span>
<button
hx-delete="/items/<%= item.id %>"
hx-target="#item-<%= item.id %>"
hx-swap="outerHTML"
hx-confirm="确认删除?"
>删除</button>
</li>
EJS(Embedded JavaScript)语法最接近 HTML,学习成本最低,适合与 htmx 搭配。Pug(Jade)语法简洁但缩进敏感,片段模板较难阅读。Handlebars 无逻辑哲学与 htmx 的 HTML-first 理念吻合。三者都可以,选熟悉的即可。
Rails + Hotwire(对比视角)
Rails 7 内置了 Hotwire(Turbo + Stimulus),是 Rails 生态中 htmx 的对标方案:
| 特性 | htmx | Rails Hotwire/Turbo |
|---|---|---|
| 哲学 | HTML 属性,后端无关 | 与 Rails 深度集成 |
| 体积 | ~14KB | Turbo ~16KB + Stimulus |
| 后端 | 任意语言 | 主要针对 Rails |
| 局部更新 | hx-target / hx-swap | Turbo Frames / Turbo Streams |
| 实时推送 | htmx-ext-sse | Turbo Streams over WebSocket |
| 约定 | 最少约定,最大灵活 | 强约定(ERB 模板、命名规则) |
如果技术栈是 Rails,Hotwire 通常是更好的选择——深度集成、约定清晰、官方维护。如果使用 Python/Go/Node/PHP 等后端,htmx 是更通用的方案,几乎零学习成本接入任何现有项目。两者理念一致,在超媒体应用场景下都是优秀的选择。
htmx 请求需要返回 HTML 片段,而普通浏览器请求需要完整页面。一个常见反模式是维护两套模板。正确做法是使用模板继承和 include:完整页面模板 include 片段模板,htmx 请求直接渲染片段模板,普通请求渲染包含该片段的完整页面。Jinja2 的 {% include %} 和 Go 的 {{template "name"}} 都支持这种模式。
htmx 后端集成的核心要点:① htmx 与后端无关,任何能返回 HTML 的语言/框架都可以;② 服务端的核心任务是检查 HX-Request 头,区分全页请求和 htmx 片段请求,返回不同内容;③ FastAPI + Jinja2 是 Python 生态最推荐的组合,is_htmx(request) 判断函数应该提取为工具函数;④ Go 的 html/template 通过 {{define "name"}} 命名模板块,配合 ExecuteTemplate 按需渲染片段;⑤ Django 用户推荐安装 django-htmx 中间件,它提供 request.htmx 属性和 HttpResponseClientRedirect 等辅助类;⑥ 避免维护两套模板——使用模板 include/继承,片段模板同时服务于全页和局部请求。