Blazor 如何用 _Imports.razor 应用新布局并避免登录循环
结论
无限刷新由当前配置中的两个问题共同引起:
External/_Imports.razor中的@layout ExternalLayout会影响External目录及其所有子目录,包括External/Shared/ExternalLayout.razor。这样一来,ExternalLayout会再次套用自身,造成布局递归。- 移动 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 引起的刷新循环属于不同问题,但在浏览器中的表现可能相似。
最小修改方案
如果不想大幅调整目录,至少完成以下修改:
- 将
ExternalLayout.razor从Components/External/Shared移到Components/Layout。 - 将布局指令限制在
Components/External/Pages/_Imports.razor。 - 恢复
Routes.razor的DefaultLayout="typeof(Layout.MainLayout)"。 - 从原 Identity 页面目录的
_Imports.razor中复制必要指令,并重点检查[ExcludeFromInteractiveRouting]。 - 保留
ExternalLayout中的HttpContext检查和强制刷新逻辑。 - 确认
Login.razor没有[Authorize],同时确认/Account/Login与RedirectToLogin中的跳转地址一致。
launchSettings.json 中的:
"launchUrl": "account/login"
只会影响开发时浏览器最初打开的地址,不会决定页面使用的布局或渲染模式,通常不会引起刷新循环。
备注:内容仅供参考。
