一个内部管理后台,五个列表页,两个带校验的表单。为了「体验好一点」,引入了 React、路由、状态库和一整套构建配置。结果 node_modules 比业务代码大两个数量级,从改一行文案到上线要十五分钟,还要维护一份和后端重复的数据模型。
后来重做时换了个方向:页面由服务端渲染,交互靠 htmx。一个十几 KB 的脚本,没有构建步骤,部署就是把模板和静态文件拷过去。
它的模型:HTML 自己发请求#
htmx 的做法是给 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 标签引入。
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}
)
模板里,表单提交后把新条目追加到列表末尾:
<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 不替你解决。
- 富交互、离线、高频操作仍然该用前端框架,别硬扛。
- 迁移可以从一个列表页开始,验证延迟和模板性能之后再扩大范围。
完