编程 HTMX v4 深度拆解:当超媒体驱动范式遇见「HTML First」——从隐式继承到显式配置、ESM/npm 支持与 ~11k 新架构(2026)

2026-07-17 09:17:14 +0800 CST views 10

HTMX v4 深度拆解:当超媒体驱动范式遇见「HTML First」——从隐式继承到显式配置、ESM/npm 支持与 ~11k 新架构(2026)

背景:为什么我们重新发明了轮子,然后又把它改回去?

2012 年,Google 推出 AngularJS,第一次将「数据绑定」和「声明式视图」带入主流 Web 开发。2013 年,Facebook 开源 React,提出了虚拟 DOM 和单向数据流。2014 年,Vue.js 以更低的门槛吸收了 React 的核心思想。此后十年,整个前端生态都在向「客户端渲染优先」(CSR-first)靠拢——React/Vue/Angular 成了默认选择,哪怕只是做一个列表页,也得配上 npm、webpack/vite、一套状态管理、路由系统。

结果呢?一个「简单」的后台管理页面,bundle 超过 500KB,First Contentful Paint 要等 2 秒,而它的所有数据本质上只是从 /api/users 返回的一个 JSON 数组。更荒谬的是,这个页面在服务器上已经有完整的 HTML 渲染——我们把它拆掉,然后在浏览器里用 JavaScript 重新「组装」了一遍。

这不仅仅是性能问题。这是认知浪费:用 JavaScript 去模拟一个 HTML 天生就能做的事(发送请求、更新 DOM),用 JSON 去模拟一个 HTTP 响应天生的能力(携带 HTML 片段、超媒体链接)。

HTMX 的作者 Carson Gross 在 2020 年提出了一个反直觉的问题:为什么只有 <a><form> 能发 HTTP 请求?为什么只有 click 和 submit 事件能触发交互?为什么只能替换整个页面?

这个问题的答案藏在上个世纪的 REST 架构风格里。Roy Fielding 在 2000 年的博士论文中指出,HTML 本身就是一种超媒体(Hypermedia)——它天然支持通过链接和表单来驱动应用状态迁移。问题在于 HTML 的超媒体表达能力在实践中被大幅阉割了。

HTMX v4(4.0.0-beta5,目标是 2026 年夏正式发布)是对这个问题的最新解答:把 HTML 补完为完整的超媒体,让服务端渲染重新成为一等公民,同时在工程层面拥抱现代 JavaScript 生态(ESM、npm)。这是一次架构级的重新设计。


一、超媒体驱动架构:为什么 HATEOAS 值得你再学一遍

1.1 REST 的真相与 HATEOAS

大多数人对 REST 的理解停留在「JSON over HTTP」——用 HTTP 方法做 CRUD,用 URL 路径表示资源。这不是 REST,这是「HTTP API」。

真正的 REST(Representational State Transfer)有两个关键约束:

  1. 客户端-服务端无状态通信:服务端不保存客户端状态,所有状态在超媒体(HTML)里。
  2. 超媒体即应用状态引擎(HATEOAS):客户端只知道一个入口 URL,后续所有状态迁移(下一步能做什么、该访问什么 URL)都通过响应中的超媒体(链接、表单)来驱动。

举一个 HATEOAS 的具体例子:

<!-- 服务端返回的第一个页面 -->
<a href="/orders">查看订单</a>
<form method="POST" action="/orders">
  <input name="product" value="iPhone 16">
  <button type="submit">下单</button>
</form>

<!-- 客户端提交表单后,服务端返回 -->
<div id="order-confirmation">
  <p>订单 #12345 已创建</p>
  <a href="/orders/12345">查看详情</a>    <!-- 动态生成的链接,驱动下一步状态 -->
  <a href="/orders">返回订单列表</a>
</div>

注意这里的关键:客户端不需要知道下一个 URL 是什么。服务端在响应中嵌入了链接,客户端跟随即可。如果这个订单在某些状态下只能被取消而不能被查看,服务端根本不返回「查看详情」的链接即可。这就是超媒体对业务规则的天然表达。

1.2 SPA 如何背离了这个方向

React/Vue SPA 的问题不是「慢」——通过 SSR(Next.js/Nuxt)和各种优化,React 应用可以很快。问题在于架构上的根本性背叛

  • 客户端需要预先知道所有 API 端点(URL + HTTP 方法 + 请求体结构)
  • 状态管理在客户端(Redux/Pinia),服务端返回的只是原始数据
  • 超媒体链接被降级为数据字段,客户端需要额外的路由配置来映射它们
传统 SPA 架构:
  浏览器 → GET /users → { users: [...] } → 客户端决定如何渲染
  
HTMX 架构:
  浏览器 → GET /users → <tr>...</tr> HTML片段 → 浏览器直接更新 DOM

SPA 告诉你:「把服务器当作数据库的前端」。HTMX 告诉你:「把服务器当作应用的真正后端,HTML 片段就是 API 的响应格式」。

1.3 HTMX 的核心哲学

HTMX 的核心哲学浓缩在官网首页的一句话里:

「HTML can do that!」

HTMX 通过 HTML 属性(而非 JavaScript API)来扩展 HTML 的超媒体能力。这些属性允许任意 HTML 元素在任意事件上发起任意 HTTP 请求,并用响应中的 HTML 片段来更新任意 DOM 目标。


二、HTMX v4 核心概念详解

2.1 基础:超媒体控制(Hypermedia Controls)

HTMX 的最小使用单位是超媒体控制——通过 HTML 属性声明的交互能力。

<!-- HTMX v4 最简示例 -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>

<button hx-post="/clicked" hx-swap="outerHTML">
  Click Me
</button>

点击这个按钮,HTMX 发送 POST /clicked,收到响应后用响应内容替换按钮本身outerHTML)。整个过程无需一行 JavaScript。

这是理解 HTMX 的基础单元:一个元素 + 一个触发事件 + 一个 HTTP 方法 + 一个目标 URL + 一个交换策略 = 一个超媒体控制

2.2 触发事件(Triggers)

默认触发事件是 click(对于可交互元素)或 trigger(显式指定)。所有可用触发事件:

<!-- 鼠标悬停触发 -->
<div hx-get="/hover-content" hx-trigger="mouseenter">
  悬停查看详情
</div>

<!-- 键盘事件触发 -->
<input type="text" 
       hx-get="/search" 
       hx-trigger="keyup changed delay:300ms">

<!-- 页面加载时触发 -->
<div hx-get="/dashboard" hx-trigger="load">

<!-- 循环轮询 -->
<div hx-get="/notifications" hx-trigger="every 5s">

<!-- 跨元素触发 -->
<button hx-delete="/item/1" hx-trigger="confirmed">
  删除
</button>
<!-- 另一个按钮也可以触发同一请求 -->
<button hx-delete="/item/1" hx-trigger="alsoconfirmed from:#confirm-dialog">
  从对话框确认删除
</button>

2.3 交换策略(Swap Strategies)

HTMX 支持 7 种交换策略,通过 hx-swap 指定:

策略行为
innerHTML用响应替换目标元素的内部 HTML(默认)
outerHTML用响应替换目标元素本身
afterbegin插入为目标的第一个子元素
afterend插入为目标之后的相邻元素
beforebegin插入为目标之前的相邻元素
beforeend插入为目标的最后一个子元素
delete删除目标元素
none不做 DOM 更新(用于触发服务端逻辑)
<!-- 追加评论,而不是替换 -->
<form hx-post="/comments" hx-target="#comments-list" hx-swap="beforeend">
  <textarea name="content"></textarea>
  <button type="submit">发表评论</button>
</form>

<div id="comments-list">
  <!-- 评论列表,新评论会被追加到这里 -->
</div>

2.4 请求目标与参数

<!-- 指定更新哪个元素 -->
<div hx-get="/user/1/profile" hx-target="#profile-section">
  加载中...
</div>

<!-- 包含其他元素的值 -->
<input type="text" name="search" id="search-box" placeholder="搜索...">
<button hx-get="/search" 
        hx-include="#search-box"
        hx-trigger="click"
        hx-target="#results">
  搜索
</button>

<!-- 使用 CSS 选择器指定多个目标 -->
<div hx-post="/update-both"
     hx-target="#left-panel, #right-panel"
     hx-swap="innerHTML">
  同时更新两个面板
</div>

2.5 CSS 过渡动画(CSS Transitions)

HTMX 最优雅的特性之一:借助原生的 CSS transition,不需要 JavaScript 动画库:

<!-- 关键:给目标加一个 CSS 类,然后在 CSS 中定义过渡 -->
<style>
.card {
  transition: opacity 300ms ease-out, transform 300ms ease-out;
  opacity: 1;
}
.htmx-swapping .card {
  opacity: 0;
  transform: translateY(-10px);
}
.htmx-settling .card {
  opacity: 1;
  transform: translateY(0);
}
</style>

<div id="card-container" hx-get="/cards" hx-trigger="load">
  <!-- HTMX 会自动在交换期间添加 htmx-swapping/htmx-settling 类 -->
</div>

2.6 WebSocket 与 SSE:服务端推送

HTMX 原生支持服务端推送,无需 WebSocket 库:

<!-- Server-Sent Events:服务端推送消息 -->
<div hx-sse="connect /events">
  <div hx-sse="swap:message">
    <!-- 每当服务端发来 'message' 事件,这里自动更新 -->
    等待消息...
  </div>
</div>
<!-- WebSocket:双向通信 -->
<div hx-ws="connect:/ws/chat">
  <div hx-ws="send:submit">
    <form>
      <input name="message" placeholder="发送消息...">
      <button type="submit">发送</button>
    </form>
  </div>
  <div hx-ws="swap:chat_message">
    <!-- 收到的聊天消息显示在这里 -->
  </div>
</div>

三、HTMX v4 重大架构变化:beta 5 中的核心新特性

HTMX v4 不是一次增量更新,而是一次架构级的重新设计。以下是 beta 5 中最值得关注的七个变化。

3.1 ESM 模块支持 + npm 一级支持

这是 v4 最重要的变化。v1/v2 时代,HTMX 只支持 CDN 引入(<script src="...">),与现代 JavaScript 打包工具(Vite/Rollup/webpack)的集成体验很差。

v4 彻底改变了这个局面:

# 通过 npm 安装
npm install htmx.org@4.0.0-beta5
// ESM 命名导入
import htmx from 'htmx.org';

// 使用 htmx 的底层 API
htmx.ajax('/url', { target: '#result', swap: 'innerHTML' });
htmx.find('#my-element');           // document.querySelector
htmx.findAll('.items');             // document.querySelectorAll
htmx.on('htmx:afterSwap', (evt) => {
  console.log('Swap 完成:', evt.detail.target);
});
htmx.logAll();  // 开启所有日志
<!-- CDN 方式(仍然支持,但路径变了) -->
<script type="module" 
  src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5/dist/htmx.esm.min.js">
</script>

这也意味着 HTMX 现在可以通过 Vite/Rollup 进行 tree-shaking,导入体积进一步降低。

3.2 Bundle 体积:从 ~16k 降到 ~11k

版本压缩后体积依赖数
HTMX v1~16k (min.gz'd)0
HTMX v4~11k (min.br'd)0

Brotli 压缩(.br)比 gzip 压缩率更高,所以 11k Brotli 压缩后的体积相当于更小的 gzip 体积。这是 31% 的体积缩减,同时功能更多(ESM、更多扩展内置)。

3.3 隐式属性继承:v2 隐式 → v4 显式

这是 v4 最影响现有项目迁移的行为变化。

v2(隐式继承): 子元素自动继承父元素的 HTMX 属性,直到被覆盖。

<!-- v2 风格:父 div 设置 hx-get,子按钮自动继承 -->
<div hx-get="/api/data" hx-swap="innerHTML" hx-trigger="load">
  <p>加载中...</p>
  <button hx-swap="outerHTML">点击</button>  <!-- 自动继承 hx-get -->
  <div>更多内容</div>                         <!-- 自动继承 hx-get -->
</div>

v4(显式继承): 子元素不再自动继承,需要显式声明。

<!-- v4 风格:属性必须显式声明 -->
<div hx-get="/api/data" hx-swap="innerHTML" hx-trigger="load">
  <p>加载中...</p>
  <!-- 需要显式声明需要的属性 -->
  <button hx-get="/api/data" hx-swap="outerHTML">点击</button>
  <div>其他内容(不再意外触发 HTMX 请求)</div>
</div>

这个变化的原因:隐式继承容易产生意外的 HTMX 行为。在大型 HTML 模板中,子元素可能在你不注意的时候继承了父元素的 HTMX 配置,导致难以追踪的 bug。v4 的显式继承让行为更可预测。

如果想恢复 v2 行为,在页面初始化时配置:

<script>
  htmx.config.implicitInheritance = true;  // 恢复 v2 隐式继承
</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>

3.4 错误响应处理:4xx/5xx 不再被静默忽略

v2 默认行为: HTTP 4xx/5xx 响应不做 DOM 交换(静默忽略)。

v4 默认行为: 4xx/5xx 响应同样会触发 DOM 交换。

// v4 改变了 noSwap 默认值
// v2: htmx.config.noSwap = [204, 304];     // 只跳过空响应
// v4: htmx.config.noSwap = [204, 304, 400, 404, 500, ...];  // 也跳过客户端/服务端错误

// 如果想恢复 v2 行为:
htmx.config.noSwap = [204, 304, '4xx', '5xx'];

这意味着:如果你的服务端在出错时返回了带有错误信息的 HTML 片段,v4 会把这个片段渲染到页面上,而不是什么都不做。对于表单验证等场景,这是更合理的默认行为。

3.5 生命周期钩子变化:htmx:load 替换为 htmx.init 事件

v4 调整了 HTMX 的初始化时机:

// v2
document.addEventListener('htmx:load', function(evt) {
  // HTMX 加载完成后触发
  console.log('HTMX 已初始化');
});

// v4:改用 init 事件
document.addEventListener('htmx:init', function(evt) {
  console.log('HTMX 初始化中...');
  // 可以在这里配置 htmx.config
});

// v4 新增:在 DOM 扫描之前触发(最早的机会)
document.addEventListener('htmx:beforeProcessNode', function(evt) {
  // 可以在这里动态修改元素属性
});

完整的 v4 事件生命周期(按触发顺序):

// 1. HTMX 开始初始化
'htmx:init'

// 2. 处理每个元素之前
'htmx:beforeProcessNode'

// 3. 请求发出前
'htmx:beforeRequest'

// 4. 请求发出后(无论成功失败)
'htmx:afterRequest'

// 5. DOM 交换后
'htmx:afterSwap'

// 6.  settle 动画结束后
'htmx:afterSettle'

// 7. 触发器元素还原后
'htmx:afterRestore'

3.6 htmax:所有热门扩展打包进一个文件

v4 引入了 htmax——一个打包了最热门扩展的「全能」版本:

<!-- 一个文件,包含 htmx + 8 个最常用扩展 -->
<script src="/js/htmax.min.js"></script>

<!-- SSE 直接用,不再需要引入扩展 -->
<div hx-sse="connect /events">
  <div hx-sse="swap:message">消息</div>
</div>

<!-- WebSocket 直接用 -->
<div hx-ws="connect:/ws/chat">
  ...
</div>

<!-- preload 直接用 -->
<button hx-get="/data" hx-preload>预加载</button>

内置的 8 个扩展(htmax 捆绑):

  • hx-sse(Server-Sent Events)
  • hx-ws(WebSocket)
  • hx-preload(预加载)
  • hx-browser-indicator(浏览器状态指示)
  • hx-download(文件下载)
  • hx-optimistic(乐观更新)
  • hx-targets(多目标)
  • hx-live(实时更新)

3.7 扩展系统:从 data-hx-ext 到命名空间前缀

v4 统一了扩展的命名风格——不再需要单独的扩展注册步骤,直接使用带前缀的属性:

<!-- v2 风格:需要先引入扩展,再使用 -->
<script src="htmx.min.js"></script>
<script src="extensions/hx-sse.js"></script>
<div hx-ext="sse" hx-sse="connect /events">
  ...
</div>

<!-- v4 风格:直接使用扩展前缀,无需注册步骤(htmax 或单独引入扩展后) -->
<div hx-sse="connect /events">
  <div hx-sse="swap:message">消息</div>
</div>

<div hx-ws="connect:/ws/chat">
  <div hx-ws="send:submit">
    <form>...</form>
  </div>
</div>

<button hx-post="/like" hx-optimistic="class:add;remove:liked" hx-vals='{"id": 42}'>
  👍 点赞
</button>

四、完整代码实战:从零构建一个 HTMX v4 应用

4.1 场景:一个任务管理应用(Todo App)

我们构建一个完整的任务管理应用,展示 HTMX 的各项能力。服务端用 Python/Flask,前端纯 HTML + HTMX v4。

目录结构:

todo-app/
├── app.py              # Flask 后端
├── templates/
│   ├── base.html       # 基础模板
│   ├── index.html      # 主页
│   └── components/     # HTMX 片段组件
│       ├── todo_item.html
│       └── stats.html
└── static/
    └── js/
        └── htmx.min.js

4.2 后端:Flask + HTMX 片段

# app.py
from flask import Flask, render_template, request, redirect, url_for
from flask import make_response
import uuid

app = Flask(__name__)

# 内存存储(演示用)
todos = [
    {"id": "1", "title": "完成 HTMX v4 文章", "done": False, "priority": "high"},
    {"id": "2", "title": "Review Connect RPC", "done": True, "priority": "medium"},
    {"id": "3", "title": "部署 Valkey 集群", "done": False, "priority": "high"},
]

def get_todos():
    return todos

@app.route("/")
def index():
    return render_template("index.html", todos=get_todos())

# === HTMX 片段端点 ===

@app.route("/todos/partial/stats")
def stats():
    total = len(todos)
    done = sum(1 for t in todos if t["done"])
    return render_template("components/stats.html", done=done, total=total)

@app.route("/todos/partial/<todo_id>")
def todo_item(todo_id):
    todo = next((t for t in todos if t["id"] == todo_id), None)
    if todo:
        return render_template("components/todo_item.html", todo=todo)
    return "", 404

@app.route("/todos/partial/list")
def todo_list():
    """返回整个列表(用于重置)"""
    return render_template("components/todo_list.html", todos=get_todos())

@app.route("/todos", methods=["POST"])
def create_todo():
    """创建新任务"""
    title = request.form.get("title", "").strip()
    priority = request.form.get("priority", "medium")
    
    if not title:
        # HTMX v4 默认会交换 400 响应,所以返回错误 HTML
        return """<div class="error">标题不能为空</div>""", 400
    
    todo = {
        "id": str(uuid.uuid4())[:8],
        "title": title,
        "done": False,
        "priority": priority
    }
    todos.append(todo)
    
    # 返回新任务 HTML 并追加到列表
    resp = make_response(render_template("components/todo_item.html", todo=todo))
    resp.headers["HX-Retarget"] = "#todo-list"
    resp.headers["HX-Reswap"] = "beforeend"
    return resp

@app.route("/todos/<todo_id>/toggle", methods=["POST"])
def toggle_todo(todo_id):
    """切换完成状态——HTMX 乐观更新演示"""
    todo = next((t for t in todos if t["id"] == todo_id), None)
    if not todo:
        return "", 404
    
    todo["done"] = not todo["done"]
    
    # 返回更新后的项目 HTML
    return render_template("components/todo_item.html", todo=todo)

@app.route("/todos/<todo_id>", methods=["DELETE"])
def delete_todo(todo_id):
    """删除任务"""
    global todos
    todos = [t for t in todos if t["id"] != todo_id]
    # HTMX 会自动移除目标元素,这里返回空
    return ""

@app.route("/todos/clear-completed", methods=["DELETE"])
def clear_completed():
    """清除已完成任务"""
    global todos
    deleted_count = sum(1 for t in todos if t["done"])
    todos = [t for t in todos if not t["done"]]
    
    resp = make_response("")
    resp.headers["HX-Trigger"] = '{"refreshStats": true}'
    return resp

if __name__ == "__main__":
    app.run(debug=True, port=5000)

4.3 前端:HTMX v4 模板

<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>HTMX v4 Todo App</title>
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>
  <!-- 也可以用 npm + ESM:
  <script type="module">
    import htmx from 'htmx.org';
    window.htmx = htmx;
  </script>
  -->
  <style>
    :root {
      --bg: #f8fafc;
      --surface: #ffffff;
      --border: #e2e8f0;
      --text: #1e293b;
      --text-muted: #64748b;
      --primary: #6366f1;
      --danger: #ef4444;
      --success: #22c55e;
    }
    
    body {
      font-family: system-ui, -apple-system, sans-serif;
      background: var(--bg);
      color: var(--text);
      max-width: 680px;
      margin: 0 auto;
      padding: 2rem 1rem;
    }
    
    /* === HTMX 动画状态 === */
    .htmx-request {
      opacity: 0.6;
      pointer-events: none;
    }
    .htmx-swapping {
      opacity: 0;
      transform: translateY(-8px);
      transition: opacity 200ms, transform 200ms;
    }
    .htmx-settling {
      opacity: 1;
      transform: translateY(0);
      transition: opacity 200ms, transform 200ms;
    }
    
    /* === 任务卡片样式 === */
    .todo-item {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 1rem;
      margin-bottom: 0.75rem;
      display: flex;
      align-items: center;
      gap: 0.75rem;
      transition: box-shadow 200ms;
    }
    .todo-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
    .todo-item.done .todo-title {
      text-decoration: line-through;
      color: var(--text-muted);
    }
    .todo-item.high { border-left: 3px solid var(--danger); }
    .todo-item.medium { border-left: 3px solid var(--primary); }
    
    .todo-title { flex: 1; font-size: 1rem; }
    .priority-badge {
      font-size: 0.75rem;
      padding: 0.125rem 0.5rem;
      border-radius: 999px;
      background: var(--bg);
      color: var(--text-muted);
    }
    
    /* === 表单 === */
    .todo-form {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 1.5rem;
      margin-bottom: 2rem;
    }
    .todo-form input[type="text"] {
      flex: 1;
      padding: 0.625rem 0.875rem;
      border: 1px solid var(--border);
      border-radius: 6px;
      font-size: 0.95rem;
      outline: none;
      transition: border-color 200ms;
    }
    .todo-form input[type="text"]:focus {
      border-color: var(--primary);
    }
    .todo-form button[type="submit"] {
      background: var(--primary);
      color: white;
      border: none;
      padding: 0.625rem 1.25rem;
      border-radius: 6px;
      font-size: 0.95rem;
      cursor: pointer;
      transition: background 200ms;
    }
    .todo-form button[type="submit"]:hover {
      background: #4f46e5;
    }
    
    /* === 错误提示 === */
    .error {
      background: #fef2f2;
      border: 1px solid #fecaca;
      color: var(--danger);
      padding: 0.5rem 0.75rem;
      border-radius: 6px;
      margin-bottom: 1rem;
      font-size: 0.875rem;
    }
    
    /* === 统计栏 === */
    .stats {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1.5rem;
      font-size: 0.875rem;
      color: var(--text-muted);
    }
  </style>
</head>
<body hx-headers='{"X-Requested-With": "XMLHttpRequest"}'>
  <!-- HTMX v4: 全局配置 -->
  <script>
    // HTMX v4 配置:如果你从 ESM 导入,可以在这里访问 window.htmx
    // htmx.config.defaultSwapStyle = 'innerHTML';
    // htmx.config.defaultTransition = true;
  </script>
  
  {{ embed() }}
</body>
</html>
<!-- templates/index.html -->
{% extends "base.html" %}

{% block content %}
<h1 style="font-size:1.75rem; margin-bottom:1.5rem;">
  📋 任务管理
  <span hx-get="/todos/partial/stats" 
        hx-trigger="refreshStats from:body"
        hx-swap="innerHTML"
        style="font-size:0.875rem; font-weight:normal; color:var(--text-muted);">
    <!-- 初始加载统计 -->
    {% include "components/stats.html" %}
  </span>
</h1>

<!-- 新建任务表单 -->
<form class="todo-form"
      hx-post="/todos"
      hx-target="#todo-list"
      hx-swap="afterbegin"   <!-- 新任务插入到列表最前面 -->
      hx-indicator="#form-loading"
      _="on htmx:after-request if isFormValid(this) then reset()">
  
  <div style="display:flex; gap:0.75rem; align-items:center;">
    <input type="text" 
           name="title" 
           placeholder="输入新任务..." 
           required
           style="flex:1;">
    
    <select name="priority" style="padding:0.5rem; border-radius:6px; border:1px solid var(--border);">
      <option value="high">🔴 高优先级</option>
      <option value="medium" selected>🔵 中优先级</option>
    </select>
    
    <button type="submit">
      <span id="form-loading" class="htmx-indicator">⟳</span>
      添加任务
    </button>
  </div>
</form>

<!-- 任务列表 -->
<div id="todo-list">
  {% include "components/todo_list.html" %}
</div>

<!-- 清除已完成按钮 -->
<div style="margin-top:1.5rem; text-align:right;">
  <button hx-delete="/todos/clear-completed"
          hx-target="#todo-list"
          hx-swap="innerHTML"
          style="background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:0.875rem;">
    🗑️ 清除已完成
  </button>
</div>

<!-- 加载更多(无限滚动) -->
<div hx-get="/todos/partial/list?page=2"
     hx-trigger="revealed"
     hx-swap="beforeend"
     hx-swap="afterbegin"
     style="text-align:center; padding:1rem; color:var(--text-muted);">
  滚动加载更多...
</div>

{% endblock %}

4.4 HTMX 片段组件

<!-- templates/components/todo_item.html -->
<div class="todo-item {% if todo.done %}done{% endif %} {{ todo.priority }}"
     id="todo-{{ todo.id }}"
     hx-target="#todo-{{ todo.id }}"
     hx-swap="outerHTML">
  
  <!-- 切换完成状态 -->
  <input type="checkbox" 
         {% if todo.done %}checked{% endif %}
         hx-post="/todos/{{ todo.id }}/toggle"
         hx-swap="outerHTML"
         style="width:18px; height:18px; cursor:pointer;"
         aria-label="切换完成状态">
  
  <span class="todo-title">{{ todo.title }}</span>
  
  <span class="priority-badge">
    {% if todo.priority == 'high' %}高优{% else %}中优{% endif %}
  </span>
  
  <!-- 删除按钮 -->
  <button hx-delete="/todos/{{ todo.id }}"
          hx-swap="delete"
          hx-confirm="确认删除「{{ todo.title }}」?"
          style="background:none; border:none; cursor:pointer; font-size:1.1rem; padding:0.25rem;"
          title="删除">
    🗑️
  </button>
</div>
<!-- templates/components/stats.html -->
<div style="display:inline;">
  <span>已完成 {{ done }}/{{ total }}</span>
  <span style="color:var(--primary);">
    {{ ((done / total * 100) | int if total > 0 else 0) }}%
  </span>
</div>
<!-- templates/components/todo_list.html -->
{% for todo in todos %}
  {% include "components/todo_item.html" %}
{% endfor %}

4.5 高级特性:OOB 交换与条件渲染

OOB(Out-Of-Band)交换:当一个请求的响应需要更新多个区域时使用:

@app.route("/todos/<todo_id>/toggle")
def toggle_todo(todo_id):
    todo = next((t for t in todos if t["id"] == todo_id), None)
    if not todo:
        return "", 404
    
    todo["done"] = not todo["done"]
    
    # 主要响应:更新任务本身
    main_resp = render_template("components/todo_item.html", todo=todo)
    
    # OOB:同时更新统计栏
    # 使用特殊标记让 HTMX 把这部分路由到其他目标
    stats_html = render_template("components/stats.html", 
                                  done=sum(1 for t in todos if t["done"]),
                                  total=len(todos))
    
    # hx-swap-oob 标记:此片段更新 #stats 元素
    oob_swap = f'<div id="stats" hx-swap-oob="true">{stats_html}</div>'
    
    return main_resp + oob_swap

五、从 HTMX v2 迁移到 v4:完整迁移指南

5.1 一步兼容配置

如果你的项目使用了隐式属性继承或依赖 4xx 静默处理,添加这两行配置即可:

<script>
  // 恢复 v2 行为(可选)
  htmx.config.implicitInheritance = true;
  htmx.config.noSwap = [204, 304, '4xx', '5xx'];
</script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>

5.2 逐步迁移策略

第一阶段:升级 CDN

<!-- v2 -->
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>

<!-- v4 -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-beta5"></script>

第二阶段:切换到 ESM + npm(可选但推荐)

npm install htmx.org@4.0.0-beta5
// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    alias: {
      'htmx.org': '/node_modules/htmx.org/dist/htmx.esm.min.js'
    }
  }
});
// main.js
import htmx from 'htmx.org';

// 确保全局可用(HTMX 依赖 window.htmx)
window.htmx = htmx;

// v4 新配置
htmx.config.defaultSwapStyle = 'innerHTML';
htmx.config.timeout = 10000;      // 10秒超时
htmx.config.includeValsTemplates = true;  // 表单值包含模板

// v4 新事件
htmx.on('htmx:init', () => {
  console.log('HTMX v4 初始化完成');
});

第三阶段:处理隐式继承变化

搜索你的 HTML 中所有带有 hx- 属性的父元素,检查其子元素是否意外触发了 HTMX 行为。将需要的属性显式添加到子元素:

<!-- v2: 子按钮自动继承父 div 的 hx-get -->
<div hx-get="/api/data" hx-swap="innerHTML">
  <p>加载内容...</p>
  <button>刷新</button>   <!-- 自动触发 hx-get -->
</div>

<!-- v4: 显式声明 -->
<div hx-get="/api/data" hx-swap="innerHTML">
  <p>加载内容...</p>
  <!-- 仅在需要的按钮上添加属性 -->
  <button hx-get="/api/data" hx-swap="innerHTML">刷新</button>
</div>

第四阶段:处理错误响应

如果你的服务端在 4xx/5xx 时返回了 HTML 错误信息,需要检查这些 HTML 是否适合在 v4 中显示:

# 如果想在 v4 中继续静默忽略错误,更新 noSwap 配置
@app.before_request
def set_no_swap_header():
    # 或者在 HTMX 配置中处理
    pass

5.3 迁移检查清单

检查项v2 行为v4 行为行动
属性继承隐式显式审查所有父容器 HTMX 配置
4xx 响应不交换交换检查错误处理 HTML 是否合适
htmx:load 事件存在替换为 htmx:init更新事件监听器
扩展引入data-hx-ext前缀属性统一扩展使用方式
CDN URLhtmx.org@2htmx.org@4更新版本号

六、扩展生态:HTMX 的可扩展性设计

6.1 内置扩展 vs 自定义扩展

HTMX 的扩展系统允许你注册自定义行为:

// 注册一个自定义扩展
htmx.defineExtension('my-logger', {
  // 初始化
  init: function(api) {
    console.log('My Logger 扩展已初始化');
  },
  
  // 处理每个被扫描的元素
  onProcessing: function(node, target, event) {
    console.log('处理节点:', node, event);
  },
  
  // 默认配置
  defaultConfig: {
    logLevel: 'info'
  }
});
<!-- 使用自定义扩展 -->
<div hx-get="/data" hx-ext="my-logger" my-logger:logLevel="debug">
  使用自定义扩展
</div>

6.2 常用扩展实战:SSE + WebSocket

<!-- SSE:实时更新股票价格 -->
<div class="stock-ticker"
     hx-sse="connect /sse/stocks"
     hx-sse="swap:price_update"
     hx-sse="swap:news_item">
  
  <div id="btc-price" hx-sse="swap:btc">
    $67,234
  </div>
  
  <div id="news-feed" hx-sse="swap:news">
    <!-- 新闻条目追加 -->
  </div>
</div>
# Flask SSE 服务端
from flask import Response
import json
import time

@app.route("/sse/stocks")
def stocks_sse():
    def generate():
        while True:
            price = get_latest_btc_price()
            yield f"event: btc\ndata: ${price}\n\n"
            time.sleep(5)
    
    return Response(generate(), mimetype='text/event-stream')

七、性能对比:HTMX vs React/Vue 生态

维度HTMX v4React 18 + ViteVue 3 + Vite
初始 JS 体积~11k~150k+~100k+
无框架依赖❌ (React)❌ (Vue)
服务端渲染原生 HTML需要 SSR 框架需要 SSR 框架
SEO 友好✅ 原生需要 Next.js需要 Nuxt
学习曲线低(HTML 属性)中(JSX + 状态)中(Vue 语法)
动态交互受限强大强大
与现有后端集成无缝需要 API 层需要 API 层
团队要求理解 HTML需要专业前端需要专业前端
适合场景内容驱动型 CRUD高度交互应用高度交互应用

关键洞察:HTMX 和 React/Vue 不是竞争关系,而是互补的。如果你的应用 80% 的时间是展示数据和简单交互(CRUD 表单、列表、详情页),HTMX 能以 1% 的 JS 体积完成 80% 的工作;如果你的应用需要复杂的客户端状态管理(如 Figma、Notion、Google Docs),React/Vue 仍是正确答案。


八、什么时候选 HTMX:一个工程决策框架

应用复杂度
    │
    │        React/Vue SPA
    │       ┌──────────────┐
高   │       │ 复杂状态管理   │
    │       │ 离线优先      │
    │       │ 实时协作      │
    │       │ 游戏/IDE     │
    │       └──────────────┘
    │
    │     HTMX + SSR 混合
    │    ┌────────────────┐
中   │    │ 大量表单交互    │
    │    │ CMS/管理后台    │
    │    │ 实时数据展示    │
    │    │ 多步骤向导      │
    │    └────────────────┘
    │
低   │  纯 HTMX (SSR-first)
    │ ┌────────────────────┐
    │ │ 博客/文档/电商列表   │
    │ │ 简单 CRUD          │
    │ │ 静态内容为主        │
    │ └────────────────────┘
    └──────────────────────────────────→
         简单                    复杂
              服务端渲染能力

推荐 HTMX 的场景:

  • 后台管理界面(Admin Dashboard)
  • CMS(内容管理系统)管理后台
  • 数据表格 + 筛选 + 分页
  • 表单驱动的业务流程(审批、订单、工单)
  • 需要 SEO 的内容型应用
  • 团队以服务端为主(Python/Go/Java 后端,前端资源有限)
  • 遗留系统现代化(不想重写整个前端)

不推荐 HTMX 的场景:

  • 实时协作类应用(多人同时编辑)
  • 复杂的客户端状态(购物车、复杂表单状态)
  • 图形密集型应用(数据可视化、游戏)
  • 需要离线优先能力的 PWA

九、HTMX v4 的工程启示:2026 年的「HTML First」思潮

HTMX v4 的发布,是 2026 年「HTML First」思潮的一个缩影。这个思潮的核心理念是:

不要因为 JavaScript 能做某件事,就用 JavaScript 去做这件事。

过去十年,前端社区形成了一种「JavaScript 优先」的思维惯性:HTTP 请求用 fetch,DOM 更新用 innerHTML,路由用 react-router,状态用 Redux。但这些能力 HTML 和 HTTP 本身就有。HTMX 的成功(GitHub 33k+ Stars)说明,有大量开发者愿意回归 HTML,重新发现它的表达能力。

这个思潮也在影响其他项目:

  • Alpine.js:轻量替代 Vue,用于简单交互(17k Stars)
  • Stimulus:Basecamp 出品的 HTML-first 框架
  • Hotwire(Turbo):Basecamp 的同门方案,用 Turbo Frames 做服务端渲染
  • Phoenix LiveView:Elixir 生态的服务端渲染方案,理念与 HTMX 相通

2026 年,AI 编程助手的普及进一步推动了这个趋势:当 AI 能自动生成服务端 HTML 模板时,客户端状态管理的价值被进一步稀释。HTMX v4 在这个时间点的架构升级,是一次恰到好处的生态位确认。


十、总结

HTMX v4(4.0.0-beta5,Summer 2026 正式版)是这个项目历史上最重要的一次更新。它在保持零依赖、体积极小(~11k)、HTML-first 哲学的同时,通过以下变化完成了与现代 JavaScript 生态的对接:

新特性意义
ESM + npm 一级支持融入现代打包工具链
显式属性继承提高大型项目的可维护性
4xx/5xx 默认交换更合理的错误处理默认行为
htmax 全家桶开箱即用的扩展生态
Brotli 压缩(~11k)31% 体积优化
统一扩展前缀命名更直观的扩展使用方式
HCON 规范结构化超媒体控制的标准化

HTMX 不是银弹,但它解决了一个被严重忽视的问题:服务端渲染 + HTML 片段交换是大多数 Web 应用最自然的架构,而过去的十年我们绕了很大一个弯。HTMX v4 让这条路重新变得优雅且现代。

推荐文章

如何开发易支付插件功能
2024-11-19 08:36:25 +0800 CST
php客服服务管理系统
2024-11-19 06:48:35 +0800 CST
程序员茄子在线接单