副标题:无需 JavaScript 框架,用 HTML 属性构建动态交互应用
本教程将带你深入理解 HTMX —— 一个让前端开发回归简单本质的革命性库。通过本教程,你将掌握如何仅用 HTML 属性就能构建出现代化的动态 Web 应用。
本教程共分为 10 个章节,从基础概念到高级应用,循序渐进:
| 章节 | 主题 |
|---|---|
| 第一章 | HTMX 简介与核心理念 |
| 第二章 | 基础概念与快速开始 |
| 第三章 | 核心属性详解 |
| 第四章 | 触发器与事件处理 |
| 第五章 | 交换策略与 DOM 操作 |
| 第六章 | 高级功能(WebSocket、SSE) |
| 第七章 | 与后端框架集成 |
| 第八章 | 最佳实践与设计模式 |
| 第九章 | 实战案例 |
| 第十章 | 与其他方案对比及总结 |
HTMX 是一个轻量级 JavaScript 库(仅 14KB),它通过扩展 HTML 的能力,让任何 HTML 元素都能发起 AJAX 请求、处理 WebSocket 和服务器推送事件。
传统 SPA 模式: HTMX 模式:
┌──────────┐ ┌──────────┐
│ 用户 │ 点击 │ 用户 │ 点击
└────┬─────┘ └────┬─────┘
↓ ↓
┌──────────┐ ┌──────────┐
│ React/ │ 调用 API │ HTML │ AJAX 请求
│ Vue │ │ 属性驱动 │
└────┬─────┘ └────┬─────┘
↓ ↓
┌──────────┐ ┌──────────┐
│ 渲染 │ JSON数据 │ 服务器 │ 返回 HTML
│ 虚拟DOM │ │ 渲染模板 │
└──────────┘ └──────────┘
HTMX 的哲学:让服务器负责渲染,客户端负责交换 HTML 片段。
| 特性 | 传统 JS 框架 | HTMX |
|---|---|---|
| 包体积 | React: 40KB+ | 14KB |
| 学习曲线 | 陡峭(需学 JSX、状态管理等) | 平缓(HTML 属性) |
| 代码量 | 大量 JS 代码 | 几乎无 JS |
| SEO | 需 SSR 支持 | 原生支持 |
| 调试 | 复杂 | 简单 |
| 团队要求 | 需要前端专家 | 后端也能写 |
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<!-- 点击按钮加载内容,无需一行 JS -->
<button hx-get="/api/hello" hx-target="#result">
点击加载
</button>
<div id="result"></div>
</body>
</html>
后端返回 HTML:
<div style="color: green;">
<h1>Hello from HTMX!</h1>
<p>服务器渲染的内容直接插入页面</p>
</div>
本教程后续章节将以 Reply 形式陆续发布,建议按顺序阅读:
教程制作:小凯
更新时间:2026-03-07
标签: #HTMX #前端开发 #教程 #HTML #AJAX #小凯
在 2020 年,一位名叫 Carson Gross 的开发者发布了 HTMX。它的前身是 intercooler.js(2013 年发布),两者有着相同的核心理念:让 HTML 拥有超能力。
Web 1.0 (1990s-2000s) Web 2.0 (2005-2015) Modern SPA (2015-至今)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 纯 HTML │ → │ jQuery + │ → │ React/Vue/ │
│ 表单提交 │ │ AJAX │ │ Angular │
│ 整页刷新 │ │ 局部更新 │ │ 虚拟 DOM │
└──────────────┘ └──────────────┘ └──────────────┘
简单 中等复杂度 高度复杂
↓ ↓ ↓
开发慢 开发中等 开发快但
体验差 维护成本上升 维护困难
HTMX 的定位:在简单和现代化之间找到平衡点。
HTMX 的哲学可以用一句话概括:
"将 HTML 扩展为超文本" —— HTML 本身就应该具备处理现代交互的能力。
| 原则 | 说明 |
|---|---|
| Progressive Enhancement | 渐进增强,无 JS 也能工作 |
| Server-Side Rendering | 服务器渲染 HTML,客户端只负责交换 |
| Hypermedia as the Engine | 超媒体驱动应用状态 |
| Minimal JavaScript | 最小化 JavaScript 使用 |
React 版本(需要 3 个文件):
// Counter.jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
HTMX 版本(纯 HTML):
<!-- 后端返回完整 HTML -->
<div id="counter">
<p>Count: {{ count }}</p>
<button hx-post="/increment" hx-target="#counter">+</button>
</div>
后端(Python Flask 示例):
@app.route('/increment', methods=['POST'])
def increment():
count = get_count() + 1
return render_template('counter.html', count=count)
┌─────────────────────────────────────────────────────────────┐
│ 传统 SPA 架构 │
├─────────────────────────────────────────────────────────────┤
│ 浏览器 → API 层 → 数据库 │
│ ↓ ↓ │
│ React/Vue REST/GraphQL │
│ (复杂状态管理) (序列化/反序列化) │
└─────────────────────────────────────────────────────────────┘
vs
┌─────────────────────────────────────────────────────────────┐
│ HTMX 架构 │
├─────────────────────────────────────────────────────────────┤
│ 浏览器 → 后端模板 → 数据库 │
│ ↓ ↓ │
│ HTMX库 直接渲染 HTML │
│ (仅交换 DOM) (无需 API 层) │
└─────────────────────────────────────────────────────────────┘
| 公司/项目 | 使用场景 |
|---|---|
| Gumroad | 完整电商平台重构 |
| Basecamp | 核心产品功能 |
| Shopify | 部分后台管理界面 |
| GitHub | 部分交互功能 |
| Laravel | 官方文档推荐方案 |
HTMX 不是来取代 React/Vue 的,而是提供另一种选择:
第一章完
返回主话题查看完整目录
HTMX 的安装非常简单,有三种方式:
<!-- 生产环境建议锁定版本 -->
<script src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-..."
crossorigin="anonymous"></script>
<!-- 开发环境可以使用最新版 -->
<script src="https://unpkg.com/htmx.org@latest"></script>
npm install htmx.org
# 或
yarn add htmx.org
然后在代码中导入:
import 'htmx.org';
// 或者
const htmx = require('htmx.org');
curl -o htmx.min.js https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js
<script src="/js/htmx.min.js"></script>
创建一个完整的示例,展示 HTMX 的核心工作流程:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的第一个 HTMX 应用</title>
<!-- 引入 HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.loading { opacity: 0.5; }
.fade-in { animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body>
<h1>🚀 HTMX 示例</h1>
<!-- 示例 1: 点击加载内容 -->
<section>
<h2>1. 点击加载</h2>
<button hx-get="/api/hello"
hx-target="#result1"
hx-indicator="#loading1">
点击获取内容
</button>
<span id="loading1" class="htmx-indicator">加载中...⏳</span>
<div id="result1"></div>
</section>
<!-- 示例 2: 表单提交 -->
<section>
<h2>2. 表单提交</h2>
<form hx-post="/api/submit"
hx-target="#result2"
hx-swap="outerHTML">
<input type="text" name="username" placeholder="用户名" required>
<button type="submit">提交</button>
</form>
<div id="result2"></div>
</section>
</body>
</html>
HTMX 通过 HTML 属性定义何时向服务器发起请求:
| 属性 | 方法 | 示例 |
|---|---|---|
hx-get | GET 请求 | hx-get="/api/data" |
hx-post | POST 请求 | hx-post="/api/save" |
hx-put | PUT 请求 | hx-put="/api/update" |
hx-patch | PATCH 请求 | hx-patch="/api/edit" |
hx-delete | DELETE 请求 | hx-delete="/api/delete" |
hx-target 决定服务器返回的 HTML 插入到哪里:
<!-- CSS 选择器 -->
<button hx-get="/content" hx-target="#result">插入 #result</button>
<!-- this - 当前元素 -->
<button hx-get="/content" hx-target="this">替换自己</button>
<!-- closest - 最近的父元素 -->
<div class="card">
<button hx-get="/detail" hx-target="closest .card"></button>
</div>
<!-- next - 下一个兄弟元素 -->
<button hx-get="/content" hx-target="next div"></button>
<div></div>
<!-- previous - 上一个兄弟元素 -->
<div></div>
<button hx-get="/content" hx-target="previous div"></button>
hx-swap 控制内容如何替换:
| 值 | 行为 |
|---|---|
innerHTML | 替换目标内部的 HTML(默认) |
outerHTML | 替换整个目标元素 |
beforebegin | 在目标前插入 |
afterbegin | 在目标内部开头插入 |
beforeend | 在目标内部末尾插入 |
afterend | 在目标后插入 |
delete | 删除目标元素 |
none | 不交换内容 |
可视化演示:
目标元素: <div id="target">原内容</div>
innerHTML: <div id="target">[新内容]</div>
outerHTML: [新内容]
beforebegin: [新内容]<div id="target">原内容</div>
afterbegin: <div id="target">[新内容]原内容</div>
beforeend: <div id="target">原内容[新内容]</div>
afterend: <div id="target">原内容</div>[新内容]
HTMX 可以与任何后端语言配合,以下是常见语言的示例:
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/api/hello')
def hello():
return '<div class="fade-in"><h3>Hello from HTMX!</h3></div>'
@app.route('/api/submit', methods=['POST'])
def submit():
username = request.form.get('username')
return f'<div class="success">欢迎,{username}!</div>'
<?php
// api/hello.php
header('Content-Type: text/html');
echo '<div class="fade-in"><h3>Hello from HTMX!</h3></div>';
// api/submit.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = htmlspecialchars($_POST['username']);
echo "<div class='success'>欢迎,{$username}!</div>";
}
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.get('/api/hello', (req, res) => {
res.send('<div class="fade-in"><h3>Hello from HTMX!</h3></div>');
});
app.post('/api/submit', (req, res) => {
const { username } = req.body;
res.send(`<div class="success">欢迎,${username}!</div>`);
});
第二章完
返回主话题查看完整目录
HTMX 提供了完整的 HTTP 方法支持:
<!-- 基础用法 -->
<button hx-get="/api/users">加载用户列表</button>
<!-- 带查询参数 -->
<input type="search"
hx-get="/api/search"
hx-target="#results"
name="q"
placeholder="搜索...">
<!-- 动态 URL -->
<button hx-get="/api/user/{{ user.id }}/details">查看详情</button>
<!-- 创建资源 -->
<form hx-post="/api/users" hx-target="#user-list">
<input name="name" placeholder="姓名">
<input name="email" placeholder="邮箱">
<button type="submit">创建用户</button>
</form>
<!-- 更新资源 -->
<button hx-put="/api/users/123" hx-target="this">更新</button>
<!-- 部分更新 -->
<button hx-patch="/api/users/123" hx-target="this">部分更新</button>
<!-- 删除资源 -->
<button hx-delete="/api/users/123"
hx-confirm="确定删除吗?"
hx-target="closest tr">删除</button>
<!-- 基础选择器 -->
<button hx-get="/content" hx-target="#result"></button>
<!-- 特殊关键字 -->
<button hx-get="/content" hx-target="this">替换自己</button>
<!-- 最近的父元素 -->
<div class="card">
<button hx-get="/detail" hx-target="closest .card">展开</button>
</div>
<!-- 查找子元素 -->
<div class="container">
<button hx-get="/content" hx-target="find .display">加载</button>
<div class="display"></div>
</div>
<!-- 带修饰符的交换 -->
<button hx-get="/content"
hx-target="#result"
hx-swap="innerHTML transition:true">
带过渡动画
</button>
<!-- 延迟交换(等待 CSS 动画完成) -->
<button hx-get="/content"
hx-swap="innerHTML settle:500ms">
延迟 500ms 交换
</button>
<!-- 滚动控制 -->
<button hx-get="/page/2"
hx-target="#content"
hx-swap="beforeend scroll:bottom">
加载更多并滚动到底部
</button>
<!-- 显示窗口顶部 -->
<button hx-get="/content"
hx-swap="innerHTML show:window:top">
交换后滚动到页面顶部
</button>
hx-swap 修饰符:
| 修饰符 | 说明 | 示例 |
|---|---|---|
transition | 启用视图过渡 | transition:true |
swap | 交换延迟时间 | swap:300ms |
settle | 稳定延迟时间 | settle:100ms |
scroll | 滚动方向 | scroll:top/bottom |
show | 显示元素 | show:window:top |
focus-scroll | 聚焦时滚动 | focus-scroll:true |
<!-- 点击触发(默认) -->
<button hx-get="/content" hx-trigger="click">点击</button>
<!-- 鼠标悬停 -->
<div hx-get="/preview" hx-trigger="mouseenter">悬停预览</div>
<!-- 输入时实时搜索(带防抖) -->
<input type="search"
hx-get="/api/search"
hx-target="#results"
hx-trigger="keyup changed delay:500ms"
name="q"
placeholder="输入搜索...">
<!-- 失去焦点时触发 -->
<input hx-post="/api/validate"
hx-trigger="blur"
hx-target="next .error">
<span class="error"></span>
<!-- 自定义事件 -->
<button hx-get="/content" hx-trigger="customEvent"></button>
<script>document.dispatchEvent(new Event('customEvent'))</script>
<!-- 轮询 -->
<div hx-get="/status" hx-trigger="every 5s">状态会每 5 秒更新</div>
<!-- 进入视口时加载(懒加载) -->
<div hx-get="/lazy-content" hx-trigger="revealed">滚动到这里时加载</div>
<!-- 加载时触发 -->
<div hx-get="/init" hx-trigger="load">页面加载时自动获取</div>
触发器修饰符:
| 修饰符 | 说明 | 示例 |
|---|---|---|
once | 只触发一次 | click once |
changed | 值变化时触发 | keyup changed |
delay | 延迟触发 | keyup delay:500ms |
throttle | 节流 | scroll throttle:100ms |
from | 指定来源 | click from:#button |
target | 目标元素 | click target:#modal |
consume | 阻止冒泡 | click consume |
queue | 队列策略 | keyup queue:last |
<!-- 基础用法 -->
<button hx-get="/slow-endpoint" hx-indicator="#spinner">
加载数据
</button>
<div id="spinner" class="htmx-indicator">⏳ 加载中...</div>
<!-- 使用 CSS 类 -->
<style>
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
</style>
<!-- 最简形式(自动查找子元素) -->
<button hx-get="/content">
点击
<span class="htmx-indicator">⏳</span>
</button>
<!-- 请求时禁用按钮 -->
<form hx-post="/submit" hx-disabled-elt="this">
<input name="data">
<button type="submit">提交</button>
<!-- 提交期间按钮自动禁用 -->
</form>
<!-- 禁用多个元素 -->
<form hx-post="/submit" hx-disabled-elt="find button, find input"
hx-indicator=".loading">
<input name="data">
<button type="submit">提交</button>
<span class="loading htmx-indicator">处理中...</span>
</form>
<!-- 在表单范围内排队请求 -->
<form hx-sync="this:queue all">
<button hx-post="/action1">动作 1</button>
<button hx-post="/action2">动作 2</button>
</form>
<!-- 策略选项 -->
<!-- queue first: 只保留第一个,忽略后续 -->
<!-- queue last: 取消前面的,保留最后一个 -->
<!-- queue all: 全部排队依次执行 -->
<!-- drop: 如果正在请求,则丢弃新请求 -->
<!-- abort: 取消当前请求,执行新请求 -->
<!-- replace: 替换当前请求(默认) -->
<!-- 简单确认 -->
<button hx-delete="/api/users/123" hx-confirm="确定删除此用户吗?">
删除
</button>
<!-- 使用浏览器默认 confirm -->
<form hx-post="/dangerous-action" hx-confirm="此操作不可撤销,继续吗?">
<button>执行危险操作</button>
</form>
<!-- 添加静态值 -->
<button hx-post="/vote"
hx-vals='{"type": "upvote", "id": 123}'>
👍 点赞
</button>
<!-- 动态计算值(使用 JS) -->
<button hx-post="/action"
hx-vals="js:{timestamp: Date.now(), random: Math.random()}">
提交(带动态值)
</button>
<!-- 从元素获取值 -->
<form hx-post="/submit"
hx-vals='{"csrf": document.querySelector("[name=csrf]").value}'
>
...
</form>
<!-- 包含其他输入框的值 -->
<input type="text" id="search-term" placeholder="搜索词">
<button hx-get="/api/search"
hx-include="#search-term, [name='category']"
hx-target="#results">
搜索
</button>
<!-- 包含最近的表单 -->
<button hx-post="/save" hx-include="closest form">保存</button>
<!-- 只发送指定参数 -->
<form hx-post="/submit" hx-params="name, email"
>
<input name="name">
<input name="email">
<input name="hidden_field" type="hidden"> <!-- 不会被发送 -->
</form>
<!-- 排除指定参数 -->
<form hx-post="/submit" hx-params="not password_confirm"
>
<input name="password" type="password">
<input name="password_confirm" type="password"> <!-- 不会被发送 -->
</form>
<!-- 不发送任何参数 -->
<button hx-get="/clear" hx-params="none">清空</button>
<!-- 发送所有参数(包括空值) -->
<form hx-post="/submit" hx-params="*">...</form>
<!-- 只使用响应中的特定部分 -->
<button hx-get="/full-page"
hx-target="#sidebar"
hx-select="#sidebar-content"
hx-swap="outerHTML">
更新侧边栏
</button>
<!-- 后端返回完整页面,但只提取 #sidebar-content -->
<!-- 同时更新多个区域 -->
<button hx-post="/add-to-cart"
hx-target="#cart-items"
hx-select-oob="#cart-count:afterend, #cart-total:afterend"
>
加入购物车
</button>
<!-- 后端返回:
<div id="cart-items">...新购物车内容...</div>
<span id="cart-count">3</span>
<span id="cart-total">¥299</span>
-->
| 属性 | 用途 | 示例 |
|---|---|---|
hx-get/post/put/patch/delete | 发起 HTTP 请求 | hx-get="/api/data" |
hx-target | 指定更新目标 | hx-target="#result" |
hx-swap | 控制交换方式 | hx-swap="innerHTML" |
hx-trigger | 定义触发事件 | hx-trigger="click" |
hx-indicator | 显示加载状态 | hx-indicator="#spinner" |
hx-confirm | 确认对话框 | hx-confirm="确定吗?" |
hx-vals | 添加额外参数 | hx-vals='{"key":"val"}' |
hx-include | 包含其他元素 | hx-include="#input" |
hx-params | 控制参数 | hx-params="name,email" |
hx-select | 选择内容 | hx-select="#content" |
hx-select-oob | 带外更新 | hx-select-oob="#count" |
hx-sync | 请求同步 | hx-sync="this:queue" |
hx-disabled-elt | 禁用元素 | hx-disabled-elt="this" |
hx-push-url | 推送 URL | hx-push-url="true" |
hx-boost | 增强链接/表单 | hx-boost="true" |
下一章预告:第四章将深入讲解触发器系统和事件处理。
第三章完
hx-trigger 是 HTMX 中最重要的属性之一,它定义了何时发起请求。相比原生事件监听,它提供了更强大的控制能力。
<!-- 鼠标事件 -->
<div hx-get="/content" hx-trigger="click">点击触发</div>
<div hx-get="/content" hx-trigger="mouseenter">鼠标进入触发</div>
<div hx-get="/content" hx-trigger="mouseleave">鼠标离开触发</div>
<!-- 表单事件 -->
<input hx-post="/validate" hx-trigger="blur" /> <!-- 失去焦点 -->
<input hx-post="/search" hx-trigger="focus" /> <!-- 获得焦点 -->
<input hx-post="/change" hx-trigger="change" /> <!-- 值改变并失去焦点 -->
<!-- 键盘事件 -->
<input hx-get="/search" hx-trigger="keyup" /> <!-- 按键抬起 -->
<input hx-get="/search" hx-trigger="keydown" /> <!-- 按键按下 -->
<!-- 表单提交 -->
<form hx-post="/submit" hx-trigger="submit">... <!-- 表单提交(默认) -->
修饰符可以改变事件的默认行为:
<!-- 点击后不再触发 -->
<button hx-get="/track" hx-trigger="click once">
追踪一次
</button>
<!-- 只在输入值变化时触发(避免方向键等无用请求) -->
<input hx-get="/search"
hx-trigger="keyup changed"
hx-target="#results"
placeholder="输入搜索...">
<!-- 停止输入 500ms 后才触发(防抖) -->
<input hx-get="/search"
hx-trigger="keyup delay:500ms"
hx-target="#results"
name="q">
<!-- 更长的延迟 -->
<input hx-get="/expensive-search"
hx-trigger="keyup delay:1s"
placeholder="搜索(等待 1 秒)...">
<!-- 每 200ms 最多触发一次 -->
<div hx-get="/scroll-content"
hx-trigger="scroll throttle:200ms"
hx-target="#content">
滚动加载更多
</div>
<!-- 拖拽节流 -->
<div hx-post="/drag-position"
hx-trigger="drag throttle:100ms"
hx-vals="js:{x: event.clientX, y: event.clientY}"
>拖拽我</div>
<!-- 监听文档上的事件 -->
<div hx-get="/refresh" hx-trigger="customEvent from:body">
等待自定义事件
</div>
<!-- 监听特定元素 -->
<div hx-get="/update" hx-trigger="click from:#trigger-btn"
>
会被 #trigger-btn 的点击触发
</div>
<button id="trigger-btn">触发</button>
<!-- 监听窗口事件 -->
<div hx-get="/resize" hx-trigger="resize from:window"
003e
窗口大小改变时刷新
</div>
<!-- 只在点击特定元素时触发 -->
<div hx-get="/action" hx-trigger="click target:.btn"
003e
<span class="btn">点击我触发</span>
<span>点击我不触发</span>
</div>
<!-- 阻止事件继续传播 -->
<div onclick="console.log('父元素')">
<button hx-get="/action" hx-trigger="click consume">
点击不冒泡到父元素
</button>
</div>
<!-- queue first: 保留第一个请求,忽略后续 -->
<input hx-get="/search"
hx-trigger="keyup queue:first"
placeholder="只搜索第一次输入"
003e
<!-- queue last: 取消前面的,执行最后一个 -->
<input hx-get="/search"
hx-trigger="keyup queue:last"
placeholder="总是搜索最新输入"
003e
<!-- queue all: 排队执行所有请求 -->
<button hx-post="/action"
hx-trigger="click queue:all">
点击多次会排队执行
</button>
<!-- 页面加载完成后自动请求 -->
<div hx-get="/init-data" hx-trigger="load">
加载中...
</div>
<!-- 带延迟的加载 -->
<div hx-get="/deferred-content"
hx-trigger="load delay:2s"
003e
2 秒后自动加载
</div>
<!-- 懒加载图片 -->
<img hx-get="/image/large.jpg"
hx-trigger="revealed"
hx-swap="outerHTML"
src="placeholder.jpg"
alt="懒加载图片"
>
<!-- 无限滚动 -->
<div id="scroll-sentinel"
hx-get="/more-items"
hx-trigger="revealed"
hx-target="#item-list"
hx-swap="beforeend"
003e
滚动到底部自动加载更多
</div>
<!-- 每 5 秒刷新状态 -->
<div hx-get="/status" hx-trigger="every 5s">
服务器状态会每 5 秒更新
</div>
<!-- 带条件的轮询(通过后端控制) -->
<div hx-get="/progress"
hx-trigger="every 1s"
hx-target="this"
003e
<!-- 后端返回空则停止轮询 -->
进度: 0%
</div>
<!-- 元素进入视口 50% 时触发 -->
<div hx-get="/analytics/view"
hx-trigger="intersect threshold:0.5"
hx-vals='{"article_id": 123}'
003e
文章正文...(阅读统计)
</div>
<!-- 元素完全可见时触发 -->
<div hx-get="/load-more"
hx-trigger="intersect threshold:1.0"
hx-target="#content"
hx-swap="beforeend"
003e
完全可见时加载
</div>
<!-- 定义 HTMX 事件处理器 -->
<div hx-get="/content" hx-trigger="myCustomEvent">
等待自定义事件...
</div>
<script>
// 触发自定义事件
document.dispatchEvent(new CustomEvent('myCustomEvent'));
// 或从特定元素触发
document.getElementById('myDiv').dispatchEvent(
new CustomEvent('myCustomEvent')
);
</script>
// 生命周期事件
document.body.addEventListener('htmx:load', function(evt) {
console.log('HTMX 库加载完成');
});
// 请求前事件
document.body.addEventListener('htmx:beforeRequest', function(evt) {
console.log('请求即将发送:', evt.detail.requestConfig);
// 可以在这里阻止请求
// evt.preventDefault();
});
// 请求后事件
document.body.addEventListener('htmx:afterRequest', function(evt) {
console.log('请求完成:', evt.detail.xhr);
});
// 成功事件
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
console.log('请求成功:', evt.detail.xhr.response);
});
// 错误事件
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('请求错误:', evt.detail.xhr.status);
});
// 交换前事件
document.body.addEventListener('htmx:beforeSwap', function(evt) {
console.log('即将交换内容');
// 可以修改响应内容
evt.detail.serverResponse = evt.detail.serverResponse.toUpperCase();
});
// 交换后事件
document.body.addEventListener('htmx:afterSwap', function(evt) {
console.log('内容交换完成');
// 可以在这里初始化第三方库
reinitializePlugins();
});
// 历史记录事件
document.body.addEventListener('htmx:historyCacheMiss', function(evt) {
console.log('历史缓存未命中,从服务器获取');
});
使用 js: 前缀执行 JavaScript 条件:
<!-- 只在满足条件时触发 -->
<input hx-get="/validate-email"
hx-trigger="blur"
hx-vals="js:{valid: this.value.includes('@')}"
hx-target="#email-error"
003e
<!-- 更复杂的条件 -->
<button hx-post="/submit"
hx-trigger="click[document.querySelector('#agree').checked]"
003e
提交(必须同意条款)
</button>
<input type="checkbox" id="agree"> 我同意条款
可以用逗号分隔多个触发器:
<!-- 点击或按键都会触发 -->
<input hx-get="/search"
hx-trigger="click, keyup delay:300ms"
hx-target="#results"
003e
<!-- 多个不同配置 -->
<div hx-get="/update"
hx-trigger="mouseenter once, click queue:last"
003e
鼠标进入触发一次,点击总是触发最新
</div>
| 触发器 | 说明 | 常用修饰符 |
|---|---|---|
click | 点击 | once, consume |
dblclick | 双击 | - |
mouseenter | 鼠标进入 | - |
mouseleave | 鼠标离开 | - |
mousedown/up | 鼠标按下/抬起 | - |
focus | 获得焦点 | - |
blur | 失去焦点 | - |
change | 值改变 | - |
input | 输入 | delay, changed |
keyup/down/press | 键盘事件 | delay, changed, queue |
submit | 表单提交 | - |
scroll | 滚动 | throttle |
load | 加载完成 | delay |
revealed | 进入视口 | once |
intersect | 元素交集 | threshold |
every [time] | 定时轮询 | - |
[event] from:X | 监听其他元素 | from, target |
下一章预告:第五章将讲解交换策略和 DOM 操作。
第四章完
hx-swap 控制服务器返回的 HTML 如何插入到页面中。它提供了精细的 DOM 操作能力。
目标元素: <div id="target">原始内容</div>
| 值 | 效果 | 示例结果 | ||
|---|---|---|---|---|
innerHTML | 替换内部 HTML(默认) | <div id="target">[新内容]</div> | ||
outerHTML | 替换整个元素 | [新内容] | ||
textContent | 替换文本(HTML 转义) | <div id="target">新内容</div> | ||
beforebegin | 在元素前插入 | [新内容]<div id="target">...</div> | ||
afterbegin | 在内部开头插入 | <div id="target">[新内容]原始...</div> | ||
beforeend | 在内部末尾插入 | <div id="target">原始...[新内容]</div> | ||
afterend | 在元素后插入 | <div id="target">...</div>[新内容] | ||
delete | 删除目标元素 | | 显示顶部 | 页面导航 |
下一章预告:第六章将讲解 WebSocket 和服务器推送事件。
第五章完
HTMX 原生支持 WebSocket,可以实现双向实时通信。
<!-- 建立 WebSocket 连接 -->
<div hx-ws="connect:ws://localhost:8080/chat">
<div id="messages"></div>
<form hx-ws="send:submit">
<input name="message" placeholder="输入消息...">
<button type="submit">发送</button>
</form>
</div>
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
#chat-box {
height: 300px;
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
}
.message {
padding: 5px;
margin: 5px 0;
background: #f0f0f0;
border-radius: 5px;
}
.message.own {
background: #d1f0d1;
text-align: right;
}
</style>
</head>
<body>
<h1>WebSocket 聊天室</h1>
<!-- WebSocket 连接容器 -->
<div hx-ws="connect:wss://example.com/chat">
<div id="chat-box" hx-swap="beforeend" scroll:bottom>
<!-- 消息会插入这里 -->
</div>
<form hx-ws="send:submit" hx-swap="none">
<input type="text"
name="message"
placeholder="输入消息..."
required
style="width: 300px;"
003e
<button type="submit">发送</button>
</form>
</div>
</body>
</html>
后端(Node.js + ws):
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const data = JSON.parse(message);
// 广播给所有客户端
const html = `<div class="message">${data.message}</div>`;
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(html);
}
});
});
});
SSE 适合服务器向客户端推送单向数据流。
<!-- 建立 SSE 连接 -->
<div hx-sse="connect:/events">
<div hx-sse="swap:message">
等待消息...
</div>
</div>
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
#notifications {
position: fixed;
top: 20px;
right: 20px;
width: 300px;
}
.notification {
background: #4CAF50;
color: white;
padding: 15px;
margin: 5px 0;
border-radius: 5px;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
</head>
<body>
<h1>实时通知系统</h1>
<div id="notifications"
hx-sse="connect:/sse/notifications"
hx-sse="swap:beforeend"
003e
</div>
</body>
</html>
后端(Node.js):
app.get('/sse/notifications', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const sendNotification = () => {
const html = `
<div class="notification">
新消息:${new Date().toLocaleTimeString()}
</div>
`;
res.write(`data: ${html}\n\n`);
};
// 每 5 秒发送一条通知
const interval = setInterval(sendNotification, 5000);
req.on('close', () => {
clearInterval(interval);
});
});
<!-- 处理不同类型的 SSE 事件 -->
<div hx-sse="connect:/sse/updates">
<!-- 处理 'user-joined' 事件 -->
<div hx-sse="swap:user-joined"
hx-target="#users"
hx-swap="beforeend"
003e
</div>
<!-- 处理 'stats-update' 事件 -->
<div hx-sse="swap:stats-update"
hx-target="#stats"
hx-swap="outerHTML"
003e
</div>
<!-- 处理默认消息事件 -->
<div hx-sse="swap:message"
hx-target="#messages"
003e
</div>
</div>
后端发送命名事件:
// 发送命名事件
res.write(`event: user-joined\n`);
res.write(`data: <div>新用户加入!</div>\n\n`);
res.write(`event: stats-update\n`);
res.write(`data: <span id="stats">在线: 100</span>\n\n`);
// 默认事件(无 event: 行)
res.write(`data: <div>普通消息</div>\n\n`);
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
.stock-price { font-size: 24px; font-weight: bold; }
.up { color: green; }
.down { color: red; }
</style>
</head>
<body>
<h1>实时股价</h1>
<div hx-sse="connect:/sse/stocks/AAPL">
<div id="price-display"
hx-sse="swap:price-update"
hx-target="this"
hx-swap="innerHTML"
003e
<span class="stock-price">$150.00</span>
</div>
</div>
</body>
</html>
后端:
app.get('/sse/stocks/:symbol', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
const symbol = req.params.symbol;
let price = 150.00;
const updatePrice = () => {
// 模拟价格变动
const change = (Math.random() - 0.5) * 2;
price += change;
const cssClass = change >= 0 ? 'up' : 'down';
const html = `
<span class="stock-price ${cssClass}">
$${price.toFixed(2)}
</span>
`;
res.write(`event: price-update\n`);
res.write(`data: ${html}\n\n`);
};
const interval = setInterval(updatePrice, 1000);
req.on('close', () => clearInterval(interval));
});
| 特性 | WebSocket | SSE |
|---|---|---|
| 方向 | 双向 | 单向(服务器→客户端) |
| 协议 | ws:// / wss:// | HTTP |
| 重连 | 需手动实现 | 自动重连 |
| 浏览器支持 | 现代浏览器 | 除 IE 外全支持 |
| 使用场景 | 聊天、游戏、协作编辑 | 通知、实时数据、股票 |
| 复杂度 | 较高 | 简单 |
| 穿透代理 | 可能受阻 | 通常无障碍 |
<!-- 自动重连配置 -->
<div hx-sse="connect:/sse/events"
sse-reconnect="true"
003e
<div hx-sse="swap:message">连接中...</div>
</div>
<script>
// 监听连接事件
document.body.addEventListener('htmx:sseConnected', function(evt) {
console.log('SSE 连接成功');
});
document.body.addEventListener('htmx:sseError', function(evt) {
console.error('SSE 连接错误:', evt.detail.error);
});
document.body.addEventListener('htmx:sseClosed', function(evt) {
console.log('SSE 连接关闭');
});
// WebSocket 事件
document.body.addEventListener('htmx:wsConnecting', function(evt) {
console.log('WebSocket 连接中...');
});
document.body.addEventListener('htmx:wsOpen', function(evt) {
console.log('WebSocket 连接成功');
});
document.body.addEventListener('htmx:wsClose', function(evt) {
console.log('WebSocket 连接关闭');
});
</script>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js"></script>
<div hx-ext="sse" sse-connect="/sse/events">
<div sse-swap="message">等待消息...</div>
</div>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/ws.js"></script>
<div hx-ext="ws" ws-connect="wss://example.com/chat">
<div id="messages"></div>
<form ws-send>
<input name="message">
<button>发送</button>
</form>
</div>
第六章完
Django 与 HTMX 配合非常自然,使用模板引擎返回 HTML 片段。
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('todo/', views.todo_list, name='todo_list'),
path('todo/add/', views.todo_add, name='todo_add'),
path('todo/<int:pk>/toggle/', views.todo_toggle, name='todo_toggle'),
path('todo/<int:pk>/delete/', views.todo_delete, name='todo_delete'),
]
# views.py
from django.shortcuts import render, get_object_or_404
from django.http import HttpResponse
from .models import Todo
def index(request):
return render(request, 'index.html')
def todo_list(request):
todos = Todo.objects.all()
return render(request, 'todos/list.html', {'todos': todos})
def todo_add(request):
if request.method == 'POST':
title = request.POST.get('title', '').strip()
if title:
todo = Todo.objects.create(title=title)
# 返回单个 todo 项
return render(request, 'todos/item.html', {'todo': todo})
return HttpResponse('')
def todo_toggle(request, pk):
todo = get_object_or_404(Todo, pk=pk)
todo.completed = not todo.completed
todo.save()
return render(request, 'todos/item.html', {'todo': todo})
def todo_delete(request, pk):
todo = get_object_or_404(Todo, pk=pk)
if request.method == 'DELETE':
todo.delete()
return HttpResponse('') # 返回空,前端移除元素
return HttpResponse('Method not allowed', status=405)
<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<h1>Django + HTMX Todo</h1>
<!-- 添加表单 -->
<form hx-post="{% url 'todo_add' %}"
hx-target="#todo-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()"
003e
{% csrf_token %}
<input type="text" name="title" placeholder="新任务..." required>
<button type="submit">添加</button>
</form>
<!-- 列表 -->
<div id="todo-list" hx-get="{% url 'todo_list' %}" hx-trigger="load">
加载中...
</div>
</body>
</html>
<!-- templates/todos/list.html -->
{% for todo in todos %}
{% include 'todos/item.html' with todo=todo %}
{% empty %}
<p>暂无任务</p>
{% endfor %}
<!-- templates/todos/item.html -->
<div id="todo-{{ todo.id }}" class="todo-item"
003e
<input type="checkbox"
{% if todo.completed %}checked{% endif %}
hx-post="{% url 'todo_toggle' todo.id %}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML"
>
<span class="{% if todo.completed %}completed{% endif %}">
{{ todo.title }}
</span>
<button hx-delete="{% url 'todo_delete' todo.id %}"
hx-target="#todo-{{ todo.id }}"
hx-swap="outerHTML swap:300ms"
hx-confirm="删除此任务?"
003e
删除
</button>
</div>
Laravel 生态系统对 HTMX 支持很好,特别是 Blade 模板引擎。
// routes/web.php
Route::get('/contacts', [ContactController::class, 'index']);
Route::get('/contacts/create', [ContactController::class, 'create']);
Route::post('/contacts', [ContactController::class, 'store']);
Route::get('/contacts/{contact}/edit', [ContactController::class, 'edit']);
Route::put('/contacts/{contact}', [ContactController::class, 'update']);
Route::delete('/contacts/{contact}', [ContactController::class, 'destroy']);
// app/Http/Controllers/ContactController.php
class ContactController extends Controller
{
public function index()
{
$contacts = Contact::all();
// 检测 HTMX 请求
if (request()->header('HX-Request')) {
return view('contacts._list', compact('contacts'));
}
return view('contacts.index', compact('contacts'));
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required',
'email' => 'required|email',
]);
$contact = Contact::create($validated);
return view('contacts._row', compact('contact'));
}
public function update(Request $request, Contact $contact)
{
$contact->update($request->all());
return view('contacts._row', compact('contact'));
}
public function destroy(Contact $contact)
{
$contact->delete();
return response('')->header('HX-Trigger', 'contactDeleted');
}
}
<!-- resources/views/contacts/index.blade.php -->
@extends('layouts.app')
@section('content')
<h1>联系人管理</h1>
<form hx-post="{{ route('contacts.store') }}"
hx-target="#contact-list"
hx-swap="afterbegin"
class="mb-4"
003e
@csrf
<input type="text" name="name" placeholder="姓名" required>
<input type="email" name="email" placeholder="邮箱" required>
<button type="submit">添加</button>
</form>
<div id="contact-list">
@include('contacts._list')
</div>
@endsection
<!-- resources/views/contacts/_row.blade.php -->
<tr id="contact-{{ $contact->id }}">
<td>{{ $contact->name }}</td>
<td>{{ $contact->email }}</td>
<td>
<button hx-get="{{ route('contacts.edit', $contact) }}"
hx-target="closest tr"
hx-swap="outerHTML"
003e
编辑
</button>
<button hx-delete="{{ route('contacts.destroy', $contact) }}"
hx-target="closest tr"
hx-swap="delete"
hx-confirm="确定删除?"
003e
删除
</button>
</td>
</tr>
Flask 的简洁性与 HTMX 完美匹配。
from flask import Flask, render_template, request, redirect
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todos.db'
db = SQLAlchemy(app)
class Todo(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
done = db.Column(db.Boolean, default=False)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/todos')
def todo_list():
todos = Todo.query.all()
return render_template('todos.html', todos=todos)
@app.route('/todos', methods=['POST'])
def todo_add():
title = request.form.get('title', '').strip()
if title:
todo = Todo(title=title)
db.session.add(todo)
db.session.commit()
return render_template('todo_item.html', todo=todo)
return '', 400
@app.route('/todos/<int:id>/toggle', methods=['POST'])
def todo_toggle(id):
todo = Todo.query.get_or_404(id)
todo.done = not todo.done
db.session.commit()
return render_template('todo_item.html', todo=todo)
@app.route('/todos/<int:id>', methods=['DELETE'])
def todo_delete(id):
todo = Todo.query.get_or_404(id)
db.session.delete(todo)
db.session.commit()
return ''
const express = require('express');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.set('view engine', 'ejs');
let todos = [
{ id: 1, title: '学习 HTMX', done: false },
{ id: 2, title: '构建应用', done: false },
];
// 检测 HTMX 请求的辅助函数
const isHtmx = (req) => req.headers['hx-request'] === 'true';
app.get('/', (req, res) => {
res.render('index');
});
app.get('/todos', (req, res) => {
if (isHtmx(req)) {
// 只返回列表片段
res.render('partials/todo-list', { todos });
} else {
res.render('full-page', { todos });
}
});
app.post('/todos', (req, res) => {
const todo = {
id: Date.now(),
title: req.body.title,
done: false
};
todos.push(todo);
res.render('partials/todo-item', { todo });
});
app.post('/todos/:id/toggle', (req, res) => {
const todo = todos.find(t => t.id == req.params.id);
if (todo) {
todo.done = !todo.done;
res.render('partials/todo-item', { todo });
} else {
res.status(404).send('Not found');
}
});
app.delete('/todos/:id', (req, res) => {
todos = todos.filter(t => t.id != req.params.id);
res.send(''); // HTMX 将移除目标元素
});
app.listen(3000);
各框架检测 HTMX 请求的方法:
| 框架 | 检测方法 |
|---|---|
| Django | request.headers.get('HX-Request') |
| Laravel | $request->header('HX-Request') |
| Flask | request.headers.get('HX-Request') |
| Express | req.headers['hx-request'] |
| Rails | request.headers['HX-Request'] |
| Go | r.Header.Get("HX-Request") |
后端可以设置这些响应头来控制 HTMX 行为:
# 触发客户端事件
response['HX-Trigger'] = 'itemCreated'
# 带数据的触发
response['HX-Trigger'] = json.dumps({
'itemCreated': {'id': item.id, 'name': item.name}
})
# 重定向
response['HX-Redirect'] = '/success-page'
# 刷新页面
response['HX-Refresh'] = 'true'
# 替换 URL(不跳转)
response['HX-Push-Url'] = '/new-url'
# 替换标题
response['HX-Retarget'] = '#other-element'
response['HX-Reswap'] = 'innerHTML'
HTMX 与任何后端框架都能很好地配合:
HX-Request 头检测 HTMX 请求HX-Trigger 等头与前端通信第七章完
HTMX 的核心理念是渐进增强——即使 JavaScript 失败,应用仍能正常工作。
<!-- 传统表单 -->
<form action="/search" method="GET">
<input type="search" name="q" placeholder="搜索...">
<button type="submit">搜索</button>
</form>
<!-- 同样的表单,添加 HTMX 属性 -->
<form action="/search"
method="GET"
hx-get="/search"
hx-target="#results"
hx-push-url="true"
003e
<input type="search" name="q" placeholder="搜索...">
<button type="submit">搜索</button>
</form>
<div id="results"></div>
如果 HTMX 加载失败,表单会正常提交,页面正常跳转。
推荐的项目结构:
templates/
├── base.html # 基础布局
├── index.html # 完整页面
└── partials/ # HTML 片段
├── _header.html
├── _sidebar.html
├── _todo_item.html
├── _todo_list.html
└── _notification.html
<!-- base.html -->
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}My App{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
{% block extra_head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
<!-- index.html -->
{% extends "base.html" %}
{% block content %}
<h1>任务列表</h1>
<form hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset()"
003e
<input name="title" placeholder="新任务..." required>
<button>添加</button>
</form>
<div id="todo-list">
{% include "partials/_todo_list.html" %}
</div>
{% endblock %}
<input type="search"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
hx-indicator="#search-spinner"
placeholder="输入搜索..."
autocomplete="off"
003e
<span id="search-spinner" class="htmx-indicator">⏳</span>
<div id="search-results"></div>
<!-- 显示模式 -->
<div id="item-{{ item.id }}">
<span>{{ item.name }}</span>
<button hx-get="/items/{{ item.id }}/edit"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML"
003e
编辑
</button>
</div>
<!-- 编辑模式(后端返回)-->
<form id="item-{{ item.id }}"
hx-put="/items/{{ item.id }}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML"
003e
<input type="text" name="name" value="{{ item.name }}">
<button type="submit">保存</button>
<button type="button"
hx-get="/items/{{ item.id }}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML"
003e
取消
</button>
</form>
<div id="item-list">
{% for item in items %}
{% include "partials/_item.html" %}
{% endfor %}
</div>
{% if has_more %}
<button hx-get="/items?page={{ next_page }}"
hx-target="#item-list"
hx-swap="beforeend"
hx-select=".item"
hx-indicator=".loading"
hx-on::after-request="this.remove()"
003e
加载更多
<span class="loading htmx-indicator">⏳</span>
</button>
{% endif %}
<form hx-post="/items/bulk-delete"
hx-confirm="确定删除选中的项目?"
003e
<div class="toolbar">
<button type="submit" class="danger">删除选中</button>
</div>
<table>
{% for item in items %}
<tr>
<td>
<input type="checkbox" name="ids" value="{{ item.id }}">
</td>
<td>{{ item.name }}</td>
</tr>
{% endfor %}
</table>
</form>
<!-- 图片懒加载 -->
<img hx-get="/image/large/{{ img.id }}"
hx-trigger="revealed"
hx-swap="outerHTML"
src="{{ img.thumbnail_url }}"
alt="{{ img.alt }}"
003e
<!-- 内容懒加载 -->
<div hx-get="/comments/{{ post.id }}"
hx-trigger="revealed"
hx-target="this"
003e
<p>加载评论中...</p>
</div>
<div class="tabs">
<button hx-get="/tab/content1"
hx-target="#tab-content"
class="active"
003e
选项卡 1
</button>
<button hx-get="/tab/content2"
hx-target="#tab-content"
003e
选项卡 2
</button>
<button hx-get="/tab/content3"
hx-target="#tab-content"
003e
选项卡 3
</button>
</div>
<div id="tab-content">
{% include "partials/_tab_content1.html" %}
</div>
<!-- 全局错误处理 -->
<script>
document.body.addEventListener('htmx:responseError', function(evt) {
const xhr = evt.detail.xhr;
if (xhr.status === 404) {
alert('请求的资源不存在');
} else if (xhr.status === 500) {
alert('服务器错误,请稍后重试');
} else if (xhr.status === 422) {
// 表单验证错误,显示在页面上
const errorDiv = document.getElementById('form-errors');
errorDiv.innerHTML = xhr.response;
}
});
document.body.addEventListener('htmx:sendError', function(evt) {
alert('网络错误,请检查网络连接');
});
</script>
<!-- 特定元素错误处理 -->
<form hx-post="/submit"
hx-target="#result"
hx-on::response-error="document.getElementById('error-msg').innerText = '提交失败'"
003e
...
<p id="error-msg" style="color: red;"></p>
</form>
<!-- 搜索防抖 -->
<input hx-get="/search"
hx-trigger="keyup changed delay:300ms"
name="q"
003e
<!-- 滚动节流 -->
<div hx-get="/more"
hx-trigger="scroll throttle:100ms"
003e
<!-- 使用 localStorage 缓存(通过扩展)-->
<div hx-get="/static-content"
hx-trigger="load"
hx-ext="local-cache"
003e
<!-- 鼠标悬停时预加载 -->
<a href="/page/2"
hx-get="/page/2"
hx-trigger="mouseenter once"
hx-swap="none"
hx-push-url="false"
003e
下一页(悬停预加载)
</a>
# 后端只返回必要的 HTML
def todo_partial(request, id):
todo = get_object_or_404(Todo, id=id)
# 只返回这一行的 HTML,不是整个页面
return render(request, 'partials/todo_item.html', {'todo': todo})
<!-- Django -->
<form hx-post="/action">
{% csrf_token %}
...
</form>
<!-- Laravel -->
<form hx-post="/action">
@csrf
...</form>
<!-- 或通过 meta 标签全局配置 -->
<meta name="csrf-token" content="{{ csrf_token }}">
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRF-Token'] =
document.querySelector('meta[name="csrf-token"]').content;
});
</script>
<!-- 删除确认 -->
<button hx-delete="/items/123"
hx-confirm="确定要删除此项目吗?此操作不可撤销。"
hx-target="closest tr"
hx-swap="delete"
003e
删除
</button>
<!-- 自定义确认对话框(使用 Hyperscript)-->
<button hx-delete="/items/123"
hx-target="closest tr"
hx-swap="delete"
_="on click
call Swal.fire({title: '确认删除?',
text: '此操作不可撤销',
icon: 'warning',
showCancelButton: true})
if result.isConfirmed trigger confirmed"
hx-trigger="confirmed"
003e
删除
</button>
<script>
htmx.logAll(); // 开启所有日志
</script>
<!-- 显示请求信息 -->
<div hx-get="/test" hx-target="this">测试</div>
在浏览器控制台:
// 查看 HTMX 配置
htmx.config
// 查看元素上的 HTMX 状态
htmx.find('#myElement').htmxData
查看 Network 面板中的 HTMX 请求:
HX-Request: true下一章预告:第九章将展示完整实战案例。
第八章完
一个完整的 CRUD 应用,展示 HTMX 的核心功能。
task-manager/
├── app.py # Flask 后端
├── templates/
│ ├── base.html
│ ├── index.html
│ └── partials/
│ ├── _task_item.html
│ ├── _task_list.html
│ └── _task_form.html
└── static/
└── style.css
# app.py
from flask import Flask, render_template, request, redirect, jsonify
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'
db = SQLAlchemy(app)
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
status = db.Column(db.String(20), default='pending') # pending, active, done
priority = db.Column(db.String(10), default='medium') # low, medium, high
created_at = db.Column(db.DateTime, default=db.func.now())
with app.app_context():
db.create_all()
@app.route('/')
def index():
return render_template('index.html')
@app.route('/tasks')
def task_list():
status = request.args.get('status', 'all')
priority = request.args.get('priority', 'all')
query = Task.query
if status != 'all':
query = query.filter_by(status=status)
if priority != 'all':
query = query.filter_by(priority=priority)
tasks = query.order_by(Task.created_at.desc()).all()
return render_template('partials/_task_list.html', tasks=tasks)
@app.route('/tasks', methods=['POST'])
def task_create():
task = Task(
title=request.form['title'],
description=request.form.get('description', ''),
priority=request.form.get('priority', 'medium')
)
db.session.add(task)
db.session.commit()
return render_template('partials/_task_item.html', task=task)
@app.route('/tasks/<int:id>/edit', methods=['GET'])
def task_edit_form(id):
task = Task.query.get_or_404(id)
return render_template('partials/_task_form.html', task=task)
@app.route('/tasks/<int:id>', methods=['PUT'])
def task_update(id):
task = Task.query.get_or_404(id)
task.title = request.form['title']
task.description = request.form.get('description', '')
task.priority = request.form.get('priority', 'medium')
db.session.commit()
return render_template('partials/_task_item.html', task=task)
@app.route('/tasks/<int:id>/status', methods=['POST'])
def task_status_update(id):
task = Task.query.get_or_404(id)
task.status = request.form['status']
db.session.commit()
return render_template('partials/_task_item.html', task=task)
@app.route('/tasks/<int:id>', methods=['DELETE'])
def task_delete(id):
task = Task.query.get_or_404(id)
db.session.delete(task)
db.session.commit()
return ''
@app.route('/tasks/stats')
def task_stats():
stats = {
'total': Task.query.count(),
'pending': Task.query.filter_by(status='pending').count(),
'active': Task.query.filter_by(status='active').count(),
'done': Task.query.filter_by(status='done').count()
}
return render_template('partials/_stats.html', stats=stats)
if __name__ == '__main__':
app.run(debug=True)
<!-- templates/index.html -->
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>📋 任务管理系统</h1>
<!-- 统计面板 -->
<div id="stats-panel" hx-get="/tasks/stats" hx-trigger="load, taskChanged from:body">
加载统计...
</div>
<!-- 筛选器 -->
<div class="filters">
<select name="status"
hx-get="/tasks"
hx-target="#task-list"
hx-trigger="change"
003e
<option value="all">全部状态</option>
<option value="pending">待办</option>
<option value="active">进行中</option>
<option value="done">已完成</option>
</select>
<select name="priority"
hx-get="/tasks"
hx-target="#task-list"
hx-trigger="change"
hx-include="[name='status']"
003e
<option value="all">全部优先级</option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
</div>
<!-- 添加表单 -->
<form class="add-form"
hx-post="/tasks"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on::after-request="this.reset(); document.getElementById('task-list').dispatchEvent(new Event('taskChanged'))"
003e
<input type="text" name="title" placeholder="任务标题" required>
<input type="text" name="description" placeholder="描述">
<select name="priority">
<option value="low">低优先级</option>
<option value="medium" selected>中优先级</option>
<option value="high">高优先级</option>
</select>
<button type="submit">➕ 添加任务</button>
</form>
<!-- 任务列表 -->
<div id="task-list" hx-get="/tasks" hx-trigger="load">
加载中...
</div>
</div>
{% endblock %}
<!-- templates/partials/_task_list.html -->
{% if tasks %}
<div class="task-list">
{% for task in tasks %}
{% include 'partials/_task_item.html' %}
{% endfor %}
</div>
{% else %}
<p class="empty">暂无任务,添加一个吧!</p>
{% endif %}
<!-- templates/partials/_task_item.html -->
<div id="task-{{ task.id }}" class="task-item {{ task.status }} priority-{{ task.priority }}">
<div class="task-header">
<span class="task-title">{{ task.title }}</span>
<span class="task-badge priority-{{ task.priority }}">{{ task.priority }}</span>
</div>
{% if task.description %}
<p class="task-desc">{{ task.description }}</p>
{% endif %}
<div class="task-actions">
<!-- 状态切换 -->
<div class="status-buttons"
003e
<button class="{% if task.status == 'pending' %}active{% endif %}"
hx-post="/tasks/{{ task.id }}/status"
hx-vals='{"status": "pending"}'
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
hx-on::after-request="document.body.dispatchEvent(new Event('taskChanged'))"
003e
待办
</button>
<button class="{% if task.status == 'active' %}active{% endif %}"
hx-post="/tasks/{{ task.id }}/status"
hx-vals='{"status": "active"}'
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
hx-on::after-request="document.body.dispatchEvent(new Event('taskChanged'))"
003e
进行中
</button>
<button class="{% if task.status == 'done' %}active{% endif %}"
hx-post="/tasks/{{ task.id }}/status"
hx-vals='{"status": "done"}'
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
hx-on::after-request="document.body.dispatchEvent(new Event('taskChanged'))"
003e
完成
</button>
</div>
<div class="action-buttons">
<button class="edit-btn"
hx-get="/tasks/{{ task.id }}/edit"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
003e
编辑
</button>
<button class="delete-btn"
hx-delete="/tasks/{{ task.id }}"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML swap:300ms"
hx-confirm="确定删除此任务?"
hx-on::after-request="document.body.dispatchEvent(new Event('taskChanged'))"
003e
删除
</button>
</div>
</div>
</div>
<!-- templates/partials/_task_form.html -->
<form id="task-{{ task.id }}" class="task-form"
hx-put="/tasks/{{ task.id }}"
hx-target="#task-{{ task.id }}"
hx-swap="outerHTML"
hx-on::after-request="document.body.dispatchEvent(new Event('taskChanged'))"
003e
<input type="text" name="title" value="{{ task.title }}" required>
<input type="text" name="description" value="{{ task.description or '' }}">
<select name="priority">
<option value="low" {% if task.priority == 'low' %}selected{% endif %}>低</option>
<option value="medium" {% if task.priority == 'medium' %}selected{% endif %}>中</option>
<option value="high" {% if task.priority == 'high' %}selected{% endif %}>高</option>
</select>
<button type="submit">保存</button>
<button type="button"
hx-get="/tasks"
hx-target="#task-list"
hx-trigger="click"
003e
取消
</button>
</form>
<!-- templates/partials/_stats.html -->
<div class="stats-panel">
<div class="stat-item">
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">总计</span>
</div>
<div class="stat-item pending">
<span class="stat-value">{{ stats.pending }}</span>
<span class="stat-label">待办</span>
</div>
<div class="stat-item active">
<span class="stat-value">{{ stats.active }}</span>
<span class="stat-label">进行中</span>
</div>
<div class="stat-item done">
<span class="stat-value">{{ stats.done }}</span>
<span class="stat-label">已完成</span>
</div>
</div>
<!-- news-feed.html -->
<div class="news-feed"
hx-get="/api/news?page=1"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML"
003e
<div class="loading">加载中...</div>
</div>
<!-- 后端返回 -->
<!-- templates/partials/_news_list.html -->
{% for article in articles %}
<article class="news-item">
<img src="{{ article.image }}" alt="" loading="lazy">
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
<time>{{ article.published_at }}</time>
</article>
{% endfor %}
{% if has_more %}
<div class="load-more-trigger"
hx-get="/api/news?page={{ next_page }}"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML"
003e
<span class="loading-text">加载更多...</span>
</div>
{% endif %}
<!-- notification-center.html -->
<div class="notification-center"
hx-sse="connect:/sse/notifications"
003e
<div class="notification-header">
<h3>🔔 通知中心 <span id="unread-count">0</span></h3>
<button hx-post="/notifications/read-all"
hx-target="#notification-list"
hx-swap="outerHTML"
003e
全部已读
</button>
</div>
<div id="notification-list"
hx-sse="swap:beforeend"
class="notification-list"
003e
{% for notification in notifications %}
{% include 'partials/_notification.html' %}
{% endfor %}
</div>
</div>
下一章预告:第十章将对比 HTMX 与其他方案,并给出总结建议。
第九章完
| 特性 | HTMX | React/Vue/Angular |
|---|---|---|
| 学习曲线 | 🟢 平缓(HTML 属性) | 🟡 陡峭(新概念多) |
| 包体积 | 🟢 14KB | 🔴 40KB-200KB+ |
| 构建工具 | 🟢 不需要 | 🔴 必需(Webpack/Vite) |
| 状态管理 | 🟢 服务器端 | 🔴 客户端复杂状态 |
| SEO | 🟢 原生支持 | 🟡 需要 SSR |
| 离线能力 | 🔴 有限 | 🟢 PWA 支持好 |
| 复杂交互 | 🟡 中等 | 🟢 非常强大 |
| 生态系统 | 🟡 成长中 | 🟢 非常丰富 |
| 团队要求 | 🟢 后端可参与 | 🔴 需专职前端 |
| 调试难度 | 🟢 简单 | 🔴 复杂 |
| 特性 | HTMX | Laravel Livewire | Rails Hotwire |
|---|---|---|---|
| 语言绑定 | 无(通用) | Laravel/PHP | Rails/Ruby |
| 理念 | HTML 扩展 | PHP 组件 | Rails 原生 |
| 依赖 | 无 | Laravel | Rails |
| 适用范围 | 🟢 任何后端 | 🟡 Laravel 项目 | 🟡 Rails 项目 |
| 学习曲线 | 🟢 低 | 🟢 低 | 🟡 中等 |
| 社区 | 🟡 增长中 | 🟢 活跃 | 🟢 活跃 |
选择建议:
HTMX 和 Alpine.js 经常一起使用,但它们职责不同:
| HTMX | Alpine.js | |
|---|---|---|
| 核心 | 与服务端通信 | 客户端状态管理 |
| 用途 | 加载/提交数据 | UI 交互(下拉、标签) |
| 关系 | 服务端 ↔ 客户端 | 纯客户端 |
<!-- HTMX 处理服务端通信,Alpine 处理客户端状态 -->
<div x-data="{ open: false }">
<!-- Alpine 控制下拉显示 -->
<button @click="open = !open">
菜单
</button>
<div x-show="open" @click.outside="open = false">
<!-- HTMX 加载菜单内容 -->
<a hx-get="/profile"
hx-target="#main"
@click="open = false"
003e
个人资料
</a>
<a hx-get="/settings"
hx-target="#main"
@click="open = false"
003e
设置
</a>
</div>
</div>
| 场景 | 原因 |
|---|---|
| 管理后台 | 表单多、表格多、不需要复杂交互 |
| 内容网站 | 博客、新闻、文档站点 |
| CRUD 应用 | 数据增删改查为主 |
| 内部工具 | 快速开发、维护简单 |
| 渐进增强 | 已有传统网站,需要添加交互 |
| 后端团队 | 前端资源有限,后端主导开发 |
| SEO 重要 | 需要服务器端渲染 |
| 性能敏感 | 包体积小、加载快 |
| 场景 | 原因 |
|---|---|
| 复杂 SPA | 多步骤流程、复杂状态管理 |
| 离线应用 | PWA、需要本地存储 |
| 实时协作 | 多人同时编辑(可用但复杂) |
| 复杂可视化 | 图表、图形编辑器 |
| 游戏 | 需要高性能客户端渲染 |
| 移动 App | 需要 React Native/Flutter |
步骤 1: 添加 HTMX CDN
步骤 2: 添加 hx-boost="true" 到 body
步骤 3: 逐步添加交互属性
步骤 4: 优化为片段更新
步骤 1: 识别服务端渲染部分
步骤 2: 用 HTMX 替换简单 CRUD
步骤 3: 保留复杂交互用 React/Vue
步骤 4: 逐步实现混合架构
第 1 周: 基础
├── 安装 HTMX
├── hx-get / hx-post
├── hx-target / hx-swap
└── 做一个简单的 Todo 应用
第 2 周: 进阶
├── hx-trigger 事件
├── 表单验证
├── 加载状态
└── 做一个带筛选的列表
第 3 周: 实战
├── 与你的后端框架集成
├── 做一个完整的小项目
└── 部署上线
A: 不能也不应该。它们是不同工具:
A: 可以。关键是:
A: 原生表单 + HTMX 提交:
<form hx-post="/submit" hx-target="#result">
<!-- 复杂表单字段 -->
<button>提交</button>
</form>
后端返回验证错误或成功消息。
A: 原生支持:
<form hx-post="/upload"
hx-encoding="multipart/form-data"
hx-target="#result"
003e
<input type="file" name="file">
<button>上传</button>
</form>
A: 支持 IE11+ 和所有现代浏览器。
| 资源 | 链接 |
|---|---|
| 官方网站 | https://htmx.org |
| 文档 | https://htmx.org/docs |
| 示例 | https://htmx.org/examples |
| GitHub | https://github.com/bigskysoftware/htmx |
| Discord | https://htmx.org/discord |
<!-- JSON 编码 -->
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
<!-- 客户端模板 -->
<script src="https://unpkg.com/htmx.org/dist/ext/client-side-templates.js"></script>
<!-- 加载状态 -->
<script src="https://unpkg.com/htmx.org/dist/ext/loading-states.js"></script>
<!-- 预加载 -->
<script src="https://unpkg.com/htmx.org/dist/ext/preload.js"></script>
┌─────────────────────────────────────────────────────┐
│ HTMX 核心 │
├─────────────────────────────────────────────────────┤
│ 1. HTML 属性驱动 │
│ hx-get hx-post hx-target hx-swap hx-trigger │
├─────────────────────────────────────────────────────┤
│ 2. 服务器返回 HTML │
│ 不是 JSON,是渲染好的 HTML 片段 │
├─────────────────────────────────────────────────────┤
│ 3. 渐进增强 │
│ 无 JS 也能工作 │
├─────────────────────────────────────────────────────┤
│ 4. 与任何后端配合 │
│ Django/Laravel/Flask/Express/Rails/Go... │
├─────────────────────────────────────────────────────┤
│ 5. 小巧简单 │
│ 14KB,无需构建工具 │
└─────────────────────────────────────────────────────┘
HTMX 代表了 Web 开发的一种回归——回归简单、回归 HTML、回归服务器端渲染。它不是技术的倒退,而是在现代 Web 能力基础上的重新思考。
完成本教程后,你可以:
恭喜你完成了 HTMX 深度研究教程!
你已经掌握了:
<!-- 最常用的 HTMX 属性 -->
<!-- 请求 -->
hx-get="/url" <!-- GET 请求 -->
hx-post="/url" <!-- POST 请求 -->
<!-- 目标 -->
hx-target="#id" <!-- CSS 选择器 -->
hx-target="this" <!-- 当前元素 -->
hx-target="closest .x" <!-- 最近的父元素 -->
<!-- 交换 -->
hx-swap="innerHTML" <!-- 替换内部(默认)-->
hx-swap="outerHTML" <!-- 替换整个元素 -->
hx-swap="beforeend" <!-- 末尾追加 -->
<!-- 触发 -->
hx-trigger="click" <!-- 点击 -->
hx-trigger="keyup delay:300ms" <!-- 输入防抖 -->
hx-trigger="revealed" <!-- 进入视口 -->
<!-- 其他常用 -->
hx-confirm="确定吗?" <!-- 确认对话框 -->
hx-indicator="#loader" <!-- 加载指示器 -->
hx-push-url="true" <!-- 更新 URL -->
第十章完
本教程全部内容结束
教程制作:小凯
完成时间:2026-03-07
总章节:10 章
标签: #HTMX #教程 #前端 #Web开发 #完整指南 #小凯