- 前端
【免费下载链接】htmx
htmx - high power tools for HTML
hx-preserve是 htmx 中用于在 DOM 替换(swap)过程中按id保留元素原样不变的核心属性,适用于视频播放器、音频、地图画布、聊天滚动区域等需要维持内部状态与交互连续性的场景。读完本文你将掌握hx-preserve的完整用法、与hx-swap-oob的组合技巧、它的实现原理(pantry 暂存机制与moveBeforeAPI)、已知限制及测试验证方式。
一、hx-preserve 是什么
在 htmx 的常规工作流中,当某个请求返回响应后,htmx 会用响应内容替换目标元素的 DOM。这种"整块替换"模型虽然简单,但在某些场景下会带来副作用:例如一个正在播放的视频、一段正在进行的音频、一个已聚焦且光标位于特定位置的输入框,如果被整体替换,播放状态、焦点和光标位置都会丢失。
hx-preserve属性正是为解决这类问题而设计:带有hx-preserve的元素,在 htmx 更新其任意祖先元素时,会按id被保留下来,不被响应中的新内容覆盖。该属性的官方定义位于 hx-preserve.md,其核心语义为:
Elements with
hx-preserveset are preserved byidwhen htmx updates any ancestor element.
也就是说,保留的匹配依据是id,而不是元素类型或属性集合。
使用前提
使用hx-preserve有两条硬性要求:
- 必须为元素设置一个不会变化的
id,否则 htmx 无法定位"旧元素"与"响应中的新元素"之间的对应关系; - 服务端响应中必须包含一个具有相同
id的元素——但响应中该元素的类型(tagName)和其他属性都会被忽略,htmx 只利用这个id作为"标记",实际保留的是页面上现有的那个元素。
二、基本用法与两种书写形式
hx-preserve支持两种等价的书写方式:
<!-- 布尔属性形式 --> <div id="video-player" hx-preserve> <!-- 播放器内容 --> </div> <!-- 显式取值形式 --> <div id="video-player" hx-preserve="true"> <!-- 播放器内容 --> </div>根据官方文档的说明,hx-preserve不是继承属性——它只会作用于自身带有该属性的元素,不会传递给子元素。因此如果你需要保留多个相互独立的元素,需要分别在每个元素上设置。
一个完整的最小示例
<!-- 页面上的原始内容 --> <div hx-get="/refresh" hx-target="#content"> <div id="content"> <div id="video" hx-preserve> <!-- 正在播放的视频,希望 swap 后继续播放 --> </div> <div id="news"> <!-- 普通内容,允许被替换 --> </div> </div> </div><!-- /refresh 的响应 --> <div id="content"> <div id="video hx-preserve"> <!-- 这里放什么内容都无所谓,页面上的原 video 会被保留 --> </div> <div id="news"> 最新新闻... </div> </div>交换完成后,#news会被更新为响应中的新内容,而#video依旧是页面上的原始 DOM 节点,其播放状态、滚动位置等内部状态得以完整保留。
典型适用场景
官方文档在 docs.md 中给出的典型例子是"希望保持播放状态的视频播放器"(a video player that you wish to remain playing)。其他常见场景还包括:
- 音频播放器 / 播客进度条,swap 后不希望重新加载或暂停;
- Canvas / WebGL 绘图区域、代码编辑器等持有大量内部状态的组件;
- 滚动位置敏感的容器(如聊天消息列表);
- 需要维持展开/收起状态的面板。
三、源码级实现原理:pantry 暂存机制与 moveBefore
要深入理解hx-preserve的行为边界,需要阅读它的底层实现。相关逻辑全部集中在 src/htmx.js 中,由两个函数协作完成:
1. handlePreservedElements:把旧元素"暂存"起来
在正式执行 swap 之前,htmx 会调用handlePreservedElements(fragment)(src/htmx.js),遍历响应 fragment 中所有[hx-preserve], [data-hx-preserve]元素(注意:data-hx-preserve是等价的 data 属性形式):
function handlePreservedElements(fragment) { forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function(preservedElt) { const id = getAttributeValue(preservedElt, 'id') const existingElement = getDocument().getElementById(id) if (existingElement != null) { if (preservedElt.moveBefore) { // 如果 moveBefore API 存在,则使用它 let pantry = find('#--htmx-preserve-pantry--') if (pantry == null) { getDocument().body.insertAdjacentHTML('afterend', "<div id='--htmx-preserve-pantry--'></div>") pantry = find('#--htmx-preserve-pantry--') } pantry.moveBefore(existingElement, null) } else { preservedElt.parentNode.replaceChild(existingElement, preservedElt) } } }) }从源码可以看出关键机制:
- 首先在当前文档中按
id查找现有的旧元素(getElementById),而不是在 fragment 里找; - 若找到了旧元素,htmx 会把旧元素从当前位置移出,暂存到一个特殊容器
#--htmx-preserve-pantry--中(该容器通过insertAdjacentHTML('afterend', ...)插入到body之后,仅作为临时存放点); - 优先使用现代浏览器提供的
moveBeforeAPI 完成"移动而不复制";若浏览器不支持moveBefore,则退回到replaceChild,即用旧元素直接替换 fragment 中对应的新元素副本。
2. restorePreservedElements:把旧元素"放回"原位
完成 swap 之后,htmx 调用restorePreservedElements()(src/htmx.js)恢复现场:
function restorePreservedElements() { const pantry = find('#--htmx-preserve-pantry--') if (pantry) { for (const preservedElt of [...pantry.children]) { const existingElement = find('#' + preservedElt.id) existingElement.parentNode.moveBefore(preservedElt, existingElement) existingElement.remove() } pantry.remove() } }这段代码的执行顺序是:把 pantry 中暂存的旧元素通过moveBefore移回新 DOM 中对应id的位置,随后删除响应带来的那个"占位副本",最后移除整个 pantry 容器。整个过程对页面上的普通元素完全透明,旧元素仿佛从未离开过。
3. 在 swap 流程中的调用时机
hx-preserve的暂存与恢复被精确地嵌入到主 swap 流程中(src/htmx.js):
// partial swaps — 在 oob 之后、主 swap 之前 var hasPartials = findAndSwapPartials(fragment, settleInfo, ...) ... handlePreservedElements(fragment) // ① 主 swap 前:暂存旧元素 swapWithStyle(swapSpec.swapStyle, contextElement, target, fragment, settleInfo) // ② 执行 swap restorePreservedElements() // ③ swap 后:放回旧元素注意该机制不仅用于普通 swap,在 Out of Band(OOB)交换路径中同样被调用(src/htmx.js),这保证了hx-swap-oob场景下被保留元素也能正确暂存与恢复。从源码结构可以推断:凡是经过 htmx 统一 swap 管线的替换操作,都会先执行 preserve 预处理,再执行 swap。
四、在 hx-swap-oob 内部使用 hx-preserve
hx-preserve可以作用于hx-swap-oob元素的内部内容。官方文档给出了一个典型场景:OOB 更新通知区域,但希望其中的某个内部组件(如"重试按钮"的倒计时状态)保持不变:
<div id="notify" hx-swap-oob="true"> Notification updated but keep the same retain <div id="retain" hx-preserve></div> </div>这条规则与 hx-swap-oob 文档配合阅读更完整:当 OOB 元素被换入时,其内部带hx-preserve的子元素会以原有状态保留。
保留元素可能被"搬移"
文档还强调了一个容易被忽略的行为:当在 partial 或 OOB 响应中 swap 时,hx-preserve元素可能从其当前位置被移除,并重新定位到新位置。也就是说,它不仅能"保持状态",还能跟随响应中的结构变化而"搬家":
<div id="new_location"> Just relocated the video here <div id="video" hx-preserve></div> </div>在这个例子中,原页面某处的#video元素会保持其播放状态,同时被移动到#new_location容器内。这一行为在 test/attributes/hx-preserve.js 中有对应的测试用例:通过hx-swap-oob='innerHTML:#d5'将保留元素连同普通元素一起重定位到另一个目标容器,测试断言#d5的内容为<div id="d3" hx-preserve="">Old Content</div><div id="d4">New oob Content</div>,即旧内容(Old Content)原样保留了。
五、与 History 支持(浏览器前进/后退)的关系
根据官方文档,当使用 History Support(即hx-push-url、hx-replace-url等带来的浏览器历史集成)时,诸如浏览器后退按钮触发的恢复场景中,hx-preserve元素的状态也会被一并保留。
这意味着你可以放心地把hx-preserve与 hx-push-url、hx-replace-url 组合使用——用户在页面间导航并返回时,被保留元素(例如一个播放中的视频)不会因历史快照恢复而被重置。
六、已知限制与注意事项
官方文档 hx-preserve.md 明确列出了以下限制与注意点:
1. 部分元素无法被完美保留
<input type="text">等可输入元素:焦点(focus)和光标位置(caret position)会丢失。虽然元素本身会被保留,但用户正在编辑的焦点体验会被打断;<iframe>以及某些类型的视频:同样无法被可靠地保留。
对于这些场景,文档推荐使用morphdom 扩展(htmx-extensions 仓库中的 morphdom-swap 扩展)——它会对新旧 DOM 做更细致的差异化合并(DOM reconciliation),从而更优雅地处理输入框、iframe 等复杂元素。
2. 避免与 hx-swap="none" 组合
避免在可能包含hx-preserve元素的请求上使用 hx-swap 设置为none。因为swap: none意味着不执行替换,此时 preserve 的暂存/恢复流程可能无法按预期工作,甚至可能导致保留元素丢失。如果请求的响应中可能携带hx-preserve元素,请使用真实的 swap 策略(如innerHTML、outerHTML)。
3. 响应中必须存在相同 id 的标记元素
保留依赖"响应中有一个同id的占位元素"。如果响应中根本没有这个id,那么旧元素不会被保留、也不会被移动——这从实现上可以理解:handlePreservedElements中的findAll(fragment, ...)找不到匹配节点,自然不会有任何保留动作。这一点在 test/attributes/hx-preserve.js 的"preserved element that might not be existing"用例中得到验证:响应中带hx-preserve的新元素(#d1)在页面原 DOM 中不存在时,会被正常插入,innerHTML为新内容。
4. 与 hx-select / hx-select-oob 的交互
- 若保留元素位于
hx-select选中的范围之外,它不会被 swap,自然也不受 preserve 影响(测试用例验证#d1内容保持Old Content); - 若保留元素是
hx-swap-oob/hx-select-oob的一部分,它同样不会被 swap 覆盖(测试用例验证#d3保持旧内容,而普通兄弟节点#d4更新为新内容)。
七、IDE 与编辑器支持
如果你使用 JetBrains 系列 IDE(WebStorm、PhpStorm 等),htmx 官方仓库提供了 Web Types 定义文件 htmx.web-types.json,其中已包含hx-preserve的条目(含完整描述文本与文档链接)。配置该 Web Types 后,在 HTML 中书写hx-preserve即可获得自动补全、语法高亮与属性提示,降低拼写错误的概率。
八、测试验证:行为即规范
htmx 仓库为hx-preserve配备了完整的单元测试,位于 test/attributes/hx-preserve.js,覆盖了以下关键行为:
| 测试用例 | 验证点 |
|---|---|
| handles basic response properly | 基本场景:保留元素保持旧内容,普通元素更新为新内容 |
| preserved element that might not be existing | 响应中的保留元素在页面不存在时,正常插入 |
| preserved element should not be swapped if it lies outside of hx-select | 位于hx-select选区外时不受影响 |
| preserved element should not be swapped if it is part of a oob swap | OOB 交换中保留元素不被覆盖 |
| preserved element should not be swapped if it is part of a hx-select-oob swap | hx-select-oob交换中保留元素不被覆盖 |
| preserved element should relocated unchanged ... | OOB 重定位到不同目标时,保留元素"原样搬家" |
| when moveBefore is disabled/missing ... | 浏览器不支持moveBefore时,退化为replaceChild拷贝路径 |
最后一个用例尤其值得注意:它通过fragment.firstChild.moveBefore = undefined模拟旧浏览器环境,并直接调用内部函数htmx._('handlePreservedElements')验证回退路径——这印证了 src/htmx.js 中"先检测moveBefore,不可用则用replaceChild"的分支设计。
九、总结:hx-preserve 的最佳实践
- 对需要保持播放/编辑/滚动等内部状态的元素设置
hx-preserve,并始终配一个稳定不变的id; - 服务端响应中保留同
id的占位元素即可,占位元素的内容与属性无关紧要; - 组合
hx-swap-oob可实现"OOB 更新 + 局部状态保留";利用"搬家"行为还可以在更新布局的同时维持元素状态; - 对
<input type="text">、iframe、部分视频等元素,优先考虑 morphdom 扩展做差异化合并; - 避免在可能携带
hx-preserve元素的请求上使用hx-swap="none"; - 记忆要点:保留靠 id、旧元素进 pantry、swap 后放回原位、浏览器不支持
moveBefore时自动回退——理解了 src/htmx.js 中这段实现,就能准确预判它在各种 swap 组合下的行为。
- 前端
【免费下载链接】htmx
htmx - high power tools for HTML
相关推荐
htmx hx-target 属性完全指南:精确控制 AJAX 响应内容的替换目标
htmx hx target 属性完全指南:精确控制 AJAX 响应内容的替换目标 hx target 是 htmx 中用于指定"响应内容到底替换哪个元素"的核
前端HTMX中hx-preserve属性在OOB交换中的问题解析与修复
HTMX中hx preserve属性在OOB交换中的问题解析与修复 在HTMX前端框架的使用过程中,开发者发现了一个关于 hx preserve 属性与OOB
前端Cursor插件数据库集成:SQL与NoSQL数据库选择终极指南
Cursor插件数据库集成:SQL与NoSQL数据库选择终极指南 Cursor插件数据库集成 是现代开发工作流中不可或缺的一环,它能让你的AI助手直接与数据库交
AI 技能AI 插件插件系统AI Agent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考