Chapter 08

与后端集成:Flask/FastAPI/Go/Rails

htmx 与任何后端框架无缝配合,只需服务器返回 HTML。本章覆盖主流后端的集成模式与最佳实践

核心原则

htmx 与后端集成没有任何特殊要求,只需遵循三个原则:

  1. 返回 HTML,不是 JSON:htmx 请求期望服务器返回 HTML 片段,而非数据
  2. 正确的 Content-Type:响应头必须是 Content-Type: text/html
  3. 检查 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>
Node.js 后端的 htmx 模板选择

EJS(Embedded JavaScript)语法最接近 HTML,学习成本最低,适合与 htmx 搭配。Pug(Jade)语法简洁但缩进敏感,片段模板较难阅读。Handlebars 无逻辑哲学与 htmx 的 HTML-first 理念吻合。三者都可以,选熟悉的即可。

Rails + Hotwire(对比视角)

Rails 7 内置了 Hotwire(Turbo + Stimulus),是 Rails 生态中 htmx 的对标方案:

特性htmxRails Hotwire/Turbo
哲学HTML 属性,后端无关与 Rails 深度集成
体积~14KBTurbo ~16KB + Stimulus
后端任意语言主要针对 Rails
局部更新hx-target / hx-swapTurbo Frames / Turbo Streams
实时推送htmx-ext-sseTurbo Streams over WebSocket
约定最少约定,最大灵活强约定(ERB 模板、命名规则)
选择 htmx 还是 Hotwire?

如果技术栈是 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/继承,片段模板同时服务于全页和局部请求。