
React 太重了?用 Python + HTMX 快速构建内部工具的实战教程
本文通过一个完整的实战项目,带你从零开始用 FastAPI + Jinja2 + HTMX 构建一个内部管理面板,无需构建工具、无需 API 层、无需前端状态管理
原文来源:DEV Community: React is Overkill – Why Python + HTMX is Dominating in 2026 — 一个内部仪表盘从 React SPA 迁移到 FastAPI + HTMX 的经验分享
为什么 2026 年还要谈一个 14KB 的前端库?
如果你已经厌倦了每次开新项目都要经历「安装依赖 → 再装依赖的依赖 → 配置工具链 → 看 npm 下载全球 GDP」的过程,HTMX 可能会让你重新感受 Web 开发的原始快乐。
HTMX(GitHub,40k+ stars)是一个只有 ~14KB 的 JavaScript 库,它的核心理念可以用一句话说清楚:给 HTML 元素添加发起 HTTP 请求的能力,然后用服务器返回的 HTML 片段直接更新页面。
没有 Virtual DOM,没有组件生命周期,没有客户端路由,没有状态管理库需要争论。你只需要在 input 上写 hx-get="/search" 和 hx-trigger="keyup changed delay:300ms",搜索结果 div 就会自动更新。
这套理念在 2026 年重新获得了大量关注。不是因为它新,而是因为很多人开始意识到:SPA 时代解决了一些实际问题,但也带来了大量的"附带复杂性"(accidental complexity)——尤其对于那些功能需求并不复杂的内部工具和 CRUD 应用。
本教程将带你从头构建一个完整的内部管理面板,涵盖搜索、分页、编辑、删除等常见功能,全程不需要写一行前端 JavaScript。
—— 广告 ——
第一步:项目初始化
首先,确保你安装了 Python 3.10+ 和 pip:
mkdir htmx-admin-demo
cd htmx-admin-demo
python3 -m venv venv
source venv/bin/activate # Windows 用 venv\Scripts\activate安装依赖。我们只需要四个包:
pip install fastapi uvicorn jinja2 python-multipart httpx- FastAPI:高性能 Python Web 框架,支持异步,自动 OpenAPI 文档
- Uvicorn:ASGI 服务器,运行 FastAPI 用
- Jinja2:Python 最流行的模板引擎,和 Flask/Django 用的同一个
- python-multipart:处理表单提交需要
- httpx:用来在服务端请求外部 API(非必须,但后面会用到)
然后创建一个简单目录结构:
mkdir templates static第二步:FastAPI 应用骨架
创建 main.py:
from fastapi import FastAPI, Request
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")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(
"index.html", {"request": request, "title": "HTMX Admin Demo"}
)创建 templates/base.html 作为基础模板,所有页面共用 layout:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="sidebar">
<h2>管理面板</h2>
<ul>
<li><a href="/">仪表盘</a></li>
<li><a href="/users">用户管理</a></li>
<li><a href="/orders">订单管理</a></li>
</ul>
</nav>
<main id="content">
{% block content %}{% endblock %}
</main>
</body>
</html>创建 templates/index.html:
{% extends "base.html" %}
{% block content %}
<div class="dashboard">
<h1>仪表盘</h1>
<div class="stats-grid" hx-get="/api/stats" hx-trigger="load" hx-swap="innerHTML">
<div class="stat-card">加载中...</div>
</div>
</div>
{% endblock %}注意到这里了吗?hx-get="/api/stats" hx-trigger="load"——页面的统计数据是通过 HTMX 在页面加载时异步请求获取的,不需要你写任何 fetch() 或 axios 调用。
第三步:模拟数据层
作为示例,我们使用一个简单的内存数据存储。实际项目中你可以换成 SQLite、PostgreSQL 或其他数据库。
创建 data.py:
from dataclasses import dataclass, field
from typing import List, Optional
import random
@dataclass
class User:
id: int
name: str
email: str
role: str
status: str
@dataclass
class Order:
id: int
user_id: int
product: str
amount: float
status: str
# 模拟数据
users_db: List[User] = [
User(1, "张三", "zhangsan@example.com", "管理员", "活跃"),
User(2, "李四", "lisi@example.com", "编辑", "活跃"),
User(3, "王五", "wangwu@example.com", "用户", "非活跃"),
User(4, "赵六", "zhaoliu@example.com", "编辑", "活跃"),
User(5, "钱七", "qianqi@example.com", "用户", "活跃"),
]
orders_db: List[Order] = [
Order(1, 1, "企业版许可证", 999.00, "已完成"),
Order(2, 2, "基础版许可证", 299.00, "处理中"),
Order(3, 3, "高级支持服务", 599.00, "已完成"),
Order(4, 1, "额外存储空间", 199.00, "已取消"),
]
def get_stats():
"""返回仪表盘统计数据"""
return {
"total_users": len(users_db),
"active_users": sum(1 for u in users_db if u.status == "活跃"),
"total_orders": len(orders_db),
"total_revenue": sum(o.amount for o in orders_db if o.status != "已取消"),
}
def search_users(query: str) -> List[User]:
"""搜索用户(模糊匹配)"""
if not query:
return users_db
q = query.lower()
return [u for u in users_db
if q in u.name.lower() or q in u.email.lower()]
def update_user_status(user_id: int, status: str) -> Optional[User]:
"""更新用户状态"""
for u in users_db:
if u.id == user_id:
u.status = status
return u
return None第四步:API 路由(纯 HTML 片段)
这是 HTMX 模式的核心——所有 API 返回 HTML 片段而非 JSON:
from fastapi import Form, Query
from data import get_stats, search_users, update_user_status, users_db
@app.get("/api/stats", response_class=HTMLResponse)
async def stats(request: Request):
stats = get_stats()
return templates.TemplateResponse(
"partials/stats.html",
{"request": request, "stats": stats}
)
@app.get("/api/users", response_class=HTMLResponse)
async def users_list(
request: Request,
page: int = Query(1, ge=1),
search: str = Query("", max_length=100),
):
results = search_users(search)
per_page = 5
total_pages = max(1, (len(results) + per_page - 1) // per_page)
page = min(page, total_pages)
start = (page - 1) * per_page
end = start + per_page
page_users = results[start:end]
return templates.TemplateResponse(
"partials/user_table.html",
{
"request": request,
"users": page_users,
"page": page,
"total_pages": total_pages,
"search": search,
}
)
@app.post("/api/users/{user_id}/toggle-status", response_class=HTMLResponse)
async def toggle_user_status(request: Request, user_id: int):
user = update_user_status(user_id, "非活跃" if users_db[user_id-1].status == "活跃" else "活跃")
return templates.TemplateResponse(
"partials/user_row.html",
{"request": request, "user": user}
)第五步:HTMX 模板片段
创建 templates/partials/stats.html:
<div class="stat-card">
<h3>总用户</h3>
<p class="stat-value">{{ stats.total_users }}</p>
</div>
<div class="stat-card">
<h3>活跃用户</h3>
<p class="stat-value">{{ stats.active_users }}</p>
</div>
<div class="stat-card">
<h3>总订单</h3>
<p class="stat-value">{{ stats.total_orders }}</p>
</div>
<div class="stat-card">
<h3>总收入</h3>
<p class="stat-value">¥{{ "%.2f"|format(stats.total_revenue) }}</p>
</div>创建 templates/partials/user_table.html:
<div class="table-controls">
<input
type="text"
name="search"
placeholder="搜索用户..."
hx-get="/api/users"
hx-trigger="keyup changed delay:300ms"
hx-target="#user-table"
hx-swap="outerHTML"
value="{{ search }}"
/>
</div>
<table id="user-table">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr id="user-{{ user.id }}">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>{{ user.status }}</td>
<td>
<button
hx-post="/api/users/{{ user.id }}/toggle-status"
hx-target="#user-{{ user.id }}"
hx-swap="outerHTML"
class="btn-{{ 'danger' if user.status == '活跃' else 'success' }}"
>
{{ '停用' if user.status == '活跃' else '启用' }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if total_pages > 1 %}
<div class="pagination">
{% for p in range(1, total_pages + 1) %}
<button
class="page-btn {% if p == page %}active{% endif %}"
hx-get="/api/users?page={{ p }}&search={{ search }}"
hx-target="#user-table"
hx-swap="outerHTML"
>{{ p }}</button>
{% endfor %}
</div>
{% endif %}创建 templates/partials/user_row.html:
<tr id="user-{{ user.id }}">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>{{ user.status }}</td>
<td>
<button
hx-post="/api/users/{{ user.id }}/toggle-status"
hx-target="#user-{{ user.id }}"
hx-swap="outerHTML"
class="btn-{{ 'danger' if user.status == '活跃' else 'success' }}"
>
{{ '停用' if user.status == '活跃' else '启用' }}
</button>
</td>
</tr>第六步:用户管理页面
创建 templates/users.html:
{% extends "base.html" %}
{% block content %}
<div class="users-page">
<h1>用户管理</h1>
<div
id="user-table"
hx-get="/api/users"
hx-trigger="load"
hx-swap="outerHTML"
>
加载中...
</div>
</div>
{% endblock %}在 main.py 中添加路由:
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
return templates.TemplateResponse(
"users.html", {"request": request, "title": "用户管理"}
)第七步:添加一些基本的 CSS
创建 static/style.css:
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: #1a1a2e;
color: white;
padding: 20px;
}
.sidebar ul { list-style: none; padding: 0; }
.sidebar a {
color: #a0a0c0;
text-decoration: none;
display: block;
padding: 8px 12px;
border-radius: 6px;
}
.sidebar a:hover { background: #16213e; color: white; }
main { flex: 1; padding: 24px; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-card h3 { margin: 0; font-size: 14px; color: #666; }
.stat-value { font-size: 28px; font-weight: bold; margin: 8px 0 0; }
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th, td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
th { background: #f8f9fa; font-weight: 600; }
.btn-danger { background: #e74c3c; }
.btn-success { background: #27ae60; }
button {
color: white;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
}
button:hover { opacity: 0.9; }
.table-controls { margin-bottom: 12px; }
.table-controls input {
padding: 8px 12px;
width: 300px;
border: 1px solid #ddd;
border-radius: 6px;
}
.pagination {
display: flex;
gap: 6px;
margin-top: 16px;
}
.page-btn {
background: #f0f0f0;
color: #333;
}
.page-btn.active { background: #3498db; color: white; }第八步:运行你的应用
在项目根目录执行:
uvicorn main:app --reload --port 8000打开浏览器访问 http://localhost:8000,你就应该能看到一个带搜索、分页、状态切换功能的管理面板了。
现在数一数你写了多少行 JavaScript?一行都没有。
HTMX 的核心机制详解
现在我们已经有了一个能跑的应用,让我们深入理解 HTMX 是如何工作的。
hx-get、hx-post、hx-put、hx-delete
这些属性告诉 HTMX 用哪种 HTTP 方法向指定 URL 发起请求:
<button hx-get="/api/users">获取用户</button>
<form hx-post="/api/users" hx-target="#result">提交用户</form>hx-trigger
定义触发请求的事件:
load:页面加载时自动触发click、mouseenter:标准 DOM 事件keyup changed delay:300ms:输入后 300ms 无变化才触发(搜索防抖)every 5s:每 5 秒自动轮询
hx-target 和 hx-swap
hx-target:指定将返回的 HTML 插入到哪里(CSS 选择器)hx-swap:指定插入方式innerHTML:替换目标元素的内部内容outerHTML:替换目标元素本身beforebegin/afterbegin/beforeend/afterend:在目标元素的四周插入none:仅执行请求,不修改页面
hx-trigger 的高级用法
HTMX 还支持很多高级的触发方式:
<!-- 滚动到可见区域时触发 -->
<div hx-get="/api/load-more" hx-trigger="revealed">
<!-- 无限滚动内容 -->
</div>
<!-- 定期轮询 -->
<div hx-get="/api/notifications" hx-trigger="every 10s">
<!-- 通知栏 -->
</div>
<!-- 多事件 -->
<input hx-get="/search" hx-trigger="keyup, search">什么时候用 HTMX,什么时候用 React
HTMX 并非万能药。它能出色地处理大部分 CRUD 应用:
HTMX 的优势场景:
- 内部管理工具(仪表盘、CRM、ERP)
- 内容管理系统
- 电子商务后台
- 博客和文档站点
- 仅需简单交互的落地页
- 预算有限、快速交付的个人项目
React/SPA 依然必要的场景:
- 富客户端交互应用(Figma、Notion 这类)
- 协作编辑(Google Docs 模式)
- 复杂数据可视化
- 桌面级应用体验
- 离线优先的 PWA
关键在于:大多数实际应用都属于第一类。 大部分创业项目在前 18 个月需要的只是一个能用的 CRUD 应用加上一点搜索和筛选。HTMX 可以在几天内交付它,而一个 React SPA 可能需要几周。
性能与部署
HTMX 应用在生产环境中的表现:
- 初始加载更快:服务端渲染 HTML,首屏无需等待 JS 下载、解析和执行
- 更小的 bundle:HTMX 本身 ~14KB (gzipped ~6KB),对比 React + ReactDOM ~42KB
- 更少的网络请求:SPA 通常需要 3-5 次往返加载(HTML → JS → API 数据),HTMX 可以在一次请求中返回完整页面片段
- 内存占用更低:没有 Virtual DOM 维护开销,适合低端设备
部署方式和传统后端应用一样简单。以 Python 应用为例:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]配合 Nginx 反向代理即可上线。不需要 Node.js 构建服务器、不需要 CDN 部署前端、不需要处理 CORS——只有一个应用。
局限性你要知道
公平起见,HTMX 也有它的短板:
- 不适合高度交互的 UI——拖拽、复杂动画、富文本编辑器这些还是需要原生 JS 或专门的库
- 服务器开销增加——每次交互都走服务器,对后端性能要求更高
- SEO 不需要额外处理——因为是服务端渲染,天然对搜索引擎友好,但这本身不是问题
- 前端生态工具不能用——React DevTools、Vue DevTools 这种调试工具派不上用场
但这些局限性对内部工具和小型应用来说几乎不是问题。
总结
2026 年,HTMX 的流行不是对"古老技术"的怀旧,而是对"过度工程化"的理性反思。当你的应用只是一个管理员面板 + 几个 CRUD 页面时,没有必要把 12 个依赖文件和 300KB 的 JS bundle 压在用户身上。
Python + HTMX 的组合让我们重新体验到 Web 开发的快感:写服务端代码 → 渲染 HTML → 前端直接展示。没有构建步骤,没有 node_modules,没有 API 层臃肿,没有状态管理噩梦。
如果你还没有试过,今晚花两小时跑一下这个教程的示例。你可能会惊讶地发现:原来构建一个可用的 Web 应用可以这么简单。
© 2026 四月 · CC BY-NC-SA 4.0
原文链接:https://www.aprilzz.com/tutorials/python-htmx-practical-tutorial
相关文章
Bash4LLM⁺ 使用教程:用纯 Bash 脚本优雅调用 LLM API
Bash4LLM⁺ 是一个纯 Bash 编写的 LLM API 包装器,无需 Python/Node.js,单脚本即可调用 Groq 等提供商的 Chat Completions API
Pyodide 314.0 发布:Python 包可直接发布 WebAssembly wheels 到 PyPI
Pyodide 314.0 迎来里程碑式更新:PEP 783 被正式采纳,Python 包维护者现在可以将 Emscripten 平台的 WebAssembly wheels 直接发布到 PyPI,无需经过 Pyodide 核心团队的手动构建。
pytest 实战指南:从基础到高效测试的完整工作流
面向 Python 开发者的 pytest 使用指南,从基础 fixture 管理到高级插件生态,涵盖测试组织、参数化、mock 技巧和 CI 集成