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)有两个关键约束:
- 客户端-服务端无状态通信:服务端不保存客户端状态,所有状态在超媒体(HTML)里。
- 超媒体即应用状态引擎(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 URL | htmx.org@2 | htmx.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 v4 | React 18 + Vite | Vue 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 让这条路重新变得优雅且现代。