在Vue/Nuxt、React/Next/TanstackStart、RazorPages折腾一圈后,还是回到了Blazor,但这回有SSR+HTMX+Alpine的加持
1、为什么折腾Vue/Nuxt、React/Next/TanstackStart、RazorPages后,又回到了Blazor
- 我的后端是AspNetCore,且很坚定,可能这就是生态锁定
- 即使现代前端有了TS加持,但毕竟是胶水层,且每个框架都或多或少有漏网之鱼,这些都影响了强类型的体感和安全感
- 我真得很喜欢RazorPages,简单直接,但是生态要和JS强绑定。出早了,如果晚一些,缺脚的交互部分能够使用HTMX和Alpine的理念补上,生态建起来,说不定都不会有Blazor这种怪咖
- 折腾一圈后,让我对如何使用Blazor,有新的思考,这也是本文的主题
2、初看Blazor很香,但深度使用后,总有尾大不掉之感
- Blazor是在现代前端框架之后建立起来的,所以语法上即有Vue模板语法的简洁,又有JSX的灵活
- Blazor从MVC、RazorPages演化而来,天生就有服务端的基因,并在.NET8的BlazorWebApp中实现SSR、Server和Wasm的集成。MVC/Razorpages的多年积累,使Blazor比之Nuxt/Next等主流前端元框架,在服务端领域有着更加精彩的表现
- 但是,Blazor先天的缺陷依然在那里,1)BlazorServer,因网络条件引发的交互延迟、断连,服务端线路的内存消耗;2)BlazorWasm,首次浏览几十M的负载,以及每次在浏览器启动Net运行时的时滞。给人感觉就是,给了你解决方案了,但问题都没解决干净;3)Auto?估计没人愿意碰这东西,BlazorWebApp刚出时,社区对Auto是很关注的,反而忽略了SSR。但是,现在好像很少人谈Auto了,SSR则更多的被关注、讨论和实验。这一继承自MVC/RazorPages的SSR,在增强式导航加持下,成为Blazor生态中最稳的选择,而且.NET11,还有一大批MVC/RazorPages上的特性将被转移到SSR,体感能进一步升级。
3、下面是我对如何使用Blazor的新思考和新实践,总的原则是将SSR立于主导,尽可能缩小Server/Wasm交互的使用范围
- 无论使用哪种交互方式,将交互位置设为“每页/每组件”,很多人可能用了很久Blazor,也不清楚全局交互和每页交互的区别。先说结论,这个选择影响非常大,它决定了底层浏览器的行为,进而影响导航、认证、预渲染等核心功能。
- 交互位置选择“每页/组件”后,布局页不要放交互式组件,只要放了交互式组件,任何页面都要建立SignalR连接或者下载和启动WASM,完全失去SSR立于主导这一原则。布局页的客户端交互功能,交给CSS和Alpine.js,实现如导航栏收起展开、折叠菜单等动态功能。
- 页面首先考虑SSR,如果要上客户端交互,先考虑Alpine;如果要上服务端交互,先考虑HTMX。SSR+HTMX+Alpine的组合,应该能解决十之八九的功能需求,剩下的复杂交互,才交给Server或者WASM,并尽量划小到组件范围。
- 对于最多只会有百来个人使用的后台管理,可以全部页面开Server交互,但交互位置仍然选择为“每页/每组件”,因为这会极大的减少认证的复杂性,进而提升认证的稳定性。
- 关于预渲染,.NET10之前是个大问题,但现在不是啥问题了。一是我们以SSR为主导,大部分页面是不会有预渲染的;二是现在有PersistState,基本解决交互页面预渲染带来的问题。但我认为预渲染,仍然是一个需要每页斟酌选择的问题,比如后台管理页面,自然是可以全部关闭的。
- 在此模式下,Server交互的内存占用有望砍掉80%,在此基础上,你的钱带子也有能力集群部署,将服务器尽可能的部署到离用户更近的位置,近而解决Server交互延迟的问题。而WASM,目前这个方案仅可以减少负载,而负载从来不是WASM的最大问题,希望未来将WASM的运行时切到CoreCLR后,能带来真正近JS的体验。
4、全局交互和每页交互,究竟有啥区别?
- 全局交互下:1)首次请求,有预渲染时,服务端渲染请求页面为HTML并发给浏览器,之后在浏览器水合、建立交互,水合过程中会再次执行组件生命周期,PersistState就是用于处理这个过程中二次请求数据的问题;无预渲染时,Server交互会下载一个JS脚本,WASM则要下载大负载,然后水合、建立交互、渲染页面,所以这个过程会有空白,Server交互的空白时间短很多。2)此后请求,在浏览器端被Server或Wasm拦截,进行差量更新,具体原理大家都懂。但这里有一个关键细节,就是之后的导航请求,不会再走浏览器到服务器的请求响应了。认证方式中,最简单也最安全的认证就是Cookie,登陆成功后SetCookie,浏览器自动保存,之后请求,浏览器自动携带。由于不再走这个过程,所以就要将认证状态保存到Server的线路中,或Wasm的浏览器运行时中,给认证带来很大的麻烦,需要一大堆辅助工具和抽象。
- 每页交互:无论是首次请求,还是后续请求,无论是SSR,还是Server或WASM交互,一律都走浏览器到服务器的请求响应,心智模式极大降低。比如认证,你知道每次都一定会走Cookie认证,绝大多数情况下你不再需要考虑SignalR线路或者WASM运行时中保存和使用认证状态的问题。
5、如何以SSR为主导?SSR页面如何添加交互功能?如何集成HTMX和Alpine?
这是这篇文章的重点,也是我的实践,核心问题其实就一个,如何集成HTMX和Alpine。这是一个完整的解决方案,开始大量贴代码。
1)App.razor,引入HTMX、Alpine和胶水代码
HTMX和Alpine包,直接使用CDN引入,因为只有几K到几十K,CDN好用
点击查看代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["KhaasWebAdmin.styles.css"]" />
<ImportMap />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<!-- HTMX:处理AJAX请求、DOM交换和服务器交互 -->
<script defer src="@Assets["https://unpkg.com/htmx.org@2.0.7"]"></script>
<!-- Alpine,Alpine插件必须在Alpine Core之前加载 -->
<script defer src="@Assets["https://unpkg.com/@alpinejs/persist@3.x.x/dist/cdn.min.js"]"></script>
<script defer src="@Assets["https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"]"></script>
<script defer src="@Assets["https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"]"></script>
<!-- HTMX+Alpine+BlazorSSR增强式导航的胶水代码 -->
<script defer src="@Assets["htmx-alpine-integration.js"]"></script>
</body>
</html>
2)www/htmx-alpine-integration.js,SSR+HTMX+Alpine的胶水代码。
需要胶水代码的主要原因,是因为SSR的增强式导航,它会对比DOM,有变化的才更新,这会影响HTMX和Alpine对DOM的监听和挂载。我们要做的,是即保留SSR的增强式导航,又让HTMX和Alpine对DOM的感知,和普通请求响应一样。好在SSR增强式导航提供了增强式导航前、导航后等事件,我们可以主动介入HTMX和Alpine的挂载,其中HTMX无状态,所以比较简单,而Alpine会相对复杂些。
点击查看代码
/* ==========================================================================
htmx-alpine-integration.js
Blazor SSR + Alpine.js + HTMX 增强导航生命周期管理
核心职责:
1) 在 Blazor 增强导航期间管理 Alpine/HTMX 的初始化和销毁
2) 区分"跨页面导航"与"同一页面导航",采取不同初始化策略
- 跨页面导航 (enhancedload 触发): 销毁旧内容 → 初始化新内容
- 同一页面导航 (enhancedload 不触发): 仅增量初始化新增元素
3) 导航完成后重建布局页 Alpine 状态
(Blazor DOM diff 会覆盖 Alpine 对布局元素的修改)
4) 支持流式渲染 (StreamRendering) 的增量更新
使用前提:
- 布局页内容区域必须有 id="main-content" (或 config.contentSelector 指定的选择器)
- 布局根元素必须有 class="mit-layout"
========================================================================== */
// ==========================================================================
// 1. 配置
// ==========================================================================
const CONFIG = {
contentSelector: '#main-content',
debug: false
};
const LOG = {
prefix: '[Blazor-HTMX-Alpine]',
debug: (...args) => CONFIG.debug && console.log(LOG.prefix, ...args),
warn: (...args) => console.warn(LOG.prefix, ...args),
error: (...args) => console.error(LOG.prefix, ...args)
};
// ==========================================================================
// 2. 导航状态
// ==========================================================================
const navState = {
isNavigating: false,
contentLoaded: false // true = enhancedload 已触发(跨页面导航)
};
// ==========================================================================
// 3. Alpine 生命周期工具
// ==========================================================================
const AlpineLifecycle = {
/** 冻结响应式更新 — 导航期间阻止 Alpine 对中间 DOM 状态做出反应 */
freeze() {
Alpine.deferMutations();
},
/** 解冻并刷新所有缓存的变更 */
unfreeze() {
if (typeof Alpine.flushAndStopDeferringMutations === 'function') {
Alpine.flushAndStopDeferringMutations();
}
},
/** 重建布局根 Alpine 指令树(导航后 Blazor DOM diff 会覆盖 Alpine 修改) */
rebuildLayout() {
const root = document.querySelector('.mit-layout');
if (!root) return;
Alpine.destroyTree(root);
Alpine.initTree(root);
},
/** 重置内容区域: 销毁旧 Alpine/HTMX → 初始化新内容 */
replaceContent(area) {
Alpine.destroyTree(area);
Alpine.initTree(area);
if (window.htmx) htmx.process(area);
},
/** 增量初始化: 只初始化新增元素(同一页面导航/流式渲染用) */
initElement(node) {
Alpine.initTree(node);
if (window.htmx) htmx.process(node);
}
};
// ==========================================================================
// 4. Alpine 初始化控制
// ==========================================================================
/* 禁用 Alpine 默认的自动 DOM 观察器,避免与手动初始化冲突导致重复绑定 */
document.addEventListener('alpine:init', () => {
Alpine.stopObservingMutations();
LOG.debug('✅ Alpine 自动 DOM 观察已禁用,初始化控制权已移交');
});
// ==========================================================================
// 5. 初始加载: 首次访问时初始化布局 Alpine
// ==========================================================================
/* Alpine 自动观察禁用后,首次硬刷新不会触发任何导航事件,
因此需要在 DOM 就绪后手动初始化布局根元素的 Alpine 指令树 */
function bootstrapLayout() {
const root = document.querySelector('.mit-layout');
if (root && window.Alpine) {
Alpine.initTree(root);
LOG.debug('✅ 初始加载: 布局页 Alpine 已初始化');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrapLayout);
} else {
bootstrapLayout();
}
// ==========================================================================
// 6. 导航事件处理
// ==========================================================================
/* --- 导航开始(所有导航类型均触发) --- */
function onNavStart() {
navState.isNavigating = true;
navState.contentLoaded = false;
/* 在 Blazor 替换内容前,主动销毁内容区 Alpine,避免残留监听器 */
const oldArea = document.querySelector(CONFIG.contentSelector);
if (oldArea) {
Alpine.destroyTree(oldArea);
LOG.debug('🧹 导航前已清理内容区 Alpine');
}
AlpineLifecycle.freeze();
LOG.debug('🚀 导航开始, Alpine 已冻结');
}
/* --- 跨页面导航: 新内容加载完成(仅跨页面导航触发) --- */
function onContentLoaded() {
try {
/* 防御性: enhancednavigationstart 未在 MS 官方文档中出现,若未触发则在此补冻 */
if (!navState.isNavigating) {
AlpineLifecycle.freeze();
navState.isNavigating = true;
}
const area = document.querySelector(CONFIG.contentSelector);
if (!area) {
LOG.warn(`⚠️ 内容区域 "${CONFIG.contentSelector}" 未找到`);
return;
}
navState.contentLoaded = true;
AlpineLifecycle.replaceContent(area);
LOG.debug('✅ 跨页面导航: 内容区域已更新');
} catch (err) {
LOG.error('❌ 跨页面导航初始化失败:', err);
} finally {
AlpineLifecycle.unfreeze();
AlpineLifecycle.rebuildLayout();
navState.isNavigating = false;
}
}
/* --- 导航完成(所有类型均触发): 处理同一页面导航 --- */
function onNavEnd() {
if (!navState.contentLoaded) {
try {
const area = document.querySelector(CONFIG.contentSelector);
if (area) {
AlpineLifecycle.initElement(area);
LOG.debug('✅ 同一页面导航: 增量初始化完成');
}
} catch (err) {
LOG.error('❌ 同一页面导航初始化失败:', err);
} finally {
AlpineLifecycle.unfreeze();
AlpineLifecycle.rebuildLayout();
}
}
navState.isNavigating = false;
LOG.debug('✅ 导航流程全部完成');
}
// ==========================================================================
// 7. 注册 Blazor 增强导航事件
// ==========================================================================
/* 注意:仅 enhancedload 在 MS 官方文档中有记载(learn.microsoft.com 导航文档),
enhancednavigationstart / enhancednavigationend 属于未文档化内部事件,
未来版本可能被移除。onContentLoaded 中已添加防御性状态检查做降级保护。 */
function registerEvents() {
if (!window.Blazor) {
LOG.warn('⏳ Blazor 尚未加载, 将在 DOMContentLoaded 后重试');
return;
}
Blazor.addEventListener('enhancednavigationstart', onNavStart);
Blazor.addEventListener('enhancedload', onContentLoaded);
Blazor.addEventListener('enhancednavigationend', onNavEnd);
LOG.debug('✅ Blazor 增强导航事件已注册');
}
if (window.Blazor) {
registerEvents();
} else {
document.addEventListener('DOMContentLoaded', registerEvents);
}
// ==========================================================================
// 8. 流式渲染支持 (MutationObserver 增量更新)
// ==========================================================================
if (window.MutationObserver) {
const streamObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE && node.closest(CONFIG.contentSelector)) {
AlpineLifecycle.initElement(node);
LOG.debug('✅ 流式渲染元素已初始化:', node);
}
}
}
});
const startObserver = () => {
const area = document.querySelector(CONFIG.contentSelector);
if (area) {
streamObserver.observe(area, { childList: true, subtree: true });
LOG.debug('✅ 流式渲染观察器已启动');
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserver);
} else {
startObserver();
}
}
// ==========================================================================
// 9. 全局错误兜底(防止 Alpine 永久冻结)
// ==========================================================================
window.addEventListener('error', (event) => {
if (navState.isNavigating) {
LOG.error('⚠️ 导航期间发生未捕获错误, 正在恢复 Alpine:', event.error);
AlpineLifecycle.unfreeze();
navState.isNavigating = false;
}
});
LOG.debug('✅ 胶水代码初始化完成');
3)MainLayout.razor,布局页。
我让AI,模仿MudBlazor的布局页组件,创建了MitLayout、MitDrawer、MitMain、MitMainContent、MitAppBar、、MitContainer、MitNavMenu、MitNavGroup、MitNavLink系列组件,但交互功能使用Alpine实现。有几个坑需要注意:
1)将组件状态闭合在组件内,Alpine是可以创建全局状态的,AI可能会想通过这种方式来实现,但这样的话,不符合闭合原则,组件状态不应该和外部状态耦合。同时,这个思路也会受到胶水代码的影响,坑会更多;
2)胶水代码中,每次增强式导航时,MainLayout.razor的Alpine状态会需要被重建,这会导致关闭或伸展的菜单恢复原状,体验不好,这时可以利用HTML的dataset特性来保留数据,因为增强式导航时,这块DOM是不会重建的,所以dataset的状态会被保留。Alpine也有提供persist插件用于将状态持久化到localStorage,但这种情况选择dataset是最佳实践;
3)如果使用了MudBlazor,注意在布局页,只放MudThemeProvider,其它几个Provider,如Dialog、Snackbar、Popup,放到具体需要这些交互功能的页面或组件,因为这几个是交互式组件,布局页一放,就不再走SSR了。
点击查看代码
@inherits LayoutComponentBase
<MudThemeProvider />
<MitLayout>
<MitDrawer Width="280px" Breakpoint="Breakpoint.Md">
<MitNavMenu>
<MudText Typo="Typo.h6" Class="px-4 pt-2">My Application</MudText>
<MudText Typo="Typo.body2" Class="px-4 mud-text-secondary">Secondary Text</MudText>
<MudDivider Class="my-4" />
<MitNavLink Href="/">
<IconContent>@((MarkupString)Icons.Material.Filled.Home)</IconContent>
<ChildContent>首页</ChildContent>
</MitNavLink>
<MitNavGroup Title="测试" Expanded="true">
<MitNavLink Href="/test/counter-test" Match="NavLinkMatch.All">计数器测试</MitNavLink>
<MitNavLink Href="/test/htmx-test" Match="NavLinkMatch.All">Htmx测试</MitNavLink>
<MitNavLink Href="/test/alpine-test" Match="NavLinkMatch.All">Alpine测试</MitNavLink>
<MitNavLink Href="/test/flurl-test" Match="NavLinkMatch.All">Flurl测试</MitNavLink>
<MitNavLink Href="/test/flurl-test-ssr" Match="NavLinkMatch.All">Flurl测试SSR</MitNavLink>
<MitNavLink Href="/test/auth-test" Match="NavLinkMatch.All">Auth测试</MitNavLink>
</MitNavGroup>
<AuthorizeView Roles="admin">
<Authorized>
<MitNavGroup Title="用户权限" Expanded="true">
<MitNavLink Href="/identity/users" Match="NavLinkMatch.Prefix">用户管理</MitNavLink>
<MitNavLink Href="/identity/roles" Match="NavLinkMatch.Prefix">角色管理</MitNavLink>
</MitNavGroup>
</Authorized>
</AuthorizeView>
</MitNavMenu>
</MitDrawer>
<MitMain>
<MitAppBar Height="76" Elevation="0">
<button class="menu-btn" x-data="{ active: false }" x-on:click="$dispatch('toggle-drawer')"
x-on:drawer-state.window="active = $event.detail.open" x-bind:class="{ 'is-active': active }">
<svg class="chevron-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 18l6-6-6-6" />
</svg>
</button>
<span class="appbar-title">My Applications</span>
<span class="appbar-spacer"></span>
<span class="appbar-auth">
<AuthorizeView>
<Authorized>
<span class="appbar-username">@userName</span>
<a href="/logout" class="appbar-btn">登出</a>
</Authorized>
<NotAuthorized>
<a href="/login" class="appbar-btn">登录</a>
</NotAuthorized>
</AuthorizeView>
</span>
</MitAppBar>
<MitMainContent ElementId="main-content">
<MitContainer>
@Body
</MitContainer>
</MitMainContent>
</MitMain>
</MitLayout>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code
{
[CascadingParameter]
public Task<AuthenticationState> AuthenticationState { get; set; } = default!;
private string? userName;
protected override async Task OnParametersSetAsync()
{
var authState = await AuthenticationState;
var user = authState.User;
userName = user.FindFirst(ClaimTypes.Name)?.Value ?? user.Identity?.Name;
}
}
4)MitNavGroup.razor
贴一个集成了Alpine的轻交互组件。这些组件,让AI模仿着MudBlazor写的,轻交互组件,应该都是没有问题的。你用了哪个组件库,就让AI模仿这个组件库的相应组件来写,HTML和CSS都是可以直接延用的,AI改的主要是状态交互部分。所以,就只贴一个比较有代表性的组件。Blazor的UI库,还是推荐MudBlazor,规范性、丰富度、性能、C#优先、生态、费用,综合起来是最好的。其中我比较看中表格,包括功能、易用性和性能表现,大家比较的时候,也可以重点看这个。MudBlazor是我体验过的里面最好的一个。有些组件库,比如新出的blueprints,我是很喜欢的,但它的组件交互时,总是有顿感、不利索,其它很多组件库也有这个毛病,大家体验一下表格多选就一清二楚了。我开始以为是Server模式的延迟,但blueprints和MudBlazor一样,官网组件都是WASM。
点击查看代码
@inherits MitComponentBase
<nav class="@Classname" disabled="@(Disabled ? true : null)" style="@Style" aria-label="@Title"
@attributes="UserAttributes" x-data="{
expanded: @(Expanded.ToString().ToLowerInvariant()),
toggle() { this.expanded = !this.expanded },
init() {
var saved = this.$el.dataset.mitNavExpanded;
if (saved !== undefined) this.expanded = saved === 'true';
},
destroy() {
this.$el.dataset.mitNavExpanded = this.expanded;
}
}">
<button class="@ButtonClassname" x-on:click="toggle()" x-bind:class="{ 'mit-expanded': expanded }"
x-bind:aria-expanded="expanded" aria-label="@Title">
@if (IconContent is not null)
{
<span class="mit-nav-link-icon">@IconContent</span>
}
<div class="mit-nav-link-text">
@if (TitleContent is not null)
{
@TitleContent
}
else
{
@Title
}
</div>
@if (!HideExpandIcon)
{
<span class="mit-nav-link-expand-icon" x-bind:class="{ 'mit-transform': expanded }">
@if (ExpandIconContent is not null)
{
@ExpandIconContent
}
else
{
<svg viewBox="0 0 24 24" width="24" height="24">
<path d="M7 10l5 5 5-5z" />
</svg>
}
</span>
}
</button>
<div class="mit-navgroup-collapse" x-show="expanded" x-collapse>
<nav class="mit-navmenu">
@ChildContent
</nav>
</div>
</nav>
@code
{
/// <summary>分组标题文字</summary>
[Parameter]
public string? Title { get; set; }
/// <summary>自定义标题内容(优先级高于 Title)</summary>
[Parameter]
public RenderFragment? TitleContent { get; set; }
/// <summary>分组图标</summary>
[Parameter]
public RenderFragment? IconContent { get; set; }
/// <summary>是否默认展开</summary>
[Parameter]
public bool Expanded { get; set; } = true;
/// <summary>是否禁用分组</summary>
[Parameter]
public bool Disabled { get; set; }
/// <summary>是否隐藏展开图标</summary>
[Parameter]
public bool HideExpandIcon { get; set; }
/// <summary>自定义展开图标</summary>
[Parameter]
public RenderFragment? ExpandIconContent { get; set; }
/// <summary>按钮额外 CSS 类</summary>
[Parameter]
public string? HeaderClass { get; set; }
/// <summary>子内容(嵌套的导航链接或分组)</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
private string Classname
{
get
{
var css = new List<string> { "mit-nav-group" };
if (!string.IsNullOrWhiteSpace(Class))
css.Add(Class);
if (Disabled)
css.Add("mit-nav-group-disabled");
return string.Join(" ", css);
}
}
private string ButtonClassname
{
get
{
var css = new List<string> { "mit-nav-link" };
if (!string.IsNullOrWhiteSpace(HeaderClass))
css.Add(HeaderClass);
return string.Join(" ", css);
}
}
}
5)HTMX的集成
.NET8其实已经提供了一个很好用的组件RazorComponentResult,可以在服务端将组件渲染成HTML,和HTMX是绝配。然后,razor组件有后置代码,提供给HTMX调用的MinimalApi,正好可以组织在后置代码中。我还安装了一个自动注册MinimalApi的小库MinimalHelpers.Routing/MinimalHelpers.Routing.Analyzers。这一套组合拳下来,几乎完美实现HTMX的集成。这不是AI想出来的,是我的首创,忍不住给自己点个赞。
点击查看代码
// HtmxTest.razor
@page "/test/htmx-test"
<h2>HTMX 测试=============================</h2>
<div id="msg">HTMX 测试区域</div>
<button hx-get="@HtmxTest.HtmxEndpoints.GetHtmxTestHeader" hx-target="#msg" hx-swap="innerHTML">加载HomeHeader</button>
@code {
}
// HtmxTest.razor.cs
public partial class HtmxTest
{
// 定义HTMX端点,统一命名为HtmxEndpoints
public class HtmxEndpoints : IEndpointRouteHandlerBuilder
{
// 端点路径常量,命名约定为:页面路由 + "__" + 端点方法名(小写下划线)
public const string GetHtmxTestHeader = "/test/htmx-test/__get_htmx_test_header";
// 定义端点
public static void MapEndpoints(IEndpointRouteBuilder builder)
{
builder.MapGet(GetHtmxTestHeader, (HttpRequest request) =>
{
// 通过RazorComponentResult,将Blazor组件渲染为HTML
return new RazorComponentResult<HtmxTestHeader>();
/* 直接返回HTML
string html = """
<h1>MinimalAPI 返回原生HTML</h1>
<h2>MinimalAPI 返回原生HTML</h2>
""";
return Results.Content(html, "text/html");
*/
});
}
}
}
6)认证集成
我的认证中心使用了ABP,并集成了Flurl的请求认证。由于交互位置是每页/每组件,集成非常简单,对接其它认证中心,也是大差不差。对于Blazor项目,我是建议将认证分离出来的,不要集成Identity,Identity的集成代码,我是啃过的,光集成辅助工具就有七八个文件,页面代码得有几十个,真没必要。一旦集成进来了,你就会想搞懂里面的代码逻辑,不搞懂,总会担心它炸,而且你一定会觉得它放置代码的位置不符合你的口味。最后说一下ABP,很多人觉得它重,其实你分开两边看。如果是纯后端的,这块我觉得一点不重,很规整了、很省心,我曾经自己基于裸的AspNetCore封装过、也折腾过Fastendpoints,最后发现还是得ABP,尤其是现在业界又开始回归模块化单体,免费版的ABP就非常香了。另外一块,我是觉得比较重的,就是ABP的前端,为了集成不同的前端框架,ABP搞了很多约定、抽象和代理,即复杂、又不灵活,搞过一阵后,果断放弃了。ABP项目一律推荐NoUI,不仅可以使劲撸ABP的免费版,还大大提升了前端的性能和灵活性。动态/静态代理不要用,将Flurl框架和规则定义好,将Swagger给AI一扔,啥都写的好好的,你的分层项目还少了Http.Client层,清爽很多。而商业版的UI,现在用AI复现,也很简单。但是,ABP的RazorPages这块,还是要看看,因为NoUI的认证中心页面,还是使用了RazorPages,ABP提供了虚拟文件系统,可以很方便的覆盖认证中心自带的登录、注册等页面。
点击查看代码
// 配置认证服务(本机Cookie+ABP/OIDC)
builder.Services
// 配置默认方案:1)本地认证使用Cookie;2)认证登陆使用ABP认证中心,标准的OIDC
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
// Cookie认证-Admin本地认证
.AddCookie(options =>
{
options.Cookie.Name = "__Host-KhaasWebAdmin"; // 推荐使用 __Host- 前缀,确保安全属性正确设置
options.SlidingExpiration = true; // 启用滑动过期,用户活动时自动续期
options.ExpireTimeSpan = TimeSpan.FromMinutes(60); // Cookie 的过期时间,通常设置为比 OIDC token 稍长,避免频繁登录
options.Cookie.HttpOnly = true; // HttpOnly 属性,防止客户端脚本访问 Cookie,降低 XSS 攻击风险
options.Cookie.SameSite = SameSiteMode.Lax; // SameSite 属性,推荐设置为 Lax,兼顾安全和跨站点登录体验
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // __Host- 前缀要求 Secure,设为 Always 确保兼容
options.AccessDeniedPath = "/access-denied"; // 可选:访问被拒绝时的重定向路径,根据需要实现对应页面
})
// OIDC认证方案对接 ABP/OpenIddict 认证中心,处理登录和登出流程
.AddOpenIdConnect(options =>
{
// AuthOptions可能还不可用,所以直接从配置中读取并验证必要的认证参数
var authOptions = builder.Configuration.GetSection("Authentication").Get<AuthOptions>();
if (string.IsNullOrEmpty(authOptions?.ClientId) ||
string.IsNullOrEmpty(authOptions?.ClientSecret) ||
string.IsNullOrEmpty(authOptions?.Authority))
{
throw new InvalidOperationException("认证配置无效,请检查 ClientId、ClientSecret 和 Authority 设置");
}
options.Authority = authOptions.Authority; // 认证中心地址,必须以 https:// 开头,确保安全通信
options.ClientId = authOptions.ClientId; // 客户端ID,必须与认证中心注册的应用一致
options.ClientSecret = authOptions.ClientSecret; // 客户端密钥,确保安全存储,生产环境建议使用安全的机密管理工具
options.RequireHttpsMetadata = authOptions.RequireHttpsMetadata; // 根据配置决定是否要求 HTTPS 元数据地址,生产环境建议始终启用以确保安全
options.ResponseType = OpenIdConnectResponseType.Code; // 使用授权码模式,确保安全性和兼容性
options.UsePkce = true; // 启用 PKCE 增强授权码流程的安全性,防止授权码被截获后滥用
options.SaveTokens = true; // 保存 token 到认证票据中
options.GetClaimsFromUserInfoEndpoint = false; // 从 userinfo 补充用户信息,避免只依赖 id_token 中有限 claims
options.MapInboundClaims = true; // 启用自动映射标准 OIDC claims 到 Microsoft 预定义的 claim 类型,简化在应用中使用
options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Disable; // 当前 OpenIddict 客户端未开放 PAR,因此显式禁用以避免 ID2183
// scope 完全由配置驱动,确保只申请后台真正需要的权限范围
options.Scope.Clear();
foreach (var scope in authOptions.Scopes)
{
options.Scope.Add(scope);
}
// OIDC 回调标记,供 Flurl OnError 识别并跳过重定向,避免劫持认证响应
options.Events.OnTokenValidated = context =>
{
context.HttpContext.Items["__OidcCallback"] = true;
context.Response.OnCompleted(() =>
{
context.HttpContext.Items.Remove("__OidcCallback");
return Task.CompletedTask;
});
return Task.CompletedTask;
};
});
builder.Services.AddCascadingAuthenticationState(); // 注册认证状态级联服务,使认证状态可以在组件树中共享和访问
builder.Services.AddAuthorization(); // 配置授权服务,启用基于策略的授权和角色权限控制
builder.Services.AddScoped<IPermissionService, PermissionService>(); // 注册权限服务(Scoped,每个请求/线路独立实例)
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>(); // 替换默认 PolicyProvider,支持 [Authorize("PermissionName")]

浙公网安备 33010602011771号