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)
选型理由:
- Axum:Rust 异步 Web 框架事实标准,组合式 API,性能极强
- Askama:编译时模板引擎,零运行时依赖,性能比 Tera 快 2-5 倍
- 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