Chapter 03

目标控制:hx-target 与 hx-swap

精确控制 htmx 响应内容插入的位置和方式,掌握越界更新实现一次请求更新多个区域

hx-target:指定更新目标

默认情况下,htmx 将服务器响应的 HTML 内容更新到发出请求的元素自身hx-target 让你指定任意其他元素作为更新目标。

标准 CSS 选择器

<!-- 通过 ID 定位(最常用) -->
<button hx-get="/api/users" hx-target="#user-list">加载用户</button>
<div id="user-list"></div>

<!-- 通过 class 定位 -->
<button hx-get="/api/stats" hx-target=".dashboard-stats">刷新统计</button>

<!-- CSS 选择器完整支持 -->
<button hx-get="/api/data" hx-target="main > .content">更新内容</button>

<!-- 指向自身 -->
<div hx-get="/api/widget" hx-target="this" hx-trigger="load">
  加载中...
</div>

htmx 特殊选择器

除了标准 CSS 选择器,htmx 提供了一组相对定位选择器,特别适合列表、表格、树形结构等重复元素中的操作:

this
指向触发请求的元素本身。等效于省略 hx-target 时的默认行为,但明确写出更清晰。
closest selector
从触发元素向上遍历 DOM 树,找到第一个匹配 CSS 选择器的祖先元素。等同于 JS 中的 element.closest(selector)
find selector
在触发元素的后代中,找到第一个匹配 CSS 选择器的元素。等同于 JS 中的 element.querySelector(selector)
next
触发元素的下一个兄弟元素。也可以写成 next selector,在后续兄弟元素中找第一个匹配的。
previous
触发元素的上一个兄弟元素。也可以写成 previous selector
document
指向整个文档。配合特定 hx-swap 策略可以更新 <title> 等文档级元素。
window
指向 window 对象,常配合事件触发使用。
<!-- closest:在列表项中操作,定位到父 <li> -->
<ul>
  <li class="todo-item">
    <span>学习 htmx</span>
    <button
      hx-delete="/api/todos/1"
      hx-target="closest li"
      hx-swap="outerHTML"
    >删除</button>
  </li>
  <li class="todo-item">
    <span>部署应用</span>
    <button
      hx-delete="/api/todos/2"
      hx-target="closest li"
      hx-swap="outerHTML"
    >删除</button>
  </li>
</ul>

<!-- find:触发元素内部的特定子元素 -->
<div class="card">
  <button hx-get="/api/details/1" hx-target="find .details">展开</button>
  <div class="details"></div>
</div>

<!-- next:更新紧随其后的元素 -->
<input
  name="username"
  hx-get="/api/check-username"
  hx-trigger="blur"
  hx-target="next .hint"
/>
<span class="hint">输入用户名以检查可用性</span>

hx-swap:控制内容替换策略

hx-swap 定义如何将服务器响应的 HTML 插入到目标元素中。htmx 提供了 8 种策略:

innerHTML(默认)
替换目标元素的内部内容(子节点),保留目标元素本身。最常用的策略。
outerHTML
替换目标元素本身(含元素标签)。用于完全替换一个组件,如删除列表项后服务器返回空字符串。
beforebegin
在目标元素的开始标签之前插入内容(成为目标元素的前一个兄弟)。
afterbegin
在目标元素内容的最前面插入(成为第一个子节点)。用于在列表顶部插入新项。
beforeend
在目标元素内容的最后面插入(成为最后一个子节点)。用于在列表底部追加新项(无限滚动)。
afterend
在目标元素的结束标签之后插入内容(成为目标元素的后一个兄弟)。
delete
删除目标元素,忽略服务器响应内容。用于"成功删除"场景,服务器返回 200 空响应,元素从 DOM 中移除。
none
不更新 DOM(但仍然执行其他副作用,如 HX-Trigger 响应头触发的事件)。适用于只需触发服务端动作但不更新页面的场景。
DOM 插入位置示意: <div id="target"> ← beforebegin 插在这里 <div id="target"> ← afterbegin(第一个子节点前) [现有内容] ← beforeend(最后一个子节点后) </div> </div> ← afterend 插在这里
<!-- 场景1:新评论插入到列表顶部 -->
<form
  hx-post="/api/comments"
  hx-target="#comments"
  hx-swap="afterbegin"
>
  <textarea name="content"></textarea>
  <button>发布评论</button>
</form>
<div id="comments">
  <!-- 新评论将插在这里最前面 -->
</div>

<!-- 场景2:无限滚动追加内容 -->
<div
  hx-get="/api/posts?page=2"
  hx-trigger="revealed"
  hx-target="#post-list"
  hx-swap="beforeend"
>
  加载更多...
</div>

<!-- 场景3:删除并移除元素 -->
<button
  hx-delete="/api/todos/7"
  hx-target="closest .todo"
  hx-swap="outerHTML"
  hx-confirm="确认删除?"
>删除</button>

<!-- 场景4:只触发服务端动作,不更新 DOM -->
<button
  hx-post="/api/mark-all-read"
  hx-swap="none"
>全部标为已读</button>

hx-swap 修饰符

hx-swap 的值可以追加修饰符,用空格分隔,精细控制动画时序和滚动行为:

<!-- swap 之前等待 200ms(让旧内容的淡出动画完成) -->
<div
  hx-get="/api/data"
  hx-swap="innerHTML swap:200ms"
></div>

<!-- settle(新内容添加到 DOM 后等 100ms 再移除 htmx-settling 类) -->
<div
  hx-get="/api/data"
  hx-swap="innerHTML settle:100ms"
></div>

<!-- 滚动:更新后将目标元素滚动到视口顶部 -->
<button
  hx-get="/api/page/2"
  hx-target="#content"
  hx-swap="innerHTML scroll:#content:top"
>下一页</button>

<!-- 聚焦:更新后将新内容中的第一个输入框聚焦 -->
<button
  hx-get="/api/edit-form"
  hx-target="#modal"
  hx-swap="innerHTML focus-scroll:true"
>编辑</button>

hx-select:只取响应的一部分

当服务器返回完整 HTML 页面(例如,后端模板统一渲染完整页面),但只需要其中某个片段时,hx-select 允许你从响应中提取特定元素:

<!-- 服务器返回完整 HTML 页面,但只取 #main-content 的内容 -->
<a
  href="/products"
  hx-get="/products"
  hx-target="#page-content"
  hx-select="#main-content"
  hx-push-url="true"
>产品列表</a>

<!-- 服务器返回的完整 HTML:
<html>
  <head>...</head>
  <body>
    <nav>...</nav>
    <div id="main-content">   ← 只取这部分
      <h1>产品列表</h1>
      ...
    </div>
    <footer>...</footer>
  </body>
</html>
-->
hx-select 与 hx-boost 的配合

在整个页面都启用了 hx-boost 的情况下,htmx 默认取响应的 <body> 内容。配合 hx-select 可以进一步细化,只提取 body 中的主要内容区域,跳过导航栏等重复部分——实现类似 SPA 的局部刷新。

hx-select-oob:越界更新

这是 htmx 最强大的特性之一。Out-of-Band(OOB,越界)更新允许服务器在一次响应中同时更新多个不相关的 DOM 区域,无需多次请求。

工作原理

服务器在 HTML 响应中包含带有 hx-swap-oob="true" 属性的元素。htmx 会:

  1. 将主要响应内容插入 hx-target 指定的区域(正常流程)
  2. 扫描响应中所有带 hx-swap-oob 属性的元素
  3. 根据这些 OOB 元素的 id 找到页面中对应的元素并替换
<!-- 前端:添加 Todo -->
<form
  hx-post="/api/todos"
  hx-target="#todo-list"
  hx-swap="beforeend"
>
  <input name="title" placeholder="新待办事项"/>
  <button type="submit">添加</button>
</form>

<ul id="todo-list">...</ul>

<!-- 计数器(需要同时更新) -->
<span id="todo-count">3 个待办</span>
# 服务器返回两部分内容
@app.post("/api/todos")
async def create_todo(title: str):
    todo = await db.create_todo(title)
    count = await db.count_todos()

    # 主要内容:新的 todo 项(插入列表末尾)
    # OOB 内容:更新计数器
    html = f"""
<li class="todo-item">
  {todo.title}
  <button hx-delete="/api/todos/{todo.id}"
          hx-target="closest li"
          hx-swap="outerHTML">删除</button>
</li>

<span id="todo-count" hx-swap-oob="true">{count} 个待办</span>
"""
    return HTMLResponse(html)
hx-swap-oob 的值

hx-swap-oob="true" 默认使用 outerHTML 策略替换整个目标元素。也可以指定策略:hx-swap-oob="innerHTML"hx-swap-oob="beforeend:#some-id"(向指定 id 的元素追加内容)。OOB 元素必须有 id 属性以便 htmx 找到页面中的对应元素。

越界更新的典型场景

# 购物车:添加商品时同时更新购物车数量和总价
@app.post("/api/cart/add")
async def add_to_cart(product_id: int):
    cart = await add_item(product_id)

    return HTMLResponse(f"""
<!-- 主要内容:成功提示(替换按钮) -->
<button class="btn-success" disabled>已加入购物车 ✓</button>

<!-- OOB:更新导航栏的购物车数量 -->
<span id="cart-count" hx-swap-oob="true">{cart.item_count}</span>

<!-- OOB:更新购物车侧边栏总价 -->
<span id="cart-total" hx-swap-oob="true">¥{cart.total:.2f}</span>
""")