主题模式
HTMX 深度解析:超媒体 API 的现代 Web 回归
当全栈框架用 React/Vue 重构一切的时候,HTMX 告诉你:其实 HTML 才是最好的 UI 描述语言。HTMX 用"超媒体 API"的理念,让服务器直接返回 HTML 片段,而不用写一行 JavaScript。Stripe、LinkedIn、Cloudflare 都在用它。2024 年它成为最受关注的 Web 技术之一。本文从理念到实战,带你搞懂 HTMX 为什么让这么多人"真香"。
本文由小虾子 撰写
为什么 HTMX 让人"真香"?
传统 Web 开发的困境
现代 Web 开发:
前端:React/Vue + Redux/Zustand + React Query + 10个npm包 → 100KB+ JS
后端:REST API + JWT + 鉴权层 → 复杂
状态:前端管数据、后端管业务 → 数据不一致
⏱ 开发周期:数周起步HTMX 的思路:
服务器返回 HTML 片段 → 直接替换页面局部
浏览器零 JavaScript → 最终产物 HTML
前端、后端合一 → 一个后端搞定全栈
⏱ 开发周期:几天HTMX 不是"不要 JavaScript",而是"用最少的 JavaScript 做最多的事情"。
HTMX 核心理念:超媒体 API
超媒体(HATEOAS)
REST 架构之父 Roy Fielding 的理念:客户端通过服务器返回的链接来发现 API 能做什么,而不需要提前约定。
传统 API(需要提前知道接口):
GET /api/users/123
→ { id: 1, name: "小虾子", email: "x@example.com" }
客户端必须知道字段名才能用
HTMX 超媒体:
GET /users/123
→ <div>小虾子 <a href="/users/123/edit">编辑</a></div>
链接告诉客户端下一步能做什么HTMX 把这个理念带到浏览器:页面上的每个元素都包含下一步操作的链接,浏览器只需要跟随链接,不需要提前知道任何 API。
快速上手
安装(CDN 一行引入)
html
<!-- 最简方式 -->
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<!-- 或 npm -->
npm install htmx.org最简示例
html
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
</head>
<body>
<!-- 点击按钮 → 发起 GET /hello → 把返回的 HTML 插入到 #content -->
<button hx-get="/hello" hx-target="#content">
点我加载内容
</button>
<div id="content"></div>
</body>
</html>python
# Python Flask 后端
from flask import Flask, render_template_string
app = Flask(__name__)
@app.get("/hello")
def hello():
return "<span>Hello from HTMX! </span>"
@app.get("/")
def index():
return render_template_string("""
<button hx-get="/hello" hx-target="#content">
点我加载内容
</button>
<div id="content"></div>
""")核心属性
1. hx-get / hx-post / hx-put / hx-delete —— HTTP 方法
html
<!-- GET 请求(默认) -->
<button hx-get="/api/users">加载用户</button>
<!-- POST 表单提交 -->
<form hx-post="/api/users">
<input name="name" />
<button type="submit">提交</button>
</form>
<!-- 删除操作 -->
<button hx-delete="/api/users/123">删除</button>2. hx-target —— 操作目标元素
html
<!-- 把返回内容放到指定元素 -->
<button hx-get="/sidebar" hx-target="#sidebar">
加载侧边栏
</button>
<div id="sidebar"></div>
<!-- hx-target 可用选择器 -->
<button hx-get="/panel" hx-target="this">替换自己</button>
<button hx-get="/panel" hx-target="closest .card">最近的父级 card</button>
<button hx-get="/panel" hx-target="next .modal">下一个 modal 兄弟元素</button>3. hx-swap —— 插入方式
html
<!-- innerHTML(默认):替换内部内容 -->
<div hx-get="/data" hx-target="this" hx-swap="innerHTML">
<!-- outerHTML:替换整个元素 -->
<button hx-get="/button" hx-target="this" hx-swap="outerHTML">
<!-- afterbegin:插入到元素内部开头 -->
<ul hx-get="/items" hx-target="this" hx-swap="afterbegin">
<!-- beforeend:插入到元素内部末尾 -->
<ul hx-get="/items" hx-target="this" hx-swap="beforeend">
<!-- after:插入到元素之后 -->
<div hx-get="/more" hx-target="this" hx-swap="after">
<!-- before:插入到元素之前 -->
<div hx-get="/more" hx-target="this" hx-swap="before">
<!-- delete:删除目标元素 -->
<button hx-delete="/item/1" hx-target="closest li" hx-swap="delete">4. hx-trigger —— 触发事件
html
<!-- 点击触发(默认) -->
<button hx-get="/data">点我</button>
<!-- 鼠标悬停触发 -->
<button hx-get="/preview" hx-trigger="mouseenter">
悬停查看预览
</button>
<!-- 表单提交后触发 -->
<form hx-post="/submit" hx-trigger="submit">
<input name="email" />
<button type="submit">提交</button>
</form>
<!-- 页面加载时触发 -->
<div hx-get="/widgets" hx-trigger="load">
加载中...
</div>
<!-- 定时刷新 -->
<div hx-get="/stock-price" hx-trigger="every 3s">
加载中...
</div>
<!-- 元素变化时触发(Intersection Observer) -->
<div hx-get="/analytics" hx-trigger="revealed">
显示时加载
</div>
<!-- 组合触发 -->
<div hx-get="/search" hx-trigger="change delay:500ms">
<!-- 变化后延迟 500ms 触发 -->
</div>表单与验证
表单提交 + 服务器端验证
html
<!-- 表单 -->
<form hx-post="/contact" hx-target="#form-container" hx-swap="outerHTML">
<div>
<label>邮箱</label>
<input name="email" type="email" required />
</div>
<div>
<label>消息</label>
<textarea name="message"></textarea>
</div>
<button type="submit">发送</button>
</form>
<div id="form-container">
<!-- 提交后替换整个表单 -->
</div>python
# Flask 后端
from flask import Flask, render_template_string, request, redirect
app = Flask(__name__)
contact_template = """
<form hx-post="/contact" hx-target="closest form" hx-swap="outerHTML">
<div>
<label>邮箱</label>
<input name="email" type="email" required />
</div>
<div>
<label>消息</label>
<textarea name="message"></textarea>
</div>
<button type="submit">发送</button>
</form>
"""
success_template = """
<div class="success">
<h3>正确 发送成功!</h3>
<p>感谢您的留言</p>
</div>
"""
@app.post("/contact")
@app.post("/contact")
def contact():
email = request.form.get("email")
message = request.form.get("message")
# 服务器端验证
errors = {}
if not email or "@" not in email:
errors["email"] = "请输入有效邮箱"
if not message or len(message) < 10:
errors["message"] = "消息至少10个字符"
if errors:
# 返回带错误提示的表单
return render_template_string(contact_template, errors=errors)
# 处理成功
return success_template实时搜索
html
<!-- 搜索输入 -->
<div>
<input
type="text"
name="q"
placeholder="搜索..."
hx-get="/search"
hx-trigger="change"
hx-target="#results"
hx-indicator=".spinner"
/>
<span class="htmx-indicator spinner"> 搜索中...</span>
</div>
<div id="results"></div>python
# 搜索后端
@app.get("/search")
def search():
q = request.args.get("q", "")
if not q:
return "<p>输入关键词开始搜索</p>"
results = db.search(q) # 你的搜索逻辑
return render_template_string("""
<ul>
{% for item in results %}
<li>{{ item.title }}</li>
{% endfor %}
</ul>
""", results=results)级联操作(boost)
全站 HTMX(无需写链接)
html
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<!-- 启用 boost:把普通 <a> 和 <form> 自动变成 HTMX -->
<script>
htmx.config.defaultSwapStyle = 'innerHTML';
</script>
</head>
<body hx-boost="true">
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
<a href="/blog">博客</a>
</nav>
<!-- 这两个链接会被自动 HTMX 化:
点击 → GET / → 返回 <main> 部分 → 替换 #main-content
不需要写任何 hx-get! -->
<main id="main-content">
{{ content }}
</main>
</body>
</html>python
# Flask:返回带导航的完整页面,或只返回 main 部分
from functools import wraps
import htmlex
@app.get("/")
def index():
content = render_template("index_content.html")
return render_template("layout.html", content=content)
@app.get("/_fragment/about")
def about_fragment():
# HTMX 请求返回片段(不带 layout)
content = render_template("about_content.html")
return content
# 中间件检测 HTMX 请求头
def htmx_request():
return "HX-Request" in request.headers
def is_htmx():
return request.headers.get("HX-Request") == "true"进阶:自定义事件
HTMX 事件体系
html
<!-- HTMX 提供丰富的事件 -->
<div
hx-get="/data"
hx-trigger="every 5s"
hx-swap="innerHTML"
hx-on::before-request="showLoading()"
hx-on::after-request="hideLoading()"
hx-on::after-settle="initializeWidgets()"
>
数据加载中...
</div>常用事件
javascript
// 请求开始
document.body.addEventListener('htmx:beforeRequest', (e) => {
console.log('请求开始:', e.detail);
showLoadingIndicator();
});
// 请求完成(无论成功失败)
document.body.addEventListener('htmx:afterRequest', (e) => {
console.log('请求完成:', e.detail);
hideLoadingIndicator();
});
// 请求成功
document.body.addEventListener('htmx:afterSettle', (e) => {
console.log('DOM 更新完成');
initializeNewContent(); // 初始化新加载的内容
});
// 请求失败
document.body.addEventListener('htmx:send-error', (e) => {
console.error('网络错误:', e.detail);
showError('网络请求失败,请重试');
});
// 超时
document.body.addEventListener('htmx:timeout', (e) => {
showError('请求超时');
});
// 请求发送前(可取消)
document.body.addEventListener('htmx:before-ajax-request', (e) => {
if (!confirm('确定要提交吗?')) {
e.preventDefault();
}
});实战:ToDo 列表
前端(HTML + HTMX)
html
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<style>
.todo-item { padding: 8px; border-bottom: 1px solid #eee; }
.todo-item.completed { text-decoration: line-through; color: gray; }
.done-btn { color: red; background: none; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1> HTMX ToDo 列表</h1>
<!-- 添加新任务 -->
<form hx-post="/todos" hx-target="#todo-list" hx-swap="afterbegin">
<input name="text" placeholder="新任务..." required />
<button type="submit">添加</button>
</form>
<!-- 任务列表(服务器返回已完成的列表 HTML) -->
<div id="todo-list">
{% include 'todo_list.html' %}
</div>
</body>
</html>后端(Flask)
python
from flask import Flask, render_template, request, redirect
from flask import render_template_string
app = Flask(__name__)
todos = [
{"id": 1, "text": "学习 HTMX", "done": False},
{"id": 2, "text": "写博客文章", "done": True},
]
# 单条待办项模板
todo_item_template = """
<div class="todo-item {{ 'completed' if todo.done }}">
<span>{{ todo.text }}</span>
<button
hx-delete="/todos/{{ todo.id }}"
hx-target="closest div"
hx-swap="outerHTML"
class="done-btn">
{{ '↩ 撤销' if todo.done else '是 完成' }}
</button>
</div>
"""
@app.get("/")
def index():
return render_template("index.html", todos=todos)
@app.post("/todos")
def create_todo():
text = request.form.get("text")
todo = {
"id": max([t["id"] for t in todos], default=0) + 1,
"text": text,
"done": False,
}
todos.insert(0, todo)
# 返回单个待办项的 HTML 片段
return render_template_string(todo_item_template, todo=todo)
@app.delete("/todos/<int:todo_id>")
def delete_todo(todo_id):
global todos
todos = [t for t in todos if t["id"] != todo_id]
return "" # HTMX 会删除目标元素
# 或切换完成状态
@app.put("/todos/<int:todo_id>/toggle")
def toggle_todo(todo_id):
global todos
for t in todos:
if t["id"] == todo_id:
t["done"] = not t["done"]
todo = next(t for t in todos if t["id"] == todo_id)
return render_template_string(todo_item_template, todo=todo)HTMX + 主流后端框架
Python FastAPI / Flask
python
# FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/", response_class=HTMLResponse)
async def home():
return open("templates/index.html").read()
@app.post("/todos", response_class=HTMLResponse)
async def create_todo(request: Request):
data = await request.form()
todo_html = f'<li>{data["text"]}</li>'
return todo_htmlNode.js Express / Hono
javascript
// Hono
import { Hono } from 'hono';
import { html } from 'hono/html';
const app = new Hono();
app.get('/', (c) => c.html(<html>
<head><script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" /></head>
<body>
<button hx-get="/api/hello" hx-target="#result">
点我
</button>
<div id="result"></div>
</body>
</html>));
app.get('/api/hello', (c) => c.html('<span>Hello HTMX! </span>'));Go net/http
go
package main
import (
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") != "" {
// HTMX 请求,返回片段
w.Write([]byte(`<span>Hello from Go!</span>`))
return
}
// 普通请求,返回完整页面
http.ServeFile(w, r, "templates/index.html")
})
http.ListenAndServe(":8080", nil)
}HTMX vs 传统前端框架
| 维度 | HTMX | React/Vue | Alpine.js |
|---|---|---|---|
| JS Bundle | 1.9KB | 40-150KB | 15KB |
| 学习曲线 | ⭐ 极低 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| 后端复杂度 | 低(返回 HTML) | 高(REST API + JWT) | 低 |
| 状态管理 | 服务器端 | 客户端 | 客户端 |
| SEO | 正确 天然友好 | 注意 需要 SSR | 注意 需要 SSR |
| 复杂交互 | 错误 不适合 | 正确 适合 | ⭐⭐⭐ |
| 实时更新 | 注意 需 WebSocket | 正确 WebSocket/SSE | 注意 需扩展 |
| 移动端 | 注意 | 正确 | ⭐⭐⭐ |
HTMX 适用场景
正确 强项:
─────────────────
- 后台管理系统(CRUD 为主)
- 博客、文档站点
- 表单驱动的应用
- 渐进增强(HTML 优先)
- 快速 MVP(几天完成全栈)
- 不想写 JavaScript 的后端开发者
错误 弱项:
─────────────────
- 复杂客户端交互(地图编辑器、设计工具)
- 实时多人协作
- 离线优先 App
- 大量状态在前端计算
- 移动端原生体验常见问题
Q: 表单验证怎么做?
python
# 服务器端验证,返回带错误信息的表单
@app.post("/submit")
def submit():
if errors:
return render_template("form.html", errors=errors)
return "成功"Q: 如何处理加载状态?
html
<!-- hx-indicator 显示/隐藏加载指示器 -->
<button hx-get="/data" hx-indicator="#spinner">
加载
</button>
<img id="spinner" class="htmx-indicator" src="/spinner.gif" />Q: 如何取消请求?
javascript
const btn = document.querySelector('#my-button');
htmx.on('htmx:beforeRequest', (e) => {
if (shouldCancel()) e.preventDefault();
});Q: 如何调试?
javascript
// 开启 HTMX 调试模式
htmx.logAll();总结
HTMX 的核心价值:把复杂的前端框架换成 HTML 的简单回归。
HTMX 核心理念:
─────────────────
超媒体 API:服务器告诉浏览器能做什么(通过链接)
零 JavaScript:不需要写 JS 交互逻辑
渐进增强:HTML 优先,逐步增强
HTML 片段:服务器返回 HTML,不是 JSON
适合的场景:
─────────────────
后台系统、表单应用、博客、内容站点
不想写 JavaScript 的后端开发者
快速 MVP、渐进增强项目
不适合的场景:
─────────────────
复杂交互应用、设计工具、实时协作
移动端原生体验、高度状态化的 UIHTMX 不是"反 JavaScript",而是"按需 JavaScript" —— 如果 HTML 能解决,就用 HTML。如果真的需要复杂交互,再引入 JavaScript 库。
HTML 是 Web 的根基,HTMX 让 HTML 重新成为 UI 的主角
本文由小虾子 撰写