PAYATHON 2026

Blazor 中如何将会话数据从 SSR 传到 Interactive Server?

技术老齐

结论

这类需求可以分三部分处理:

  1. 身份信息由 ASP.NET Core Authentication 和 AuthenticationStateProvider 管理,包括 UserId、角色和权限声明。
  2. 自定义会话保存在服务端。Interactive Server 只持有 SessionId 或其他不透明标识。
  3. 首次预渲染需要的数据通过 PersistentComponentState 从 SSR 传给 Interactive Server。

HttpContext.Items 是当前 HTTP 请求使用的临时字典。SSR 请求结束后,Interactive Server 会创建独立的 SignalR circuit 和 DI scope,Items 以及 SSR 阶段的 scoped service 都不会自动进入 circuit。

CircuitHandler 用来处理 circuit 的打开、断线、重连和关闭,不负责传递 SSR 数据。

为什么原来的几种写法会失败

RenderFragment 无法跨渲染模式边界

静态 SSR 组件向 Interactive Server 根组件传递 ChildContent、Body 等 RenderFragment 参数时,Blazor 需要先序列化参数,再把它们交给交互式组件。

RenderFragment 本质上是委托,无法序列化,因此会出现类似错误:

Cannot pass the parameter 'ChildContent' ... because the parameter is of the delegate type 'Microsoft.AspNetCore.Components.RenderFragment'

不要给接收 ChildContent 的 wrapper 或 layout 单独设置 @rendermode。可以把交互边界放在不接收 RenderFragment 参数的根组件上,例如 Routes,再在交互子树内使用 wrapper。

Middleware scope 和 circuit scope 不是同一个 scope

Middleware 处理 SSR HTTP 请求。之后,Interactive Server 通过 SignalR 创建 circuit,并为它建立单独的 scoped services。

即使 middleware 修改了某个 scoped service,它修改的也是 HTTP 请求 scope 中的实例。Interactive Server 注入组件的通常是另一个实例。

Interactive Server 中没有稳定可用的 HttpContext

Interactive Server 组件在初始 HTTP 请求结束后仍可能长期运行,期间还可能断线和重连。组件的生命周期并不对应某一个完整的 HTTP 请求。

预渲染期间可以读取 HttpContext,但不能把它作为 circuit 长期使用的上下文。

推荐的数据结构

访问令牌不应放进传输 DTO。交接对象通常只需要服务端会话标识,以及少量页面显示所需的数据:

public sealed record SessionHandoff(
    string SessionId,
    string? UserId);

Interactive Server 取得 SessionId 后,再从数据库、分布式缓存或其他服务端会话存储中读取 tokens 和完整会话记录。

可以在 circuit 中用一个 scoped service 保存当前状态:

public sealed class SessionContext
{
    public SessionHandoff? Current { get; private set; }

    public bool IsInitialized => Current is not null;

    public void Initialize(SessionHandoff handoff)
    {
        Current = handoff;
    }
}

注册服务:

builder.Services.AddScoped<SessionContext>();

builder.Services
    .AddRazorComponents()
    .AddInteractiveServerComponents();

应用部署在多台服务器上时,会话记录需要存入共享数据库或分布式缓存,不能只保存在单机内存中。

用一个根组件完成状态交接

可以使用 PersistentComponentState 完成状态交接。这种写法也适用于不支持服务级持久化便利 API 的项目。

创建 SessionBootstrap.razor:

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Http
@implements IDisposable
@inject PersistentComponentState PersistentState
@inject SessionContext Session

@if (_ready)
{
    @ChildContent
}

@code {
    private const string PersistenceKey = "session-handoff";

    [Parameter, EditorRequired]
    public RenderFragment ChildContent { get; set; } = default!;

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

    private PersistingComponentStateSubscription? _subscription;
    private SessionHandoff? _handoff;
    private bool _ready;

    protected override void OnInitialized()
    {
        if (PersistentState.TryTakeFromJson<SessionHandoff>(
                PersistenceKey, out var restored)
            && restored is not null)
        {
            // Interactive Server 阶段
            _handoff = restored;
        }
        else if (HttpContext?.Items[SessionMiddleware.ItemKey]
                 is CustomSession customSession)
        {
            // SSR 预渲染阶段
            _handoff = new SessionHandoff(
                customSession.SessionId,
                customSession.UserId);
        }

        if (_handoff is not null)
        {
            Session.Initialize(_handoff);
        }

        _ready = true;

        _subscription = PersistentState.RegisterOnPersisting(
            PersistStateAsync);
    }

    private Task PersistStateAsync()
    {
        if (_handoff is not null)
        {
            PersistentState.PersistAsJson(
                PersistenceKey,
                _handoff);
        }

        return Task.CompletedTask;
    }

    public void Dispose()
    {
        _subscription?.Dispose();
    }
}

执行过程中会创建两个组件实例:

  • SSR 实例从 HttpContext.Items 读取数据,在预渲染结束时保存状态。
  • Interactive Server 实例取出持久化状态,然后初始化 circuit scope 中的 SessionContext。

PersistentComponentState 传递的是一次性快照,各个 scope 并不会共享同一个对象。

把 wrapper 放在交互边界内部

在 App.razor 中,把渲染模式设置在 Routes 上:

<HeadOutlet @rendermode="InteractiveServer" />
<Routes @rendermode="InteractiveServer" />

然后在 Routes.razor 内使用 SessionBootstrap:

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

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

这样,SessionBootstrap、Router、layout 和页面都在同一个 Interactive Server 子树内,ChildContent 无须跨越渲染模式边界进行序列化。

页面只需注入状态服务:

@inject SessionContext Session

<p>User: @Session.Current?.UserId</p>

初始化逻辑统一放在 SessionBootstrap 中,不必在每个页面重复实现。

如果应用只让少数页面进入交互模式,没有让整个 Routes 使用 Interactive Server,那么每个独立的交互根都要有自己的初始化入口。多个渲染边界无法仅靠 layout 自动合并。

与 AuthenticationStateProvider 如何配合

AuthenticationStateProvider 用来描述当前用户的认证状态,包括:

  • 用户是否已认证;
  • ClaimsPrincipal;
  • UserId、角色和权限 claims。

token 和业务会话应由专门的服务管理,不宜放进 AuthenticationStateProvider。

整个流程可以这样安排:

请求携带认证 Cookie
        ↓
ASP.NET Core Authentication 验证 Cookie
        ↓
HttpContext.User 得到 ClaimsPrincipal
        ↓
Blazor AuthenticationStateProvider 提供认证状态
        ↓
SessionContext 根据 SessionId 加载业务会话

服务注册通常包括:

builder.Services.AddCascadingAuthenticationState();

builder.Services
    .AddAuthentication()
    .AddCookie();

builder.Services.AddAuthorization();

认证中间件应在需要读取用户信息的自定义会话 middleware 之前运行:

app.UseAuthentication();
app.UseAuthorization();

app.UseMiddleware<SessionMiddleware>();

具体顺序还取决于自定义 middleware 是否依赖 HttpContext.User,以及应用中终结请求的 endpoint 配置。

如果用户身份由自定义会话决定,可以实现 ASP.NET Core authentication handler,并由它构造 ClaimsPrincipal。MVC、Razor Components、API 和授权策略就能使用同一套身份信息。

token 建议保留在服务端会话存储中。组件通过专门的服务执行需要 token 的操作,无须直接持有 token:

public sealed class UserApiClient
{
    private readonly SessionContext _session;
    private readonly ISessionStore _sessionStore;

    public UserApiClient(
        SessionContext session,
        ISessionStore sessionStore)
    {
        _session = session;
        _sessionStore = sessionStore;
    }

    public async Task<UserProfile> GetProfileAsync(
        CancellationToken cancellationToken)
    {
        var sessionId = _session.Current?.SessionId
            ?? throw new InvalidOperationException(
                "Session hasn't been initialized.");

        var record = await _sessionStore.GetAsync(
            sessionId,
            cancellationToken);

        return await CallRemoteApiAsync(
            record.AccessToken,
            cancellationToken);
    }
}

PersistentComponentState 和 CircuitHandler 的分工

使用 PersistentComponentState

以下情况适合使用 PersistentComponentState:

  • SSR 已经查询过数据,不希望 Interactive Server 再查一次;
  • 预渲染结果要在 circuit 启动后继续使用;
  • 需要把一次性的初始化快照交给交互组件;
  • 需要避免预渲染和交互阶段出现内容闪烁。

它不能替代常规的会话数据库,也不负责状态续期、撤销或多服务器同步。

对于 Interactive Server,框架会通过相应的持久化状态机制处理数据。不过,原始 access token、refresh token、密码和长期密钥仍不应放入持久化状态。传递不透明的 SessionId,再从服务端加载会话,能更方便地处理撤销、过期和日志泄露风险。

使用 CircuitHandler

CircuitHandler 适合处理这些工作:

  • 记录 circuit 的打开和关闭;
  • 为 circuit 分配或释放资源;
  • 处理连接断开、恢复和清理;
  • 实现 circuit 级监控或审计。

从初始 HttpContext.Items 复制数据不应作为它的主要职责。IHttpContextAccessor.HttpContext 在 circuit 生命周期内可能为空,也可能只对应某一次短暂连接,不能代表整个 circuit。

是否可以使用 [PersistentState]

如果目标框架支持服务级持久化注册,可以把持久化属性放进 scoped service,再通过 RegisterPersistentService 注册,从而减少手写 PersistAsJson 和 TryTakeFromJson 的代码。

不同 .NET 版本提供的便利 API 和具体签名可能不同。如果项目要同时支持 .NET 8 和较新版本,前面的 SessionBootstrap 写法更容易控制,也能明确看出实际持久化了哪些字段。

无论使用 [PersistentState],还是手动调用 PersistentComponentState,都应遵循这些原则:

  • 在一个公共根组件中完成初始化;
  • 只传递必要的最小状态;
  • 完整会话保存在服务端;
  • 不要在各个页面重复读取 HttpContext。

是否应该关闭预渲染

对于后台管理系统或登录后的业务应用,如果首屏不需要 SEO,并且页面必须等用户会话加载后才能显示,可以关闭预渲染,少执行一次组件初始化:

<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />

关闭预渲染不会合并 HTTP 请求 scope 和 circuit scope,HttpContext.Items 也不会因此在 Interactive Server 中长期可用。此时仍需根据认证 claims 或会话 Cookie 中的标识,从服务端存储初始化 SessionContext。

以下情况适合保留预渲染:

  • 公开页面需要 SEO;
  • 需要尽快输出可读 HTML;
  • 首屏内容不依赖敏感会话;
  • 可以使用 PersistentComponentState 处理初始化快照。

纯登录后应用通常可以关闭预渲染。对于同时包含公开内容和登录功能的应用,可以让公开路由保留 SSR,只关闭后台交互区域的预渲染,无须全站使用同一种策略。

相关官方资料

备注:内容仅供参考。