编程 HTMX 深度实战:当"超媒体驱动"重塑 Web 开发范式——从 REST 哲学到不用一行 JavaScript 的现代化全栈指南(2026)

2026-06-11 12:24:17 +0800 CST views 13

HTMX 深度实战:当"超媒体驱动"重塑 Web 开发范式——从 REST 哲学到不用一行 JavaScript 的现代化全栈指南(2026)

前言:Web 开发的范式轮回

2026 年的 Web 开发,似乎进入了一个有趣的轮回。

一边是 Next.js 15 的 React Server Components、Streaming SSR、Partial Hydration,一堆概念越堆越高;另一边是 HTMX——一个仅 14KB gzipped 的 JavaScript 库,用 HTML 属性声明式地实现了 AJAX、SSE、WebSocket,让"不用写 JavaScript 的全栈开发"成为可能。

这不仅仅是复古,更是一种哲学的回归:超媒体控制(Hypermedia Control)——Roy Fielding 在 REST 架构论文中提过,却被业界遗忘了二十年的核心理念。

本文从 HTMX 的超媒体驱动理念出发,配合 Rust + Axum + HTMX + SSE 的完整实战,讲解如何在 2026 年用最少的工具链,构建出性能极佳、开发体验极好、运维负担极低的现代 Web 应用。

一、超媒体控制:HTMX 的思想根源

1.1 REST 的第三个层次,你可能没听过

Leonard Richardson 在 2008 年提出了 REST 成熟度模型(Richardson Maturity Model),将 REST 分为 4 个层次:

Level 0: POX(Plain Old XML)—— HTTP 作为传输协议
         ↑ 用 POST 做一切事,不利用 HTTP 语义

Level 1: 资源(Resources)—— 用 URI 定位资源
         ↑ /users/123,而不是 /getUser?id=123

Level 2: HTTP 动词(Verbs)—— 正确使用 GET/POST/PUT/DELETE
         ↑ GET 获取,POST 创建,PUT 更新,DELETE 删除

Level 3: 超媒体控制(HATEOAS)—— 响应中包含下一步操作的链接
         ↑ 服务器告诉你"接下来可以做什么"

HATEOAS(Hypermedia As The Engine Of Application State)是 REST 最核心的特征,也是最少被实现的一层。Roy Fielding 说过:"如果一个 API 不能让你仅凭入口 URL 就探索整个系统,那它就不是 REST。"

1.2 HTMX 如何实现 HATEOAS

HTMX 将 HATEOAS 发挥到了极致。传统 REST API 返回 JSON:

{
  "id": 123,
  "name": "张三",
  "email": "zhang@example.com"
}

HTMX 的 API 返回 HTML 片段:

<div hx-get="/users/123" hx-target="#user-info">
  <p>张三</p>
  <p>zhang@example.com</p>
  <button hx-put="/users/123" hx-include="[name=email]">
    更新邮箱
  </button>
</div>

这个 HTML 片段本身包含了:

  • 显示什么内容
  • 点击后做什么操作hx-put
  • 更新哪个区域hx-target="#user-info"

服务器不仅返回数据,还返回了"下一步可以做什么"的 UI——这才是真正的超媒体驱动。

1.3 HTMX vs React:不是非此即彼,而是各司其职

HTMX 擅长的场景:
✅ 表单提交和页面局部更新
✅ 多步骤向导和多步骤表单
✅ 数据表格的排序、分页、筛选
✅ 无限滚动和懒加载列表
✅ 实时通知(SSE)
✅ 简单的交互(折叠、标签页)

React 擅长的场景:
✅ 复杂的前端状态管理
✅ 大量实时交互的游戏类应用
✅ 复杂的动画和手势交互
✅ Canvas/WebGL 类应用
✅ 离线优先的 PWA

HTMX 的定位不是替代 React,而是填补服务器端渲染(SSR)和 SPA 之间的空白,让不需要复杂前端状态的应用,用更简单的方式构建。

二、HTMX 核心 API:一切围绕 HTML 属性

2.1 请求触发器(hx-trigger)

<!-- 点击触发(默认) -->
<button hx-get="/api/users">加载用户</button>

<!-- 鼠标悬停触发 -->
<div hx-get="/api/tooltip" hx-trigger="mouseenter">悬停查看</div>

<!-- 键盘事件触发 -->
<input type="text" hx-get="/api/search" hx-trigger="keyup changed delay:300ms">

<!-- 表单变化触发 -->
<select name="category" hx-get="/api/subcategories" hx-trigger="change">

<!-- 页面加载时触发 -->
<div hx-get="/api/notifications" hx-trigger="load">

<!-- 定时轮询 -->
<div hx-get="/api/live-data" hx-trigger="every 5s">

2.2 请求目标和交换策略(hx-target, hx-swap)

<!-- 默认:用响应内容替换当前元素 -->
<button hx-get="/api/fragment">点我替换</button>

<!-- 指定目标元素 -->
<button hx-get="/api/fragment" hx-target="#results">点我更新 results</button>

<!-- 交换策略 -->
<div hx-get="/api/fragment" hx-swap="innerHTML">  <!-- 默认:替换内部内容 -->
<div hx-get="/api/fragment" hx-swap="outerHTML"> <!-- 替换整个元素 -->
<div hx-get="/api/fragment" hx-swap="beforebegin"><!-- 插入到元素之前 -->
<div hx-get="/api/fragment" hx-swap="afterend">  <!-- 插入到元素之后 -->
<div hx-get="/api/fragment" hx-swap="delete">    <!-- 删除目标元素 -->
<div hx-get="/api/fragment" hx-swap="none">      <!-- 不替换,仅触发回调 -->

2.3 请求参数(hx-include, hx-params)

<!-- 包含指定元素的值 -->
<button hx-get="/api/search" hx-include="[name=query]">搜索</button>

<!-- 只发送特定参数 -->
<input name="q" hx-get="/api/search" hx-params="q">

<!-- 不发送任何参数 -->
<button hx-delete="/api/users/1" hx-params="none">

<!-- 带额外数据 -->
<button hx-post="/api/log"
        hx-vals='{"action": "click", "button_id": "btn-1"}'>

2.4 CSS 选择器的高级用法

<!-- 最近的 form 表单 -->
<button hx-post="/api/submit" hx-include="closest form">

<!-- 所有 checked 的 checkbox -->
<button hx-delete="/api/batch" hx-include="checked">

<!-- 非禁用输入框 -->
<input hx-get="/api/validate" hx-include=":not([disabled])">

<!-- 最近祖先中的所有 input -->
<div hx-put="/api/profile" hx-include="closest div :input">

2.5 SSE:服务器推送的最简实现

传统的 Server-Sent Events 需要手写 JavaScript:

const source = new EventSource("/api/events");
source.onmessage = (event) => {
  document.getElementById("notifications").innerHTML += event.data;
};

HTMX 一行属性搞定:

<div hx-sse="connect /api/events"
     hx-sse="swap event:notification">
  <!-- 实时通知会出现在这里 -->
</div>

或者监听多个 SSE 事件:

<div hx-sse="connect /api/events">
  <div hx-sse="swap event:notification"
       hx-swap="beforeend">
    <!-- 收到 notification 事件就追加到末尾 -->
  </div>
  <div hx-sse="swap event:error"
       hx-swap="innerHTML">
    <!-- 收到 error 事件就替换 -->
  </div>
</div>

三、实战:Rust + Axum + HTMX + SSE 构建聊天应用

3.1 项目概览与技术选型理由

技术栈:
- 后端:Rust + Axum 1.x(异步 Web 框架事实标准)
- 运行时:Tokio(Rust 异步运行时)
- 前端:HTMX + Alpine.js(交互状态)
- 模板引擎:Askama(Jinja2 风格的 Rust 原生模板)
- 实时:Server-Sent Events
- 数据库:SQLite + SQLx(编译时检查的 SQL)

选型理由:

  1. Axum:Rust 异步 Web 框架事实标准,组合式 API,性能极强
  2. Askama:编译时模板引擎,零运行时依赖,性能比 Tera 快 2-5 倍
  3. SQLx:编译时 SQL 检查,编译不过 = SQL 有错,无需运行时才发现

3.2 项目初始化

# Cargo.toml
[package]
name = "rustgpt"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "fs"] }
askama = "0.13"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "sqlite"] }
uuid = { version = "1", features = ["v4"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tokio-stream = "0.1"
futures = "0.3"

3.3 数据库 Schema

-- schema.sql

CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL,
    role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
    content TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    FOREIGN KEY (session_id) REFERENCES sessions(id)
);

CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);

3.4 数据模型

// src/models.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
    pub id: String,
    pub title: String,
    pub created_at: i64,
    pub updated_at: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Message {
    pub id: i64,
    pub session_id: String,
    pub role: String,
    pub content: String,
    pub created_at: i64,
}

#[derive(Debug, Deserialize)]
pub struct ChatRequest {
    pub session_id: String,
    pub message: String,
}

3.5 SSE 流式响应的核心实现

这是整个项目最关键的部分——SSE 流式输出让 AI 的回复"一个字一个字地出现":

// src/handlers/stream.rs
use axum::{
    extract::State,
    response::sse::{Event, Sse},
    routing::post,
    Router,
};
use futures::StreamExt;
use std::sync::Arc;
use tokio_stream::wrappers::BroadcastStream;
use tower_http::cors::CorsLayer;

use crate::AppState;

// 流式 SSE 事件
#[derive(Clone, serde::Serialize)]
struct SseEvent {
    event: String,
    data: String,
}

impl SseEvent {
    fn new(event: impl Into<String>, data: impl Into<String>) -> Self {
        Self {
            event: event.into(),
            data: data.into(),
        }
    }

    fn to_sse_event(&self) -> Event {
        Event::default()
            .event_data(&self.data)
            .event(self.event.clone())
    }
}

// SSE 路由处理
pub async fn sse_handler(
    State(state): State<Arc<AppState>>,
    session_id: axum::extract::Path<String>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, std::convert::Infallible>>> {
    let topic = format!("chat:{}", session_id.as_str());

    // 创建广播频道(允许多个客户端订阅同一个 session)
    let (tx, rx) = tokio::sync::broadcast::channel::<String>(100);

    // 启动 AI 消息处理器(后台任务)
    // 实际项目中这里连接真实 LLM API
    let state_clone = state.clone();
    let topic_clone = topic.clone();
    let tx_clone = tx.clone();

    tokio::spawn(async move {
        // 模拟 AI 流式输出(实际项目中这里是真实 LLM 调用)
        let words = ["你", "好", "!", "这", "是", "一", "个", "例", "子", "回", "复", "。"];
        for word in words {
            let payload = SseEvent::new("message", word).to_sse_event();
            let _ = tx_clone.send(serde_json::to_string(&SseEvent::new("message", word)).unwrap());
            tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
        }
        // 发送完成信号
        let _ = tx_clone.send(serde_json::to_string(&SseEvent::new("done", "")).unwrap());
    });

    // 将广播流转换为 SSE 流
    let stream = BroadcastStream::new(rx)
        .map(|result| {
            let event = match result {
                Ok(data) => Event::default().event_data(&data),
                Err(broadcast::error::RecvError::Lagged(n)) => {
                    Event::default().comment(format!("lagged {} events", n))
                }
                Err(broadcast::error::RecvError::Closed) => {
                    return Ok(Event::default().comment("channel closed"));
                }
            };
            Ok::<_, std::convert::Infallible>(event)
        });

    Sse::new(stream)
        .keep_alive(
            axum::response::sse::KeepAlive::new()
                .interval(std::time::Duration::from_secs(15))
                .text("keep-alive-text"),
        )
}

3.6 聊天消息处理与模板渲染

// src/handlers/chat.rs
use crate::models::{ChatRequest, Message};
use crate::templates::{ChatMessageTemplate, ChatHistoryTemplate};
use crate::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
use std::sync::Arc;

pub async fn send_message(
    State(state): State<Arc<AppState>>,
    axum::extract::Json(req): axum::extract::Json<ChatRequest>,
) -> impl IntoResponse {
    let now = chrono::Utc::now().timestamp();

    // 保存用户消息到数据库
    let user_msg = Message {
        id: 0,
        session_id: req.session_id.clone(),
        role: "user".to_string(),
        content: req.message.clone(),
        created_at: now,
    };

    sqlx::query(
        "INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)"
    )
    .bind(&user_msg.session_id)
    .bind(&user_msg.role)
    .bind(&user_msg.content)
    .bind(user_msg.created_at)
    .execute(&*state.db)
    .await
    .map_err(|e| {
        tracing::error!("Failed to insert user message: {}", e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    // 更新 session 时间
    sqlx::query("UPDATE sessions SET updated_at = ? WHERE id = ?")
        .bind(now)
        .bind(&req.session_id)
        .execute(&*state.db)
        .await
        .ok();

    // 渲染用户消息 HTML 片段(用于 HTMX 更新)
    let html = ChatMessageTemplate { message: &user_msg }
        .render()
        .map_err(|e| {
            tracing::error!("Template error: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?
        .to_string();

    // HTMX 响应:返回 HTML 片段 + 触发 SSE 事件
    (
        StatusCode::OK,
        [(
            "HX-Trigger",
            serde_json::json!({
                "aiThinking": { "sessionId": req.session_id }
            }).to_string(),
        )],
        html,
    )
        .into_response()
}

3.7 Askama 模板

<!-- templates/chat_message.html -->

{% macro message(msg) %}
<div class="message message-{{ msg.role }}"
     id="msg-{{ msg.id }}"
     hx-sse="swap event:done">
    <div class="message-header">
        {% if msg.role == "user" %}
        <span class="avatar">👤</span>
        <span class="role">你</span>
        {% else %}
        <span class="avatar">🤖</span>
        <span class="role">RustGPT</span>
        {% endif %}
        <span class="timestamp">{{ msg.created_at }}</span>
    </div>
    <div class="message-content">
        {{ msg.content }}
    </div>
</div>
{% endmacro %}

<!-- 单条消息渲染 -->
<div class="message message-{{ message.role }}"
     id="msg-{{ message.id }}"
     hx-swap-oob="afterend">
    <div class="message-header">
        {% if message.role == "user" %}
        <span class="avatar">👤</span>
        <span class="role">你</span>
        {% else %}
        <span class="avatar">🤖</span>
        <span class="role">RustGPT</span>
        {% endif %}
    </div>
    <div class="message-content">
        {{ message.content }}
    </div>
</div>
<!-- templates/chat_page.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RustGPT - Rust + HTMX 聊天</title>
    <!-- HTMX:整个页面唯一的 JS 依赖,14KB -->
    <script src="https://unpkg.com/htmx.org@2.0.4"
            integrity="sha384-HGfzdea7ErKwfXJb14Vv5n2q5RW3b6sDMQIbxJnU46WCgdX3"></script>
    <!-- Alpine.js:轻量级前端状态管理,15KB -->
    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: #0f0f0f;
            color: #e0e0e0;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }

        .chat-container {
            flex: 1;
            max-width: 800px;
            width: 100%;
            margin: 0 auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
        }

        .messages {
            flex: 1;
            overflow-y: auto;
            display: flex;
            flex-direction: column;
            gap: 16px;
            padding: 20px 0;
        }

        .message {
            max-width: 75%;
            padding: 12px 16px;
            border-radius: 16px;
            line-height: 1.6;
        }

        .message.user {
            align-self: flex-end;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            border-bottom-right-radius: 4px;
        }

        .message.assistant {
            align-self: flex-start;
            background: #1a1a1a;
            border: 1px solid #333;
            border-bottom-left-radius: 4px;
        }

        .message-header {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-bottom: 6px;
            font-size: 12px;
            opacity: 0.7;
        }

        .input-area {
            display: flex;
            gap: 12px;
            padding: 16px;
            background: #1a1a1a;
            border-radius: 16px;
            border: 1px solid #333;
        }

        .input-area textarea {
            flex: 1;
            background: transparent;
            border: none;
            color: #e0e0e0;
            font-size: 15px;
            line-height: 1.5;
            resize: none;
            min-height: 24px;
            max-height: 120px;
        }

        .input-area textarea:focus {
            outline: none;
        }

        .input-area button {
            padding: 8px 20px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            border: none;
            border-radius: 8px;
            color: white;
            cursor: pointer;
            font-weight: 600;
            transition: opacity 0.2s;
        }

        .input-area button:hover {
            opacity: 0.9;
        }

        .input-area button:disabled {
            opacity: 0.5;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
<div class="chat-container"
     hx-ext="sse"
     sse-connect="/api/chat/{{ session.id }}/stream">

    <div class="messages" id="messages">
        {% for msg in messages %}
        <div class="message {{ msg.role }}"
             id="msg-{{ msg.id }}"
             hx-swap-oob="afterend">
            <div class="message-header">
                <span>{% if msg.role == "user" %}👤{% else %}🤖{% endif %}</span>
                <span>{% if msg.role == "user" %}你{% else %}RustGPT{% endif %}</span>
            </div>
            <div class="message-content">{{ msg.content }}</div>
        </div>
        {% endfor %}

        <!-- AI 思考中的占位符(由 SSE 事件触发显示) -->
        <div id="ai-thinking"
             class="message assistant"
             style="display: none;"
             hx-sse="swap event:aiThinking">
            <div class="message-header">
                <span>🤖</span>
                <span>RustGPT</span>
            </div>
            <div class="message-content">
                <span class="thinking-dots">思考中<span>.</span><span>.</span><span>.</span></span>
            </div>
        </div>
    </div>

    <!-- 消息输入表单 -->
    <form class="input-area"
          hx-post="/api/chat/{{ session.id }}"
          hx-target="#messages"
          hx-swap="beforeend"
          hx-disable-elt="#send-btn"
          hx-on::after-request="this.reset(); document.getElementById('ai-thinking').style.display='block'">
        <textarea
            name="message"
            placeholder="输入消息..."
            rows="1"
            required
            onkeydown="if(event.key==='Enter' && !event.shiftKey) { event.preventDefault(); this.closest('form').requestSubmit(); }"
        ></textarea>
        <button type="submit" id="send-btn">发送</button>
    </form>
</div>

<style>
.thinking-dots span {
    animation: blink 1.4s infinite both;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes blink {
    0%, 80%, 100% { opacity: 0; }
    40% { opacity: 1; }
}
</style>
</body>
</html>

3.8 主程序入口

// src/main.rs
mod handlers;
mod models;
mod templates;
mod state;

use axum::{
    routing::{get, post},
    Router,
};
use state::AppState;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())
        .init();

    // 初始化数据库
    let db = sqlx::sqlite::SqlitePoolOptions::new()
        .max_connections(5)
        .connect("sqlite://chat.db")
        .await?;

    // 运行 migrations
    sqlx::migrate!("./migrations").run(&db).await?;

    let state = Arc::new(AppState::new(db));

    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any);

    let app = Router::new()
        .route("/", get(handlers::chat::chat_page))
        .route("/api/chat/:session_id", post(handlers::chat::send_message))
        .route("/api/chat/:session_id/stream", get(handlers::stream::sse_handler))
        .route("/api/sessions", get(handlers::session::list_sessions))
        .route("/api/sessions", post(handlers::session::create_session))
        .layer(cors)
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await?;
    tracing::info!("Server running at http://localhost:3000");

    axum::serve(listener, app).await?;
    Ok(())
}

四、Django + HTMX:企业级 B2B 后台的实战

4.1 项目背景与技术选型

实际项目需求:

  • B2B 后台系统,用户管理、订单处理、数据看板
  • 团队后端 Python,前端能力有限
  • 需要快速迭代,不能在前端投入太多时间

技术栈:

后端:Django 5 + Jinja2 模板
前端:HTMX + Alpine.js + Tailwind CSS
数据库:PostgreSQL
部署:Docker

4.2 多步骤表单的 HTMX 实现

多步骤表单(如活动预订:选日期 → 选时段 → 选票种 → 填信息)是 HTMX 最擅长的场景之一。

# views.py
from django.shortcuts import render
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib import messages

def booking_wizard(request):
    """多步骤预订向导主页"""
    return render(request, 'booking/wizard.html')


@require_http_methods(["POST"])
def booking_step1(request):
    """步骤1:选择日期,返回时段选项片段"""
    date = request.POST.get('date')

    # 验证日期
    if not date:
        return HttpResponse('<p class="error">请选择日期</p>')

    # 获取可用时段
    slots = get_available_slots(date)

    # 返回 HTML 片段(HTMX 会替换 step2 区域)
    return render(request, 'booking/partials/step2_slots.html', {
        'slots': slots,
        'selected_date': date,
    })


@require_http_methods(["POST"])
def booking_step2(request):
    """步骤2:选择时段,返回票种选项"""
    date = request.POST.get('date')
    slot_id = request.POST.get('slot_id')

    if not slot_id:
        return HttpResponse('<p class="error">请选择时段</p>')

    ticket_types = get_ticket_types(slot_id)

    return render(request, 'booking/partials/step3_tickets.html', {
        'ticket_types': ticket_types,
        'date': date,
        'slot_id': slot_id,
    })


@require_http_methods(["POST"])
def booking_submit(request):
    """步骤3:提交预订"""
    date = request.POST.get('date')
    slot_id = request.POST.get('slot_id')
    ticket_type = request.POST.get('ticket_type')
    name = request.POST.get('name')
    email = request.POST.get('email')

    # Django 原生 redirect() 在 HTMX 中有问题!
    # 需要使用 HttpResponseClientRedirect 或设置 HX-Redirect 头

    booking = create_booking(date, slot_id, ticket_type, name, email)

    # ✅ 正确做法:设置 HX-Redirect 头
    # django-htmx 提供了 HttpResponseClientRedirect
    response = HttpResponseClientRedirect(f'/booking/{booking.id}/confirmation')
    response['HX-Redirect'] = f'/booking/{booking.id}/confirmation'
    return response
<!-- templates/booking/wizard.html -->
{% extends "base.html" %}
{% load django_htmx %}

{% block content %}
<div x-data="{ step: 1, date: '', slotId: '', ticketType: '' }">

    <!-- 进度指示器 -->
    <div class="flex justify-center mb-8">
        <template x-for="i in 3" :key="i">
            <div class="flex items-center">
                <div :class="step >= i ? 'bg-indigo-600 text-white' : 'bg-gray-200'"
                     class="w-8 h-8 rounded-full flex items-center justify-center font-bold">
                    <span x-text="i"></span>
                </div>
                <div x-show="i < 3" :class="step > i ? 'bg-indigo-600' : 'bg-gray-200'"
                     class="w-16 h-1"></div>
            </div>
        </template>
    </div>

    <form id="booking-form" class="max-w-lg mx-auto">

        <!-- 步骤1:选日期 -->
        <div x-show="step === 1">
            <label class="block mb-2 font-medium">选择日期</label>
            <input type="date"
                   name="date"
                   x-model="date"
                   hx-post="{% url 'booking_step1' %}"
                   hx-trigger="change"
                   hx-target="#step2-container"
                   hx-swap="innerHTML"
                   class="w-full px-4 py-2 border rounded-lg">

            <!-- 步骤2:时段(HTMX 动态加载) -->
            <div id="step2-container" class="mt-4"></div>
        </div>

        <!-- 步骤3:票种 -->
        <div x-show="step === 2">
            <div id="step3-container"></div>

            <!-- 步骤4:个人信息 -->
            <div id="step4-container" class="mt-4 space-y-4">
                <div>
                    <label>姓名</label>
                    <input type="text" name="name" required class="w-full px-4 py-2 border rounded-lg">
                </div>
                <div>
                    <label>邮箱</label>
                    <input type="email" name="email" required class="w-full px-4 py-2 border rounded-lg">
                </div>
                <button type="submit"
                        hx-post="{% url 'booking_submit' %}"
                        hx-include="#step3-container"
                        hx-swap="none"
                        class="w-full bg-indigo-600 text-white py-3 rounded-lg font-bold">
                    确认预订
                </button>
            </div>
        </div>

    </form>
</div>
{% endblock %}

4.3 HTMX + Django 的坑:重定向问题

这是 Django + HTMX 组合中最容易踩的坑:

问题:Django 的 redirect() 返回 HTTP 302,但 HTMX 默认将其视为 HTML 片段替换,导致页面空白、URL 不变。

错误做法

# ❌ 错误:返回空白页面
from django.shortcuts import redirect

def booking_submit(request):
    booking = create_booking(...)
    return redirect(f'/booking/{booking.id}/confirmation')

正确做法(两种):

# ✅ 方法1:安装 django-htmx,使用 HttpResponseClientRedirect
# pip install django-htmx
from django_htmx.http import HttpResponseClientRedirect

def booking_submit(request):
    booking = create_booking(...)
    return HttpResponseClientRedirect(f'/booking/{booking.id}/confirmation')

# ✅ 方法2:手动设置 HX-Redirect 响应头
def booking_submit(request):
    booking = create_booking(...)
    response = HttpResponse(status=204)  # 204 No Content
    response['HX-Redirect'] = f'/booking/{booking.id}/confirmation'
    response['HX-Redirect-Redisplay'] = 'false'
    return response

五、FastAPI + HTMX:现代 Python 全栈开发

5.1 为什么 FastAPI + HTMX 是绝配

FastAPI 的优势:

  • 异步高性能(与 Node.js 持平)
  • 自动 OpenAPI 文档
  • Pydantic 数据验证
  • Python 生态深度集成

HTMX 的优势:

  • 无需前后端分离
  • 后端直接返回 HTML 片段
  • 渐进增强(没有 JS 也能基本工作)

两者结合:FastAPI 处理 API + 模板渲染,HTMX 处理前端交互,Python 程序员不需要写 JavaScript。

5.2 分页的 HTMX 实现

# main.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
import sqlalchemy as sa

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# 异步数据库
engine = create_async_engine("sqlite+aiosqlite:///data.db")
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


@app.get("/users", response_class=HTMLResponse)
async def list_users(request: Request, page: int = 1, per_page: int = 20):
    """用户列表页面(支持 HTMX 分页)"""
    async with async_session() as session:
        # 总数
        total = await session.scalar(sa.select(sa.func.count()).select_from(User))

        # 分页数据
        offset = (page - 1) * per_page
        result = await session.execute(
            sa.select(User)
            .order_by(User.id.desc())
            .offset(offset)
            .limit(per_page)
        )
        users = result.scalars().all()

        total_pages = (total + per_page - 1) // per_page

        return templates.TemplateResponse("users/list.html", {
            "request": request,
            "users": users,
            "page": page,
            "total_pages": total_pages,
            "per_page": per_page,
        })


@app.get("/users/table-rows", response_class=HTMLResponse)
async def users_table_rows(request: Request, page: int = 1, per_page: int = 20):
    """返回表格行 HTML 片段(HTMX 分页用)"""
    async with async_session() as session:
        offset = (page - 1) * per_page
        result = await session.execute(
            sa.select(User)
            .order_by(User.id.desc())
            .offset(offset)
            .limit(per_page)
        )
        users = result.scalars().all()

        total = await session.scalar(sa.select(sa.func.count()).select_from(User))
        total_pages = (total + per_page - 1) // per_page

        return templates.TemplateResponse("users/table_rows.html", {
            "request": request,
            "users": users,
            "page": page,
            "total_pages": total_pages,
        })
<!-- templates/users/list.html -->
<div id="users-table">
    <!-- 表格行(可被 HTMX 替换) -->
    <tbody id="table-body"
           hx-get="/users/table-rows"
           hx-trigger="userUpdated from:body"
           hx-swap="innerHTML">
        {% include "users/table_rows.html" %}
    </tbody>
</div>

<!-- 分页控件 -->
<div class="flex justify-between items-center mt-4"
     id="pagination"
     hx-get="/users/table-rows"
     hx-target="#table-body"
     hx-swap="innerHTML">

    <div class="text-sm text-gray-600">
        第 {{ page }} / {{ total_pages }} 页,共 {{ total }} 条
    </div>

    <div class="flex gap-2">
        <!-- 上一页 -->
        {% if page > 1 %}
        <button hx-vals='{"page": {{ page - 1 }}}'
                class="px-3 py-1 border rounded hover:bg-gray-100">
            ‹ 上一页
        </button>
        {% endif %}

        <!-- 页码按钮(省略中间页) -->
        {% for p in range(max(1, page-2), min(total_pages+1, page+3)) %}
        <button hx-vals='{"page": {{ p }}}'
                class="px-3 py-1 border rounded {% if p == page %}bg-indigo-600 text-white{% endif %}">
            {{ p }}
        </button>
        {% endfor %}

        <!-- 下一页 -->
        {% if page < total_pages %}
        <button hx-vals='{"page": {{ page + 1 }}}'
                class="px-3 py-1 border rounded hover:bg-gray-100">
            下一页 ›
        </button>
        {% endif %}
    </div>
</div>
<!-- templates/users/table_rows.html -->
{% for user in users %}
<tr class="border-b hover:bg-gray-50">
    <td class="px-4 py-2">{{ user.id }}</td>
    <td class="px-4 py-2">{{ user.name }}</td>
    <td class="px-4 py-2">{{ user.email }}</td>
    <td class="px-4 py-2">
        <button hx-get="/users/{{ user.id }}/edit"
                hx-target="#user-{{ user.id }}"
                hx-swap="outerHTML"
                class="text-indigo-600 hover:underline">
            编辑
        </button>
    </td>
</tr>
{% empty %}
<tr>
    <td colspan="4" class="px-4 py-8 text-center text-gray-500">
        暂无数据
    </td>
</tr>
{% endfor %}

六、HTMX + Alpine.js 组合:轻量级响应式 UI

6.1 为什么需要 Alpine.js

HTMX 只负责服务器通信,不处理客户端状态(如折叠、标签页切换、实时计算)。Alpine.js 补充了这部分能力:

<!-- Alpine.js 处理 UI 状态 -->
<div x-data="{ open: false, tab: 'overview' }">

    <!-- 折叠面板 -->
    <button @click="open = !open" class="accordion-trigger">
        展开详情
    </button>
    <div x-show="open" x-collapse>
        这里是详情内容...
    </div>

    <!-- 标签页 -->
    <div class="tabs">
        <button @click="tab = 'overview'" :class="tab === 'overview' && 'active'">
            概览
        </button>
        <button @click="tab = 'details'" :class="tab === 'details' && 'active'">
            详情
        </button>
    </div>
    <div x-show="tab === 'overview'">概览内容</div>
    <div x-show="tab === 'details'">详情内容</div>

    <!-- 实时计算(不用 JS) -->
    <div x-data="{ quantity: 1, price: 99 }">
        <span>总价:</span>
        <span x-text="quantity * price"></span>
    </div>
</div>

<!-- HTMX 处理服务器通信 -->
<form hx-post="/api/order"
      hx-target="#order-result">
    <input type="number" name="quantity" x-model="quantity">
    <button type="submit">下单</button>
</form>

6.2 完整的 HTMX + Alpine.js 工作流

<div x-data="app()">

    <!-- 搜索 + 实时过滤 -->
    <div class="mb-4">
        <input type="text"
               x-model="searchQuery"
               @input.debounce.300ms="$dispatch('searchChanged', { query: searchQuery })"
               placeholder="搜索..."
               class="w-full px-4 py-2 border rounded-lg">
    </div>

    <!-- HTMX 驱动的列表(自动响应 Alpine 的 searchChanged 事件) -->
    <div hx-get="/api/items"
         hx-trigger="searchChanged from:window"
         hx-vals="javascript:{ query: Alpine.store('searchQuery') }"
         hx-target="#items-list"
         hx-swap="innerHTML">

        <div id="items-list" class="grid gap-4">
            <!-- 内容由 HTMX 动态加载 -->
            <div class="animate-pulse">加载中...</div>
        </div>
    </div>

    <!-- 模态框(Alpine 控制状态,HTMX 填充内容) -->
    <div x-show="$store.modal.open"
         x-transition:enter="transition ease-out duration-300"
         x-transition:enter-start="opacity-0"
         x-transition:enter-end="opacity-100"
         class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">

        <div @click.away="$store.modal.close()"
             class="bg-white rounded-xl p-6 max-w-lg w-full">

            <div id="modal-content"
                 hx-get="/api/modal-content"
                 hx-trigger="load"
                 hx-swap="innerHTML">
                <!-- HTMX 加载模态框内容 -->
            </div>

            <div class="flex justify-end gap-2 mt-4">
                <button @click="$store.modal.close()"
                        class="px-4 py-2 border rounded hover:bg-gray-100">
                    取消
                </button>
                <button @click="$store.modal.confirm()"
                        hx-post="/api/confirm"
                        hx-target="#modal-content"
                        class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">
                    确认
                </button>
            </div>
        </div>
    </div>

</div>

<script>
function app() {
    return {
        searchQuery: '',
        init() {
            Alpine.store('modal', {
                open: false,
                itemId: null,
                open(itemId) {
                    this.itemId = itemId;
                    this.open = true;
                    // 触发 HTMX 加载
                    document.getElementById('modal-content')
                        .setAttribute('hx-vals', JSON.stringify({ id: itemId }));
                    htmx.process(document.getElementById('modal-content'));
                },
                close() { this.open = false; },
                async confirm() {
                    // HTMX 处理 POST 请求
                }
            });
        }
    }
}
</script>

七、性能优化:HTMX 应用的深层调优

7.1 网络请求优化

减少请求体积:使用 HX-Boost 将整个页面转换为 AJAX 请求,避免页面闪烁:

<!-- 替代 <a href="/page"> -->
<a hx-boost="true" href="/page">页面链接</a>

hx-boost="true" 将链接转换为 HTMX AJAX 请求,只替换 <body> 内容,首屏加载减少 60%+。

请求去抖动(Debounce):

<!-- 300ms 防抖,等用户停止输入后再搜索 -->
<input type="text"
       name="search"
       hx-get="/api/search"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#results"
       hx-indicator="#spinner">

请求节流(Throttle,限制频率):

<!-- 最多每 2 秒发一次请求 -->
<div hx-get="/api/live-stats"
     hx-trigger="every 2s"
     hx-swap="morph:settle">

7.2 SSE 连接优化

连接复用:多个 HTMX 元素共享同一个 SSE 连接:

<div hx-sse="connect /api/stream">
    <!-- 通知区域 -->
    <div hx-sse="swap event:notification"
         hx-swap="afterbegin"
         class="fixed top-4 right-4 z-50">
    </div>

    <!-- 在线人数 -->
    <div hx-sse="swap event:onlineCount"
         hx-swap="innerHTML"
         class="online-counter">
    </div>

    <!-- 系统消息 -->
    <div hx-sse="swap event:systemMessage"
         hx-swap="beforeend">
    </div>
</div>

7.3 HTMX 请求指示器

<!-- 请求进行中显示指示器 -->
<button hx-post="/api/submit"
        hx-indicator="#spinner"
        hx-disabled-elt="this">
    提交
    <svg id="spinner"
         class="htmx-indicator w-4 h-4 animate-spin"
         viewBox="0 0 24 24">
        <circle cx="12" cy="12" r="10" stroke="currentColor"
                stroke-width="4" fill="none" opacity="0.25"/>
        <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor"
              stroke-width="4" fill="none" stroke-linecap="round"/>
    </svg>
</button>

<!-- 全局请求指示器 -->
<div id="global-indicator"
     class="htmx-indicator fixed top-4 left-1/2 transform -translate-x-1/2
            bg-indigo-600 text-white px-4 py-2 rounded-full shadow-lg z-50">
    加载中...
</div>

<script>
document.body.addEventListener('htmx:requestStart', () => {
    document.getElementById('global-indicator').classList.remove('htmx-indicator');
});
document.body.addEventListener('htmx:afterRequest', () => {
    document.getElementById('global-indicator').classList.add('htmx-indicator');
});
</script>

八、生产环境避坑清单

坑 1:HTMX + CSRF 保护

Django 等框架需要 CSRF token,HTMX 请求不会自动携带:

<!-- 在表单中包含 CSRF token -->
<form hx-post="/api/submit">
    {% csrf_token %}
    <!-- 或手动添加 -->
    <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
# FastAPI:配置 HTMX 请求的 CSRF 白名单
from fastapi_csrf import CSRFMiddleware

app.add_middleware(
    CSRFMiddleware,
    exempt_routes=["/api/public"],  # 公开接口豁免
)

坑 2:HTMX 请求的超时处理

<!-- 设置 30 秒超时 -->
<button hx-post="/api/long-task"
        hx-timeout="30000"
        hx-error-class="error"
        @hx-error="alert('请求超时,请重试')">
    执行长时间任务
</button>
// 全局超时处理
document.body.addEventListener('htmx:timeout', (event) => {
    event.detail.xhr.abort();
    showToast('网络超时,请检查网络连接', 'error');
});

坑 3:内存泄漏(SSE 连接未关闭)

// 页面切换时断开 SSE 连接
window.addEventListener('beforeunload', () => {
    htmx.removeAllSSEListeners();
});

总结:超媒体驱动为什么是正确方向

回顾整个 HTMX 生态,我们发现一个清晰的趋势:

服务器重新掌控 UI

在 SPA 时代,前端用 JavaScript 从 API 获取 JSON 数据,然后用 React/Vue 组装 UI。这个模式下,服务器退化成了一个"数据存储 + 访问接口"。

HTMX 代表的超媒体驱动则反过来:服务器返回完整的 HTML UI 片段,浏览器只需要渲染。好处是:

  • UI 一致性:服务器永远是真相来源,前端无法绕过业务逻辑
  • 可访问性:HTML 天然支持屏幕阅读器,无需额外 ARIA 编写
  • SEO 友好:每个页面都有完整 HTML,不需要预渲染
  • 开发效率:Python/Rust 程序员用熟悉的模板引擎写 UI,无需 JavaScript

2026 年的今天,前端社区已经对 React 的复杂性产生了倦怠。Next.js App Router 的灾难级 API 变更、Redux 的样板代码地狱、TypeScript 类型体操的认知负荷——这些都在推动开发者寻找更简单的方案。

HTMX 提供了一条路:不要求你放弃服务器端渲染的生产力和可靠性,同时给你足够的前端交互能力。它不是万能药,但它解决了 Web 开发中 80% 的常见交互需求,而代价是从零学习到熟练使用只需要半天。

最好的工具,是那个让你专注于解决业务问题,而不是工具本身的工具。


相关资源

  • HTMX 官方文档:https://htmx.org/
  • HTMX SSE 扩展:https://htmx.org/extensions/server-sent-events/
  • Alpine.js:https://alpinejs.dev/
  • django-htmx:https://django-htmx.readthedocs.io/
  • Rust Axum 框架:https://docs.rs/axum/latest/axum/

标签:HTMX|超媒体|HATEOAS|REST|全栈开发|FastAPI|Django|Rust|Axum|Alpine.js|Server-Sent Events|HTMX实战|现代化Web

推荐文章

什么是Vue实例(Vue Instance)?
2024-11-19 06:04:20 +0800 CST
File 和 Blob 的区别
2024-11-18 23:11:46 +0800 CST
Vue3 结合 Driver.js 实现新手指引
2024-11-18 19:30:14 +0800 CST
一些高质量的Mac软件资源网站
2024-11-19 08:16:01 +0800 CST
Go语言中的mysql数据库操作指南
2024-11-19 03:00:22 +0800 CST
初学者的 Rust Web 开发指南
2024-11-18 10:51:35 +0800 CST
在Vue3中实现代码分割和懒加载
2024-11-17 06:18:00 +0800 CST
PHP来做一个短网址(短链接)服务
2024-11-17 22:18:37 +0800 CST
程序员茄子在线接单