上一篇文章我们已经基本讲清楚了Blazor框架中的路由机制,这一节课再补充一些有关路由、页面跳转的一些边角知识。
我们虽然已经掌握了路由机制的运行原理,但也只是原理而已,有一个非常重要的,在实际开发过程中需要用到的东西还没有讲到:如何实现页面间的互相跳转。我们先不谈Blazor框架,把讨论范畴拉大一点,对于所有SPA前端框架而言,其实跳转有两种实现方式:
不过有意思的是,在Blazor框架中,实际我们是无法直接实现#1这种跳转的,因为Blazor框架本身,拦截了所有对于<a>
标签中链接的点击事件,然后由框架代码以#2的方式去实现“伪跳转”。换句话说,你可以简单的认为,除非用户在页面上是以“在新窗口/标签页中打开链接”的方式来点击链接,否则,在Blazor应用中,所有跳转都是以#2的形式实现的。(除了域外跳转)
我们作为框架的使用者,不必过分好奇Blazor框架是怎么实现这一点的,只需要知道这么个事就行了。总之就是记住:通常情况下,所有跳转都是无刷新跳转。
从具体行为角度来讲,几乎所有的跳转都是框架在做伪跳转。从书写代码的角度来讲,实现跳转大概有两类方法:
跳转这个事,从用户角度来看,就是在变更URL,而既然说到URL,就不得不考虑一个重要的事:查询字符串。
比如https://xxx.com/a/b/c
,这是只包含路径的URL,而https://xxx.com/a/b/c?key1=value1&key2=value2
就是在前者的基础上,向URL中编码了两个k-v,这玩意就是查询字符串。
所以除了跳转本身之外,如何在Blazor WASM程序中读取到查询字符串中的信息,以及如何构造出包含查询字符串的跳转链接,也是相当重要的内容。也会在本文中进行介绍。
接下来,坐稳扶好,我们来开始跳转的旅程。
<a>
标签去做一个超链接这个就是框架无关的,纯HTML实现的页面跳转。我们先通过命令dotnet new blazorwasm-empty --hosted -o HelloNav
新建一个blazorwasm-empty
项目,然后在Pages
目录中新添加两个页面组件:Page1.razor
和Page2.razor
,代码分别如下:
@page "/Page1"
<h3>Page1</h3>
@page "/Page2"
<h3>Page2</h3>
接下来把默认布局组件MainLayout.razor
改写成下面的样子
@inherits LayoutComponentBase
<nav>
<ol>
<li><a href="/">Home</a></li>
<li><a href="/Page1">Page1</a></li>
<li><a href="/Page2">Page2</a></li>
</ol>
</nav>
<main>
@Body
</main>
运行效果如下:
可以看到,在点击链接跳转的过程中,客户端浏览器完全没有发送任何网络请求,浏览器完全没有刷新行为。不光如此,在我们以JS的方式调用window.history.forward()
和window.history.back()
的时候,即点击当前Tab的前进、后退按钮时,页面也没有刷新行为。
你们有没有注意到一个问题:虽然我们这个简单示例中没有涉及任何CSS样式,但根据浏览器的默认行为,一个用户已经访问过的链接,浏览器在默认情况下会把它的颜色显示为灰色,而不是纯蓝色。纯蓝色的链接代表着一个“之前从未访问过的链接”。
而我们上面录制的示例中,无论怎么点击,来回跳转,那三个链接都是蓝色的。
那么,这时,我有一个问题想问大家:请大家猜一下为什么浏览器没有识别出“已经访问过的链接”,以下三个选项请选择一个正确答案:
window.history
栈表中,但这些访问记录其实并没有写进浏览器全局的访问历史中去(在chrome内核浏览器中,可以通过在地址栏输入@history
来查看浏览器全局存储的访问历史记录),而只是让框架写进了当前Tab的session history中去,即window.history
里正确答案是:#3
这里就要解释一下浏览器是如何识别一个链接是否被访问过。
首先,大家要清楚,当谈及浏览器的历史记录的时候,其实是有两个完全不同的概念的:
@history
就可以打开访问历史管理页面,这里浏览器存储着全局性的、关闭重启浏览器不会清零的,所有URL的访问历史记录window.history
,它里面存储着当前Tab的跳转历史,浏览器的“前进”,“后退”功能就是靠着window.history.forward()
和window.history.back()
实现的。前端应用程序通过编程接口其实是可以控制这个栈表中的内容的,这其实也是所有前端SPA框架做无刷新跳转时需要做的一件事:不光页面要渲染,URL地址要改变,还要把跳转前的页面URL放进这个栈表中去浏览器在检测一个链接是否被访问过时,只是简单的去查看#1中有没有这个URL,如果有,就是被访问过的,如果没有,就没被访问过,就这么简单。
另外,每当Tab中的栈表记录有变更的时候,这个变更其实是会同步到浏览器全局历史记录列表中去的。换句话说,SPA框架在实现无刷新跳转的时候,跳转记录依然会被浏览器全局历史记录列表检测到。除了一种特殊情况:隐私窗口。
当你打开隐私窗口进行网页浏览的时候,浏览器就完全不存储全局历史记录了,所有的链接都是蓝色的。
现在我们在正常浏览器窗口环境下,再重新模拟一下上面的Gif图:
这下,行为符合我们的直觉了吧?
<NavLink>
组件去实现一个超链接Blazor框架提供了一个与标准<a>
标签特别相似的一个内置组件,叫NavLink
,用它也可以来在页面上写超链接。简单来说,它底层就是一个<a>
,只不过框架把它包装了一层而已,并且包装的过程中也没有添加什么额外的魔法。只添加了一个额外功能:
如果链接上的路径,与当前页面的URL前缀匹配的话,那么在渲染时就会给
<a>
添加一个CSS class,这个class名默认为"active"
,并且可以进行自定义。
这是什么意思呢?我们来举一个例子说明了下:
首先我们新建一个页面,Pages/NavLinkSample.razor
,内容如下:
@page "/NavLinkSample"
@page "/NavLinkSample/a"
@page "/NavLinkSample/a/b"
@page "/NavLinkSample/a/b/c"
@page "/NavLinkSample/a/b/c/d"
<h3>NavLinkSample</h3>
这个页面组件匹配五个不同的请求路径,等会你就会看到为什么这么攒这个例子了。然后把MainLayout.razor
改写成下面这样:
@inherits LayoutComponentBase
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<nav>
<ol>
<li><NavLink href="/Page1">Page1</NavLink></li>
<li><NavLink href="/Page2">Page2</NavLink></li>
<li><NavLink href="/">/</NavLink></li>
<li><NavLink href="/NavLinkSample">/NavLinkSample</NavLink></li>
<li><NavLink href="/NavLinkSample/a">/NavLinkSample/a</NavLink></li>
<li><NavLink href="/NavLinkSample/a/b">/NavLinkSample/a/b</NavLink></li>
<li><NavLink href="/NavLinkSample/a/b/c">/NavLinkSample/a/b/c</NavLink></li>
<li><NavLink href="/NavLinkSample/a/b/c/d">/NavLinkSample/a/b/c/d</NavLink></li>
</ol>
</nav>
<main>
@Body
</main>
首先是把新加的五个路径都在布局组件中给它们分别写个<NavLink>
,然后在布局组件中添加了一个全局的css样式,让带类名active
的元素背景色显示为黄色,前景色显示为红色。
这个例子运行起来是下面这样的:
看到了吧?效果非常直观,NavLink
非常适合在布局组件中实现那种层级式、目录式的导航链接,除了链接本身的功能外,还非常方便的能以active
这个css 类名,来区分出目前的目录层级。
如果我们修改一下链接元素的展示文本的话,这个例子会更直观,比如我们把MainLayout.razor
的代码修改为如下:
@inherits LayoutComponentBase
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<nav>
<span><NavLink href="/">Home</NavLink></span>
<span><NavLink href="/NavLinkSample"> -> NavLinkSample</NavLink></span>
<span><NavLink href="/NavLinkSample/a"> -> a</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b"> -> b</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c"> -> c</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c/d"> -> d</NavLink></span>
</nav>
<main>
@Body
</main>
效果如下:
NavigationManager
上面提到的<a>
和<NavLink>
其实本质上是同一种东西:让最终渲染结果上出现一个<a>
,然后Blazor框架内部再拦截对链接的点击,实现无刷新跳转。那么一个非常自然的想法就出现了:既然跳转的具体实现是Blazor框架做的,那我们程序员能不能直接指挥框架,让它去跳转啊,就没必要非得往页面上先造一个<a>
了。
这个用来指挥框架做跳转的东西,是一个Blazor框架在启动时就创建好的一个对象,跟HttpClient
实例一样,框架启动时就把它初始化在DI池中,它的类型就是NavigationManager
。这个类型共有以下几个比较重要的公开成员:
首先是两个重要的公开属性:Uri
和BaseUri
: 通过这两个公开的只读属性,我们可以获得当前页面的“完整URI”,以及当前站点的“BaseURI”。
我们之前简单的提到过
<head>.<base>
标签,这里的“BaseURI”其实取的就是当前文档的<head>.<base>
标签里的值,也是浏览器、以及Blazor框架在运行时,从相对路径计算绝对路径的一个基准
其次是最核心的NavigateTo
方法,调用这个方法就可以实现页面跳转。这个方法有三个重载,分别是:(string, bool)
, (string, bool, bool)
以及(string, NavigationOptions)
首先是(string, bool)
重载,这个重载没有设置默认参数值,两个参数都需要调用方显式填充
forceLoad
:当这个参数为true
时,Blazor框架会放弃对跳转的拦截,转而让浏览器去执行默认的跳转逻辑,这意味着页面会完全刷新。当这个参数为false
时,Blazor框架会执行无刷新跳转。其次是(string, bool, bool)
重载,这个重载有设置后续两个bool
参数的默认值,默认均为false
(string, bool)
重载完全一致replace
:当这个参数为true
时,跳转的新URI会替换掉当前Tab Session History中的栈顶。当这个参数为false
时,跳转的URI会作为一个新记录,添加进当前Tab的session history中。
true
99%的情况下,上面两个重载其实都够用了,在实际使用时上面两个重载能组合出三种用法:
this.navMgr.NavigateTo("/Product/ProductDetail")
(string, bool)
重载,并置forceLoad
为true
(string, bool, bool)
重载,并置replace
为true
最后是第三个重载:(string, NavigationOptions)
,它是通过一个结构体来扩展功能的,但实际上也没扩展出什么功能出来,我们来看这个结构体的三个属性:
public readonly struct NavigationOptions
{
/// <summary>
/// If true, bypasses client-side routing and forces the browser to load the new page from the server, whether or not the URI would normally be handled by the client-side router.
/// </summary>
public bool ForceLoad { get; init; }
/// <summary>
/// If true, replaces the currently entry in the history stack.
/// If false, appends the new entry to the history stack.
/// </summary>
public bool ReplaceHistoryEntry { get; init; }
/// <summary>
/// Gets or sets the state to append to the history entry.
/// </summary>
public string? HistoryEntryState { get; init; }
}
第一个成员依然是forceLoad
的语义,第二个成员依然是replace
的语义。有意思的是第三个成员:在做这一次跳转的时候,我们可以附加一些自定义的数据进去,Blazor框架会为我们记录住这个信息。
什么意思呢?你可以这样简单理解:在每次跳转发生时,浏览器也好,框架也罢,都会去修改当前浏览器Tab的session history栈:要么是向栈内新加一个项,要么是替换掉栈顶的项。除此之外,Blazor框架还可以为栈内的每个项,都保存一份自定义数据,这份数据是Blazor框架在保存着,相当于一个扩展功能。
目前我们只关注跳转本身,至于HistoryEntryState
,我们放在稍微后面一点再演示。
总之,从API设计上能看出来,有关跳转的三个最重要的元素,分别是:
至此,有关NavigationManager
的基本知识就讲完了,在进入示例代码环节之前,还要再说最后一个知识点:域外跳转。
对于域外跳转,即像调用this.navMgr.NavigateTo("https://www.baidu.com")
这种调用,框架默认会放弃对跳转的拦截:这也是非常自然的事情。不过在实践中请务必注意:如果你要做域外跳转,那么传入的URI参数一定是要从协议开始的,比如https://www.baidu.com
,而不能只从主机名开始,比如baidu.com
,对于后者,框架会认为你是想跳转去当前站点的/baidu.com
页面,而不是做域外跳转。
接下来我们通过对MainLayout.razor
做一点修改,来向大家展示Uri
,BaseUri
以及NavigateTo
这三个重要的成员的功能:
@inherits LayoutComponentBase
+@inject NavigationManager navigationManager
+
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<nav>
<span><NavLink href="/">Home</NavLink></span>
<span><NavLink href="/NavLinkSample"> -> NavLinkSample</NavLink></span>
<span><NavLink href="/NavLinkSample/a"> -> a</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b"> -> b</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c"> -> c</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c/d"> -> d</NavLink></span>
</nav>
+
+<div>
+ <p>NavigationManager.Uri == @this.navigationManager.Uri</p>
+ <p>NavigationManager.BaseUri == @this.navigationManager.BaseUri</p>
+ <p>
+ <button style="display:inline-block" @onclick=@this.NavTo>Nav to</button>(
+ <input @bind=@this.navTo @bind:event="oninput">,
+ <input @bind=@this.forceLoad @bind:event="oninput">,
+ <input @bind=@this.replace @bind:event="oninput" >)
+ </p>
+</div>
<main>
@Body
</main>
+
+@code {
+ private string navTo = "";
+ private string forceLoad = "false";
+ private string replace = "false";
+
+ private void NavTo()
+ {
+ this.navigationManager.NavigateTo(this.navTo, bool.Parse( forceLoad), bool.Parse(replace));
+ }
+}
运行起来,首先是观察普通的页内跳转,即replace
和forceLoad
都为false
时的行为:
其次是我们新开个tab,来观察当forceLoad
为true
时的行为,此时框架放弃了对跳转的拦截,转由浏览器去执行最原始的跳转,页面会整个刷新
注意观察,前半段过程我们总共执行了三次跳转:
/
到/NavLinkSample
,刷新跳转/NavLinkSample
到/NavLinkSample/a
,是无刷新跳转,这次跳转我们第二个参数赋值是false
/NavLinkSample/a
到/NavLinkSample/a/b
,刷新跳转这都没什么,在我们预料之内,但有意思的是,后半段我们开始按浏览器的回退按钮时
/NavLinkSample/a/b
退到/NavLinkSample/a
,回退时整个页面刷新/NavLinkSample/a
退到/NavLinkSample
,回退时页面没有刷新/NavLinkSample
到/
,回退时整个页面刷新这就通过实践验证了Blazor框架跳转的一个特性:在跳转时如果做的是无刷新跳转,那么浏览器在回退时,框架也会拦截回退请求,做无刷新回退。
我们这次再来展示replace
参数的效果,如下所示:
在上例中我们做了两次跳转,第一次是无刷新跳转,第二次是刷新跳转,两次跳转时的replace
参数都置为true
。可以看到,无论跳转是不是刷新跳转,当前Tab的session history中都没有新添加任何东西,用户是无法通过浏览器的“后退”按钮回退到跳转前的。
最后,我们来展示一下域外跳转,在下面的例子中,我们传给NavigateTo
的URI参数为一个完整的域外URI,但forceLoad
和replace
都置为false
可以看到,在域外跳转时,forceLoad
显然是不会起作用的,并且replace
也不会起作用。
NavigationManager
暴露出的几个勾子NavigateTo
只是NavigationManager
的一个基础功能,除了以编程的方式直接调用NavigateTo
去实现页面跳转外,我们还可以用NavigationManager
实现一些其它的功能。这里只介绍两个比较常用和重要的功能:
LocationChanged
事件,将额外逻辑写在事件回调中RegisterLocationChangingHandler
方法LocationChanged
事件LocationChanged
是NavigationManager
暴露的一个事件,它的具体签名如下:
public event EventHandler<LocationChangedEventArgs> LocationChanged;
其中事件参数LocationChangedEvenArgs
的实现如下:
public class LocationChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of <see cref="LocationChangedEventArgs" />.
/// </summary>
/// <param name="location">The location.</param>
/// <param name="isNavigationIntercepted">A value that determines if navigation for the link was intercepted.</param>
public LocationChangedEventArgs(string location, bool isNavigationIntercepted)
{
Location = location;
IsNavigationIntercepted = isNavigationIntercepted;
}
/// <summary>
/// Gets the changed location.
/// </summary>
public string Location { get; }
/// <summary>
/// Gets a value that determines if navigation for the link was intercepted.
/// </summary>
public bool IsNavigationIntercepted { get; }
/// <summary>
/// Gets the state associated with the current history entry.
/// </summary>
public string? HistoryEntryState { get; internal init; }
}
非常好理解,其中
Location
属性是跳转的目标IsNavigationIntercepted
是一个指示字段
true
时,代表本次跳转是用户在浏览器上触发,然后被Blazor框架拦截到的。false
时,代表本次跳转是由代码中对NavigateTo
方法的调用触发的。HistoryEntryState
:我们前面在讲NavigateTo
重载的时候提到过它。我们讲过,我们可以在跳转时,通过NavigationTo(string, NavigationOptions)
重载,给本次跳转生成的session history栈项附加一份数据。这份数据是Blazor框架替我们在保存着。
需要在心里明确的是,这个事件是属于DI池中的NavigationManager
对象的,而不是属于某个组件的。为什么要强调这个事情呢?因为显然的,我们对这个事件的额外监听函数,如果注册行为发生在组件内部,那么一定要记得在组件Dispose的时候把监听函数解绑掉。
下面是一个展示的例子,依然是在MainLayout.razor
上做的示例。在例子中,我们并没有做什么有意思的事情,只是在监听到页面有跳转的时候,在控制台上输出事件参数的详情而已。
注意,虽然我们是在布局组件中获取NavigationManager
实例并且进行事件监听的,但也要记得在Dispose的时候对监听函数进行解绑,你就不要想一些有的没的,记得解绑就对了。
@inherits LayoutComponentBase
@implements IDisposable
@inject NavigationManager navigationManager
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<nav>
<span><NavLink href="/">Home</NavLink></span>
<span><NavLink href="/NavLinkSample"> -> NavLinkSample</NavLink></span>
<span><NavLink href="/NavLinkSample/a"> -> a</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b"> -> b</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c"> -> c</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c/d"> -> d</NavLink></span>
</nav>
<div>
<button @onclick=@this.BackToHome>click to back to Home, but in NavigateTo way</button>
</div>
<main>
@Body
</main>
@code {
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
Console.WriteLine($"args.Location == {args.Location}");
Console.WriteLine($"args.IsNavigationIntercepted == {args.IsNavigationIntercepted}");
Console.WriteLine($"args.HistoryEntryState == {args.HistoryEntryState}");
}
private void BackToHome(MouseEventArgs args)
{
this.navigationManager.NavigateTo(
"/",
new NavigationOptions
{
HistoryEntryState = $"mouse click client x == {args.ClientX}, mouse click client y == {args.ClientY}"
});
}
protected override void OnInitialized()
{
this.navigationManager.LocationChanged += OnLocationChanged;
}
public void Dispose()
{
this.navigationManager.LocationChanged -= OnLocationChanged;
}
}
它的运行效果如下所示:
我们来说明一下运行效果Gif图中出现的四次跳转:
/NavLinkSample/a
。
IsNavigationIntercepted == true
,这表示我们可以从监听函数里得知,本次跳转是由用户在浏览器触发的NavigateTo
方法,以代码的方式跳转到了/
IsNavigationIntercepted == False
,这表示我们可以从监听函数里得知,本次跳转是代码触发的/NavLinkSample/a
,行为和第一次跳转一致,没什么可说的IsNavigationIntercepted == False
: 这里有点意思,按我们的直觉来说,无论是用户点击页面上的链接,还是浏览器的“前进”,“后退”按钮,跳转这件事本身,都应该先是浏览器去处理,再有必要的话由Blazor拦截。但实际情况是,“前进”和“后退”按钮,都是在最初就被代码接管了的,然后“直接”发送给Blazor框架(实际实现要复杂得多,但你可以这样简单理解)。IsNavigationIntercepted
的语义理解为:“跳转是否是由用户点击链接产生的”。当它为false
时,只能说明跳转不是由点击链接触发的,但并不能说明跳转就一定是NavigateTo
的调用触发的,也有可能是浏览器的“前进”或“后退”按钮触发的NavigateTo
附加的那句自定义数据这个例子里有三个关键点:
OnInitialized{Async}
生命周期函数中,并且一定要记得实现IDisposable
接口,并在Dispose()
方法的实现中,对事件监听函数进行解绑LocationChanged
的监听函数中,HistoryEntryState
的值可能来自两种场合
NavigateTo
跳转时,直接能拿到NavigateTo
时附加的NavigationOptions.HistoryEntryState
IsNavigationIntercepted == False
时,只能说明跳转不是由点击链接触发,而不能说明跳转一定是由NavigateTo
的调用触发的
另外还需要注意的一点是:当你在一个组件的OnInitialized
中注册LocationChanged
的监听函数时,这个监听函数就只会在无刷新跳转场合正常工作,比如我们对上面的代码做一点点小的修改,如下:
new NavigationOptions
{
+ ForceLoad = true,
HistoryEntryState = $"mouse click client x == {args.ClientX}, mouse click client y == {args.ClientY}"
});
此时,你会发现,点击按钮跳转后,OnLocationChanged
方法就不会被执行,如下:
这背后的原因其实很简单:因为对于无刷新跳转来说,跳转后,相当于客户端浏览器重新完整的加载我们的前端代码。这时候,是跳转先发生,然后才有Blazor框架的执行、App.razor
的渲染、MainLayout
的初始化及渲染。
而我们对监听函数的挂载,写在OnInitialized
生命周期函数中,是跳转先发生,然后才有的监听函数挂载,监听函数自然不会监听到任何东西。
那你可能会想,诶,那我把监听函数的挂载,别写在组件里啊,我直接写在Program.cs
中,如下修改Program.cs
,行不行呢?
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using HelloNav.Client;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services.AddHttpClient("HelloNav.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress));
// Supply HttpClient instances that include access tokens when making requests to the server project
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("HelloNav.ServerAPI"));
-await builder.Build().RunAsync();
+var host = builder.Build();
+
+host.Services.GetService<NavigationManager>()!.LocationChanged += (object? sender, LocationChangedEventArgs args) => {
+ Console.WriteLine($"args.Location == {args.Location}");
+ Console.WriteLine($"args.IsNavigationIntercepted == {args.IsNavigationIntercepted}");
+ Console.WriteLine($"args.HistoryEntryState == {args.HistoryEntryState}");
+};
+
+await host.RunAsync();
答案是:还是不行,还是不能监听到刷新跳转。理由还是一样的:虽然你把监听函数的挂载时机提前到了一个非常靠前的时候,这时甚至页面没有渲染任何东西,但终究事情的发展顺序还是:先跳转、再加载客户端WASM程序。
所以,最后一个需要注意的知识点,总结起来就是:对LocationChanged
事件的监听,只有在无刷新跳转场合下才有用。
RegisterLocationChangingHandler
方法框架暴露出LocationChanged
事件的设计意图是:当用户需要在跳转时做一些额外工作的时候,可以通过挂载监听函数的形式去完成。无论你挂载不挂载,挂载几个,挂载什么样的监听函数,都不会影响“跳转”这件事本身的运行。
而RegisterLocationChangingHandler
就不一样了:它给了程序员一些控制“跳转”这件事本身的能力。这个接口的功能是比较强的,但是,我并不准备花时间去仔细介绍它,有兴趣的读者可以自行研究相关文档与源代码,原因主要是:在实际开发过程中,极少有需求需要hack进跳转逻辑中去做事。
不过虽然很少有正经需求需要hack进跳转逻辑中,但有一种需求是非常常见的:在用户跳转的时候,提示一下用户:“请问你真的是需要走吗?”。或者有那种“你所填写的内容还未保存/提交,你确定你要离开吗?”。简单来说,这类需求其实是需要拦截住跳转行为,然后执行一些逻辑,再根据一些条件,去判断本次跳转应当不应当实际发生,如果是,那么执行跳转逻辑,如果不是,那就取消掉本次跳转逻辑。
重点在于“取消跳转”上,这个能力是LocationChanged
事件无论如何都不能实现的。
下面是一个例子,依然是在MainLayout.razor
上做示例,这个例子只演示了如何“取消跳转”,对于上面提到的“弹出提示”什么的,并没有演示到,因为就我们目前的知识储备来说,我们还做不到弹出一个提示框。
@inherits LayoutComponentBase
@implements IDisposable
@inject NavigationManager navigationManager
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<nav>
<span><NavLink href="/">Home</NavLink></span>
<span><NavLink href="/NavLinkSample"> -> NavLinkSample</NavLink></span>
<span><NavLink href="/NavLinkSample/a"> -> a</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b"> -> b</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c"> -> c</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c/d"> -> d</NavLink></span>
</nav>
<main>
@Body
</main>
@code {
private IDisposable? locationChangingHandlerRegistration = default!;
protected override void OnInitialized()
{
this.locationChangingHandlerRegistration = this.navigationManager.RegisterLocationChangingHandler(OnLocationChanging);
}
private ValueTask OnLocationChanging(LocationChangingContext ctx)
{
string target = this.navigationManager.ToBaseRelativePath(ctx.TargetLocation);
if (target != "NavLinkSample/a" && target != "")
{
Console.WriteLine($"sorry, you're going to {target}, but only nav to \"NavLinkSample/a\" and Home page is allowed");
ctx.PreventNavigation();
}
return ValueTask.CompletedTask;
}
public void Dispose()
{
this.locationChangingHandlerRegistration?.Dispose();
}
}
运行效果如下:
注意点有以下几个:
RegisterLocationChangingHandler
是一个方法,接收一个函数指针作为参数,函数指针的签名为ValueTask (LocationChangingContext)
。
RegisterLocationChangingHandler
返回一个注册回执,这个回执是用以在Dispose
方法中解除注册的。所以跟LocationChanged
事件一样:有注册,就要有解绑,当然组件也要实现IDisposable
接口LocationChangingContext.PreventNavigation()
就可以停止本次跳转LocationChanged
一样,它也只能影响无刷新跳转,背后的原因也是一样的。好了,有关RegisterLocationChangingHandler
我们就浅浅的介绍在这里,知道有这么回事就行了,实际开发中很少会用到这个接口,真正有需求用到的话再查文档也不迟。
RegisterLocationChangingHandler
的语法糖:<NavigationLock>
组件NavigationLock
是一个不可见的组件,什么意思呢?在代码中,你可以像调用其它组件一样,去把它写进你当前的页面/组件中去。但它实际上并不渲染任何可见的内容。听起来有点怪是吧?先不着急,我们马上就会写个小例子来说明。
同样的禁止页面跳转,我们可以把MainLayout.razor
改写成下面这样,为了避免阅读混乱,这里就不使用diff展示了,而直接展示代码:
@inherits LayoutComponentBase
<style>
.active{
background-color:yellow;
color:red;
}
</style>
<NavigationLock ConfirmExternalNavigation="@this.confirmExternalNavigation" OnBeforeInternalNavigation="OnBeforeNavigation" />
<div>
Allow internal navigation: <input type="checkbox" @bind="@this.allowInternalNavigation" @bind:event="oninput" />
<br/>
Popup confirm diaglog before external navigation: <input type="checkbox" @bind="@this.confirmExternalNavigation" @bind:event="oninput" />
</div>
<nav>
<span><NavLink href="/">Home</NavLink></span>
<span><NavLink href="/NavLinkSample"> -> NavLinkSample</NavLink></span>
<span><NavLink href="/NavLinkSample/a"> -> a</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b"> -> b</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c"> -> c</NavLink></span>
<span><NavLink href="/NavLinkSample/a/b/c/d"> -> d</NavLink></span>
<br/>
<span><NavLink href="https://baidu.com"> Baidu</NavLink></span>
</nav>
<main>
@Body
</main>
@code {
private bool allowInternalNavigation = true;
private bool confirmExternalNavigation = true;
private void OnBeforeNavigation(LocationChangingContext ctx)
{
Console.WriteLine($"this.allowNavigate == {this.allowInternalNavigation}");
if(!allowInternalNavigation)
{
ctx.PreventNavigation();
}
}
}
代码很简单,只做了下面几件事:
页面上写了两个checkbox,并把checkbox打勾与否的状态,与私有字段this.allowInternalNavigation
与this.confirmExternalNavigation
绑定起来
调用了组件NavigationLock
,并向它传递了两个参数
ConfirmExternalNavigation
,默认值为false
,而当它为true
时,会在页面向外部站点跳转时,向用户弹出一个提示框。类似于:“你所做的修改即将丢失,确定跳转吗?”
注意,这个参数的值,并不影响站内跳转
OnBeforeInternalNavigation
,这是一个EventCallback<LocationChangingContext>
事件回调,它会拦截所有的站内跳转,在这个回调函数中,我们就可以禁止站内跳转
注意,这个回调函数,并不拦截站外跳转
这个程序运行起来是这样的:
当两个勾都打上,即“允许站内跳转”且“需要用户确认站外跳转”时,效果如下:
当只打第一个勾,即“允许站内跳转”且“站外跳转不需要确认”时,效果如下:
当两个勾都不打,即“不允许站内跳转”且“站外跳转不需要确认”时,效果如下:
效果非常直观,非常容易理解。。
NavigationLock
组件与RegisterLocationChangingHanlder
方法的比较我们在上面小节的标题其实也说了,NavigationLock
其实就是RegisterLocationChangingHandler
的语法糖,二者基本是等价的,虽然功能是等价的,但命名上显然有着不同的引导暗示:
NavigationLock
组件RegisterLocationChangingHandler
方法我为什么说这是“引导暗示”呢?因为这俩玩意,其实真的大差不差
NavigationLock
暴露的是事件,事件回调类型是EventCallback<LocationChangingContext>
RegisterLocationChangingHandler
需要的则是一个函数指针,类型为Func<LocationChangingContext, ValueTask>
其实没什么区别,只不过在实际使用中,使用NavigationLock
是不需要向当前页面注入@inject NavigationManager navMgr
的,而使用RegisterLocationChangingHandler
就必须拿到NavigationManager
对象了。
比起NavigationManager
对象,LocationChangingContext
提供的功能相当有限,对跳转的干涉方式只有一种:PreventNavigation
即取消/阻止本次跳转。
所以我才说命名上的取向只是“引导暗示”,程序员其实完全可以在当前页面注入了NavigationManager
对象的情况下,在NavigationLock
的OnBeforeInternalNavigation
事件回调函数中,使用NavigationManager
去对跳转做更多花活。
NavigationLock
组件吗?作为一个视觉隐形,但又有具体实际功能的特殊组件,你或许会和我一样,在第一次接触它时,问出一个非常自然的问题:如果同一个页面渲染了多个NavigationLock
,会有什么行为?
我做了一些实验,我试图在同一个页面上渲染多个NavigationLock
并观察它们的行为,并总结出规律出来,但我的观察结果是:确实有规律,但很难用语言描述。
换句话说,当一个页面上渲染了多个NavigationLock
组件时,是很难用语言讲清楚具体执行时会发生什么的。。你可以简单的把这种行为称为“未定义行为” -- 但实际并不是,你只是可以这样去理解。
在开发中应当避免这种事情的发生,也就是说,我们上面把NavigationLock
写在布局组件的行为,是一种非常蠢的行为,不要效仿。
在实际开发中,请确保仅在页面组件中使用NavigationLock
组件,并保证仅使用了一次。
上面我们讲了怎么实现跳转,怎么拦截跳转。学会这两板斧基本就能胜任工作中80%的开发任务了,而剩下的20%开发任务中,会有19%与URL的查询字符串相关,最后那1%就完全是各种疑难杂症了,需要你发挥主观能动性,具体问题具体分析。
有关查询字符串的知识就是路由、跳转方面的最后一板斧了,主要包括以下内容:
这部分内容非常简单,我尽量少写废话,直接展示示例。每个示例都新建一个页面组件,并且把上面MainLayout.razor
里乱七八糟的内容都清空,现在的MainLayout.razor
应该长下面这样:
@inherits LayoutComponentBase
@Body
@page "/ParseQueryParameter"
@using System.Text;
<pre>
@this.ProductDetails
</pre>
@code {
[Parameter]
[SupplyParameterFromQuery]
public string? ProductName { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "price")]
public decimal? ProductPrice { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "ExtraInfos")]
public string[]? ProductExtraInfos { get; set; }
public string ProductDetails
{
get
{
StringBuilder sb = new StringBuilder();
sb.AppendLine($"Product: ");
sb.AppendLine($" Name == {(ProductName != null ? ProductName : "<Null>")}");
sb.AppendLine($" Price == {(ProductPrice.HasValue ? ProductPrice.Value : "<Null>")}");
sb.AppendLine($" ExtraInfos {(ProductExtraInfos != null ? $"Count == {ProductExtraInfos.Length}" : "== <Null>")}");
if(ProductExtraInfos != null)
{
for(int i = 0; i < ProductExtraInfos.Length; ++i)
{
sb.AppendLine($" # {i:D2}: {ProductExtraInfos[i]}");
}
}
return sb.ToString();
}
}
}
执行效果如下所示:
几个注意点:
SupplyParameterFromQuery
需要和Parameter
一起同时使用,框架才会正确的解析查询字符串中的值,单独使用SupplyParameterFromQuery
是不生效的。net8.0修正了这个"Bug"bool
, DateTime
, decimal
, double
, float
, Guid
, int
, long
, string
以及这些基础类型的nullable版本,比如string?
和double?
default
:对于基本类型来说就是0
,对于nullable类型来说就是null
null
string
或string[]
,然后在下游再写数据类型转换逻辑你可以选择自己手动拼字符串,然后把拼出来的字符串放在<a>
或<NavLink>
中去,或者喂给NavigateTo
方法,没问题,但多少还是有点注入风险的。
稳妥起见,你应该调用NavigationManager.GetUriWithQueryParameter[s](...)
系列方法去构造链接,这个系列方法有着巨多的重载,它以**当前页面的URL(包括查询字符串)**为基准,为你安全的生成各种跳转链接字符串。
这里举几个例子:
string target = Navigation.GetUriWithQueryParameter("full name", "Morena Baccarin");
如果当前URL长这样 | 那么上面的调用返回的字符串就长这样 | 备注 |
---|---|---|
/path?gender=female |
/path?gender=female&full%20name=Morena%20Baccarin |
参数不存在,则添加之 |
/path?full%20name=Davy%20Jones |
/path?full%20name=Morena%20Baccarin |
参数已经存在,则覆盖之 |
string target = Navigation.GetUriWithQueryParameter("full name", (string)null);
如果当前URL长这样 | 那么上面的调用返回的字符串就长这样 | 备注 |
---|---|---|
/path?gender=female |
/path?gender=female |
参数不存在,则什么也不做 |
/path?full%20name=Davy%20Jones |
/path |
参数已经存在,则移除之 |
string target = Navigation.GetUriWithQueryParameters(
new Dictionary<string, object?>
{
["name"] = null,
["age"] = (int?)25,
["gender"] = "male"
}
);
如果当前URL长这样 | 那么上面的调用返回的字符串就长这样 | 备注 |
---|---|---|
/path?name=David&gender=female |
/path?age=25&gender=male |
name 被删除,age 被添加,gender 被覆盖 |
这里补充一个知识点:锚点链接。
在URL中,除了上面介绍的查询字符串外,还有一种特别常见的附加参数,叫锚点。像/path#section3
这种,其中的section3
就是锚点参数。
锚点参数与前后端交互是没有关系的,它大多是用来做页面定位信息,以提示浏览器在打开新页面的时候自动滚动到对应的元素位置处。比如下面这个例子,我们新写一个页面组件,叫Pages/Anchor.razor
,内容如下:
@page "/Anchor"
<NavLink href="/Anchor#666" >Jump to #666</NavLink>
@for(int i = 0; i < 1000; ++i)
{
<h3 id="@i">@i</h3>
}
它理想中的运行效果应当如下图所示:
但是,非常不幸的是:以上的效果仅在net8.0环境下才生效。
如果整个项目是面向net7.0编译生成的话,页内跳转则不会生效,如下图所示:
这个问题最早在2019年被社区发现,四年后在2023年才正式得到修复,并在这个PR得到了修复。
坦白讲,我是看不懂那个PR都写了些什么玩意的,但我大概能猜到这个问题背后的原因出在哪里,以下是我的猜测:
锚点链接的页内跳转,本身是由浏览器本身负责的:即页面已经加载完毕后,用户点击了一个锚点链接,浏览器会将用户的页面滚动至对应的元素处。不过Blazor的实现里,框架拦截了所有跳转,导致在旧版本的Blazor WASM程序里,用户点击一个锚点链接后,这个“跳转”被Blazor框架代码拦截了,框架代码没有做到“待页面渲染完成后,将浏览器页面滚动至目标元素处”这件事。
那么,如果你现在正在用的就是net8.0之前的旧版本,有什么workaround的办法呢?有是有的,在上面社区提的issue页面,提主本人就提到了一种workaround,
index.html
中,用JS写一个函数,来将页面滚动至ID为指定值的元素处。OnAfterRender
里,通过NavigationManager
读出锚点里元素的id,然后调用JS实现页面滚动只不过可惜的是,我们现在还没有介绍如何在Blazor WASM代码中调用JavaScript脚本。
把话题拉回来,上面我们介绍了什么是锚点链接,以及锚点链接在旧版本blazor中不work的现状,现在来说正事:既然对于查询字符串,有GetUriWithQueryParameter[s](...)
工具方法,来为我们安全的生成URL。那么框架提供了安全的生成锚点链接的工具方法了吗?
答案是:没有,锚点链接这个东西,你得自己手动拼字符串。原因也很简单:
锚点链接的执行逻辑是不牵涉前后端交互的,锚点里的元素ID是不会传递到服务端的
无论是纯天然的浏览器负责的页内跳转,还是net8.0后Blazor框架实现的页内跳转,都不会把锚点信息传递给服务端,没有注入风险
锚点链接里的内容比较简单,就是一个元素ID而已,自己拼字符串也没什么大毛病
有关路由、跳转方面的其它知识其实很多,甚至于NavigationManager
本身还有很多知识可以去学习,但这些知识大多都是实际开发中使用频率很低的知识。我不建议你去仔细追究、学习有关路由和页面跳转的一切知识 -- 不是很有必要。
掌握这些基础的、入门的基本知识足够应付日常开发了,对于开发过程中的一些棘手问题,你应当在遇到的时候再去查阅文档与互联网,而不是在初次学习框架的时候就试图把所有知识都掌握掉 -- 说实话你也做不到。
看到这个章节的标题,是不是有点懵?我们不是在说路由的事吗?你先别急,我们确实是在说路由的一个小知识点,只不过啊,小知识点只是与路由有关而已。
我们先来复习一下,怎么搞一个独立的组件类库
我们在上上一篇文章中,提到过一个小事情,即我们可以自己写一些公用组件,把这些公用组件从Client
项目中独立出去,即写一个独立的组件类库。我们先在这里新建一个解决方案把这个小知识点复习一下:
> dotnet new blazorwasm-empty -f net7.0 --hosted -o HelloNavigateAsync
上面用dotnet
命令通过官方模板创建了一个前后端一把梭的blazor wasm项目,虽然命令参数已经足够显然,但这里还是多解释一下各个参数的意义:
dotnet new
: 是新建项目/解决方案的官方标准命令blazorwasm-empty
: 是项目/解决方案的类型,这个名字指:一个空的blazor wasm项目-f net7.0
: -f
是--framework
的简写,指创建出来的项目所使用的dotnet版本是多少,这里我们使用dotnet 7.0--hosted
: 带这个参数的话,创建出来的就是一个包含三个项目的解决方案,不带这个项目的话,创建出来的就是一个独立的、纯前端的blazor wasm项目-o
: 是--output
的简写,指创建出来的东西要放在哪个目录/文件夹下我们现在在新解决方案的目录中手动新建一个类库项目HelloNavigateAsync.Components
,再把这个类库项目添加进解决方案中去,并且把这个类库项目添加进Client
项目的依赖列表中去
> cd HelloNavigateAsync
HelloNavigateAsync> dotnet new classlib -f net7.0 -n HelloNavigateAsync.Components -o Components
HelloNavigateAsync> dotnet sln add .\Components\HelloNavigateAsync.Components.csproj
HelloNavigateAsync> cd Client
HelloNavigateAsync\Client> dotnet add reference ..\Components\HelloNavigateAsync.Components.csproj
这里再补充一点参数知识:
classlib
: 指标准类库项目-n HelloNavigateAsync.Components
: 这是在指定项目的名称,项目的名称也就是整个项目的默认namespace,也是编译出来的dll文件的名字。在不指定-n
参数的值的情况下,项目的名称会默认指定为项目文件所处的目录名称dotnet sln add
: 将指定项目添加到当前目录下的解决方案定义中去dotnet add reference
: 将指定的项目作为依赖,添加到当前目录下的项目定义中去以上的所有操作,理论上,都可以通过手动创建目录+手动创建项目文件的方式,以刀耕火种的方式去完成。现在,我们用visual studio打开这个项目:
HelloNavigateAsync\Client> cd ..
HelloNavigateAsync> .\HelloNavigateAsync.sln
回想起之前的知识点:我们的组件类库是专门存放Blazor组件的,而Blazor组件是运行在浏览器上那个残废dotnet runtime上的,所以我们要向编译器及工具链说明:这不是一个标准的dotnet类库,而是一个运行在浏览器残废dotnet runtime上的类库。所以我们要把HelloNavigateAsync.Components.csproj的内容改写为以下:
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.11" />
+ </ItemGroup>
+ <ItemGroup>
+ <SupportedPlatform Include="browser" />
+ </ItemGroup>
</Project>
其中Project Sdk
的更改,是为向工具链进一步说明,我们所写的这个类库,不是普通类库,而是组件类库。这个暗示到底有什么具体用处呢?它会自动为我们引入很多有关blaozr 或者razor的包吗?就dotnet 7.0来说,好像这个额外声明确实没什么具体用处,即实际上你不改这一行,保持它为Microsoft.NET.Sdk
也没有什么问题,但在未来的dotnet版本中,会不会有一些其它行为,就不好说了,建议改。
新添加的包引用,即对M.A.Components.Web
的引用,该包即为blazor组件所必要的基础依赖库
最后SupportedPlatform
的声明,就是在向工具链声明:“该项目编译出来的dll是要在浏览器上的dotnet runtime运行的,请做必要检查,谨防代码中使用了一些不支持的API”
现在,我们把组件类库中默认的Class1.cs
删除掉,然后创建一个blazor组件,并起名叫Rect.razor
,如下:
@using Microsoft.AspNetCore.Components.Web
<div style=@Style>
</div>
@code {
[Parameter]
public int WidthInPixel { get; set; } = 80;
[Parameter]
public int HeightInPixel { get; set; } = 80;
[Parameter]
public string BackgroundColor { get; set; } = "Red";
private string Style => $"display:inline-block; width: {WidthInPixel}px; height: {HeightInPixel}px; background-color: {BackgroundColor}";
}
非常简单的一个组件,没什么实际用处,只是在屏幕上通过div
画一个矩形而已。在调用方可以通过三个参数来控制矩形的长宽和背景颜色。
现在,我们就可以直接在Client
项目中使用这个组件了,在Client
项目的Pages/Index.razor
中如下添加一行,然后运行整个解决方案,即可在页面上看到一个矩形:
@page "/"
<h1>Hello, world!</h1>
+
+<HelloNavigateAsync.Components.Rect WidthInPixel="300" HeightInPixel="300" BackgroundColor="Blue"/>
如下图所示:
一切都很完美,没有任何毛病。
这时,如果你查看浏览器的缓存数据,会发现这个组件库的dll已经被缓存起来了
而如果你清空这个缓存,重新发起一次请求的话,会发现首屏加载的过程中,这个dll文件也是被囊括进首屏加载列表中的:
这也是没什么问题的,但接下来,如果我们从Client
项目的Pages/Index.razor
中删除对Rect
的调用,你会发现,浏览器还是会向服务端去请求这个组件库dll
@page "/"
<h1>Hello, world!</h1>
-
-<HelloNavigateAsync.Components.Rect WidthInPixel="300" HeightInPixel="300" BackgroundColor="Blue"/>
如果你还对我们之前讲Blazor WASM如何部署在Nginx下的话,你就会知道,对于这种没有实际引用到的dll,在发布时只需要选择Release
模式,工具链就会通过“摇树”的方式,将它们识别出来。也就是说,如果我们是通过Release
的方式运行程序的话,这个HelloNavigateAsync.Components.dll
就不会被加载。
但是,实际测试表明,即便我们通过如下的步骤去以Release
方式去发布编译,工具链的确会帮我们去掉很多没有实际引用到的系统类库dll文件,但对于HelloNavigateAsync.Components.dll
,并没有去除:
HelloNavigateAsync\Server> dotnet publish --self-contained -c Release
...
...
...
HelloNavigateAsync\Server> cd bin\Release\net7.0\win-x64\publish
HelloNavigateAsync\Server\bin\Release\net7.0\win-x64\publish> ./HelloNavigateAsync.Server.exe --urls="http://localhost:5000;https://localhost:5001"
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:5001
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: C:\Users\neooe\source\repos\test\HelloNavigateAsync\Server\bin\Release\net7.0\win-x64\publish
你可能会说:“那既然我们都没有实际用到组件类库,那为何不直接从Client
项目文件中,解除对Components
项目的引用呢?”
你说的对,确实,解除引用后,组件类库文件就不会被加载了。但我举这个例子并不是为了让大家手动管理依赖,从而达到控制首屏加载列表的。。而是,以这个例子为引子,请大家思考下面这两个问题:
在Client
项目虽然引用了Components
组件,并没有实际使用到Components
中的组件的情况下,如何避免浏览器加载这个没用的HelloNavigateAsync.Components.dll
文件
更进一步的:在首页并没有使用Components
中的组件的时候,如何避免在首页加载的时候,浏览器要载入HelloNavigateAsync.Components.dll
。
这句话的言外之意是:虽然在Pages/Index.razor
中没有使用到Rect
组件,但可能在其它页面中使用了Rect
组件
这两个问题总结起来其实就是一句话:对于组件类库,如何做到按需加载
说了那么多,终于入活了!!
在具体介绍步骤之前,我们先对Client
做一点小小的改动,以便能更明了的展示动态加载特性。
首先,我们另外在Client
项目中新建一个页面,就叫Pages/Rect.razor
,内容如下:
@page "/Rect"
<NavLink href="/">Back to Index</NavLink>
<br/>
<HelloNavigateAsync.Components.Rect HeightInPixel="300" WidthInPixel="300" BackgroundColor="purple" />
其次在Pages/Index.razor
中添加一个链接,以方便进入/Rect
页面
@page "/"
<h1>Hello, world!</h1>
+
+<NavLink href="/Rect">To /Rect</NavLink>
然后开始动态加载的改造,再强调一遍我们改造的目的:
HelloNavigateAsync.Components.dll
/Rect
页面时,浏览器才加载HelloNavigateAsync.Components.dll
开始正式改造:
H.Components.dll
要做到这一点,需要在Client
项目文件中进行特别声明,如下:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.11" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\HelloNavigateAsync.Shared.csproj" />
<ProjectReference Include="..\Components\HelloNavigateAsync.Components.csproj" />
</ItemGroup>
+
+ <ItemGroup>
+ <BlazorWebAssemblyLazyLoad Include="HelloNavigateAsync.Components.dll" />
+ </ItemGroup>
</Project>
H.Components.dll
要做到这一点,首先要搞明白,什么是必要的时候: 其实就是“页面即将要跳转到/Rect的时候”。
Router
干活的时刻我们再在脑海中捋一遍Router
组件的工作流程,或者说前后端一把梭的Blazor WASM项目的运行过程
Server
项目将托管的Blazor WASM程序连同所有的依赖dll,都返回给用户的浏览器。用户的浏览器接收到这些内容后,JS触发dotnet runtime的运行,并在其上运行Blazor WASM程序App
被渲染,而App
的内部其实就是一个Router
组件<Router AppAssembly="@typeof(App).Assembly">
中对参数AppAssembly
进行了赋值,所以在Router
组件初始化的过程中,它就会扫描H.Client.dll
中的所有组件,读取所有组件类脑门上的@page
指令,从而知道面对用户请求时,应当渲染哪个页面组件Router
初始化完成,并开始渲染:即根据用户的请求路径,选择对应的页面组件去渲染我们上面所做的第一步:告诉框架,请不要在首屏加载H.Components.dll
,其实就是在让框架在0
步时,不要返回H.Components.dll
。
而现在要做的,就是在3
步时,让框架额外的将H.Components.dll
加载进来。
怎么做呢?思考一下,如果你是Blazor框架的设计者,你怎么设计这个功能,能让用户在Router
的初始化、渲染过程中,额外再添加一段逻辑?
显然,就是勾子函数:Router
组件有一个特意设计的事件,叫OnNavigateAsync
:用户将需要执行的额外逻辑注入到这个事件回调中去。
大致就是给Router
组件挂上一个事件回调,像这样:
<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="loadExtraLibs">
这个回调函数的触发时机也很好理解,就如同它名字一样:在navigate的时候触发执行,具体一点来说,就是跳转发生时执行。
言外之意则是,它的触发时机和用户的请求路径能不能匹配到一个页面组件无关,只要Router
工作,即所谓的“跳转”发生,它就会执行。而这个“跳转”,其实包括了一切能导致Router
组件渲染的场合,包括但不限于:各种页面跳转、页面刷新等。
现在,我们几乎搞明白了应该做什么,但还有一个小问题需要说明:即,怎么加载额外的类库dll?
在书写其它dotnet程序时,如果我们要手动从文件系统动态加载一个dll,调用的库函数是System.Reflection.Assembly.LoadFile
系列函数,或者System.Runtime.Loader.AssemblyLoadContext
类下的LoadFromAssemblyPath
之类的方法。但现在我们是在搞Web:我们要加载的dll压根就不在用户电脑的文件系统上,我们运行的程序也是执行在浏览器中的。怎么搞?
从原理上说,我们需要让Blazor WASM程序发送一个HTTP请求,先将H.Components.dll
下载到本地浏览器缓存中,然后再以某种方式将它加载到Blazor WASM程序中去。
听起来很麻烦,但幸运的是,Blazor框架早就想到了这一点,为我们提供了一个工具:Microsoft.AspNetCore.Components.WebAssembly.Services.LazyAssemblyLoader
,并且在Blazor WASM框架中,已经默认的将这个类的实例,以Singleton的方式,注入到了DI池中。
所以,我们要做的,只是将App.razor
改造成如下模样:
+@inject ILogger<App> logger
+@inject Microsoft.AspNetCore.Components.WebAssembly.Services.LazyAssemblyLoader lazyAssemblyLoader
+
-<Router AppAssembly="@typeof(App).Assembly">
+<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="LoadExtraLibs">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</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 async Task LoadExtraLibs(NavigationContext navCtx)
+ {
+ try
+ {
+ // 注意:navCtx.Path属性中的值,不以 '/' 开头
+ // 即如果这里写成 if(navCtx.Path == "/Rect"),条件是永远不对匹配成功的
+ if(navCtx.Path == "Rect")
+ {
+ await lazyAssemblyLoader.LoadAssembliesAsync(new List<string> { "HelloNavigateAsync.Components.dll" });
+ logger.LogInformation("[HelloNavigateAsync.Components.dll] loaded");
+ }
+ }
+ catch(Exception ex)
+ {
+ logger.LogError(ex.Message);
+ }
+ }
+}
理解了所有的逻辑,上面的代码就变得非常简单易懂了。但上面代码的行为,有一个小知识点需要再额外说明一下:
LazyAssemblyLoader.LoadAssembliesAsync
的行为有两种:如果当前程序没有加载目标dll,则发送网络请求去加载,如果当前程序已经加载了目标dll,则什么也不做。
所以程序的运行结果就会如下动图所示:
if
分支依然被匹配,LoadAssembliesAsync
依然被执行,只是没有什么实际作用但是,对于刷新跳转或者用户主动刷新页面,LoadAssembliesAsync
则会100%的发送网络请求,就如下图所示:
在上图中,我们把浏览器调试窗口的preserve log
选项的勾给去掉了,去掉这个勾后,每次页面刷新,network栏中的内容就会被清空,更直观的能感受到:
H.Components.dll
一样,都会走网络请求下载到本地H.Components.dll
都会有一个网络请求去下载它,由于我们在浏览器调试窗口勾选了Disable cache
,所以这个请求切切实实的会被发送到服务端而即便我们将disable cache
选项去掉勾选,网络请求依然会被发出,只不过服务端不会再传输库文件的主体,而是回复一个304,如下所示:
现在就有个小问题:为什么我们的H.Components.dll
做不到像框架系统库文件那样,在刷新跳转时,将这个没必要的HTTP请求优化掉?明明文件就在用户的浏览器缓存中啊!
这其实是两个问题:
H.Components.dll
做不到这一点?为了回答这两个问题,我们专门来开个小节,简单的介绍一点额外知识
我首先叠个甲:我要开始胡说八道了啊,以下有关浏览器缓存机制的介绍充斥着大量的错误。但是呢,我觉得如果你之前并不熟悉浏览器的工作细节,或者说你只是一个新手程序员,或者之前从未接触过前端开发,我觉得你可以不妨按我的错误思路去理解浏览器的缓存机制。
缓存机制有两层:
这层机制由以下要素构成:
浏览器发送了HTTP请求首次获得了各种数据后,都会尽量把它缓存在自己肚子里。
比如用户通过浏览器访问https://***/funny.gif
,
***/funny.gif
发送GET
请求funny.gif
存在自己肚子里,并且标记着:“这张图片是从***/funny.gif
拿到的”当用户再次请求某个之前已经缓存过的资源时,浏览器依然会向服务端发送HTTP请求,但服务端会告诉浏览器:你用缓存渲染吧
比如用户再次刷新页面访问https://***/funny.gif
浏览器依然还是会向***/funny.gif
发送一个GET
请求,只不过,这一次附加上了一些额外的信息,大致意思就是:
“哥,这张图片其实我本地就有,我在5分钟前跟你要的”
服务端接收请求,会看到上面的额外信息,如果在这5分钟期间,这张图片在服务端并没有被更改、替换的话,服务端会生成一个特殊的HTTP Response,告诉浏览器
“老弟,行,我知道了,图片没变,我也就不给你再传一遍了,没必要,你就用缓存吧”
这个HTTP Response的状态码就是304
浏览器收到304后,安心的用本地缓存里的图片数据渲染页面
这套机制是在HTTP协议层级规范、实现的,对网站开发者来说是完全透明的。而我们在浏览器调试窗口,打的那个disable cache
的勾的效果,就相当于禁止浏览器向服务端发送那句“哥,我有,五分钟前拿的”额外信息。
这样服务端每次都会把数据完整的传递一遍。
从开发调试角度来说,这一层缓存机制最大的特点就是:这些缓存对程序员来说,是不可见的。你无法在浏览器的调试窗口找到一个地方去浏览这些缓存,你无法清除这些缓存,无法改写这些缓存的内容。
第二层缓存机制则不是通用的,每个网站的开发者需要自己实现缓存逻辑,它大致包括下图的内容:
这一层缓存有多种类型,从cookie,到local storage,到cache storage,有很多具体实现。有些需要HTTP协议配合,比如cookie,有些则不需要。但不管它们的形态如何,怎么实现,将它们归在同一类的原因在于,它们有着以下共同特点:
如果你之前仔细阅读了我们介绍Blazor WASM运行原理的内容,应该还记得,Blazor WASM程序从加载到运行大致有以下几步:
浏览器加载index.html
在index.html
中夹带了一个JS文件_framework/blazor.webassembly.js
,根据浏览器的运行机制,这个JS文件会被自动执行,而也正是它
blazor.boot.json
,并按照里面的内容从服务端下载各种dll与dotnet.wasm
文件dotnet.wasm
运行:即浏览器上的dotnet runtime开始运行Client.dll
开始执行,就像其它dotnet程序运行在dotnet runtime上一样回头再看这个流程,其实Blazor框架应当不需要做什么额外工作,第一层缓存机制就可以生效并节省大量网络带宽。只是,按我们的猜测,在以blazor.webassembly.js
起头的一系列魔法中,Blazor框架肯定写了额外的逻辑:
blazor.webassembly.js
中必定有逻辑去检查cache storage中是否已经存在了需要用到的文件,如果有的话,就不需要发送HTTP请求了通过这样的进一步优化,在后续访问时,浏览器甚至都不需要向服务端发送HTTP请求了。总结一下,这部分的缓存机制是如下运行的:
blazor.boot.json
文件中就写死了首次加载时所需要的所有内容blazor.webassembly.js
,这个JS文件会根据blazor.boot.json
的内容,以及cache storage中的内容,来决定是否需要动态从服务端加载dllblazor.webassembly.js
为所有的dll都创建HTTP请求而已,而浏览器的第一层缓存机制会保证传输在网线上的不过仅仅是304 http response而已看起来,第一层缓存机制只起个兜底作用。
而我们动态加载H.Components.dll
则不同,这个按需加载的触发,并不是由blazor.webassembly.js
触发的,而是:
blazor.boot.json
中的文件都已经加载了,运行了,跑起来之后,要渲染Router
组件的时候,程序才发现:“靠,我得加载一个叫H.Components.dll
的文件”H.Components.dll
的逻辑也应当如下写:if(已经载了H.Components.dll)
{
// 什么也不做
}
else
{
if(cache storage中存在H.Components.dll)
{
从cache storage中加载
}
else
{
发送HTTP请求远程加载,并且把文件缓存在cache storage中
}
}
但就目前(net7.0)中LazyAssemblyLoader.LoadAssembliesAsync
的行为来看,实际实现的逻辑是这样的:
if(已经载了H.Components.dll)
{
// 什么也不做
}
else
{
发送HTTP请求远程加载,并且把文件缓存在cache storage中
}
我只能说,我其实也有点迷惑
这也就是为什么动态按需加载,在刷新跳转之后每次都会触发HTTP请求的原因:框架没实现。或许Blazor会在后续版本中更正这个行为,但就net7.0而已,确实是框架的实现偷懒了。
那么知道这个知识点有什么用处呢?我遗憾的告诉你,其实没有什么用处,你也没必要纠结这个细节。
上面讲的动态加载,理解了原理后,你大概能举一反三出来:动态加载其实并不是一个只适用于组件类库的特性,其实它也适用于普通类库,就像Shared
项目,也可以把它做成动态加载式的。
那么,我们发散一下思维:我们能不能把页面组件放在一个独立的类库中,然后让这个dll按需加载呢?
我提前剧透一下答案:可以,但不是以上面的方式去实现的。
这就牵涉到页面组件的特殊之处了。在编译器的角度来看,其实无论是普通的C#类,还是页面组件,还是布局组件,还是普通的组件,本质上都是一个个的类。组件虽然是用razor模板语言书写的,但经过razor引擎转译后,其实也是C#代码。
但在框架角度来看,页面组件是一种特殊的类,它特殊就特殊在:Router
组件在初始化过程中,会扫描所有页面组件,以建立路由表。
这就是如下这行代码的功效:
<Router AppAssembly="@typeof(App).Assembly">
如果我们把页面渲染的过程表示成以下几个步骤:
Router
初始化,扫描所有页面组件,建立路由表。Router
分析用户请求路径,并查找对应的页面组件。Router
渲染对应的页面组件。那么我们上面讲的动态按需加载的钩子事件回调,就发生在第2
步中。可如果我们要动态加载的是一个页面组件的话,理论上讲,就只能两种可能 :
Router
初始化过程中,动态的改变AppAssembly
属性的值,即动态的改变要扫描的范围Router
初始化之后,再添加一些新的扫描范围,让Router
去动态的扩充路由表Blazor框架选用了第二种方式:即在Router
初始化完成之后,开放了一个口子,让程序员可以扩充路由表。
具体步骤嘛,我们下面再创建一个新的类库项目,并在这个类库项目添加一个页面组件,一步步的把这个包含页面组件的类库改造成按需加载
我们通过复制粘贴的方式,将H.Components.csproj
复制到一个新目录,来创建H.Pages.csproj
HelloNavigateAsync> mkdir Pages
HelloNavigateAsync> cd Pages
HelloNavigateAsync\Pages> cp ..\Components\HelloNavigateAsync.Components.csproj HelloNavigateAsync.Pages.csproj
HelloNavigateAsync\Pages> cd ..\Client
HelloNavigateAsync\Client> dotnet add reference ..\Pages\HelloNavigateAsync.Pages.csproj
HelloNavigateAsync\Client> cd ..
HelloNavigateAsync> dotnet sln add .\Pages\HelloNavigateAsync.Pages.csproj
以上的命令相信大家都看得懂,我就不再赘述了。
然后我们在Pages
目录下新建一个页面组件,名为SpecialPage.razor
,内容如下:
@page "/SpecialPage"
<h3>SpecialPage</h3>
<Microsoft.AspNetCore.Components.Routing.NavLink href="/">Back to Index</Microsoft.AspNetCore.Components.Routing.NavLink>
然后在Client
项目下的Pages/Index.razor
中添加一个跳转链接,如下:
@page "/"
<h1>Hello, world!</h1>
<NavLink href="/Rect">To /Rect</NavLink>
+<br/>
+<NavLink href="/SpecialPage">To /SpecialPage</NavLink>
直接启动Server项目,然后试图访问/SpecialPage
,肯定是无法访问到我们上面写的那个新页面的,因为目前Client项目的路由表中并没有扫描到H.Pages.dll
,也就不知道/SpecialPage
这个请求路径对应的页面组件在哪,也就无法渲染,如下所示:
但实际上H.Pages.dll
已经被下载到浏览器缓存中去了,原因也很简单:因为我们的Client
项目直接引用了Pages
项目,而目前我们对H.Pages.dll
没有做任何特殊处理,没有向框架做任何“这是一个需要按需加载的dll”的暗示,所以它会随同dotnet系统类库与Blazor框架类库一起在初次访问时,被下载到客户端浏览器中去。
Router
组件扫描到H.Pages.dll
Router
组件有一个额外的参数叫AdditionalAssemblies
,它的类型是IEnumerable<Assembly>
,只需要将需要额外扫描的类库通过这个参数传递进去,Router
就能追加更新路由表,所以要让/SpecialPages
正常显示,我们只需要如下改造Client项目的App.razor
@inject ILogger<App> logger
@inject Microsoft.AspNetCore.Components.WebAssembly.Services.LazyAssemblyLoader lazyAssemblyLoader
-<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="LoadExtraLibs">
+<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="LoadExtraLibs" AdditionalAssemblies="@extraPageLibs">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</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 List<System.Reflection.Assembly> extraPageLibs = new List<System.Reflection.Assembly>
+ {
+ typeof(HelloNavigateAsync.Pages.SpecialPage).Assembly
+ };
+
private async Task LoadExtraLibs(NavigationContext navCtx)
{
try
{
// 注意:navCtx.Path属性中的值,不以 '/' 开头
// 即如果这里写成 if(navCtx.Path == "/Rect"),条件是永远不对匹配成功的
if(navCtx.Path == "Rect")
{
string extraLib = "HelloNavigateAsync.Components.dll";
await lazyAssemblyLoader.LoadAssembliesAsync(new List<string> { extraLib });
logger.LogInformation($"[{extraLib}] loaded");
}
}
catch(Exception ex)
{
logger.LogError(ex.Message);
}
}
}
这样改造完成后,我们确实可以正确访问到/SpecialPage
页面了,如下:
但现在我们只是正确配置了追加路由项目,还没有达成“按需加载”的目的。
原理和我们之前配置H.Components.dll
基本一致,首先需要在H.Client.csproj
做如下声明:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.11" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\HelloNavigateAsync.Shared.csproj" />
<ProjectReference Include="..\Components\HelloNavigateAsync.Components.csproj" />
<ProjectReference Include="..\Pages\HelloNavigateAsync.Pages.csproj" />
</ItemGroup>
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include="HelloNavigateAsync.Components.dll" />
+ <BlazorWebAssemblyLazyLoad Include="HelloNavigateAsync.Pages.dll" />
</ItemGroup>
</Project>
其次需要在App.razor
中做如下修改:
@inject ILogger<App> logger
@inject Microsoft.AspNetCore.Components.WebAssembly.Services.LazyAssemblyLoader lazyAssemblyLoader
<Router AppAssembly="@typeof(App).Assembly" OnNavigateAsync="LoadExtraLibs" AdditionalAssemblies="@extraPageLibs">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</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 List<System.Reflection.Assembly> extraPageLibs = new List<System.Reflection.Assembly>
- {
- typeof(HelloNavigateAsync.Pages.SpecialPage).Assembly
- };
+ private List<System.Reflection.Assembly> extraPageLibs = new List<System.Reflection.Assembly>();
private async Task LoadExtraLibs(NavigationContext navCtx)
{
try
{
// 注意:navCtx.Path属性中的值,不以 '/' 开头
// 即如果这里写成 if(navCtx.Path == "/Rect"),条件是永远不对匹配成功的
if(navCtx.Path == "Rect")
{
string extraLib = "HelloNavigateAsync.Components.dll";
await lazyAssemblyLoader.LoadAssembliesAsync(new List<string> { extraLib });
logger.LogInformation($"[{extraLib}] loaded");
}
+
+ if(navCtx.Path == "SpecialPage")
+ {
+ string extraLib = "HelloNavigateAsync.Pages.dll";
+ this.extraPageLibs.AddRange(await lazyAssemblyLoader.LoadAssembliesAsync(new List<string> { extraLib }));
+ logger.LogInformation($"[{extraLib}] loaded");
+ }
}
catch(Exception ex)
{
logger.LogError(ex.Message);
}
}
}
有了前面循序渐进的介绍,相信上面的代码改动就不需要我再额外赘述,你也能看懂了。
另外,你可能好奇,我们在代码中写的逻辑,好像会导致,每次访问/SpecialPage
都会向this.extraPageLibs
中添加一次H.Pages.dll
,那么这会不会导致重复添加呢?
答案是:不会的,而原因,有两个:
每次页面跳转,都其实是对Router
组件的一次重新渲染
实际上每次渲染,私有字段extraPageLibs
都会重新初始化
如果你有兴趣,可以看到,AdditionalAssemblies
参数,或者说叫属性,在Router
组件中的类型定义虽然是IEnumerable<Assembly>
,但你深入看进Router
组件中的RefreshRouteTable()
方法,会发现如下代码:
private void RefreshRouteTable()
{
var routeKey = new RouteKey(AppAssembly, AdditionalAssemblies);
if (!routeKey.Equals(_routeTableLastBuiltForRouteKey))
{
Routes = RouteTableFactory.Create(routeKey);
_routeTableLastBuiltForRouteKey = routeKey;
}
}
而如果你追着RouteKey
的定义再看下去,你会发现,框架中存储路由表的数据结构其实是个HashSet
,本身是会去重的
internal readonly struct RouteKey : IEquatable<RouteKey>
{
public readonly Assembly? AppAssembly;
public readonly HashSet<Assembly>? AdditionalAssemblies;
public RouteKey(Assembly appAssembly, IEnumerable<Assembly> additionalAssemblies)
{
AppAssembly = appAssembly;
AdditionalAssemblies = additionalAssemblies is null ? null : new HashSet<Assembly>(additionalAssemblies);
}
// ...
}
动态按需加载是个好特性,但我不建议你滥用它,因为大多数情况下,动态按需加载其实是没必要的。我这系列文章的目标受众其实是,需要发挥Blazor框架开发效率高的优势的全栈程序员。而这样的程序员所写的项目规模,本身就不可能太大。也就是说,在中小规模项目里,这个特性是一个你应该用不上的屠龙技。
什么时候才需要考虑应用动态按需加载呢?我认为至少要满足以下两个条件之一:
除此之外,我不建议你使用动态按需加载。
虽然这个特性略显鸡肋,但毕竟是一个有关路由组件Router
的重要知识点,所以,讲还是要讲的