起因
很多内部工具 / dashboard 不需要 React 那种"完整 SPA"——只是几个
"按钮 click → 加载片段 → 局部更新"。这些用 React + 后端 API 实施
是杀鸡用牛刀:
- 后端要做 JSON API 而不是直接 render HTML
- 前端要 setup React + router + 状态管理
- 部署两个东西
- 一改 backend schema 前后端都要同步
HTMX 是 14 KB 的 JS 库,让 HTML 元素通过属性发 AJAX 请求 + 局部替换 DOM。
后端继续 render HTML,前端就是"加几个 hx-* 属性"。
最简单的例子
<script src="https://unpkg.com/[email protected]"></script>
<button hx-get="/api/hello" hx-target="#result">
点我加载
</button>
<div id="result"></div>
后端(Django / Flask / Rails / 任意):
@app.get('/api/hello')
def hello():
return HttpResponse('<p>Hello, World!</p>')
按钮 click → 发 GET /api/hello → 服务端返 HTML 片段 → 插入 #result。
零 JS 业务代码。
hx 属性速查
<!-- 哪种请求 -->
<a hx-get="/path">...</a>
<button hx-post="/path">...</button>
<button hx-put="/path">...</button>
<button hx-delete="/path">...</button>
<!-- 替换什么 -->
<button hx-get="/x" hx-target="#dest">...</button>
<button hx-get="/x" hx-target="closest .card">...</button>
<!-- 怎么替换 -->
hx-swap="innerHTML" <!-- 默认:替换 target 的 innerHTML -->
hx-swap="outerHTML" <!-- 替换 target 整个元素 -->
hx-swap="beforebegin" <!-- 插 target 之前 -->
hx-swap="afterend" <!-- 插 target 之后 -->
hx-swap="delete" <!-- 删 target -->
hx-swap="none" <!-- 不动 DOM(仅触发 side effect) -->
<!-- 何时触发 -->
hx-trigger="click" <!-- 默认 -->
hx-trigger="keyup changed delay:500ms" <!-- input 改变 + 500ms -->
hx-trigger="every 5s" <!-- 每 5 秒 poll -->
hx-trigger="revealed" <!-- 进入视口(infinite scroll)-->
hx-trigger="load" <!-- 元素 mount 后立刻 -->
<!-- 传额外数据 -->
<button hx-post="/like"
hx-vals='{"post_id": 42}'>
Like
</button>
<!-- form 自动收集 -->
<form hx-post="/save">
<input name="title">
<button>save</button>
</form>
<!-- 自动把表单字段作 body -->
<!-- loading indicator -->
<button hx-get="/slow" hx-indicator="#spinner">
Load
</button>
<span id="spinner" class="htmx-indicator">⏳</span>
<!-- 请求期间 .htmx-indicator 自动显示(CSS 控制) -->
实战:To-do 列表(含增删改)
<ul id="todos">
<li>买菜 <button hx-delete="/todos/1" hx-target="closest li" hx-swap="delete">×</button></li>
<li>遛狗 <button hx-delete="/todos/2" hx-target="closest li" hx-swap="delete">×</button></li>
</ul>
<form hx-post="/todos" hx-target="#todos" hx-swap="beforeend">
<input name="text" required>
<button>add</button>
</form>
后端:
@app.post('/todos')
def create():
text = request.form['text']
todo = Todo.objects.create(text=text)
return HttpResponse(f'<li>{todo.text} <button hx-delete="/todos/{todo.id}" hx-target="closest li" hx-swap="delete">×</button></li>')
@app.delete('/todos/<int:id>')
def delete(id):
Todo.objects.filter(pk=id).delete()
return HttpResponse('', status=200)
完整 CRUD < 30 行 HTML + 后端 model。
零 JavaScript 业务代码。
SPA-like:boost 让普通链接 / 表单变 AJAX
<body hx-boost="true">
<a href="/about">About</a> <!-- 自动变 hx-get="/about" + 替换 body -->
<form action="/login" method="post"> <!-- 自动 hx-post -->
...
</form>
</body>
整站 SPA 体验,无需为每个链接写 hx 属性。
浏览器 back / forward 自动 work(pushState)。
Infinite scroll
<div hx-get="/posts?page=2" hx-trigger="revealed" hx-swap="afterend">
Loading more...
</div>
最后那个 div 进入视口 → 自动加载下一页 + 插到 afterend。
后端返回的 HTML 末尾再放一个同样的 trigger,无限链。
Server-sent events / WebSocket
<div hx-ext="sse" sse-connect="/events" sse-swap="message">
等通知...
</div>
服务端 push 时自动更新 div 内容。
Active search
<input type="search"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#results">
<div id="results"></div>
300ms debounce 后发请求;服务端返回结果 HTML 列表 → 替换 #results。
"Google instant search" 体验,零 JS。
编辑表单:click → 变 input → save 后变回
<div id="email-display">
{{ user.email }}
<button hx-get="/users/me/edit-email" hx-target="#email-display" hx-swap="outerHTML">
编辑
</button>
</div>
后端 GET /users/me/edit-email 返回:
<form hx-post="/users/me/email" hx-target="this" hx-swap="outerHTML">
<input name="email" value="{{ user.email }}">
<button>save</button>
<button type="button" hx-get="/users/me/email-display" hx-target="closest form" hx-swap="outerHTML">取消</button>
</form>
POST /users/me/email 处理后返回更新版 #email-display。
inline edit 模式,无 React 也优雅。
复杂前端逻辑(少量 JS)
HTMX 不替代所有 JS。需要复杂前端状态时配 Alpine.js / hyperscript:
<button x-data="{ count: 0 }" @click="count++">
Count: <span x-text="count"></span>
</button>
Alpine.js 是 14 KB 的"轻量 Vue",跟 HTMX 同生态。
HTMX 管"跟服务器交互";Alpine 管"客户端局部状态"。
与 SPA / React 对比
| HTMX + 服务端 render | React SPA | |
|---|---|---|
| bundle | 14 KB | 100+ KB |
| 后端 | 返回 HTML | 返回 JSON API |
| 前端代码量 | 极少 | 大 |
| SEO | 自然好(HTML) | 要 SSR 才好 |
| 适合 | 内部工具 / CMS / blog / 简单 CRUD | 复杂 SPA / 离线 / 极致交互 |
| 团队 | 全栈 | 前后分离 |
HTMX 适合:
- 内部 dashboard / 后台
- Django / Rails / Phoenix 等 server-rendered framework 用户
- 不想维护两套 (frontend + backend API) 代码
- 中等复杂度 web app
不适合:
- 极复杂客户端状态(编辑器 / IDE / 实时白板)
- 离线优先 PWA
- 需要超丝滑 transition / 动画
实战 case
我们一个公司内部 admin tool 用 HTMX 重写:
- 之前:React + REST API + 后端 + 部署两套 → 4 周
- 现在:Django + HTMX → 5 天
- 维护 1 套代码
- bundle 从 500 KB → 30 KB(Django 资源 + HTMX)
- 内部用户没感觉差异(其实更快)
但绝不会用 HTMX 重写"复杂 SaaS dashboard"——那个还是 React。
工具论适配场景。
SSR framework with HTMX
- Django +
django-htmx:内置 helpers - Flask + Jinja2:原生适配
- Rails 7 + Hotwire / Turbo(不是 HTMX 但理念类似)
- Phoenix + LiveView(更强大但 Elixir 专属)
- Laravel + Livewire(PHP 版 LiveView)
服务端渲染 + 轻量 JS 增强 是 2024 重新流行的方向。
完整 demo: 文章 like 按钮
<!-- post.html -->
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
<div id="like-section-{{ post.id }}">
{% include 'like_section.html' %}
</div>
</article>
<!-- like_section.html -->
<button hx-post="/posts/{{ post.id }}/like"
hx-target="#like-section-{{ post.id }}"
hx-swap="innerHTML"
{% if user_liked %}disabled{% endif %}>
{% if user_liked %}❤️ Liked{% else %}🤍 Like{% endif %}
</button>
<span>{{ post.likes_count }} likes</span>
@app.post('/posts/<int:id>/like')
@login_required
def like(id):
post = Post.objects.get(pk=id)
Like.objects.get_or_create(user=request.user, post=post)
return render(request, 'like_section.html', {
'post': post,
'user_liked': True,
})
20 行代码完成"点赞 + 实时更新计数"。
React 版本至少 100 行 + 后端 API + 状态管理。
踩过的坑
-
hx-target="closest .row" 找不到 → ".row" 必须是元素的祖先。
find / next等其它选择器更明确。 -
服务端忘记返 HTML fragment 而是返整个 page:page 被插到 target
里 → 嵌套 html / body 一片乱。返 fragment template。 -
CSRF token:hx-post 默认不带 cookie / CSRF token。django-htmx
等 framework helper 自动加;纯 HTMX 配hx-headers='{"X-CSRFToken": "..."}'。 -
back button 不工作:hx-boost 自动 pushState;自己写 hx-get 默认
不更 URL。要 history 工作加hx-push-url="true"。 -
debug 难:DOM swap 后浏览器 inspector 不显示原 HTML。
开 HTMX debug:htmx.logAll()看所有 request / response。
登录后参与评论。