コンテンツにスキップ

Razor(ASP.NET Core)

このガイドは ASP.NET Core 8+ の Razor Pages または MVC を扱います。 Blazor のページ内でサーバレンダリングされたフラグメントと htmx 駆動の スワップを混在させたい場合、パターンは Blazor Server でも機能します。

HC の dist ファイルを wwwroot/ 配下に置きます。静的ファイルは UseStaticFiles()(新規テンプレートではデフォルトで有効)で配信され ます。

wwwroot/
assets/
hc/
hc.css
hc.behaviors.min.js
macros/
index.min.js
htmx.min.js
Pages/Shared/
_Layout.cshtml
_ToastRegion.cshtml

Pages/Shared/_Layout.cshtml:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>@(ViewData["Title"] ?? "My app")</title>
<link rel="stylesheet" href="~/assets/hc/hc.css" asp-append-version="true">
<script defer src="~/assets/htmx.min.js" asp-append-version="true"></script>
<script type="module" src="~/assets/hc/hc.behaviors.min.js" asp-append-version="true"></script>
<script type="module" src="~/assets/hc/macros/index.min.js" asp-append-version="true"></script>
@* htmx リクエスト用の antiforgery トークン — auto-init の
installCsrfHeader() ビヘイビアが読み取り、data-header で
ASP.NET Core の期待するヘッダー名に変えます。 *@
<meta name="csrf-token" data-header="RequestVerificationToken"
content="@Antiforgery.GetAndStoreTokens(Context).RequestToken">
</head>
<body>
@RenderBody()
<partial name="_ToastRegion" />
</body>
</html>

asp-append-version タグヘルパーはキャッシュバスティング用ハッシュを 付けます — HC の CSS バンドルには重要です。

_ViewImports.cshtmlIAntiforgery を一度だけ注入します:

@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery

Razor のパーシャルビューは HC のパーツクラス構造に自然に対応します。

@* Pages/Shared/_Field.cshtml *@
@model FieldModel
<div class="hc-field" data-invalid="@(Model.Invalid ? "true" : null)">
<label class="hc-field__label" for="@Model.Name">@Model.Label</label>
<input
id="@Model.Name"
name="@Model.Name"
value="@Model.Value"
class="hc-input"
aria-invalid="@(Model.Invalid ? "true" : null)"
aria-describedby="@(Model.Invalid ? $"{Model.Name}-error" : null)" />
@if (!string.IsNullOrEmpty(Model.Message))
{
<p id="@($"{Model.Name}-error")" class="hc-field__message">@Model.Message</p>
}
</div>
public record FieldModel(string Name, string Label,
string Value, string Message, bool Invalid);

ページからの利用:

@page
@model CreateUserModel
<form method="post" asp-page="Create"
data-hx-post="@Url.Page("Create")"
data-hx-target="this"
data-hx-swap="outerHTML">
<partial name="_Field" model="@(new FieldModel(
Name: "email",
Label: "Email",
Value: Model.Email,
Message: ModelState["Email"]?.Errors.FirstOrDefault()?.ErrorMessage,
Invalid: !ModelState.IsValid && ModelState["Email"]?.Errors.Count > 0))" />
<button class="hc-button" data-variant="primary" type="submit">Create</button>
</form>

htmx スワップには PartialViewResult を返します:

Pages/Items/Index.cshtml.cs
public class IndexModel : PageModel
{
private readonly IItemStore _store;
public IndexModel(IItemStore store) => _store = store;
public IList<Item> Items { get; private set; } = new List<Item>();
public IActionResult OnGet()
{
Items = _store.All();
if (Request.Headers["HX-Request"] == "true")
{
return Partial("_Rows", Items);
}
return Page();
}
public IActionResult OnPostDelete(int id)
{
if (!_store.TryDelete(id, out var item))
{
return NotFound();
}
// HxTrigger is the JsonObject helper from "Toasts via
// HX-Trigger" below — System.Text.Json cannot emit the
// "hc:toast" key from a C# property name.
Response.HxTrigger("hc:toast", new
{
message = $"Deleted \"{item.Name}\".", variant = "success",
});
return new EmptyResult();
}
}

Pages/Items/_Rows.cshtml:

@model IEnumerable<Item>
@foreach (var item in Model)
{
<tr id="item-@item.Id">
<td>@item.Name</td>
<td>
<span class="hc-badge" data-variant="@item.Status">@item.StatusLabel</span>
</td>
<td>
<span class="hc-action">
<button
class="hc-button"
data-size="sm"
data-variant="error"
type="button"
data-hc-confirm="Delete @item.Name?"
data-hx-delete="/items/@item.Id"
data-hx-trigger="hc:confirmed"
data-hx-target="closest tr"
data-hx-swap="outerHTML"
data-hx-disabled-elt="this"
data-hx-indicator="closest .hc-action">
Delete
</button>
<span class="hc-spinner htmx-indicator" aria-hidden="true"></span>
</span>
</td>
</tr>
}

System.Text.Json はプロパティ名から "hc:toast"(コロン)を直接 出力できません。JSON をリテラルで組み立てるか、JsonObject を使い ます:

using System.Text.Json.Nodes;
public static class HxTriggerExtensions
{
public static void HxTrigger(this HttpResponse response,
string @event, object detail)
{
var existing = response.Headers["HX-Trigger"].ToString();
var node = string.IsNullOrEmpty(existing)
? new JsonObject()
: JsonNode.Parse(existing)!.AsObject();
node[@event] = JsonSerializer.SerializeToNode(detail);
response.Headers["HX-Trigger"] = node.ToJsonString();
}
}

ハンドラでは:

Response.HxTrigger("hc:toast", new {
message = "Saved.", variant = "success"
});

ASP.NET Core の antiforgery システムは、htmx リクエストに RequestVerificationToken ヘッダーを使います。上の _Layout.cshtml スニペットは、トークンをキットの blessed な <meta name="csrf-token"> タグに data-header="RequestVerificationToken" 付きで描画します。auto-init の behaviors バンドルの installCsrfHeader() がそれを全 htmx リクエストに付与するため、 手書きの htmx:configRequest フックは不要です。

Razor Pages では、POST ハンドラに対して antiforgery はデフォルトで 有効です。ほとんどの場合 [ValidateAntiForgeryToken] は不要です — フレームワークが配線してくれます。MVC コントローラでは、 POST / PUT / DELETE アクションに属性を付けてください。

public bool IsHtmx => Request.Headers["HX-Request"] == "true";

カスタム属性やフィルタで、htmx 以外のリクエストをフルページへ 短絡させられます:

public class HtmxOnlyAttribute : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext ctx)
{
if (ctx.HttpContext.Request.Headers["HX-Request"] != "true")
{
ctx.Result = new RedirectToActionResult("Index", "Home", null);
}
}
}
  • タグヘルパー — HC は data-* 属性に徹しているため、ASP.NET の タグヘルパー(asp-forasp-validation-for)と衝突せずに組み合い ます。
  • バリデーションサマリー — Razor の <span asp-validation-for>.hc-field ラッパーの data-invalid="true" と組み合わせ、視覚と 支援技術の合図を同期させてください。
  • Blazor Server — Blazor と htmx を混在させるときは、サーバ レンダリングの断片は MarkupString 経由で HC コンポーネントを描画し、 同じ DOM サブツリー内で Blazor の diff と htmx のスワップを交差配線 しないようにしてください。