☰
ASP.NET Core Minimal API 集成 HTMX `hx-preserve`:页面局部刷新时如何保留 DOM 元素不被替换
2026/10/15 3:04:00 网站建设 项目流程
  • 示例工程

【免费下载链接】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.

项目地址:https://gitcode.com/gh_mirrors/pr/practical-aspnetcore
点击查看免费下载

本文围绕 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>

这里有两个关键点:

  1. hx-preserve的取值:属性值为布尔语义的true。被标记的元素必须有一个稳定的id,id是 htmx 判断"新旧元素是否为同一个"的依据。
  2. 默认交换行为:未显式指定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的工作方式:

  1. 页面首次渲染时,<li hx-get="/htmx">内部是<div id="get" hx-preserve="true">GET Preserved</div>;
  2. 点击该<li>后,htmx 向/htmx发起 GET 请求,默认将响应以innerHTML交换进这个<li>;
  3. 交换发生时,htmx 在新响应中查找id="get"的元素,发现当前 DOM 中已存在同 id 且带hx-preserve="true"的元素,于是保留旧元素,不替换它;
  4. 最终效果:<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。验证步骤:

  1. 依次点击五个条目,观察控制台 Network 面板:每个动词都会向/htmx发出 AJAX 请求,非 GET 请求的请求头中携带防伪令牌;
  2. 注意页面文字:五个<div>的文本(如 "GET Preserved")始终不变,证明hx-preserve生效;
  3. 去掉响应片段中的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.

项目地址:https://gitcode.com/gh_mirrors/pr/practical-aspnetcore
点击查看免费下载
上一篇:3分钟搞定Axure RP中文界面:免费汉化包终极指南
下一篇:技术视角:Waydroid容器化架构与Linux原生Android运行环境创新

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询