工具与库 · 2026 年 11 月 11 日

HTMX:不用前端框架也能做交互

一个内部后台为了表格排序和表单校验引入了整套前端框架,构建链比业务代码还复杂。HTMX 换了个思路:让 HTML 自己发请求,服务端直接返回片段。

一个内部管理后台,五个列表页,两个带校验的表单。为了「体验好一点」,引入了 React、路由、状态库和一整套构建配置。结果 node_modules 比业务代码大两个数量级,从改一行文案到上线要十五分钟,还要维护一份和后端重复的数据模型。

后来重做时换了个方向:页面由服务端渲染,交互靠 htmx。一个十几 KB 的脚本,没有构建步骤,部署就是把模板和静态文件拷过去。

它的模型:HTML 自己发请求#

htmx 的做法是给 HTML 元素加属性,让它们能发请求、能替换自己或别的元素:

html
<button hx-get="/todos/1" hx-target="#detail" hx-swap="innerHTML">
  查看详情
</button>

这几个属性分别回答:发什么请求(hx-get)、结果放哪里(hx-target)、怎么放进去(hx-swap)、什么时候发(hx-trigger,不写就是点击)。服务端返回的是 HTML 片段,不是 JSON。浏览器拿到就直接插入 DOM,不需要前端再解析、再渲染一遍。

这意味着状态仍然在服务端。列表页的数据、表单的错误提示、分页的游标,全都由模板渲染出来,不存在「前端 store 和后端数据库谁是真的」这种问题。

一个能跑的最小例子#

后端用 FastAPI 加 Jinja2,前端不需要任何框架。安装命令以官方文档为准,htmx 本身可以直接用 CDN 的 script 标签引入。

python
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

app = FastAPI()
tpl = Jinja2Templates(directory="templates")
todos: list[str] = []

@app.get("/", response_class=HTMLResponse)
def index(request: Request):
    return tpl.TemplateResponse(
        request=request, name="index.html", context={"todos": todos}
    )

@app.post("/todos", response_class=HTMLResponse)
def add(request: Request, title: str = Form(...)):
    todos.append(title)
    # 只返回新增的那一条,不是整个页面
    return tpl.TemplateResponse(
        request=request, name="_item.html", context={"title": title}
    )

模板里,表单提交后把新条目追加到列表末尾:

html
<ul id="list">
  {% for t in todos %}<li>{{ t }}</li>{% endfor %}
</ul>

<form hx-post="/todos" hx-target="#list" hx-swap="beforeend"
      hx-disabled-elt="find button">
  <input name="title" required>
  <button>添加</button>
</form>

hx-disabled-elt 在请求期间禁用按钮,防重复提交。这类「以前要写 JS 才有的细节」,htmx 用属性给掉了。

还有一层容易被忽略的优势:渐进增强。给表单补上 action 和 method,没有 JavaScript 时浏览器照常提交,只是整页刷新;htmx 只是把它拦下来换成局部更新。脚本加载失败、被拦截、或者对面是个老浏览器,页面仍然可用。

服务端要配合的三件事#

htmx 把复杂度推回了服务端,模板因此要按「片段」来组织。

一、模板分两层。 整页模板只在首次访问时用,后续交互返回片段(_item.html、_table.html)。两层共享布局和样式,但不要复制粘贴,否则改一处忘一处。

二、URL 要能直接打开。 hx-get="/todos?page=2" 对应的地址,用浏览器直接访问时应该返回完整页面,而不是一段没有样式的 HTML。这样刷新、分享、调试都不会出问题。

三、错误要有约定。 校验失败返回什么状态码、错误信息渲染到哪个元素,团队里先定一套。约定不清楚,前端属性就会写成一堆特例。

HTTP 缓存也跟着能用:片段响应带 ETag,htmx 的后续请求会带上 If-None-Match,服务端回 304,浏览器直接复用旧片段。列表页这种「多数时候没变化」的界面,收益很直观。

适合与不适合的场景#

  • 已有服务端渲染的项目。Jinja、Django template、Rails ERB、Go template 都能直接接,改动是加属性,不是重写。
  • 表单校验与局部刷新。提交失败返回带错误信息的片段,替换表单区域,逻辑全在服务端。
  • 列表的排序、筛选、分页。链接带 hx-get 指向渲染好的片段,浏览器历史用 hx-push-url 处理。
  • 轮询和简单实时。hx-trigger="every 5s" 定时拉取,够用就别上 WebSocket。
  • 小团队、没有前端专职。省掉构建链、依赖升级和一份重复的类型定义。

不适合的场景#

需求 更适合的方案
拖拽画布、富文本、复杂编辑器 SPA 或专门的组件库
离线优先、本地缓存大量数据 PWA / SPA
高频交互(每帧都要响应) 客户端渲染
网络延迟高且交互密集 每次操作一个往返,体感会差
纯内容站 传统多页,连 htmx 都不用

判断标准可以简化成一句:每次交互能不能用「请求一次、返回一段 HTML」表达。能,htmx 就够;不能,勉强用会把服务端模板写成状态机。

另外要清楚它不负责什么:htmx 只管发请求和换 DOM,样式、组件、可访问性仍然要自己处理。它省掉的是前端框架,不是前端工作。

几个真实的坑#

  • 每个交互一个往返。本地 10ms 感觉不到,跨地域 150ms 就明显。缓解办法是 hx-indicator 给加载反馈,以及把高频操作合并(比如输入防抖用 hx-trigger="keyup changed delay:400ms")。
  • 错误响应默认不替换。4xx/5xx 不会触发 swap,表单校验失败时要么返回 200 带错误片段,要么用扩展处理错误目标。这一点不搞清楚,会以为是 htmx 坏了。
  • 重复提交和并发覆盖。hx-disabled-elt 只管按钮;真正的幂等要靠服务端 token 或版本号。
  • CSRF。片段请求也是请求,token 要么放表单隐藏域,要么统一用 hx-headers 挂在 body 上。
  • 模板性能会被放大。一个页面渲染十次 N+1 查询,交互频繁时就是灾难。先看慢查询,再谈前端优化。
  • 浏览器回退。hx-push-url 配合 hx-history-elt 使用,否则回退时拿到的是过期片段。
  • 组件库菜单要手动初始化。有些第三方组件在节点插入 DOM 后需要自己跑一次初始化,htmx 不会替你调用,用 htmx.onLoad 接一下,或者换掉这个组件。

结论#

  • 交互能用「一次请求换一段 HTML」表达时,htmx 是成本最低的方案。
  • 加属性就能用,不需要构建链,但要保证模板渲染够快。
  • 表单错误、重复提交、CSRF 这三件事必须在服务端想清楚,htmx 不替你解决。
  • 富交互、离线、高频操作仍然该用前端框架,别硬扛。
  • 迁移可以从一个列表页开始,验证延迟和模板性能之后再扩大范围。

完

#HTMX#前端#服务端渲染#Web
回到顶部 ↑