第七章 状态管理
服务器渲染模式
一、维护用户状态
服务器端 Blazor 是有状态的应用框架。 大多数情况下,应用保持与服务器的连接。 用户的状态保留在对应的线路中,也就是服务器内存中。
线路中保留的用户状态如下:
- 渲染的 UI 中组件实例的层次结构及其最新的渲染输出
- 组件实例中的字段和属性的值
- 在线路范围内的依赖关系注入 (DI) 服务实例中保留的数据
此外,还可以通过 JavaScript 互操作 调用在浏览器的内存集的 JavaScript 变量中找到用户状态。
如果用户遇到暂时的网络连接丢失问题,Blazor 会尝试将用户重新连接到其原始线路上,但是将用户重新连接到服务器内存中的原始线路是有可能失败的,尤其在如下情况中:
- 服务器不能永久保留断开连接的线路。 超时后或在服务器面临内存压力时,服务器必须释放断开连接的线路
- 在负载均衡的多服务器部署环境中,不再需要单个服务器处理整个请求量时,它可能会失败或被自动删除。 在用户尝试重新连接时,用户的原始服务器处理请求可能会变得不可用
- 用户可能会关闭并重新打开其浏览器或重载页面,这会删除浏览器内存中保留的所有状态。 例如,通过 JavaScript 互操作调用设置的 JavaScript 变量值会丢失
当无法将用户重新连接到其原始线路时,用户将收到一个具有空状态的新线路。 这等效于关闭并重新打开桌面应用。
二、跨线路保留状态
通常情况下,在用户主动创建数据,而不是简单地读取已存在的数据时,可能会选择可以跨线路的状态保持方式。
若要跨线路的保留状态,应用必须将数据保存到服务器的内存以外的其他存储位置。这种状态保留并非是自动进行的,必须在开发应用时采取措施来实现有状态的数据保留。
通常,只有用户投入了大量精力所创建的高价值状态才需要数据暂留。 在下面的示例中,保留状态可以节省时间或有助于业务产出:
- 多步骤 Web 窗体:如果多步骤 Web 窗体的多个已完成步骤的状态丢失,用户重新输入这些步骤的数据会非常耗时。 如果用户离开窗体并在稍后返回,在这种应用场景下,用户将丢失状态
- 购物车:应用中任何代表潜在收入且具有重要商业价值的组件都可以保留。 如果用户丢失了其状态,进而丢失了其购物车,则在他们稍后返回站点时可购买较少的产品或服务
其实就是可以选择性的将一些有价值的信息数据保存在服务器的特定位置,以实现跨线路保留状态。
三、保留状态的位置
保留状态的常用位置有如下几种:
- 服务器端存储
- URL
- 浏览器存储
- 内存中状态容器服务
服务器端存储
对于跨多个用户和设备的永久数据持久性,应用可以使用服务器端存储。例如关系型数据库、Blob存储、键值存储、表存储等等。
保存数据后,将保留用户的状态,并在任何新的线路中可用。
URL
URL中可暂存一些简单的用户信息。
对于表示导航状态的暂时性数据,可以将数据作为 URL 的一部分,例如已查看的实体的ID、分页查询时的当前页码等。
此外还能保留浏览器地址栏的内容,便于用户重载页面,断连后连接到其他服务器的操作。
浏览器存储
对于用户正在主动创建的暂时性数据,通用存储位置是浏览器的 localStorage
和 sessionStorage
集合:
localStorage
的应用范围限定为浏览器的窗口。 如果用户重载页面或关闭并重新打开浏览器,则状态保持不变。 如果用户打开多个浏览器选项卡,则状态跨选项卡共享。 数据保留在localStorage
中,直到被显式清除为止sessionStorage
的应用范围限定为浏览器的选项卡。如果用户重载该选项卡,则状态保持不变。 如果用户关闭该选项卡或该浏览器,则状态丢失。 如果用户打开多个浏览器选项卡,则每个选项卡都有自己独立的数据版本
通常,sessionStorage
使用起来更安全。 sessionStorage
避免了用户打开多个选项卡并遇到以下问题的风险:
- 跨选项卡的状态存储中出现 bug。
- 一个选项卡覆盖其他选项卡的状态时出现混乱行为。
如果应用必须在关闭和重新打开浏览器期间保持状态,则 localStorage
是更好的选择。
使用浏览器存储时需要注意如下事项:
- 与使用服务器端数据库类似,加载和保存数据都是异步的。
- 与服务器端数据库不同,在预渲染期间,存储不可用,因为在预渲染阶段,请求的页面在浏览器中不存在。
- 对于服务器端 Blazor 应用,持久存储几千字节的数据是合理的。 超出几千字节后,你就须考虑性能影响,因为数据是跨网络加载和保存的。
- 需要知道,用户可以查看或篡改数据。(ASP.NET Core 数据保护可以降低风险)
当选择一个第三方 NuGet 包来使用 localStorage
和 sessionStorage
时,应该考虑选择一个能够方便地使用 ASP.NET Core 数据保护的包。数据保护可对存储的数据进行加密,从而降低篡改存储数据的潜在风险。 如果 JSON 序列化的数据以纯文本形式存储,用户可以使用浏览器开发人员工具查看数据,还可以修改存储的数据。当然,是否需要进行加密就看所存储的数据而定了,并不是所有的数据都需要保护的。
内存中的状态容器服务
内存中的状态容器服务其实就是自定义一个专门用于存储数据的容器类型,并且在Program
中进行注册,然后组件通过服务注入获取这个数据容器对象进行数据的读写。
在Blazor中有内置的缓存服务IMemoryCache
,不过没有自动注册,可以在Program
中通过AddMemoryCache()
方法注册服务。
- 需要注意的是,
AddMemoryCache()
方法注册的IMemoryCache
服务是单例的,不适合用于根据会话进行状态的管理
四、受保护的浏览器存储数据
所谓受保护的浏览器存储数据,是指ASP.NET Core 提供了 ProtectedLocalStorage
/ ProtectedSessionStorage
封装类,分别用于将数据保存到浏览器的 localStorage
和 sessionStorage
。
- 这两个封装类用 ASP.NET Core 的 数据保护系统(Data Protection) 自动对数据进行加密,存到
localStorage
或sessionStorage
里的不是原始数据,而是一串加密后的乱码。即使黑客拿到这串乱码,也无法解密(除非拿到服务器密钥) - 由于数据保护密钥在服务端,因此这种保护机制仅支持用于服务器端 Blazor 应用
1、保存和加载组件中的数据
保存和加载数据都是通过ProtectedLocalStorage
或ProtectedSessionStorage
来实现的,两者都有共同的父类ProtectedBrowserStorage
,前者将数据存储在localStorage
,后者将数据存储在sessionStorage
中。
常用方法
ValueTask SetAsync([string purpose,] string key, object value)
:ProtectedBrowserStorage
的异步方法,用于将数据以键值对的形式保存在浏览器中。
purpose
主要是用于指定存储数据的目的,以便更好地组织和管理数据- 数据不会以纯文本形式存储,而是使用 ASP.NET Core 的数据保护进行保护,并保存在浏览器的
sessionStorage['key']
或localStorage['key']
ProtectedBrowserStorage
在存储数据过程中会对复杂的状态对象自动串行化和反序列化 JSON 数据
ValueTask<ProtectedBrowserStorageResult<TValue>> GetAsync<TValue>([string purpose,] string key)
:ProtectedBrowserStorage
的异步方法,根据指定的键,获取对应的值。
ValueTask DeleteAsync(string key)
:删除指定键的数据
-
DataReadWrite.razor
@page "/data-read-write" @rendermode InteractiveServer @using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage @inject ProtectedLocalStorage ProtectedLocalStorage<h3>DataReadWrite</h3><button @onclick="SaveData">保存数据</button><button @onclick="ReadData">读取数据</button>@if (_readData != null) {<div>@_readData</div> }@code {private string _testData = "schuyler";private string? _readData;private async Task SaveData(){await ProtectedLocalStorage.SetAsync("testData", _testData);}private async Task ReadData(){var result = await ProtectedLocalStorage.GetAsync<string>("testData");_readData = result.Success ? result.Value : null;} }
2、处理加载状态
由于浏览器存储是异步访问(通过网络连接进行访问)的,因此往往需要一段时间才能加载完数据并可供组件使用。 为获得最佳结果,应该在加载进行过程中渲染一条消息,而不要显示空数据或默认数据。
-
DataReadWrite.razor
@page "/data-read-write" @rendermode InteractiveServer @using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage @inject ProtectedLocalStorage ProtectedLocalStorage<h3>DataReadWrite</h3><button @onclick="SaveData">保存数据</button><button @onclick="ReadData">读取数据</button>@if (_readData != null) {<div>@_readData</div> } else {<div>暂未读取信息</div> }@code {private string _testData = "schuyler";private string? _readData;private async Task SaveData(){await ProtectedLocalStorage.SetAsync("testData", _testData);}private async Task ReadData(){var result = await ProtectedLocalStorage.GetAsync<string>("testData");_readData = result.Success ? result.Value : null;} }
3、处理预渲染
在预渲染期间,Blazor应用与浏览器之间不存在交互式连接,且浏览器尚无可在其中运行 JavaScript 代码的页面。
因此,预渲染期间,localStorage
或 sessionStorage
不可用。 如果组件尝试与存储进行交互,则会生成错误,说明由于正在预渲染组件,无法发起 JavaScript 互操作调用。
- 注意,Assembly模式下,并没有预渲染,所以不存在这个问题
处理方案1—禁用预渲染
解决此错误的一种方法是禁用预渲染。 如果应用大量使用基于浏览器的存储,则这通常是最佳选择。 预渲染会增加复杂性,且不会给应用带来好处,因为这种情况下,在 localStorage
或 sessionStorage
可用之前,应用无法预渲染任何有用的内容。
若要禁用预渲染,可以通过在应用组件层次结构中的最高级别组件(不是根组件App)处将 prerender
参数设置为 false
来指示渲染模式。
-
注意,Blazor中不支持让根组件,即
App
组件具有交互性,因此App
组件也就无法禁用(也没必要禁用)预渲染。 -
可以在App组件中,对
<Routes>
组件和<HeadOutlet>
组件禁用预渲染,来达到全局禁用预渲染的目的。 -
App.razor
<!DOCTYPE html> <html lang="en"><head>......<HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender: false)"/> </head><body><Routes @rendermode="new InteractiveServerRenderMode(prerender: false)"/>...... </body></html>
处理方案2—渲染完成后调用
如果不希望禁用预渲染,那么可以将localStorage
或 sessionStorage
的交互放在OnAfterRender(bool firstRender)
或OnAfterRenderAsync(bool firstRender)
方法中,这两个方法在组件预渲染时不会调用,仅在完成交互式渲染后才调用。
4、WebAssembly下的存储方法
WebAssembly模式下是没有提供ProtectedLocalStorage
或ProtectedSessionStorage
服务端,此时如果希望在浏览器上做数据缓存,有两种常见的处理方式,第一种是使用第三方库,第二种是通过IJSRuntime
服务使用JS来实现,个人推荐直接用JS吧,省得多安装一些插件,下面是使用JS进行存储的示例。
-
示例
public class JSTest {private readonly IJSRuntime _jsRuntime;private const string ItemKey = "itemKey";public JSTest(IJSRuntime jsRuntime){_jsRuntime = jsRuntime;}public async Task Save(){//保存数据await _jsRuntime.InvokeVoidAsync("localStorage.setItem", ItemKey, "Schuyler");}public async Task Remove(){//删除数据await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", ItemKey);}public async Task Get(){//读取数据var udata = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", ItemKey);} }
但是需要注意的是,这种直接保存的数据是没有经过加密的 ,因此对于敏感性息、慎重使用。
五、存储数据的处理方案
1、使用缓存进行状态管理
如果组件的状态不需要根据当前会话进行管理,而是整个服务的状态,那可以考虑使用缓存服务IMemoryCache
来进行管理
- 原因是
IMemoryCache
服务是单例的。
注册服务
-
Program.cs
var builder = WebApplication.CreateBuilder(args); ...... builder.Services.AddMemoryCache();
在组件中使用
-
示例
@page "/" @using Microsoft.Extensions.Caching.Memory @rendermode InteractiveServer@Data@code {[Inject]public IMemoryCache? MemoryCache{ get; set; }public string? Data;protected async override Task OnAfterRenderAsync(bool firstRender){if (firstRender){string? tempData = MemoryCache?.Get<string>("data");if (tempData == null){tempData = "测试数据";MemoryCache?.Set("data", tempData);}await base.OnAfterRenderAsync(firstRender);Data = tempData;StateHasChanged();}} }
2、使用父组件统一处理
如果多个组件依赖于基于浏览器的存储,则多次重新实现状态提供程序代码会造成代码重复。 若要避免代码重复,可以创建一个父组件,父组件负责获取状态信息,然后父组件通过级联值传递的方式将自己传递给子组件使用。
创建父组件
-
父组件-CounterStateProvider.razor
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage @inject ProtectedLocalStorage ProtectedLocalStore@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 OnInitializedAsync(){var result = await ProtectedLocalStore.GetAsync<int>("count");CurrentCount = result.Success ? result.Value : 0;isLoaded = true;}public async Task SaveChangesAsync(){await ProtectedLocalStore.SetAsync("count", CurrentCount);} }
设置渲染方式并放置父组件
-
App.razor
<!DOCTYPE html> <html lang="en"><head>......<HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender: false)"/> </head><body><Routes @rendermode="new InteractiveServerRenderMode(prerender: false)"/>...... </body></html>
-
Routes.razor
<CounterStateProvider><Router ...>...</Router> </CounterStateProvider>
子组件中使用父组件的属性和方法
-
Counter.razor
@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){CounterStateProvider.CurrentCount++;await CounterStateProvider.SaveChangesAsync();}} }
建议在以下情况下使用父组件统一处理:
- 跨多个组件使用同一个状态。
- 只有一个顶级状态对象要保留时。
若要保留多个不同的状态对象并在不同位置使用不同的对象子集,最好避免使用父组件来统一处理。