v4 新纪元
htmx 4 不是补丁版,而是从零重写。它换掉了传输层、收紧了继承模型、统一了命名法,并补上了过去只能外挂框架才做得动的两块拼图:局部响应式与差异融合。本章把 v4 的重大更新一次说清——每件都配本站服务器上的真实往返。
- fetch 取代 XHR
- 约 14 KB(min.gz)零依赖
- 事件名冒号分层
- 继承显式化
- morph 差异融合
- hx-partial 多路响应
- hx-live 响应式引擎
- hx-status 状态分流
从零重写
v2 的核心是 XMLHttpRequest,v4 换成 fetch(),随之而来的 Promise 化请求、AbortController 超时与更干净的流处理,都是这次重写的副产品。与此同时,一批「当年为了兼容而留下的岔路」被一并铲平:
| 层面 | htmx 2 | htmx 4 |
|---|---|---|
| 传输层 | XMLHttpRequest | fetch() + 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 属性 |
fetch()、属性上带 :inherited 修饰符、事件名形如 htmx:after:swap——三者任一,即是 v4。新属性家族
v4 的属性清单里,有些是新增,有些是更名,还有些是同名但语义反转——后者才是迁移时真正咬人的地方:
| 属性 | v4 的变化 | 一句话 |
|---|---|---|
| hx-query | 新增 | 第五个动词:QUERY 请求,把「带复杂条件的读取」从 GET 中分出 |
| hx-action hx-method | 新增 | 路径与动词分列两属性,动态改方法时不必换属性名 |
| hx-status:XXX | 新增 | 按状态码定制 target/swap/select/push/replace,支持 404、50x、5xx 通配 |
| 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-target 与 hx-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>QUERY 动词 · hx-query
HTTP 早有 QUERY 方法的提议:语义安全、可缓存,却允许携带比 URL 更丰富的查询体。v4 把它作为一等动词纳入核心,与 hx-get 并列——读操作从此有了「取资源」与「查条件」两种措辞。
Morph 融合:innerMorph / outerMorph
过去换内容只有「重建」一条路:整块 DOM 推倒重来,焦点、滚动、播放中的视频、正在运行的 CSS 动画一并殉葬。v4 补上了差异融合:按 id 就地比对,只改该改的地方。
| 策略 | 含义 |
|---|---|
innerMorph | 融合目标的子节点,保住焦点、滚动、动画与表单值 |
outerMorph | 连目标自身一起融合(属性与内容均按差异更新) |
outerSync | 先同步目标的属性,再替换其子节点;目标元素始终留在 DOM 里 |
textContent | 只换纯文本,不做 HTML 解析 |
before / after / prepend / append | beforebegin 等旧名的简写,写起来顺手 |
delete | 无视响应,直接删掉目标 |
none | 不换内容,但 OOB 与响应头照常处理 |
渲染于:页面首次加载
morphSkip 写全局选择器。一次响应,多处落点 · hx-partial
真实界面很少只更新一处:提交一条评论,正文列表要追加、计数角标要+1、提示条要刷新。v2 靠 hx-swap-oob 打补丁,v4 给出通盘解法——响应里直接放 hx-partial 片段,各自声明去向。
#p-main,两个 hx-partial 片段各自声明 hx-target 与 hx-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>swapEmpty: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-target、hx-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>:inherited;:append 还能在继承值之上追加而非覆盖(如 hx-include:inherited:append)。:inherited。值得继承的是那些「一块区域共享一份」的设置:hx-target、hx-include、hx-swap、hx-boost、hx-confirm、hx-headers、hx-indicator、hx-sync、hx-config、hx-encoding、hx-validate。判断方法很简单:某个元素带着 htmx 属性、自己却没有 hx-get/hx-post 之类的动词——它多半就是个继承父。事件名冒号化
v2 的 camelCase 事件名在 v4 统一改成冒号分层,读起来像路径:htmx:after:swap。同时一批零散的错误事件被合并进 htmx:error。
| htmx 2 | htmx 4 |
|---|---|
htmx:configRequest | htmx:config:request |
htmx:beforeRequest / htmx:beforeSend | htmx:before:request |
htmx:afterRequest | htmx:after:request |
htmx:beforeSwap / htmx:afterSwap | htmx:before:swap / htmx:after:swap |
htmx:afterSettle | htmx:after:settle |
htmx:load | htmx:after:init |
htmx:beforeProcessNode / htmx:afterProcessNode | htmx:before:process / htmx:after:process |
htmx:beforeHistorySave / htmx:beforeHistoryUpdate | htmx:before:history:update |
htmx:pushedIntoHistory / htmx:replacedInHistory | htmx:after:history:push / htmx:after:history:replace |
htmx:beforeTransition | htmx:before:viewTransition |
htmx:responseError | htmx:response:error |
htmx:sendError htmx:swapError htmx:targetError htmx:timeout htmx:sendAbort | htmx:error(合并) |
事件详情结构也换了:v2 直接把 XHR 摊在 detail 上,v4 统一收进 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';
});htmx:validation:validate 等)改用原生表单校验;htmx:xhr:* 系列随 XHR 一并消失。请求头协议
| 方向 | htmx 2 | htmx 4 |
|---|---|---|
| 请求 | HX-Trigger(值为元素 id) | HX-Source(值为 tag#id) |
| 请求 | HX-Trigger-Name | 移除,改用 HX-Source |
| 请求 | HX-Prompt | 改为 hx-prompt 扩展提供 |
| 请求 | —— | HX-Request-Type:full 或 partial(新增) |
| 响应 | HX-Trigger-After-Swap / HX-Trigger-After-Settle | 移除,一律用 HX-Trigger |
照旧可用的响应头:HX-Trigger、HX-Push-Url、HX-Replace-Url、HX-Redirect、HX-Location、HX-Refresh、HX-Retarget、HX-Reswap、HX-Reselect。
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"]}'>配置改名
| htmx 2 | htmx 4 |
|---|---|
defaultSwapStyle | defaultSwap |
globalViewTransitions | transitions |
historyEnabled | history(true / false / reload) |
includeIndicatorStyles | includeIndicatorCSS |
timeout | defaultTimeout(默认改为 60000ms) |
selfRequestsOnly | mode(same-origin / cors / no-cors) |
responseHandling | noSwap + hx-status |
一批配置直接移除且无对应物:refreshOnHistoryMiss、historyCacheSize、defaultSwapDelay、addedClass、settlingClass、swappingClass、allowEval、allowScriptTags、attributesToSettle、useTemplateFragments、wsReconnectDelay、wsBinaryType、disableSelector、withCredentials、scrollBehavior、getCacheBusterParam、methodsThatUseUrlParams、ignoreTitle、scrollIntoViewOnBoost、triggerSpecsCache、allowNestedOobSwaps。
<meta name="htmx-config" content="defaultSwap:outerHTML, logAll:true">。hx-vals、hx-headers、hx-config、hx-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 API | htmx 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-live 的 htmx.live.take() |
| —— | 新增 htmx.timeout();日志直连 console(受 logAll 控制) |
extensions,值为注册名(取自 registerExtension 的首参,未必等于文件名:hx-sse.js 注册为 sse、htmx-2-compat.js 注册为 compat)。迁移路径
官方给了三件过渡工具,按「先看清、再缓冲、最后收敛」的顺序用:
一、先跑官方升级检查器
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>next 标签下,latest 仍钉在 2.0.10。只查 latest 会误判「已是最新」,装 v4 必须显式写 htmx.org@4.0.0。三、收敛顺序
- 第一步只做 hx-disable 的语义归位——顺序错了会静默改行为;
- 再删掉已移除的属性(
hx-vars、hx-params、hx-ext、hx-inherit、hx-disinherit、hx-history); - 接着给真正被继承的属性补
:inherited——别无脑全加; - 然后是事件名、事件处理代码、配置项;
- 最后检查服务端的 HX-* 头处理(常在中间件或基类控制器里);
- 自定义扩展只能重写,没有平移路径。
破坏性变更速查
| 找 | 换成 |
|---|---|
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 到底在反抗什么。