ASP.NET 受核心 Blazor 保护的浏览器存储

对于用户正在主动创建的暂时性数据,通用存储位置是浏览器的 localStoragesessionStorage 集合:

  • localStorage 的作用范围限定为浏览器的实例。 如果用户重载页面或关闭并重新打开浏览器,则状态保持不变。 如果用户打开多个浏览器选项卡,则状态跨选项卡共享。 数据保留在 localStorage 中,直到被显式清除为止。 localStorage关闭最后一个“专用”选项卡时,将清除在“专用浏览”或“隐身”会话中加载的文档的数据。
  • sessionStorage 的应用范围限定为浏览器的选项卡。如果用户重载该选项卡,则状态保持不变。 如果用户关闭该选项卡或该浏览器,则状态丢失。 如果用户打开多个浏览器选项卡,则每个选项卡都有自己独立的数据版本。

通常,sessionStorage 使用起来更安全。 sessionStorage 避免了用户打开多个选项卡并遇到以下问题的风险:

  • 跨选项卡的状态存储中出现 bug。
  • 一个选项卡覆盖其他选项卡的状态时出现混乱行为。

如果应用必须在关闭和重新打开浏览器期间保持状态,则 localStorage 是更好的选择。

使用浏览器存储时的注意事项:

  • 与使用服务器端数据库类似,加载和保存数据都是异步的。
  • 在预呈现阶段,请求的页面在浏览器中不存在,因此,在预呈现期间,本地存储不可用。
  • 对于服务器端 Blazor 应用,持久存储几千字节的数据是合理的。 超出几千字节后,你就须考虑性能影响,因为数据是跨网络加载和保存的。
  • 用户可以查看或篡改数据。 ASP.NET Core 数据保护可以降低风险。 例如,ASP.NET Core 受保护的浏览器存储使用 ASP.NET Core 数据保护。

第三方 NuGet 包提供使用 localStoragesessionStorage 时采用的 API。 值得考虑的是,选择一个透明地使用 ASP.NET Core 数据保护的包。 数据保护可对存储的数据进行加密,并降低篡改存储数据的潜在风险。 如果 JSON 序列化的数据以纯文本形式存储,则用户可以使用浏览器开发人员工具查看数据,还可以修改存储的数据。 保护普通数据并不是问题。 例如,读取或修改 UI 元素的存储颜色不会对用户或组织造成严重的安全风险。 避免允许用户检查或篡改敏感数据

ASP.NET Core 受保护的浏览器存储

ASP.NET Core 受保护的浏览器存储将 ASP.NET Core 数据保护用于 localStoragesessionStorage

注释

受保护的浏览器存储依赖于 ASP.NET Core 数据保护,仅支持用于服务器端 Blazor 应用。

警告

Microsoft.AspNetCore.ProtectedBrowserStorage 是一个不受支持的实验性包,不用于生产。

此包仅适用于 ASP.NET Core 3.1 应用。

配置

  1. 将包引用添加到 Microsoft.AspNetCore.ProtectedBrowserStorage

    注释

    有关将包添加到 .NET 应用的指南,请参阅包使用工作流(NuGet 文档)中“安装和管理包”下的文章。 在 NuGet.org 中确认正确的包版本。

  2. _Host.cshtml 文件中,将以下脚本添加到结束标记 </body> 之前:

    <script src="_content/Microsoft.AspNetCore.ProtectedBrowserStorage/protectedBrowserStorage.js"></script>
    
  3. Startup.ConfigureServices 中,调用 AddProtectedBrowserStorage 以将 localStoragesessionStorage 服务添加到服务集合:

    services.AddProtectedBrowserStorage();
    

保存和加载组件中的数据

在需要将数据加载或保存到浏览器存储的任何组件中,使用 @inject 指令注入以下任意一项的实例:

  • ProtectedLocalStorage
  • ProtectedSessionStorage

具体选择取决于要使用的浏览器存储位置。 在以下示例中,使用 sessionStorage

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

可将 @using 指令放在应用的 _Imports.razor 文件而不是组件中。 使用 _Imports.razor 文件可使命名空间可用于应用的较大部分或整个应用。

若要在基于 Counter的应用的 Blazor 组件中保留 值,请修改 IncrementCount 方法以使用 ProtectedSessionStore.SetAsync

private async Task IncrementCount()
{
    currentCount++;
    await ProtectedSessionStore.SetAsync("count", currentCount);
}

在更大、更真实的应用中,存储单个字段是不太可能出现的情况。 应用更有可能存储包含复杂状态的整个模型对象。 ProtectedSessionStore 自动串行化和反序列化 JSON 数据以存储复杂的状态对象。

在前面的代码示例中,currentCount 数据存储为用户浏览器中的 sessionStorage['count']。 数据不会以纯文本形式存储,而是使用 ASP.NET Core 的数据保护进行保护。 如果在浏览器的开发人员控制台中评估了 sessionStorage['count'],则可以检查已加密的数据。

若要在用户稍后返回到 currentCount 组件时(包括用于位于新线路上时)恢复 Counter 数据,请使用 ProtectedSessionStore.GetAsync

protected override async Task OnInitializedAsync()
{
    var result = await ProtectedSessionStore.GetAsync<int>("count");
    currentCount = result.Success ? result.Value : 0;
}
protected override async Task OnInitializedAsync()
{
    currentCount = await ProtectedSessionStore.GetAsync<int>("count");
}

如果组件的参数包括导航状态,请调用 ProtectedSessionStore.GetAsync 并将非 null 结果分配给 OnParametersSetAsync,而不是 OnInitializedAsyncOnInitializedAsync 仅在首次实例化组件时调用一次。 如果用户导航到不同的 URL,而仍然停留在相同的页面上,则 OnInitializedAsync 之后不会再次调用。 有关详细信息,请参阅 ASP.NET Core Razor 组件生命周期

警告

本节中的示例仅在服务器未启用预呈现的情况下有效。 启用预呈现后,会生成错误,说明由于正在预呈现组件,无法发起 JavaScript 互操作调用。

禁用预呈现或添加其他代码以处理预呈现。 若要了解有关编写可处理预呈现的代码的详细信息,请参阅处理预呈现一节。

处理加载状态

由于浏览器存储是异步访问(通过网络连接进行访问)的,因此往往需要一段时间才能加载完数据并可供组件使用。 为获得最佳结果,请在加载进行过程中呈现一条消息,而不要显示空数据或默认数据。

一种方法是跟踪数据是否为 null(表示数据仍在加载)。 在默认 Counter 组件中,计数保留在 int 中。 通过将问号 (currentCount) 添加到类型 (),int

private int? currentCount;

请勿无条件地显示计数和“Increment”按钮,而是仅在通过选中 HasValue 加载数据时才显示这些元素:

@if (currentCount.HasValue)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

处理预呈现

预呈现期间:

  • 与用户浏览器的交互式连接不存在。
  • 浏览器尚无可在其中运行 JavaScript 代码的页面。

在预呈现期间,localStoragesessionStorage 不可用。 如果组件尝试与存储进行交互,则会生成错误,说明由于正在预呈现组件,无法发起 JavaScript 互操作调用。

解决此错误的一种方法是禁用预呈现。 如果应用大量使用基于浏览器的存储,则这通常是最佳选择。 预呈现会增加复杂性,且不会给应用带来好处,因为在 localStoragesessionStorage 可用之前,应用无法预呈现任何有用的内容。

若要禁用预呈现,请通过在应用组件层次结构中的最高级别组件(不是根组件)处将 prerender 参数设置为 false 来指示呈现模式。

注释

不支持让根组件具有交互性(例如 App 组件)。 因此,App 组件无法直接禁用预呈现。

对于基于 Blazor Web App 项目模板的应用,如果在 Routes 组件 (App) 中使用了 Components/App.razor 组件,通常会禁用预呈现:

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

此外,请禁用 HeadOutlet 组件的预呈现:

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

有关详细信息,请参阅 Prerender ASP.NET Core Razor 组件

若要禁用预呈现,请打开 _Host.cshtml 文件,并将render-mode 属性更改为 Server

<component type="typeof(App)" render-mode="Server" />

禁用预呈现时,将禁用 <head> 内容的预呈现

对于不使用 localStoragesessionStorage 的其他页面,预呈现可能很有用。 若要保持预呈现状态,可延迟加载操作,直到浏览器连接到线路。 以下是存储计数器值的示例:

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedLocalStorage ProtectedLocalStore

@if (isConnected)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

@code {
    private int currentCount;
    private bool isConnected;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isConnected = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        var result = await ProtectedLocalStore.GetAsync<int>("count");
        currentCount = result.Success ? result.Value : 0;
    }

    private async Task IncrementCount()
    {
        currentCount++;
        await ProtectedLocalStore.SetAsync("count", currentCount);
    }
}
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedLocalStorage ProtectedLocalStore

@if (isConnected)
{
    <p>Current count: <strong>@currentCount</strong></p>
    <button @onclick="IncrementCount">Increment</button>
}
else
{
    <p>Loading...</p>
}

@code {
    private int currentCount = 0;
    private bool isConnected = false;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isConnected = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        currentCount = await ProtectedLocalStore.GetAsync<int>("count");
    }

    private async Task IncrementCount()
    {
        currentCount++;
        await ProtectedLocalStore.SetAsync("count", currentCount);
    }
}

将状态保留抽取到一个公共提供者

如果多个组件依赖于基于浏览器的存储,则多次重新实现状态提供程序代码会造成代码重复。 若要避免代码重复,一种方法是创建一个封装了状态提供程序逻辑的状态提供程序父组件。 子组件可处理保留的数据,而无需考虑状态暂留机制。

在以下 CounterStateProvider 组件示例中,计数器数据会保存到 sessionStorage,并通过在状态加载完成前不呈现子内容来处理加载阶段。

组件CounterStateProvider处理预呈现时,通过在组件呈现完成后才加载状态,因为生命周期方法中的OnAfterRenderAsync不会在预呈现期间执行。

本部分中的方法无法触发在同一页上重新呈现多个订阅的组件。 如果一个订阅的组件更改了状态,它将重新渲染并显示更新后的状态,但同一页上显示该状态的其他组件则会显示过时的数据,直到它们自己下次重新渲染为止。 因此,本节中所述的方法最适合在页面上的单个组件中使用状态。

CounterStateProvider.razor:

@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

@if (isLoaded)
{
    <CascadingValue Value="this">
        @ChildContent
    </CascadingValue>
}
else
{
    <p>Loading...</p>
}

@code {
    private bool isLoaded;

    [Parameter]
    public RenderFragment? ChildContent { get; set; }

    public int CurrentCount { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isLoaded = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        var result = await ProtectedSessionStore.GetAsync<int>("count");
        CurrentCount = result.Success ? result.Value : 0;
        isLoaded = true;
    }

    public async Task IncrementCount()
    {
        CurrentCount++;
        await ProtectedSessionStore.SetAsync("count", CurrentCount);
    }
}
@using Microsoft.AspNetCore.ProtectedBrowserStorage
@inject ProtectedSessionStorage ProtectedSessionStore

@if (isLoaded)
{
    <CascadingValue Value="this">
        @ChildContent
    </CascadingValue>
}
else
{
    <p>Loading...</p>
}

@code {
    private bool isLoaded;

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    public int CurrentCount { get; set; }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            isLoaded = true;
            await LoadStateAsync();
            StateHasChanged();
        }
    }

    private async Task LoadStateAsync()
    {
        CurrentCount = await ProtectedSessionStore.GetAsync<int>("count");
        isLoaded = true;
    }

    public async Task IncrementCount()
    {
        CurrentCount++;
        await ProtectedSessionStore.SetAsync("count", CurrentCount);
    }
}

注释

有关 RenderFragment 的详细信息,请参阅 ASP.NET Core Razor 组件

若要使应用中的所有组件都可以访问状态,请使用全局交互式服务器端呈现(交互式 SSR),将 CounterStateProvider 组件包装在 Router 组件中的 <Router>...</Router> (Routes) 周围。

App 组件 (Components/App.razor) 中:

<Routes @rendermode="InteractiveServer" />

Routes 组件 (Components/Routes.razor) 中:

若要使用 CounterStateProvider 组件,请围绕需要访问计数器状态的任何其他组件包装该组件的实例。 若要使某个应用中的所有组件都可以访问该状态,请围绕 CounterStateProvider 组件 (Router) 中的 App 来包装 App.razor 组件:

<CounterStateProvider>
    <Router ...>
        ...
    </Router>
</CounterStateProvider>

注释

对于 .NET 5.0.1 和任何其他 5.x 版本,Router 组件包含的参数 PreferExactMatches 被设置为 @true。 有关详细信息,请参阅 从 ASP.NET Core 3.1 迁移到 .NET 5

已包装的组件接收并可修改保留的计数器状态。 以下 Counter 组件实现了该模式:

@page "/counter"

<p>Current count: <strong>@CounterStateProvider?.CurrentCount</strong></p>
<button @onclick="IncrementCount">Increment</button>

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

    private async Task IncrementCount()
    {
        if (CounterStateProvider is not null)
        {
            await CounterStateProvider.IncrementCount();
        }
    }
}

ProtectedBrowserStorage 进行交互无需前面的组件,该组件也不会处理“正在加载”阶段。

通常,建议在以下情况下使用状态提供程序父组件模式

  • 跨多个组件使用状态。
  • 只有一个顶级状态对象要保留时。

若要保留多个不同的状态对象并在不同位置使用不同的对象子集,最好避免全局保留状态。