在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;
    }
}

![image](https://img2024.cnblogs.com/blog/2159941/202605/2159941-20260527103058798-468466441.png)

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")]
相关推荐
csdn_aspnet3 个月前
.NET 10 中的 Blazor:新增功能及常见问题
wasm·blazor·.net10
csdn_aspnet3 个月前
Asp.Net Core 10.0 中的 Blazor 增强功能
前端·后端·asp.net·blazor·.net10
市安3 个月前
基于 Alpine 构建轻量 Nginx 错误页面 Docker 镜像
运维·nginx·docker·alpine
林鸿风采4 个月前
在Alpine Linux上部署docker,并配置开机自启
linux·docker·eureka·alpine
林鸿风采4 个月前
Alpine Linux 安装指南:轻量、安全、高效的系统部署实践
linux·运维·安全·alpine
许泽宇的技术分享5 个月前
当AI开始“画“界面:A2UI协议如何让.NET应用告别写死的UI
人工智能·ui·.net·blazor·a2ui
许泽宇的技术分享5 个月前
当AI遇见UI:用.NET Blazor实现Google A2UI协议的完整之旅
人工智能·ui·.net·blazor·a2ui
known5 个月前
基于Blazor实现的样品扫码比对管理系统
blazor
fbllfbll5 个月前
Alpine下部署Nginx+MAZANOKE在线批量压缩图片
服务器·nginx·pve·alpine·lxc容器·在线压缩图片·mazanoke