htmx

v4 新纪元

htmx 4 不是补丁版,而是从零重写。它换掉了传输层、收紧了继承模型、统一了命名法,并补上了过去只能外挂框架才做得动的两块拼图:局部响应式差异融合。本章把 v4 的重大更新一次说清——每件都配本站服务器上的真实往返。

  • fetch 取代 XHR
  • 约 14 KB(min.gz)零依赖
  • 事件名冒号分层
  • 继承显式化
  • morph 差异融合
  • hx-partial 多路响应
  • hx-live 响应式引擎
  • hx-status 状态分流

从零重写

v2 的核心是 XMLHttpRequest,v4 换成 fetch(),随之而来的 Promise 化请求、AbortController 超时与更干净的流处理,都是这次重写的副产品。与此同时,一批「当年为了兼容而留下的岔路」被一并铲平:

层面htmx 2htmx 4
传输层XMLHttpRequestfetch() + AbortController
继承祖先属性隐式向下继承须写 :inherited 修饰符,hx-disinherit 退役
事件名camelCase:htmx:afterSwap冒号分层:htmx:after:swap
扩展hx-ext 声明 + defineExtension 回调引入脚本即生效 + registerExtension 事件钩子
状态码4xx/5xx 默认不换入全部换入,仅 204/304 不换
旁路交换OOB 先于主内容主内容先,OOB/partial 后
历史快照存 localStorage整页刷新恢复,hx-history="false" 移除
配置responseHandling 数组noSwap 数组 + hx-status 属性
怎么判断一个项目用的是 v2 还是 v4?看三处:源码里用 fetch()、属性上带 :inherited 修饰符、事件名形如 htmx:after:swap——三者任一,即是 v4。

新属性家族

v4 的属性清单里,有些是新增,有些是更名,还有些是同名但语义反转——后者才是迁移时真正咬人的地方:

属性v4 的变化一句话
hx-query新增第五个动词:QUERY 请求,把「带复杂条件的读取」从 GET 中分出
hx-action hx-method新增路径与动词分列两属性,动态改方法时不必换属性名
hx-status:XXX新增按状态码定制 target/swap/select/push/replace,支持 40450x5xx 通配
hx-pending由扩展转正乐观 UI:请求期间先换入占位内容,落定后自动撤离(旧名 hx-optimistic)
hx-config更名元素级 fetch 配置(timeout、credentials、cache…),原 hx-request,取 HCON 语法
hx-ignore更名令该元素及后代完全不被 htmx 处理——这正是 v2 里 hx-disable 的含义
hx-disable语义反转请求期间禁用其他指定元素(防连点),即 v2 的 hx-disabled-elt
hx-partial新增响应里的多路片段标签,各自带 hx-targethx-swap
hx-morph-skip新增冻结该元素:morph 时属性与子节点原样不动
hx-morph-skip-children新增只冻结子节点,属性照常更新
hx-encoding沿用自定义请求体编码,如 multipart/form-data 上传
hx-validate沿用提交前先跑原生表单校验
hx-replace-url hx-history-elt hx-preload沿用替换历史条目 / 指定历史恢复作用域 / 按事件预取
:inherited :append新增修饰符显式向下继承 / 在继承值之上追加而非覆盖
新增三件 · 各安其位
<!-- 动词与路径分列:方法可来自别处,属性名不必跟着变 -->
<button hx-action="/clicked" hx-method="post" hx-swap="outerHTML">点我</button>

<!-- 按状态码分叉:422 改道、5xx 干脆不换 -->
<form hx-post="/register" hx-target="#result"
      hx-status:422="target:#errors select:#validation-errors"
      hx-status:5xx="swap:none">…</form>

<!-- 第三方组件不该被 htmx 碰:冻结它 -->
<custom-map hx-morph-skip></custom-map>
hx-disablehx-ignore改名顺序不能错:v2 的「停止 htmx 处理」在 v4 叫 hx-ignore;而 v4 的 hx-disable 换成了「请求期间禁用他元素」。若先把 v2 的 hx-disable 直接留下,页面会静默失去防连点能力——反之则整块区域被 htmx 抛弃。

QUERY 动词 · hx-query

HTTP 早有 QUERY 方法的提议:语义安全、可缓存,却允许携带比 URL 更丰富的查询体。v4 把它作为一等动词纳入核心,与 hx-get 并列——读操作从此有了「取资源」与「查条件」两种措辞。

hx-query · 第五个动词,就地问服务器
v4 把「带复杂条件的读取」从 hx-get 里分出来,独立成 hx-query 动词——语义上它仍是安全的读取,但不该再与取资源的 GET 挤同一个词。上面的响应是服务器回显的真实方法名

Morph 融合:innerMorph / outerMorph

过去换内容只有「重建」一条路:整块 DOM 推倒重来,焦点、滚动、播放中的视频、正在运行的 CSS 动画一并殉葬。v4 补上了差异融合:按 id 就地比对,只改该改的地方。

策略含义
innerMorph融合目标的子节点,保住焦点、滚动、动画与表单值
outerMorph连目标自身一起融合(属性与内容均按差异更新)
outerSync先同步目标的属性,再替换其子节点;目标元素始终留在 DOM 里
textContent只换纯文本,不做 HTML 解析
before / after / prepend / appendbeforebegin 等旧名的简写,写起来顺手
delete无视响应,直接删掉目标
none不换内容,但 OOB 与响应头照常处理
hx-swap="innerMorph" · 融合而非重建

渲染于:页面首次加载

innerMorph 按 id 就地比对、只更新差异:焦点、滚动位置、CSS 动画、播放中的视频、表单值都活下来;innerHTML 则整块推倒重建。反过来说——想清空表单就该用 innerHTML,morph 保不住「重置」这件事。
要让某块地盘在融合中纹丝不动,在服务器模板里加 hx-morph-skip(连子节点一起冻)或 hx-morph-skip-children(只冻子节点);也可在配置里给 morphSkip 写全局选择器。

一次响应,多处落点 · hx-partial

真实界面很少只更新一处:提交一条评论,正文列表要追加、计数角标要+1、提示条要刷新。v2 靠 hx-swap-oob 打补丁,v4 给出通盘解法——响应里直接放 hx-partial 片段,各自声明去向。

hx-partial · 一次响应,多处落点
角标 0
—— 主落点:尚未收信 ——
同一份 HTTP 响应里,主内容进 #p-main,两个 hx-partial 片段各自声明 hx-targethx-swap,分别飞向 #p-inbox(追加)与 #p-badge(替换)。v2 要达成此事得靠 hx-swap-oob 加元素 id 打补丁,v4 的 hx-partial 是它的通盘推广版。
服务器一回,三处更新
<!-- 主内容:照常换进 hx-target -->
<div class="echo-card"><h5>主响应已换入</h5></div>

<!-- 多路片段:各自带 hx-target 与 hx-swap -->
<hx-partial hx-target="#p-inbox" hx-swap="beforeend">
  <div class="echo-card">新消息</div>
</hx-partial>

<hx-partial hx-target="#p-badge" hx-swap="innerHTML">
  <span>7</span>
</hx-partial>
顺序也要留意:v4 里主内容先换,OOB 与 partial 后换。若响应只剩 OOB 片段,默认不再执行一次空的主交换——需要的话给 hx-swapswapEmpty:true,或设 htmx.config.allowEmptySwapAfterOOB

响应式引擎 · hx-live

「局部状态」曾是 htmx 唯一要外挂框架的地方。v4 用官方 hx-live 扩展补上了这块:状态就近挂在祖先的 data-* 上,hx-on 改它、hx-live:text 绑它——一次点击都不惊动服务器

零往返的局部状态
<section data-count="0">
  <button hx-on:click="data.count--">−1</button>
  <output hx-live:text="data.count">0</output>
  <button hx-on:click="data.count++">+1</button>
  <span hx-live:text="data.count % 2 === 0 ? '(偶数)' : '(奇数)'">(偶数)</span>
</section>

v4 的心智次序是:能用 HTML 用 HTML,CSS 能推导交给 CSS,都不够了才上 hx-live;实在不行才请求服务器。活的例子在演示章的「响应式引擎」一节。

继承显式化

v2 里祖先的 hx-targethx-headers 会被后代自动继承——方便,但也意味着「这属性到底从哪来的」成了悬案。v4 把它改成显式:谁要继承,谁就写 :inherited

同一件事,两种写法
<!-- htmx 2:隐式继承,后代自动获得 -->
<div hx-target="#output" hx-headers='{"X-Token":"abc"}'>
  <button hx-get="/items">Load</button>
</div>

<!-- htmx 4:显式声明,一眼看出源头 -->
<div hx-target:inherited="#output" hx-headers:inherited='{"X-Token":"abc"}'>
  <button hx-get="/items">Load</button>
</div>
hx-target:inherited · 显式继承,写一次罩一片
两个按钮都没写 hx-target——它俩从共同祖先那里显式继承下来了。v2 里这是默认行为,v4 里必须写 :inherited:append 还能在继承值之上追加而非覆盖(如 hx-include:inherited:append)。
别给所有属性无脑加 :inherited。值得继承的是那些「一块区域共享一份」的设置:hx-targethx-includehx-swaphx-boosthx-confirmhx-headershx-indicatorhx-synchx-confighx-encodinghx-validate。判断方法很简单:某个元素带着 htmx 属性、自己却没有 hx-get/hx-post 之类的动词——它多半就是个继承父。

事件名冒号化

v2 的 camelCase 事件名在 v4 统一改成冒号分层,读起来像路径:htmx:after:swap。同时一批零散的错误事件被合并进 htmx:error

htmx 2htmx 4
htmx:configRequesthtmx:config:request
htmx:beforeRequest / htmx:beforeSendhtmx:before:request
htmx:afterRequesthtmx:after:request
htmx:beforeSwap / htmx:afterSwaphtmx:before:swap / htmx:after:swap
htmx:afterSettlehtmx:after:settle
htmx:loadhtmx:after:init
htmx:beforeProcessNode / htmx:afterProcessNodehtmx:before:process / htmx:after:process
htmx:beforeHistorySave / htmx:beforeHistoryUpdatehtmx:before:history:update
htmx:pushedIntoHistory / htmx:replacedInHistoryhtmx:after:history:push / htmx:after:history:replace
htmx:beforeTransitionhtmx:before:viewTransition
htmx:responseErrorhtmx:response:error
htmx:sendError htmx:swapError htmx:targetError htmx:timeout htmx:sendAborthtmx:error(合并)

事件详情结构也换了:v2 直接把 XHR 摊在 detail 上,v4 统一收进 detail.ctx

改请求:从 detail 到 detail.ctx
// htmx 2
document.addEventListener('htmx:configRequest', (evt) => {
  evt.detail.headers['X-Custom'] = 'value';
  evt.detail.parameters['key'] = 'value';
  evt.detail.path = '/modified-url';
});

// htmx 4
document.addEventListener('htmx:config:request', (evt) => {
  evt.detail.ctx.request.headers['X-Custom'] = 'value';
  evt.detail.ctx.request.body.set('key', 'value');   // FormData
  evt.detail.ctx.request.action = '/modified-url';
});
两条没有 v4 对应物的分支:校验相关事件(htmx:validation:validate 等)改用原生表单校验;htmx:xhr:* 系列随 XHR 一并消失。

请求头协议

方向htmx 2htmx 4
请求HX-Trigger(值为元素 id)HX-Source(值为 tag#id
请求HX-Trigger-Name移除,改用 HX-Source
请求HX-Prompt改为 hx-prompt 扩展提供
请求——HX-Request-Typefullpartial(新增)
响应HX-Trigger-After-Swap / HX-Trigger-After-Settle移除,一律用 HX-Trigger

照旧可用的响应头:HX-TriggerHX-Push-UrlHX-Replace-UrlHX-RedirectHX-LocationHX-RefreshHX-RetargetHX-ReswapHX-Reselect

HX-* 请求头 · 服务器现场回显
v4 把请求头 HX-Trigger 改名 HX-Source,值也从「元素 id」变成「标签#id」;HX-Trigger-Name 直接移除。新增 HX-Request-Type——full 表示这次响应是整页,partial 表示只换局部,服务器据此决定要不要渲染外壳。

状态码:全都换入

这是最容易让人措手不及的一处行为变更:v2 里 4xx/5xx 默认不换入,v4 里除了 204(No Content)与 304(Not Modified)之外全部换入。服务器返回的错误 HTML 因此会直接出现在页面上——这既是能力(配合 hx-status 精准分流),也是陷阱。

两招:要么分流,要么恢复旧行为
<!-- 精准分流:422 改道到错误区,5xx 干脆不换 -->
<form hx-post="/register" hx-target="#result"
      hx-status:422="target:#errors"
      hx-status:5xx="swap:none">…</form>

<!-- 全局恢复 htmx 2 的默认:4xx/5xx 不换入 -->
<meta name="htmx-config" content='{"noSwap": [204, 304, "4xx", "5xx"]}'>
若想让某次请求「什么都不换」,最干净的办法是让服务器返回 204 No Content——比在前端打补丁体面得多。

配置改名

htmx 2htmx 4
defaultSwapStyledefaultSwap
globalViewTransitionstransitions
historyEnabledhistorytrue / false / reload
includeIndicatorStylesincludeIndicatorCSS
timeoutdefaultTimeout(默认改为 60000ms)
selfRequestsOnlymodesame-origin / cors / no-cors
responseHandlingnoSwap + hx-status

一批配置直接移除且无对应物refreshOnHistoryMisshistoryCacheSizedefaultSwapDelayaddedClasssettlingClassswappingClassallowEvalallowScriptTagsattributesToSettleuseTemplateFragmentswsReconnectDelaywsBinaryTypedisableSelectorwithCredentialsscrollBehaviorgetCacheBusterParammethodsThatUseUrlParamsignoreTitlescrollIntoViewOnBoosttriggerSpecsCacheallowNestedOobSwaps

v4 的配置值统一走 HCON——它接受 JSON,也接受更省事的写法:<meta name="htmx-config" content="defaultSwap:outerHTML, logAll:true">hx-valshx-headershx-confighx-status 的值同样是 HCON。

扩展机制重写

扩展 API 从「一个 onEvent 回调打天下」改成「按事件命名钩子」,注册函数也更了名:

从回调式到事件式
// htmx 2
htmx.defineExtension('my-ext', {
  onEvent: function (name, evt) {
    if (name === 'htmx:configRequest') { /* … */ }
  },
  transformResponse: function (text, xhr, elt) { /* … */ }
});

// htmx 4
htmx.registerExtension('my-ext', {
  init(api) { /* 拿到内部 API 备用 */ },
  htmx_config_request(elt, detail) {
    detail.ctx.request.headers['X-Tag'] = 'my-ext';
  },
  htmx_after_request(elt, detail) {
    console.log('落幕', detail.ctx.request.action);
  }
});
htmx 2 APIhtmx 4
htmx.defineExtension()htmx.registerExtension()
htmx.addClass() / removeClass() / toggleClass()element.classList 原生方法
htmx.closest() / htmx.remove() / htmx.off()elt.closest() / elt.remove() / removeEventListener()
htmx.values(elt)new FormData(elt)
htmx.swap(target, content, spec)htmx.swap(ctx)(签名变了)
htmx.takeClass()移除,改用 hx-livehtmx.live.take()
——新增 htmx.timeout();日志直连 console(受 logAll 控制)
hx-ext 属性已成历史——引入脚本即生效。想收紧可加载的扩展,用配置白名单 extensions,值为注册名(取自 registerExtension 的首参,未必等于文件名:hx-sse.js 注册为 ssehtmx-2-compat.js 注册为 compat)。

迁移路径

官方给了三件过渡工具,按「先看清、再缓冲、最后收敛」的顺序用:

一、先跑官方升级检查器

扫一遍全项目,输出 file:line 与建议改法
npx htmx.org@4.0.0 upgrade-check -- ./path/to/project

# 默认只认 .html .php .js .ts .jinja .jinja2 .j2 .erb .hbs
npx htmx.org@4.0.0 upgrade-check --ext .vue ./path/to/project

它只报告、不改写,输出即是你的工作清单。版本号请钉在你准备迁往的那个 v4 发行版上。

二、来不及大改,就用两座桥

配置开关 + 兼容扩展
<!-- 桥一:恢复 v2 的两项默认行为 -->
<meta name="htmx-config" content='{
    "implicitInheritance": true,
    "noSwap": [204, 304, "4xx", "5xx"]
}'>

<!-- 桥二:v2 的属性名与事件名在 v4 上继续可用 -->
<script src="/vendor/htmx.js"></script>
<script src="/vendor/ext/htmx-2-compat.js"></script>
版本通道是个坑:htmx 4.0.0 发布在 npm 的 next 标签下,latest 仍钉在 2.0.10。只查 latest 会误判「已是最新」,装 v4 必须显式写 htmx.org@4.0.0

三、收敛顺序

  1. 第一步只做 hx-disable 的语义归位——顺序错了会静默改行为;
  2. 再删掉已移除的属性(hx-varshx-paramshx-exthx-inherithx-disinherithx-history);
  3. 接着给真正被继承的属性补 :inherited——别无脑全加;
  4. 然后是事件名、事件处理代码、配置项;
  5. 最后检查服务端的 HX-* 头处理(常在中间件或基类控制器里);
  6. 自定义扩展只能重写,没有平移路径。

破坏性变更速查

换成
hx-vars='…'hx-vals='js:…'(值包一层 js: 前缀)
hx-params='…'移除;改用 htmx:config:request 事件过滤参数
hx-prompt='…'引入 hx-prompt 扩展,语法不变
hx-ext='…'移除;引入扩展脚本即可
hx-disinherit='…' / hx-inherit='…'移除;改用属性上的 :inherited 修饰符
hx-request='…'hx-config='…',取 HCON(兼容旧 JSON)
hx-history="false"移除;历史不再用 localStorage
data-hx-*无需改动config.prefix 默认就是 data-hx-,且是叠加而非替换
GET/DELETE 自动带上所在表单不再自动带;需要就写 hx-include="closest form"
OOB 先于主内容交换主内容先,OOB 与 partial 后
只含 OOB 的响应会清空主落点默认不再空换;要恢复请加 swapEmpty:true
4xx/5xx 不换入全部换入(204/304 除外)

任何标签都能发起请求——这才叫超文本。v4 没有改变这句话,只是把通往它的路修得更直。

回到根本——下一章 理念,说清 htmx 到底在反抗什么。

静态快照 · 部分演示需动态服务器