- 示例工程
【免费下载链接】practical-aspnetcore
Practical samples of ASP.NET Core 11, 10, 9, 8.0, 7.0, 6.0, 5.0, 3.1, 2.2, and 2.1,projects you can use. Readme contains explanations on all projects.
本文围绕 practical-aspnetcore 仓库中 hx-preserve 示例 展开,讲解 HTMX 的hx-preserve属性在 ASP.NET Core Minimal API 实战中的完整用法:如何在 GET / POST / PUT / PATCH / DELETE 五种请求触发的局部刷新(swap)过程中,让带有稳定id的元素保持原样不被替换。读完本文,你将掌握hx-preserve的标记方式、它与服务端返回 HTML 的匹配规则,以及一个带防伪令牌(Anti-Forgery)校验的完整可运行示例的搭建方法。
示例背景:hx-preserve 在 HTMX 属性体系中的位置
practical-aspnetcore 仓库的 projects/htmx/Readme.md 收录了 40 个 HTMX 集成示例,全部直接通过 jsDelivr CDN 加载 HTMX 4 浏览器库(htmx.org@4.0.0),不需要任何 HTMX NuGet 包。这些示例分为 AJAX、核心属性(Core Attributes)、表单、事件、响应头等几大类,hx-preserve被归入Core Attributes(核心属性)一栏,官方定位是"keep an element unchanged during HTML swap"——即让某个元素在 HTML 交换过程中保持不变。
与其语义相近的还有hx-swap(控制响应内容交换到哪个位置)、hx-select-oob(从响应中挑元素做带外交换)等属性;而hx-preserve解决的是另一个方向的问题:当目标区域的其余内容需要更新时,某些元素(如输入框焦点、正在播放的媒体、展开的标签页)必须原封不动地留在页面上。
一、最小演示:五条指令对应五个保留元素
原文档给出了一个非常精简的客户端骨架:在<ul>中放置五个<li>,每个<li>用hx-get/hx-post/hx-put/hx-patch/hx-delete触发指向/htmx的 AJAX 请求,每个<li>内部都有一个携带hx-preserve="true"的<div>:
<ul> <li hx-get="/htmx"> <div id="get" hx-preserve="true">GET Preserved</div> </li> <li hx-post="/htmx"> <div id="post" hx-preserve="true">POST Preserved</div> </li> <li hx-put="/htmx"> <div id="put" hx-preserve="true">PUT Preserved</div> </li> <li hx-patch="/htmx"> <div id="patch" hx-preserve="true">PATCH Preserved</div> </li> <li hx-delete="/htmx"> <div id="delete" hx-preserve="true">DELETE Preserved</div> </li> </ul>这里有两个关键点:
hx-preserve的取值:属性值为布尔语义的true。被标记的元素必须有一个稳定的id,id是 htmx 判断"新旧元素是否为同一个"的依据。- 默认交换行为:未显式指定
hx-swap和hx-target时,htmx 的默认行为是把服务端返回的 HTML 以innerHTML方式交换进触发元素自身(即每个<li>内部)。也就是说,每次点击后<li>内部本该被服务端返回的新内容整体替换——hx-preserve恰恰是这条规则的特例。
二、完整可运行示例:Program.cs 全解析
仅靠上面的 HTML 片段还不能演示出效果,仓库中的 Program.cs 用 ASP.NET Core Minimal API 补全了整个闭环:一个渲染页面的根路由 + 一组处理五类 HTTP 动词的/htmx端点。下面是完整的服务端代码:
using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(); builder.Services.AddAntiforgery(); var app = builder.Build(); app.UseAntiforgery(); app.MapGet("/", (HttpContext context, [FromServices] IAntiforgery anti) => { var token = anti.GetAndStoreTokens(context); var html = $$""" <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> <style> li{ cursor:pointer; } </style> <meta name="htmx-config" content='{ "antiForgery": {"headerName" : "{{ token.HeaderName}}", "requestToken" : "{{token.RequestToken }}" } }'> </head> <body class="container"> <h1>hx-preserve</h1> <p>Click on the below links to see the response</p> <ul> <li hx-get="/htmx"> <div id="get" hx-preserve="true">GET Preserved</div> </li> <li hx-post="/htmx"> <div id="post" hx-preserve="true">POST Preserved</div> </li> <li hx-put="/htmx"> <div id="put" hx-preserve="true">PUT Preserved</div> </li> <li hx-patch="/htmx"> <div id="patch" hx-preserve="true">PATCH Preserved</div> </li> <li hx-delete="/htmx"> <div id="delete" hx-preserve="true">DELETE Preserved</div> </li> </ul> <script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script> <script> document.addEventListener("htmx:config:request", (evt) => { let httpVerb = evt.detail.ctx.request.method.toUpperCase(); if (httpVerb === 'GET') return; let antiForgery = htmx.config.antiForgery; if (antiForgery) { // already specified on form, short circuit if (evt.detail.ctx.request.body.has(antiForgery.formFieldName)) return; if (antiForgery.headerName) { evt.detail.ctx.request.headers[antiForgery.headerName] = antiForgery.requestToken; } else { evt.detail.ctx.request.body.set(antiForgery.formFieldName, antiForgery.requestToken); } } }); </script> </body> </html> """; return Results.Content(html, "text/html"); }); var htmx = app.MapGroup("/htmx").AddEndpointFilter(async (context, next) => { if (context.HttpContext.Request.Headers.ContainsKey("HX-Request") is false) return Results.Content(""); if (context.HttpContext.Request.Method == "GET") return await next(context); await context.HttpContext.RequestServices.GetRequiredService<IAntiforgery>()!.ValidateRequestAsync(context.HttpContext); return await next(context); }); htmx.MapGet("/", (HttpRequest request) => { return Results.Content($"""<div id="get" hx-preserve="true"></div> GET => {DateTime.UtcNow}"""); }); htmx.MapPost("/", (HttpRequest request) => { return Results.Content($"""<div id="post" hx-preserve="true"></div> POST => {DateTime.UtcNow}"""); }); htmx.MapDelete("/", (HttpRequest request) => { return Results.Content($"""<div id="delete" hx-preserve="true"></div> DELETE => {DateTime.UtcNow}"""); }); htmx.MapPut("/", (HttpRequest request) => { return Results.Content($"""<div id="put" hx-preserve="true"></div> PUT => {DateTime.UtcNow}"""); }); htmx.MapPatch("/", (HttpRequest request) => { return Results.Content($"""<div id="patch" hx-preserve="true"></div> PATCH => {DateTime.UtcNow}"""); }); app.Run();1. 根路由:页面渲染与令牌注入
GET /通过IAntiforgery.GetAndStoreTokens(context)获取防伪令牌集合AntiforgeryTokenSet,然后把HeaderName与RequestToken写入<meta name="htmx-config">标签。htmx 4 会读取该 meta 配置并合并进htmx.config,于是页面脚本中的htmx.config.antiForgery.headerName和htmx.config.antiForgery.requestToken就有了值。整个页面用Results.Content(html, "text/html")以 HTML 形式返回,并使用 Pico CSS 做基础样式,li{cursor:pointer}让五个条目呈现可点击样式。
2. 客户端:htmx:config:request事件注入防伪头
在页面底部脚本中,监听器监听 htmx 的htmx:config:request事件——该事件在每次请求发出前触发,是统一修改请求参数的入口(仓库另有 htmx-config-request 示例 专门讲解这一机制):
- GET 直接跳过:
if (httpVerb === 'GET') return;,因为 GET 不产生副作用,无需防伪校验; - 防伪令牌注入:从
htmx.config.antiForgery读取配置,若请求体已带formFieldName字段(例如表单内已显式提交令牌)则短路;否则优先把令牌写入请求头(headerName),没有配置 header 名时才写入请求体字段。
这样,POST / PUT / PATCH / DELETE 请求都会自动携带防伪令牌,与服务端校验对应。
3./htmx路由组:统一端点过滤器
app.MapGroup("/htmx")为五个动词端点附加了同一个 endpoint filter,形成三道防线:
| 检查项 | 行为 | 说明 |
|---|---|---|
是否携带HX-Request请求头 | 否 → 返回空内容 | 浏览器地址栏直接访问/htmx时得到空响应,防止误操作;htmx 发起的 AJAX 请求都会自动带上该头 |
| 请求方法是否为 GET | 是 → 直接放行 | GET 只读,不校验防伪令牌 |
| 其余动词 | 调用IAntiforgery.ValidateRequestAsync校验令牌 | 校验失败会被拒绝,未携带令牌的非 GET 请求无法通过 |
五个端点分别返回带时间戳的 HTML 片段,例如 GET 端点返回:
<div id="get" hx-preserve="true"></div> GET => 2026-10-14 05:xx:xx注意响应中保留了与页面初始标记完全相同的id和hx-preserve属性——这正是保留机制生效的前提(见下一节)。
三、hx-preserve 的保留机制:id 匹配决定一切
从本示例可以清晰验证hx-preserve的工作方式:
- 页面首次渲染时,
<li hx-get="/htmx">内部是<div id="get" hx-preserve="true">GET Preserved</div>; - 点击该
<li>后,htmx 向/htmx发起 GET 请求,默认将响应以innerHTML交换进这个<li>; - 交换发生时,htmx 在新响应中查找
id="get"的元素,发现当前 DOM 中已存在同 id 且带hx-preserve="true"的元素,于是保留旧元素,不替换它; - 最终效果:
<li>里的文字仍然显示 "GET Preserved",而服务端返回的GET => {时间戳}部分不会上屏——因为整个#get元素被原样保留了。
也就是说,即使服务端每次返回的时间戳都在变化,页面上五个条目的文字也始终纹丝不动。这个"看得见的稳定性"就是hx-preserve的演示价值:它适用于输入框焦点保持、视频/音频播放不中断、展开状态的标签页等场景——任何你不希望在局部刷新时被重建的 DOM 子树。
需要强调的实现事实(均可由本示例代码佐证):
hx-preserve必须搭配id使用,且新旧内容中 id 必须一致,否则保留无从谈起;- 被保留的是元素及其整个子树,包括其中已变化的文本、输入值和事件状态;
- 该属性与
hx-swap的交换策略正交,hx-preserve相当于在既定交换策略之上附加了"白名单例外"。
四、运行与验证
该示例的目标框架为net10.0,开启了ImplicitUsings与LangVersion=preview(见 hx-preserve.csproj),仓库根目录 global.json 指定 SDK 版本10.0.0并允许rollForward: major。运行方式:
# 从仓库根目录直接指定项目运行 dotnet run --project projects/htmx/hx-preserve或先进入示例目录再运行:
cd projects/htmx/hx-preserve dotnet run启动后浏览器访问http://localhost:5000(具体端口以控制台输出为准),可以看到标题为hx-preserve的页面,下方五个条目分别标注 GET / POST / PUT / PATCH / DELETE。验证步骤:
- 依次点击五个条目,观察控制台 Network 面板:每个动词都会向
/htmx发出 AJAX 请求,非 GET 请求的请求头中携带防伪令牌; - 注意页面文字:五个
<div>的文本(如 "GET Preserved")始终不变,证明hx-preserve生效; - 去掉响应片段中的
hx-preserve="true"(或去掉 id)再试,元素会被服务端内容替换,文本随之变为GET => 时间戳,形成鲜明对比——这是最直观的对照实验。
五、源码结构一览
| 文件 | 作用 |
|---|---|
| Program.cs | 全部服务端逻辑:页面渲染、防伪令牌注入、/htmx路由组与端点过滤器 |
| README.md | 原文档:hx-preserve客户端标记的最小示例 |
| hx-preserve.csproj | 项目文件:net10.0目标框架 |
| hx-preserve.sln | 解决方案文件,可直接用 IDE 打开 |
六、注意事项与延伸阅读
- 防伪校验与本主题的关系:本示例虽然主题是
hx-preserve,但端点过滤器与令牌注入是 HTMX 4 集成 ASP.NET Core 的"标配"防线。GET 之外的动词若不携带令牌,ValidateRequestAsync会直接拒绝请求,因此把本示例的五个动词端点完整保留才能得到"可点击 → 可保留 → 校验通过"的完整体验。 - 保留元素的更新策略:
hx-preserve让元素"不进不出",如果业务上既要保留状态又需要更新其中的部分内容,应改用hx-swap-oob(带外交换,见 swap-2 示例)或把可变的文本放到保留元素之外,避免两者混淆。 - 相关属性对照:若想控制"响应内容交换到哪里",参见 swap 示例 与 hx-target 示例;若需要从响应中挑选多个元素再合并,参见 hx-select-oob 示例。这些属性与
hx-preserve共同组成了 HTMX 响应处理的完整工具集,全部可在 projects/htmx/Readme.md 中按类别检索。
- 示例工程
【免费下载链接】practical-aspnetcore
Practical samples of ASP.NET Core 11, 10, 9, 8.0, 7.0, 6.0, 5.0, 3.1, 2.2, and 2.1,projects you can use. Readme contains explanations on all projects.
相关推荐
htmx hx-preserve 属性完全指南:如何在 HTML 替换中保持元素状态不变
htmx hx preserve 属性完全指南:如何在 HTML 替换中保持元素状态不变 hx preserve 是 htmx 中用于在 DOM 替换(swap
前端Web框架ASP.NET Core Minimal API 实战:在 HTMX 4 中用 hx-config 按元素配置请求行为
ASP.NET Core Minimal API 实战:在 HTMX 4 中用 hx config 按元素配置请求行为 导读 本篇文章基于 practical
示例工程practical-aspnetcore 实战:在 ASP.NET Core Minimal API 中用 HX-Retarget 响应头动态重定向 htmx 元素
practical aspnetcore 实战:在 ASP.NET Core Minimal API 中用 HX Retarget 响应头动态重定向 htmx
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考