Blazor Server. 充分利用 Fluxor





5.00/5 (3投票s)
如何使用 Fluxor 沿着明确定义的路径推进应用程序
引言
Blazor Server 应用倾向于使用 Fluxor 的一个常见原因是为了持久化 Blazor 组件的状态。例如,默认应用的 Counter 页面在每次返回作用域时不会将其点击计数重置为零。但 Fluxor 的作用远不止于此,它是一个集成的 面向消息的中间件 (MOM) 包,可以将整个应用程序沿着清晰定义的路径推进。
Fluxor 模式和术语
以下示例中使用的模式和术语是 Fluxor 文档 中说明的。应用程序的路径是基于“用例”定义的。这些是功能的一部分,其中一个事件会产生一个效果,该效果会以某种方式改变应用程序的状态。路径的主要组成部分(事件、事件处理程序和状态管理处理程序)必须提供,但它们之间耦合非常松散,因此除了提供简单的消息实体外,无需任何用户定义的集成软件。用于构建应用程序的用例模式与更传统的层式架构模式不同。层式架构由通常实现为服务的职能层组成。它是一种多层蛋糕方法。用例模式采用多层蛋糕,并将其实现为垂直穿过各层的切片。因此,如果一个服务的功能可以在用例中逐片实现,那么它就不需要成为一个专用的实体。
用例示例
在默认的 Blazor Server 应用程序中,可以识别出两个用例。一个 Counter 用例,其中单击一个按钮,然后计算、存储和显示点击次数;另一个 Forecast 用例,其中页面初始化导致天气预报被上传、显示和存储。以下 Counter 用例的示例展示了如何将“事件、效果、状态更改”的 Fluxor 模式应用于简单路径。
Counter 用例示例
在此示例中,事件是按钮单击,效果是重新计算的点击次数,状态更改是应用程序获得新的总计数。开始构建用例的一个好方法是定义构成路径上消息流的消息。
Actions
在 Fluxor 文档中,消息被称为操作。它们是简单的 `record` 类型,具有描述性名称。需要两个操作。
public record EventCounterClicked();
public record SetCounterTotal(int TotalCount);
操作的名称通常以消息类型开头。标准消息类型是
- 事件
- Commands
- Documents
文档消息包含数据。我倾向于使用“Set”一词来描述用于更改状态的文档操作。当调试发送或接收顺序不当的消息时,这种命名约定会非常有用。
定义状态
状态是只读的不可变数据存储,用于保存点击计数总数。状态是一个 `record` 类型,其定义比操作略复杂,因为它需要有一个默认构造函数。
[FeatureState]
//defines a record with a public readonly property TotalCount
public record CounterState(int TotalCount)
{
//The required default constructor
public CounterState() : this(0)
{
}
}
定义 Counter 页面
这里的目标是实现一个能做好一件事的组件。对于页面而言,这件事就是管理 UI。组件应该是松耦合且可重用的。它不需要了解任何其他组件,也不需要具有任何预定义的功能,例如计算点击次数和存储点击计数。通过使用组件的 `OnClicked` 处理程序将 `EventCounterClicked` 事件分派到 Fluxor 的消息路由器,可以轻松实现这一点。
@page "/counter"
@using BlazorFluxor.Store
@using BlazorFluxor.Store.CounterUseCase
@using Fluxor.Blazor.Web.Components
@using Fluxor;
@inherits FluxorComponent
<PageTitle >Counter </PageTitle >
<h1 >Counter </h1 >
<p role="status" >Current count: @CounterState!.Value.TotalCount </p >
<button class="btn btn-primary" @onclick="OnClicked" >Click me </button >
@code {
[Inject]
protected IState <CounterState >? CounterState { get; set; }
[Inject]
public IDispatcher? Dispatcher { get; set; }
private void OnClicked()
{
Dispatcher!.Dispatch(new EventCounterClicked());
}
}
注入的 `IState<CounterState>` 实例有一个 `Value` 属性,用于引用 `State`,它还有一个 `StateChanged` 事件。继承该页面的 `FluxorComponent` 会订阅 `StateChanged` 事件,这会导致每次状态的 `TotalCount` 被中间件更新时页面重新渲染。任何共享相同 `CounterState TotalCount` 且在作用域内的其他 `FluxorComponent` 也会重新渲染。因此,例如,在单击 Count 页面按钮时,购物车组件的商品计数将得到更新。
Effects 类
除更新状态外还有其他效果的操作通常在 `Effects` 类中处理。`Effects` 类名是复数,但它只是一种集合,因为它包含多个消息处理程序。操作处理方法名称可以是任何名称,但所有处理程序都必须具有相同的签名,并使用 `EffectMethod` 属性进行装饰。以下处理程序处理 `EventCounterClicked` 操作,并确定收到该操作对应用程序状态的影响。
[EffectMethod]
public Task HandleEventCounterClicked
(EventCounterClicked action,IDispatcher dispatcher)
{
int totalCount = _counterState.Value.TotalCount + 5;
dispatcher.Dispatch(new SetCount (totalCount));
return Task.CompletedTask;
}
`SetCount` 操作的 `TotalCount` 属性设置为更新后的计数,然后分派到消息路由器。`SetCount` 操作在 `Reducers` 类中处理。
Reducers 类
`Reducers` 类是状态管理类。它具有将两个或多个记录减少为单个新记录实例的处理程序。它是 `State` 可以更改的唯一路径。此类格式与 `Effects` 类类似。所有处理程序都必须具有相同的签名,并使用 `ReducerMethod` 属性进行装饰。
[ReducerMethod]
public static CounterState ReduceSetCounterTotal
(CounterState state,SetCounterTotal action)
{
return state with { TotalCount = action.TotalCount };
}
}
`Record` 类型通过使用 `with` 关键字以优化方式更新自身。`reduce` 方法的作用是返回一个新的状态实例,该实例与旧状态相同,但 `TotalCount` 属性已更新为操作的 `TotalCount` 属性的值。
配置
配置并不像看起来那么困难,因为 Fluxor 将消息与其处理程序关联起来,并提供 `Reducer` 方法和 `Effects` 方法所需的所有参数,以及注入到组件中的 `IDispatcher` 和 `IState` 实例。您无需在容器中填充这些实例,Fluxor 服务会处理这些。需要在 `Program` 类的 builder 部分添加该服务。
builder.Services.AddFluxor(options = >
{
options.ScanAssemblies(typeof(Program).Assembly);
#if DEBUG
options.UseReduxDevTools();
#endif
});
上面的代码中包含了 Redux Dev Tools 选项。这是一个有用的浏览器扩展,可以绘制消息流并显示沿流每个阶段的状态值。Fluxor 所需的最后一点配置是在 `App.razor` 的第一行添加 `<Fluxor.Blazor.Web.StoreInitializer/>` 标记。推荐的文件夹结构如下所示,*Store* 是 Fluxor 的根目录。
Forecast 用例
在默认应用程序中,Forecast 用例几乎完全在 `FetchData` 页面内处理。该页面同时管理 UI 和注入的 `WeatherForecast` 服务。没有状态管理,因此每次页面进入作用域时都会加载新的每日预报。数据库错误由默认错误处理程序处理,并且不是数据库特定的。下面的代码实现了一个单页 FluxorForecast 用例,该用例维护页面状态,因此它不会在每次进入作用域时更新。UI 通过将页面中的绑定类型链接到不可变 `State` 记录中的属性来控制,并通过用智能组件填充页面来控制,这些组件仅在 `State` 需要时才渲染。`WeatherForecast` 服务完全在 `Effects` 类中实现,因此页面只需负责渲染 UI。
Forecast 状态
不可变 `State` 记录定义如下:
using BlazorFluxor.Data;
using Fluxor;
using System.Collections.Immutable;
namespace BlazorFluxor.Store.ForecastUseCase
{
[FeatureState]
public record ForecastState(
ImmutableList<WeatherForecast> Forecasts,
string? Message,
bool IsError,
bool IsLoading)
{
public ForecastState() : this(ImmutableList.Create<WeatherForecast>(),
null, false, true)
{
}
}
}
`Forecasts` 列表保存每日预报,`Message string` 用于存储错误消息。两个 `bool` 值由智能组件用于确定它们是否应渲染。下面的真值表显示了 `State` 的 `bool` 值的可能设置以及每种四种可能设置下渲染的组件。
IsLoading | IsError | 显示 |
假 | 假 | 数据表 |
True | 假 | Spinner |
假 | True | 错误对话框 |
True | True | 未定义 |
Forecast Effects 类
`WeatherForecast` 服务完全在 `ForecastUseCase.Effects` 类中实现,因此无需注入 `WeatherForecast` 服务。使用 异步流 来检索每日预报,以便一旦可用即可显示每个每日预报。这比使用返回 `Task<IEnumerable<Weatherforecast>>` 的方法更好,因为 `Task` 必须在任何数据可以显示之前完成,并且页面渲染会延迟。
[EffectMethod]
public async Task HandleEventFetchDataInitialized
(EventMsgPageInitialized action, IDispatcher dispatcher)
{
try
{
await foreach (var forecast in ForecastStreamAsync
(DateOnly.FromDateTime(DateTime.Now), 7))
{
dispatcher.Dispatch(new SetForecast(forecast, false, false));
}
}
catch (TimeoutException ex)
{
dispatcher.Dispatch(new SetDbError(ex.Message, true, false));
}
}
为了演示错误处理,`GetForecastAsyncStream` 方法在首次调用时会超时,并会显示错误对话框。后续调用不会超时。
public async IAsyncEnumerable<WeatherForecast>
ForecastStreamAsync(DateOnly startDate, int count)
{
int timeout = _cts == null ? 1500 : 2000;//timeout on first call only
using var cts = _cts = new(timeout);
try
{
await Task.Delay(1750, _cts.Token);
}
//make sure the correct OperationCanceledException is caught here
catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested)
{
throw new TimeoutException("The operation timed out.Please try again");
}
for (int i = 0; i < count; i++)
{
int temperatureIndex = Random.Shared.Next(0, Temperatures.Length);
int summaryIndex = temperatureIndex / 4;//relate summary to a temp range
await Task.Delay(125); //simulate slow data stream
yield return new WeatherForecast
{
Date = startDate.AddDays(i),
TemperatureC = Temperatures[temperatureIndex],
Summary = Summaries[summaryIndex]
};
}
}
该方法使用整数除法将温度范围关联到合适的摘要值。
private static readonly string[] Summaries = new[]
{
"Freezing", "Cold", "Mild", "Hot"
};
private static readonly int[] Temperatures = new[]
{
0,-2,-4,-6,//index range 0-3 relates to summaries[index/4]==summaries[0]
2,6,8,10, //index range 4-7 relates to summaries[index/4]==summaries[1]
12,14,16,18,
23,24,26,28
};
Forecast Reducers 类
reducer 通过调用列表的 `Add` 方法来更新 `Forecasts` 列表。该方法的设计可以创建列表的新更新实例,而不会产生通常与添加值和创建新列表相关的开销。
public static ForecastState ReduceSetForecast(ForecastState state, SetForecast action)
{
return state with
{
Forecasts = state.Forecasts.Add(action.Forecast),
IsError = action.IsError,
IsLoading = action.IsLoading
};
}
WeatherForecast 表
`WeatherForecastTable` 是一个智能组件的示例。它只是使用一个带有附加 `IsShow bool` 的模板表,如果设置为 `true`,则组件将渲染。
@if (IsShow)
{
<TableTemplate Items="Forecasts" Context="forecast">
<TableHeader>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</TableHeader>
<RowTemplate>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</RowTemplate>
</TableTemplate>
}
@code {
#nullable disable
[Parameter]
public IEnumerable<WeatherForecast> Forecasts { get; set; }
[Parameter]
public bool IsShow { get; set; }
}
FetchData 页面
@inherits FluxorComponent
@inject NavigationManager NavManager
<PageTitle>Weather Forecasts</PageTitle>
<h1>Weather Forecasts</h1>
<p>This component simulates fetching data from an async stream.
Each forecast is listed as soon as it is available.</p>
<Spinner IsVisible=@IsShowSpinner />
<WeatherForecastTable IsShow="@IsShowTable" Forecasts="@Forecasts" />
<TemplatedDialog IsShow="@IsShowError">
<OKDialog Heading="Error"
BodyText="Whoops, an error has occurred."
OnOK="NavigateToIndex">@ForecastState!.Value.Message</OKDialog>
</TemplatedDialog>
`Spinner` 可作为 NuGet 包 提供,并且模板化组件基于 .NET Foundation 的 Blazor-Workshop 中的示例。以下代码部分主要关注简化显示每个组件的逻辑。
@code {
[Inject]
protected IState<ForecastState>? ForecastState { get; set; }
[Inject]
public IDispatcher? Dispatcher { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
if (ForecastState!.Value.IsLoading is true)
{
Dispatcher!.Dispatch(new EventMsgPageInitialized());
}
}
//An expression body definition of a read-only property
protected IEnumerable<WeatherForecast> Forecasts => ForecastState!.Value.Forecasts!;
protected bool IsShowError => (ForecastState!.Value.IsLoading is false &&
ForecastState!.Value.IsError is true);
protected bool IsShowTable => (ForecastState!.Value.IsLoading is false &&
ForecastState!.Value.IsError is false);
protected bool IsShowSpinner => (ForecastState!.Value.IsLoading is true &&
ForecastState!.Value.IsError is false);
private void NavigateToIndex()
{
Dispatcher!.Dispatch(new SetStateToNew());
NavManager.NavigateTo("/");
}
}
结论
这些示例说明了使用 Fluxor 的好处。它产生了清晰的路径,易于扩展,并且易于遵循和调试。在示例中,只考虑了一个用例,但在企业应用程序中,会有很多用例。它们中的每一个都需要操作、效果处理程序、reducer 和状态,因此代码库将会很大。但这没关系,因为我们都相信“冗余代码可以是智能代码”的格言。对吧?
致谢
我想感谢 Fluxor 的作者 Peter Morris。他是一位出色的开发者,他的 GitHub 页面非常值得关注。
历史
- 2023 年 7 月 4 日:初始版本
- 2023 年 8 月 8 日:添加了 Fetch Data 示例