HTMX 实战:用超媒体构建现代 Web 应用

2026-05-30 11:09 HTMX 实战:用超媒体构建现代 Web 应用已关闭评论

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 替换有明显的卡顿。排查发现两个问题:

  1. 后端返回了不必要的字段(比如大段的备注文本)
  2. 每次请求都重新渲染了整张表格

修复:

# 只返回列表需要的字段(查询时 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/

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4180.html ,谢谢支持!

资源分享

分类:Android 标签:
Android常见布局 Android常见布局
关于90后结不起婚的原因 关于90后结不起婚的原因
010-Python库Flask开发Web界面支持sql格式化和SQLServer、mysql、sqlite和sql oracle多种数据库错误检测 010-Python库Flask开发Web界面
PostgreSQL 性能优化与查询计划分析实战 PostgreSQL 性能优化与查询计

评论已关闭!