HTMX:用 HTML 属性写动态交互(替代 1/3 SPA 场景)

起因

很多内部工具 / 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 + 状态管理。

踩过的坑

  1. hx-target="closest .row" 找不到 → ".row" 必须是元素的祖先。
    find / next 等其它选择器更明确。

  2. 服务端忘记返 HTML fragment 而是返整个 page:page 被插到 target
    里 → 嵌套 html / body 一片乱。返 fragment template。

  3. CSRF token:hx-post 默认不带 cookie / CSRF token。django-htmx
    等 framework helper 自动加;纯 HTMX 配 hx-headers='{"X-CSRFToken": "..."}'

  4. back button 不工作:hx-boost 自动 pushState;自己写 hx-get 默认
    不更 URL。要 history 工作加 hx-push-url="true"

  5. debug 难:DOM swap 后浏览器 inspector 不显示原 HTML。
    开 HTMX debug:htmx.logAll() 看所有 request / response。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。