英文:
Blazor wasm: cascading RouteData from top component to inner component fires ParameterSet twice
问题
上周,尽管我不是Blazor专家,但我被要求帮助处理一个Blazor Wasm应用程序,当应用程序导航到具有至少一个查询字符串参数的URI时,它会两次触发SetParametersAsync
方法(如果至少有一个参数,则会触发ParametersSetAsync
方法两次)。
首先,让我提供关于该应用程序以及为何此行为有问题的更多信息。为了支持资源URI共享,大多数操作会生成一个URI,应用程序会导航到该URI。在实践中,这意味着大多数操作最终会导航到一个URI,该URI上存在多个参数,这些参数从URI上的查询字符串参数中提取。
例如,假设您有一个页面(/demo
),显示一些可以通过几个字段(比如 A
、B
、C
)进行筛选的项目列表。每当用户单击筛选按钮时,将生成一个新的URI(/demo?A=...&B=...&C=...
),并将其传递给NavigationManager.NavigateTo
。这将导致调用SetParametersAsync
方法,该方法负责获取信息并向用户显示它(在此情况下,它将调用多个Web服务以获取所需的数据)。
经过一些测试,我注意到存在问题的页面(即SetParametersAsync
方法被调用两次的页面)是通过从顶级组件传递的CascadeParameter
获得RouteData
对象的实例。
为了重现这种行为,您只需要创建一个新的Blazor WebAssembly应用程序,并将默认生成的代码更改为以下内容:
@*App.razor*@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<CascadingValue Value="@routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</CascadingValue>
</Found>
...
</Router>
@*Index.razor*@
@page "/"
@inject NavigationManager NavigationManager
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?"/>
<Button @onclick="Navigate">Click me</Button>
@code {
[CascadingParameter]
public RouteData? RouteData { get; set; }
[SupplyParameterFromQuery(Name = "in")]
[Parameter]
public string Info { get; set; }
private void Navigate() {
var uri = NavigationManager.GetUriWithQueryParameter("in", DateTime.Now.ToString());
NavigationManager.NavigateTo(uri);
}
public override async Task SetParametersAsync(ParameterView parameters) {
await base.SetParametersAsync(parameters);
Console.WriteLine($"Navigation: {Info}");
}
}
如果运行上述示例,您会注意到级联RouteData
会混乱事情,该方法将使用通过URI传递的日期进行调用,然后使用空日期进行调用(我访问属性而不是直接查询字符串值)。如果没有级联RouteData
参数,那么一切都会正常运行。
我尝试搜索文档,但没有找到任何说明不应该使用级联RouteData
参数的内容。诚然:我不是Blazor专家,我也没有在我的应用程序中需要级联RouteData
(在这种情况下,可能也不需要),但我真的希望有人能确认这种行为,并且如果可能的话,指向微软的官方文档,以解释这种行为。
谢谢。
英文:
Last week, amd even though I'm no Blazor expert, I was called to help with a Blazor Wasm app that was firing the SetParametersAsync
method twice when the app navigates to an URI that has at least one querystring parameter (if there's at least one, then it will end up firing the ParametersSetAsync
method twice).
First, let me give some more info about the app and why this behavior is problematic. In order to support resource uri sharing, most actions generates an uri to which the app navigates to. In practice, this means that most actions end up navigating to an uri, which is handled by a page that has several parameters that are fed from the querystring parameters present on the URI.
For instance, suppose you have a page (/demo
) that shows a list of items which can be filtered by a couple of fields (lets say A
, B
, C
). Whenever a user clicks the filter button, a new URI is generated (/demo?A=...&B=...&C=...
) and its passed the NavigationManager,NavigateTo
. This ends up calling the SetParametersAsync
method which is responsible for getting the information and showing it to the user (in this case, it will call several web services in order to get the required data).
After some testing, I've noticed that the problematic pages (ie, those where the SetParametersAsync
method is called twice) are getting an instance of the RouteData
object through a CascadeParameter
which is being passed from the top component.
In order to reproduce the behavior, you just need to create a new Blazor WebAssemly App and change the default generated code to the following:
@*App.razor*@
<Router AppAssembly="@typeof( App ).Assembly">
<Found Context="routeData">
<CascadingValue Value="@routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof( MainLayout )"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</CascadingValue>
</Found>
...
</Router>
@*Index.razor*@
@page "/"
@inject NavigationManager NavigationManager
<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?"/>
<Button @onclick="Navigate">Click me</Button>
@code {
[CascadingParameter]
public RouteData? RouteData { get; set; }
[SupplyParameterFromQuery(Name = "in")]
[Parameter]
public string Info { get; set; }
private void Navigate() {
var uri = NavigationManager.GetUriWithQueryParameter("in", DateTime.Now.ToString( ));
NavigationManager.NavigateTo(uri);
}
public override async Task SetParametersAsync(ParameterView parameters) {
await base.SetParametersAsync(parameters);
Console.WriteLine($"Navigation: {Info}");
}
}
If you run the previous sample, you'll notice that cascading the RouteData
tends to mess things up and that the method will be called with the date that was passed through the uri and then with an empty date (I'm accessing the property, not the querystring value directly). If there's no RouteData
parameter being cascaded, then everything runs just fine.
I've tried searching the docs but haven't found anything which says that cascading shouldn't be used with RouteData
parameters. Granted: I'm not a Blazor wizard and I haven't needed to cascade the RouteData
in my apps (and in this case, it probably wasn't required either), but I'd really love that someone could confirm this behavior and, if possible, point me to some official document from MS that justifies this behavior.
Thanks.
答案1
得分: 0
SetParamnetersAsync
isn't being called twice, the component is being rendered twice. Subtle different.
这个函数并没有被调用两次,而是组件被渲染了两次。细微的不同。
英文:
SetParamnetersAsync
isn't being called twice, the component is being rendered twice. Subtle different.
This will take a little explaining, but to show that it's nothing to do with routes here's my demo App
that does the same thing:
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<CascadingValue Value="@value">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</CascadingValue>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code {
public string value => $"{DateTime.Now.ToLongTimeString()}";
}
Here's my Index with aome extra logging:
@page "/"
@inject NavigationManager NavigationManager
<PageTitle>Index</PageTitle>
<div class="m2 p-2">
<Button @onclick="Navigate">Click me</Button>
</div>
<div class="bg-dark text-white">
<pre>@_log.ToString()</pre>
</div>
@code {
[CascadingParameter] public string? Data { get; set; }
[SupplyParameterFromQuery(Name = "in")]
[Parameter] public string Info { get; set; }
private System.Text.StringBuilder _log = new();
private void Navigate()
{
var uri = NavigationManager.GetUriWithQueryParameter("in", DateTime.Now.ToString());
NavigationManager.NavigateTo(uri);
}
public override async Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
var message = $"Render at {DateTime.Now.ToLongTimeString()}";
_log.AppendLine(message);
Console.WriteLine(message);
await base.SetParametersAsync(ParameterView.Empty);
var navMessage = $"Navigation: {Info}";
_log.AppendLine($"{DateTime.Now.ToLongTimeString()} : {navMessage}");
Console.WriteLine($"{DateTime.Now.ToLongTimeString()} : {navMessage}");
}
}
Here's the log with the key element highlighted. Note the Render time verses the Navigation time. The render occured with the old data?
The reason is that when the Route
component renders, the renderer knows that Index
has a reference to the cascade and the cascaded value has changed. It therefore calls SetParametersAsync
on Index
which renders the component. It then renders RouteView
which renders Index
(again), this time with the new data.
It's one of the side effects of cascading objects, and ComponentBase
's render logic. The render doesn't know if an object has changed, so everytime it renders the owning component, all the cascade capturing components also get rendered. If you look through the Microsoft code base you will see that most cascades are set to IsFixed
.
In this instance I would probably use an event driven model rather than the cascade. I've provided an example below.
The provider and EventArgs
.
public class RouteDataProvider
{
public event EventHandler<RouteDataEventArgs>? RouteChangd;
public RouteData? RouteData { get; private set; }
public void NotifyRouteChanged(RouteData routeData)
{
this.RouteData = routeData;
this.RouteChangd?.Invoke(this, new(routeData));
}
}
public class RouteDataEventArgs : EventArgs
{
public RouteData RouteData { get; set; }
public RouteDataEventArgs(RouteData routeData)
=> this.RouteData = routeData;
}
Updated App
with a fixed cascade.
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<CascadingValue Value=_routeDataProvider IsFixed>
@{
_routeDataProvider.NotifyRouteChanged(routeData);
}
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</CascadingValue>
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code {
private readonly RouteDataProvider _routeDataProvider = new();
}
And Index
:
@page "/"
@inject NavigationManager NavigationManager
<PageTitle>Index</PageTitle>
<div class="m2 p-2">
<Button @onclick="Navigate">Click me</Button>
</div>
<div class="bg-dark text-white">
<pre>@_log.ToString()</pre>
</div>
@code {
[CascadingParameter] public RouteDataProvider? _routeDataProvider { get; set; }
[SupplyParameterFromQuery(Name = "in")]
[Parameter] public string Info { get; set; }
private System.Text.StringBuilder _log = new();
private void Navigate()
{
var uri = NavigationManager.GetUriWithQueryParameter("in", DateTime.Now.ToString());
NavigationManager.NavigateTo(uri);
}
public override async Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
var message = $"Render at {DateTime.Now.ToLongTimeString()}";
_log.AppendLine(message);
_log.AppendLine($"Route Component: {_routeDataProvider?.RouteData?.PageType.FullName ?? "Not defined"}");
Console.WriteLine(message);
await base.SetParametersAsync(ParameterView.Empty);
var navMessage = $"Navigation: {Info}";
_log.AppendLine($"{DateTime.Now.ToLongTimeString()} : {navMessage}");
Console.WriteLine($"{DateTime.Now.ToLongTimeString()} : {navMessage}");
}
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论