前几天工作室接了一个特别小的后台需求:就一个商品列表,顶部有个关键词搜索框,还要能按分类下拉筛选。数据大概几百条,用任何后端框架都能做。但我不想再引入一套 Vue 或者 React 的构建链,毕竟服务端是现成的 PHP,模板也能直接渲染。
如果按老办法,我要给输入框绑定一堆事件,然后自己写 fetch,再在回调里把那坨拼接 HTML 字符串塞到一个容器里。最烦的是还得处理 loading 状态、空结果提示、清空输入时重置列表。弄得多,代码又臭又长。
后来我发现了 HTMX,本质上它就是往 HTML 标签里塞几个属性,然后让它自己发请求、自己拿服务端返回的 HTML 片段,然后再自己更新页面里的某个区块。我原来以为这又是某种玩具级的小库,但在项目里试了一下午之后,真的回不去了。
HTMX 到底做了什么
HTMX 不是什么框架,更像是一个 HTML 的扩展。它允许你在任意元素上写 ajax 请求相关的属性,不用 JavaScript。典型例子:
<button hx-post="/api/like" hx-swap="none">
点赞
</button>
当按钮被点击时,它自动向 /api/like 发一个 POST 请求,然后把返回的内容替换到某个指定容器。默认是用按钮自身的 innerHTML 来替换,如果服务端返回的是空,那就不动。
这样以前需要写在 JS 里的逻辑,现在变成了一种“声明式交互”。服务端返回什么,页面就展示什么。是不是感觉服务端渲染又香了?
上手:实时搜索商品列表
先做一个最简单的实时搜索。后端接口 search.php 接受一个关键词 q,在数组里查一下,返回一段 HTML 列表。
前端模板:
<input
type="search"
name="q"
placeholder="输入商品名"
hx-get="search.php"
hx-trigger="input changed delay:400ms"
hx-target="#product-list"
hx-swap="innerHTML"
>
<div id="product-list">
<!-- 一开始显示全部商品 -->
</div>
这里几个属性含义可以说一下:
hx-get:往这个地址发 GET 请求。hx-trigger:触发请求的事件。这里意思是每次 input 事件发生,但只在上一次输入后 400ms 才发,算是一种简单的防抖。如果你直接写成input,那打一个字请求一次,有体验问题。hx-target:响应结果要渲染到页面哪个位置。这里是 ID 为 product-list 的元素。hx-swap:怎么把服务端返回的内容插入 target 里。innerHTML 就是替换内部内容。
再来一个分类下拉框。我想让输入框和下拉框联动:搜索条件是两个值组成。HTMX 的特性是:如果请求参数要包含页面里多个 input 的值,可以使用 hx-include。比如:
<select name="category" hx-get="search.php" hx-target="#product-list" hx-swap="innerHTML">
<option value="">全部分类</option>
<option value="shoes">鞋类</option>
<option value="clothes">服装</option>
</select>
问题来了:当我在下拉框里切换分类时,它其实会单独发送一个请求,但输入框里的关键词没一起带过去。你不希望每次搜索都像全新的一样。让多个元素一起提交,就需要在某个容器上把所有参数收集起来。通常做法是把它们放在同一个 form 里,然后 HTMX 默认会收集该表单里的所有字段。
改造过的 HTML:
<form hx-get="search.php" hx-target="#product-list" hx-swap="innerHTML" hx-trigger="submit">
<input type="text" name="q" value="关键词" />
<select name="category">
<option value="">全部分类</option>
<option value="shoes">鞋类</option>
</select>
<button type="submit">搜索</button>
</form>
但如果你想做成实时搜索,非要用 submit 事件可就难受了。那就利用 HTMX 的 hx-include 包含附近表单。
<input
name="q"
hx-get="search.php"
hx-trigger="input changed delay:400ms"
hx-target="#results"
hx-swap="innerHTML"
hx-include="closest form"
>
hx-include="closest form" 意思就是包含最近的表单内所有字段的值。这样下拉框一变,输入框一变都会带着所有字段去请求。
如果要下拉框一变化就立刻搜索,不需要点击按钮,可以在 select 上加:
<select name="category" hx-get="search.php" hx-target="#results" hx-swap="innerHTML" hx-include="closest form">
<option value="">全部</option>
...
</select>
后端 search.php 只需要返回 HTML 片段
服务端逻辑不重要,但完整案例肯定要有。我这里用 PHP 写个简单版本,模拟几个商品:
<?php
// 模拟数据库里的数据
$products = [
['id' => 1, 'name' => '经典帆布鞋', 'cat' => 'shoes'],
['id' => 2, 'name' => '轻便跑鞋', 'cat' => 'shoes'],
['id' => 3, 'name' => '格子衬衫', 'cat' => 'clothes'],
['id' => 4, 'name' => '直筒牛仔裤', 'cat' => 'clothes'],
];
$q = $_GET['q'] ?? '';
$cat = $_GET['category'] ?? '';
$filtered = array_filter($products, function($item) use ($q, $cat) {
if ($cat !== '' && $item['cat'] !== $cat) return false;
if ($q !== '' && mb_strpos($item['name'], $q) === false) return false;
return true;
});
$output = '';
if (count($filtered) === 0) {
$output = '<div class="empty">没有符合条件的商品</div>';
} else {
foreach ($filtered as $item) {
$output .= '<div class="product-item">' . htmlspecialchars($item['name']) . '</div>';
}
}
header('Content-Type: text/html; charset=utf-8');
echo $output;
注意我们返回的是纯 HTML,而不是 JSON。你不需要写 JS 去把 JSON 转成 DOM。服务端模板输出什么,页面就长什么样,这就大大减少了前端代码量。
更多招:用 hx-swap-oob 做局部边缘更新
很多交互不只是列表切换,比如商品列表中每件商品都有一个“收藏”按钮。你点了收藏之后,希望当前那一件商品的按钮文字变成“已收藏”,同时顶部的收藏总数加一。
用 HTMX 官方提供一个针对这个场景的姿势:后端返回一个带有 hx-swap-oob="true" 的额外 HTML 片段,它可以去替换页面中其他地方的元素,不受 hx-target 限制。
比如下方商品的 HTML 结构:
<div class="product" id="p1">
<div class="name">帆布鞋</div>
<button hx-post="/fav/" hx-vars='{"id":1}' hx-target="#fav-count" hx-swap="outerHTML">
收藏
</button>
</div>
点击这个按钮,它会向 /fav/ 发 POST 请求。后端保存收藏后,可以返回一段包含新旧状态的 HTML,例如:
<button id="fav-btn-1" class="active">已收藏</button> <span id="fav-count" hx-swap-oob="true">36</span>
因为返回内容里有两个元素:第一个是常规替换,第二个带有 hx-swap-oob 属性,它不但在目标区替换,还会自动寻找页面中 ID 为 fav-count 的元素并把它的内容替换成新值。所以你不需要专门给每个收藏按钮设置一个独一无二的事件,也不需要知道计数器在哪里。
浏览器历史与前进后退也能兼容
如果你用 hx-get 刷新了一个列表,地址栏还是原来的,用户按刷新页面就丢失了筛选条件。解决办法是使用 hx-push-url="true",让浏览器地址栏同步成请求的 URL。
<form
hx-get="search.php"
hx-target="#results"
hx-swap="innerHTML"
hx-push-url="true"
>
这样你发出请求的 query 串会进入浏览器历史,比如 ?q=shoes&category=clothes,当你点击前进/后退,将会触发对应的请求并恢复 DOM 状态。这一点我们一般不写 JS 实现,完全由 HTMX 帮你搞定。
这个特性对那种分享链接、SEO 需求的页面特别友好。如果只是局部刷新但没有更新 URL,别人复制链接过来就看不到同样内容。
更为进阶的东西:自定义事件触发
当一个任务完成以后,可能别的模块需要刷新。可以用 hx-trigger="customEvent" 监听页面里的自定义事件。这个事件本身也是 HTML 里的东西触发。
<div hx-get="/partial/notification" hx-trigger="newMsg from:body" hx-target="#notify">
<div id="notify"></div>
</div>
然后某处操作完成后,在 HTML 中使用 hx-trigger="click" 往 body 发送一个自定义事件?实际上更常见的是 JS 里 document.body.dispatchEvent(new Event("newMsg")),照样不用手写请求逻辑。
当然,HTMX 也支持在普通标签上用 hx-trigger="revealed"(元素进入视口时触发),实现无限滚动就直接写一个哨兵元素:
<div hx-get="/page2" hx-trigger="revealed" hx-swap="afterend">
加载中...
</div>
当这个 div 出现在屏幕里时,就去拉第二页,然后把自己替换到末尾。这基本就是 20 行 JS 才能写明白的东西。
到底哪些项目适合用它
我最初以为 HTMX 是给那些不想写任何前端代码的老古董准备的。真正用了之后发现它在“重服务端渲染”的架构里非常适合。比如 Laravel、Django、Spring Boot 等等,你有完整的模板能力和路由能力,接口直接返回 HTML 片段,让浏览器像访问页面一样和服务器交互。
尤其适合内容型、管理后台、报表系统。在这种场景下,SPA 的那套复杂状态管理往往并没有带来多少开发效率。你拼接口、做状态同步的功夫也许比直接渲染一个模板还累。
但如果你的页面是强交互的,比如类似 Excel 的在线表格、复杂图形编辑器,我并不建议用它。它能省事的部分主要在于 ajax 请求和 DOM 交换。对于非常复杂的前端状态,靠服务端片段整体刷新可能会频繁重绘,甚至导致输入框丢焦,给用户带来割裂感。
踩了几个坑
属性名大小写:HTMX 很多属性都是全小写加连字符。比如 hx-vals 里如果你写 JSON,从某些版本开始可能不支持单引号?最好写成标准 JSON。另外如果是想要传入数字,用 hx-vars。不过后来 HTMX 1.9 以后开始将 hx-vars 废弃,用 hx-vals 取代。所以新项目直接用 hx-vals='{"id": 1}' 就好。
某些元素不支持 hx-trigger。比如 img 的默认事件是 load?其实 HTMX 需要我们显式指定。还有 form 的 submit 事件触发的是普通请求,你需要改成 hx-post 等。这里容易让人搞混的是 form 内 button type=submit 点击后,如果你只在 form 上写 hx-get,那么它默认还是浏览器原生提交,而不是 htmx 请求。你需要给 form 加上 hx-post 或 hx-get 属性才行。
如果服务端返回一段很大的 HTML,每次更新都会销毁旧节点,再重绘新的。这个时候如果列表里有 input 等用户正在交互的元素,焦点就会丢失。这时候可以选择使用 hx-swap="outerHTML" 或者利用 hx-preserve 属性保留某些元素。
一个完整的简单示例:加一收藏功能
为了让你更直观地看完整流程,我整理了一个极简 demo,后端用 PHP 的假接口实现。这段代码就是完整的 HTML 页面,你可以直接在支持 HTMX 的环境下测试。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX 小案例</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div>
<span>收藏数:<strong id="fav-count">10</strong></span>
</div>
<div>
<button
hx-post="/api/fav"
hx-target="#fav-count"
hx-swap="innerHTML"
hx-vals='{"id": 88}'
>
收藏这个
</button>
</div>
</body>
</html>
假设你的服务端收到请求后,处理完逻辑,直接返回 11,页面上收藏数字会从 10 变为 11。按钮本身没变化。如果想要同时改按钮文案,可以用 hx-swap-oob 返回另一段。
从这段代码你可以看到,前端没有一行 addEventListener,整个交互完全依赖于 HTML 属性表达。某种程度上,这是一种返璞归真。
结尾:让别人少写点 JS 不是罪
用 HTMX 工作的这段时间,常常让我回想起更早做 Web 时的简单:点击链接、刷新页面、服务端渲染新页面。只不过现在 HTMX 把这些事以前所未有的轻量方式搬回了前端,并且保留了很多被现代前端框架“甩开”的朴素体验。
如果你也有那种被复杂构建工具折磨到头疼的内部项目,不妨试着在页面中引入一段 htmx,感受一下“把 HTML 当动态接口”这一套逻辑。也许你写 JS 的次数会骤减,但做出来的东西照样能跑得很欢。

