HTMX 实战:用超媒体构建现代 Web 应用
HTMX 不是另一个前端框架,它是在告诉你过去十年我们可能绕了一条远路。
我用了三年 React 写后台管理,越来越觉得哪里不对——明明只是增删改查,却要搭 Vite、装 Redux、配路由、写 TypeScript 类型、维护一套 API 文档,然后前端再渲染一遍后端已经渲染过的数据。
去年我把一个中型后台项目用 htmx 重写了前端,代码量减少了约 65%,开发周期从预估的三周压缩到一周半。这篇不是什么高谈阔论,是我带着 htmx 从头到尾写完一个真实项目的完整记录。踩过的坑、填过的洞,都会提到。
为什么要关注 HTMX
htmx 的核心主张很简单:通过 HTML 属性直接在标记语言中发起 AJAX 请求、触发 WebSocket、使用 SSE(服务器推送事件),然后用服务器的 HTML 片段更新页面。
听起来像回到 2005 年?恰恰相反。
<!-- 一个典型的 htmx 按钮 -->
<button hx-get="/api/users" hx-target="#user-list" hx-trigger="click">
加载用户列表
</button>
<div id="user-list"></div>
六行 HTML,完成了一次 AJAX 请求并将响应渲染到指定元素。直观感受就是:我不需要再写一行 JavaScript 来实现常见交互了。
选型的时候我主要想了三件事:团队上手成本、改造成本、长期维护。htmx 在这三个问题上的表现都让我意外。尤其第一点——我让一个写 Python 的同事看了一遍文档首页,15 分钟后他就在 Django 模板里写出了分页组件。
项目选型与基础搭建
我的项目是一个 B2B 后台系统,核心功能包括用户管理、订单处理、数据看板。
技术栈
后端:FastAPI + Jinja2 模板 + SQLAlchemy
前端:htmx + Alpine.js(仅对需要复杂交互的模块)+ Tailwind CSS
构建:无需构建工具,直接 CDN 引入 htmx
没错,没有构建步骤。生产环境直接引用 jsDelivr 的 CDN 版本。
<!-- 需要引入的就这一个文件 -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@1.9.10/dist/htmx.min.js">
</script>
Jinja2 模板作为页面渲染引擎,后端返回完整 HTML 片段,htmx 负责将这些片段插入到页面的指定位置。Alpine.js 只在前端需要维护复杂状态的地方使用,比如多步骤的配置表单。
目录结构
templates/
components/ # 可复用的 HTML 组件片段
pages/ # 完整页面
layouts/ # 布局模板
static/
css/ # Tailwind 产物(一次构建,后续不碰)
js/ # 只有 Alpine.js 和页面级脚本
后端 API 返回的不再是 JSON,而是 HTML 片段。一开始我对"后端直接返回 HTML"有顾虑,但仔细一想:后端本来就是渲染 HTML 的,为什么要多包一层 JSON 再在前端解析一遍?
实战:增删改查的渐进增强
1. 条件查询 + 结果刷新
先看一个最常见的场景:筛选条件 + 表格结果,每次选择条件重新请求。
<!-- 筛选区域 -->
<form hx-get="/orders" hx-target="#order-table" hx-trigger="change"
hx-push-url="true">
<select name="status">
<option value="">全部</option>
<option value="pending">待处理</option>
<option value="shipped">已发货</option>
<option value="completed">已完成</option>
</select>
<input type="text" name="keyword" placeholder="订单号/客户名"
hx-trigger="keyup changed delay:500ms"
hx-target="#order-table"
hx-get="/orders">
<button type="submit">筛选</button>
</form>
<!-- 表格结果 -->
<div id="order-table">
{% include "components/order_table.html" %}
</div>
注意:
hx-trigger="keyup changed delay:500ms"实现了防抖——用户停止输入 500ms 后自动发起请求,不需要敲回车或点搜索按钮。
后端 FastAPI 的处理也很直接:
@app.get("/orders")
async def get_orders(request: Request, status: str = "", keyword: str = "",
page: int = 1, hx_request: Optional[str] = Header(None)):
# 查询逻辑
orders = order_service.query(status=status, keyword=keyword, page=page)
# 判断是否是 htmx 请求
if hx_request:
# 只返回表格部分
return templates.TemplateResponse(
"components/order_table.html",
{"orders": orders, "page": page}
)
# 完整页面请求
return templates.TemplateResponse(
"pages/orders.html",
{"orders": orders, "page": page}
)
2. 行内编辑:不用 Modal 的优雅方案
表格里最常见的操作就是编辑单行。传统做法是弹出一个 Modal,用户改完再提交。htmx 的做法更直接:把当前行变成编辑状态。
<!-- 显示模式 -->
<tr id="user-row-{{ user.id }}">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>
<button hx-get="/users/{{ user.id }}/edit"
hx-target="#user-row-{{ user.id }}"
hx-swap="outerHTML">
编辑
</button>
<button hx-delete="/users/{{ user.id }}"
hx-target="#user-row-{{ user.id }}"
hx-swap="outerHTML"
hx-confirm="确定删除该用户?">
删除
</button>
</td>
</tr>
点击编辑后,后端返回编辑状态的行 HTML:
<!-- 编辑模式(由后端 /users/{id}/edit 返回) -->
<tr id="user-row-{{ user.id }}">
<form hx-put="/users/{{ user.id }}"
hx-target="#user-row-{{ user.id }}"
hx-swap="outerHTML">
<td><input name="name" value="{{ user.name }}"></td>
<td><input name="email" value="{{ user.email }}"></td>
<td>
<select name="role">
<option value="admin">管理员</option>
<option value="user">普通用户</option>
</select>
</td>
<td>
<button type="submit">保存</button>
<button hx-get="/users/{{ user.id }}/row"
hx-target="#user-row-{{ user.id }}"
hx-swap="outerHTML">取消</button>
</td>
</form>
</tr>
行内编辑的精髓在于让后端负责"该渲染什么",前端不需要维护编辑状态。这就是超媒体理念——状态在服务器,前端只是状态的呈现层。
3. 弹窗与确认:用 HX-Trigger 做事件联动
有些场景需要跨区域联动。比如删除订单后,需要同时更新操作日志区域和统计数字。
# 删除成功后,通过响应头触发两个事件
@app.delete("/orders/{order_id}")
async def delete_order(order_id: int, response: Response):
order_service.delete(order_id)
response.headers["HX-Trigger"] = json.dumps({
"order-deleted": {"order_id": order_id},
"stats-updated": {}
})
return "" # 删除后不返回内容
在前端监听事件:
<!-- 订单表格 -->
<div id="order-table" hx-get="/orders" hx-trigger="order-deleted from:body">
表格内容
</div>
<!-- 统计看板 -->
<div id="stats-panel" hx-get="/dashboard/stats" hx-trigger="stats-updated from:body">
统计数据
</div>
删除一个订单,表格自动刷新,统计数字自动更新,不需要写任何 JavaScript 来手动关联。
踩坑记录: 一开始我用了
HX-Trigger的字符串形式(hx-trigger="order-deleted"),发现事件只在当前元素上触发。需要改用from:body或者把事件冒泡到 document 级别才能全局监听。htmx 官方推荐在 trigger 名上做命名空间规划,避免冲突。
踩坑实录:这些问题文档里没写
坑一:分页查询的 URL 同步
当用户翻到第 3 页并筛选了状态为"待处理"时,如果刷新页面,应该保持这个状态。htmx 支持 hx-push-url 来更新浏览器地址栏,但需要后端正确渲染初始状态的页面。
我的解决方案是在模板中读取当前请求参数,渲染到 htmx 属性中:
<!-- 分页组件 -->
<div class="pagination" hx-target="#order-table" hx-push-url="true">
{% for p in range(1, total_pages + 1) %}
<a href="/orders?page={{ p }}&status={{ current_status }}"
hx-get="/orders?page={{ p }}&status={{ current_status }}"
class="{% if p == current_page %}active{% endif %}">
{{ p }}
</a>
{% endfor %}
</div>
坑二:大响应体下的性能问题
列表页加载 200 行数据时,响应体大概 80KB,htmx 解析和 DOM 替换有明显的卡顿。排查发现两个问题:
- 后端返回了不必要的字段(比如大段的备注文本)
- 每次请求都重新渲染了整张表格
修复:
# 只返回列表需要的字段(查询时 select 指定的列)
rows = db.query(Order.id, Order.order_no, Order.status, Order.amount).all()
# 返回模板时使用更简洁的片段
return templates.TemplateResponse("components/order_table_rows.html", ...)
另外,超过 100 行的数据我都加了分页。不要试图一次渲染 500 行数据,htmx 不是 React Virtualized。
坑三:文件上传的缓存问题
<form hx-put="/users/{{ user.id }}/avatar"
hx-target="#avatar-preview"
hx-encoding="multipart/form-data"
enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*">
<button type="submit">上传</button>
</form>
上传成功后,页面上的头像预览 URL 指向一个静态文件路径。由于浏览器强缓存,预览不会更新。解决方案是在 URL 后加版本戳:
# 后端返回头像 URL 时加上时间戳
avatar_url = f"/static/avatars/{user.id}.jpg?v={int(time.time())}"
与 Alpine.js 的协同:有限度的前端状态
htmx 不擅长的事情:复杂多步表单的临时状态管理。比如一个订单创建流程需要三步,用户可以在三步之间前进后退而数据不丢失。这种场景下,让后端维护每一步的状态反而更复杂。
我的做法是用 Alpine.js 管理这种局部状态:
<div x-data="{
step: 1,
formData: {
customer: null,
items: [],
shipping: {}
}
}">
<!-- 第一步:选择客户 -->
<div x-show="step === 1">
<input type="text" x-model="formData.customer"
hx-get="/customers/search"
hx-target="#customer-results"
hx-trigger="keyup changed delay:300ms">
</div>
<!-- 第二步:选择商品 -->
<div x-show="step === 2">
<!-- ... -->
</div>
<!-- 最后一步才提交 -->
<button @click="step === 3 ? submitOrder() : step++"
hx-post="/orders/create"
hx-include="this"
hx-vals='{"data": "formData"}'>
下一步
</button>
</div>
基本原则: Alpine.js 管状态(表单填写、步骤切换),htmx 管交互(查询、提交、更新)。各自管好自己的事。
实测对比:三组关键数据
项目上线运行两个月后,我拉了些数据做对比(vs 之前用 React + Redux 实现类似功能的项目):
| 维度 | React 版本 | HTMX 版本 |
|---|---|---|
| 前端代码行数 | 约 12,000 行 | 约 3,200 行 |
| 构建产物大小 | 185KB (gzip) | 14KB (htmx+gzip) |
| 开发周期 | 平均 3 周/模块 | 1.5 周/模块 |
| 新人上手 | 2-3 天 | 半天 |
| SEO (内容页) | 需 SSR/SSG | 天生支持 |
当然这不是说 htmx 全面优于 React。在列表编辑、富文本、实时协作这些场景下,React 生态的成熟度远超 htmx。 但如果你是做管理后台、内容站点、数据看板这类以 CRUD 和内容展示为主的应用,htmx 是一个非常值得认真考虑的方案。
写在最后
htmx 给我的最大启发不是技术层面的,而是思维层面的——它让我重新思考了"前端"和"后端"的边界。
过去五年,行业的主流叙事是"前后端分离让各端各司其职"。但真正这么做了之后,我发现中间多出了一个"维护接口"的庞大成本。htmx 的主张是:如果最终用户看到的是 HTML,那为什么不让服务器直接返回 HTML?
这其实不是什么新发明。Web 的第一个十年就是这么运作的,只是那时候的工具太原始,没法做流畅的用户体验。htmx 给这个"老模型"补上了缺失的一环——局部刷新和声明式交互。
你可以从下周开始,在现有项目里选一个简单的模块试水:把一个列表页面的筛选+分页改用 htmx 实现。你会很快感受到"不需要写 API 文档、不需要处理 Loading 状态、不需要手动更新 DOM"的快感。
GitHub: https://github.com/bigskysoftware/htmx 官方文档: https://htmx.org/docs/

评论已关闭!