PAYATHON 2026

Blazor 如何用 _Imports.razor 应用新布局并避免登录循环

排障赵师傅

结论

无限刷新由当前配置中的两个问题共同引起:

  1. External/_Imports.razor 中的 @layout ExternalLayout 会影响 External 目录及其所有子目录,包括 External/Shared/ExternalLayout.razor。这样一来,ExternalLayout 会再次套用自身,造成布局递归。
  2. 移动 Identity 页面时,如果没有保留模板中的静态服务端渲染配置,页面就会进入 interactive mode。此时级联的 HttpContext 为 null,NavigationManager.Refresh(forceReload: true) 会在每次渲染时强制刷新。刷新后页面仍进入 interactive mode,浏览器便会不断刷新。

把 @layout MainLayout 加回 ExternalLayout.razor 后,文件内的显式布局覆盖了从 _Imports.razor 继承的 ExternalLayout,从而打断了自引用,所以循环会停止。不过,这只能暂时避开问题,不适合作为最终方案。

建议这样处理:

  • 将 ExternalLayout.razor 移到不受 External/_Imports.razor 影响的目录。
  • 让 Routes.razor 继续使用 MainLayout 作为默认布局。
  • 仅对登录、注册等外部页面应用 ExternalLayout。
  • 保留原 Identity 页面的静态服务端渲染设置。如果当前模板使用了 [ExcludeFromInteractiveRouting],移动页面时也要保留它。

推荐的目录结构

可以将应用级布局统一放在 Layout 目录中:

MyProject/
└── Components/
    ├── Layout/
    │   ├── MainLayout.razor
    │   ├── ExternalLayout.razor
    │   └── NavMenu.razor
    │
    ├── External/
    │   └── Pages/
    │       ├── _Imports.razor
    │       ├── Login.razor
    │       ├── Register.razor
    │       ├── ForgotPassword.razor
    │       └── ResendEmailConfirmation.razor
    │
    ├── Account/
    │   ├── Pages/
    │   └── Shared/
    │       ├── AccountLayout.razor
    │       └── RedirectToLogin.razor
    │
    └── Routes.razor

ExternalLayout.razor 不能放在包含 @layout ExternalLayout 的 _Imports.razor 所控制的目录树中。

将 _Imports.razor 放进 External/Pages 会更稳妥。布局指令只需作用于可路由页面,无须影响 Shared 中的普通组件。

配置 External 页面的 _Imports.razor

Components/External/Pages/_Imports.razor 可以写成:

@using MyProject.Components.Account.Shared
@using MyProject.Components.Layout
@layout ExternalLayout
@attribute [ExcludeFromInteractiveRouting]

这些指令分别用于:

  • 通过 @layout ExternalLayout 为该目录下的页面统一指定外部布局。
  • 通过 @using MyProject.Components.Account.Shared 继续使用 Scaffold 后保留的 Identity 组件。
  • 通过 [ExcludeFromInteractiveRouting] 让页面采用静态服务端渲染,以便取得有效的 HttpContext。

是否需要 [ExcludeFromInteractiveRouting],要以项目当前 SDK 生成的 Identity 模板为准。较新的 Blazor Web App Identity 模板通常会为需要读写 Cookie 的 Account 页面关闭交互式路由。如果原来的 Components/Account/Pages/_Imports.razor 中有这个属性,移动页面时必须保留。

移动页面时不要只复制 .razor 文件。原目录中的 _Imports.razor 还可能包含命名空间、布局或渲染模式相关的指令。

ExternalLayout 的实现

移动后的 Components/Layout/ExternalLayout.razor 可以继续使用模板中的 HttpContext 检查:

@inherits LayoutComponentBase
@inject NavigationManager NavigationManager

@if (HttpContext is null)
{
    <p>Loading...</p>
}
else
{
    <main class="external-content">
        @Body
    </main>
}

@code {
    [CascadingParameter]
    private HttpContext? HttpContext { get; set; }

    protected override void OnParametersSet()
    {
        if (HttpContext is null)
        {
            NavigationManager.Refresh(forceReload: true);
        }
    }
}

不要在这里添加:

@layout MyProject.Components.Layout.MainLayout

添加后,ExternalLayout 会成为 MainLayout 的内层布局,MainLayout 中的 NavMenu 也会继续显示。

NavigationManager.Refresh(forceReload: true) 是 Identity 模板用于从 interactive mode 返回静态服务端渲染的保护措施。它能正常工作的前提是,强制刷新后,路由配置确实会让页面进入静态服务端渲染。如果刷新后仍采用 interactive mode,HttpContext 依然为 null,页面就会再次刷新。

因此,直接删除 Refresh 只会掩盖配置问题。登录、注册和确认邮件等流程通常需要通过当前 HTTP 请求设置 Cookie,或者执行其他依赖 HttpContext 的操作。

Routes.razor 应继续使用 MainLayout

DefaultLayout 是未显式指定布局时使用的后备布局,不能用来表示“未登录页面的布局”。将它改为 ExternalLayout 后,所有未声明 @layout 的普通页面都会使用外部布局。

建议恢复为:

@using MyProject.Components.Account.Shared

<Router AppAssembly="typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData"
                            DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>
                <RedirectToLogin />
            </NotAuthorized>
        </AuthorizeRouteView>

        <FocusOnNavigate RouteData="routeData" Selector="h1" />
    </Found>
</Router>

此时,各类页面会按以下规则选择布局:

  • 未声明布局的普通业务页面使用 MainLayout。
  • External/Pages 下的页面通过局部 _Imports.razor 使用 ExternalLayout。
  • 登录后的账户管理页面仍可使用原来的 AccountLayout。
  • 如果 AccountLayout 声明了 @layout MainLayout,账户管理内容外仍会显示主导航。

Routes.razor 位于 Components 目录下,不会继承 Components/External/_Imports.razor 中的 @using。Razor 的 _Imports.razor 只影响所在目录及其子目录。因此,Routes.razor 使用 RedirectToLogin 时,需要导入该组件实际所在的命名空间。

RedirectToLogin 不负责选择登录页布局

下面的组件只负责导航:

@inject NavigationManager NavigationManager

@code {
    protected override void OnInitialized()
    {
        NavigationManager.NavigateTo(
            $"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}",
            forceLoad: true);
    }
}

它不会决定 Login.razor 使用哪个布局。登录页的布局取决于页面上的 @layout、所在目录的 _Imports.razor,以及 Router 的 DefaultLayout,并按优先级生效。

跳转地址必须与实际路由一致。例如,页面仍声明:

@page "/Account/Login"

那么当前跳转地址可以继续使用。如果移动页面时修改了 @page,也要同步修改 RedirectToLogin 中的地址。

登录页本身不要添加 [Authorize]。否则,未授权用户访问登录页时会再次触发 RedirectToLogin,造成认证重定向循环。它和 ExternalLayout 引起的刷新循环属于不同问题,但在浏览器中的表现可能相似。

最小修改方案

如果不想大幅调整目录,至少完成以下修改:

  1. 将 ExternalLayout.razor 从 Components/External/Shared 移到 Components/Layout。
  2. 将布局指令限制在 Components/External/Pages/_Imports.razor。
  3. 恢复 Routes.razor 的 DefaultLayout="typeof(Layout.MainLayout)"。
  4. 从原 Identity 页面目录的 _Imports.razor 中复制必要指令,并重点检查 [ExcludeFromInteractiveRouting]。
  5. 保留 ExternalLayout 中的 HttpContext 检查和强制刷新逻辑。
  6. 确认 Login.razor 没有 [Authorize],同时确认 /Account/Login 与 RedirectToLogin 中的跳转地址一致。

launchSettings.json 中的:

"launchUrl": "account/login"

只会影响开发时浏览器最初打开的地址,不会决定页面使用的布局或渲染模式,通常不会引起刷新循环。

备注:内容仅供参考。