Blazor教程 第十课:有关路由的其它知识点

Blazor

上一篇文章我们已经基本讲清楚了Blazor框架中的路由机制,这一节课再补充一些有关路由、页面跳转的一些边角知识。

1. 实现页面跳转

我们虽然已经掌握了路由机制的运行原理,但也只是原理而已,有一个非常重要的,在实际开发过程中需要用到的东西还没有讲到:如何实现页面间的互相跳转。我们先不谈Blazor框架,把讨论范畴拉大一点,对于所有SPA前端框架而言,其实跳转有两种实现方式:

  1. 实现一个超链接,然后由浏览器去执行跳转。
    • 这种途径的好处是实现简单,坏处是跳转是由浏览器负责的,对于一个SPA应用来说,跳转到新的页面就相当于用一个新的请求路径去加载整个页面程序,整个SPA应用会重新启动、运行,实际开销比较大,并且在体验上,有明显的“页面刷新”的感知。
  2. 使用SPA框架内置的功能特性,替换页面渲染内容,然后修改浏览器地址栏中的URL,达到一种“假跳转”的效果
    • 这种途径的坏处是实现有一点点麻烦,需要在SPA程序代码中使用代码逻辑来完成“伪跳转”,虽然框架提供的各种编程接口、小工具会简化实现过程,但比起写个超链接来说,还是略麻烦
    • 这种途径的好处就是页面其实没有刷新,或者重加载这个过程:SPA应用并没有重新加载的整个过程,只是页面上必要的组件被替换,然后渲染。用户端体验比较丝滑。

不过有意思的是,在Blazor框架中,实际我们是无法直接实现#1这种跳转的,因为Blazor框架本身,拦截了所有对于<a>标签中链接的点击事件,然后由框架代码以#2的方式去实现“伪跳转”。换句话说,你可以简单的认为,除非用户在页面上是以“在新窗口/标签页中打开链接”的方式来点击链接,否则,在Blazor应用中,所有跳转都是以#2的形式实现的。(除了域外跳转)

我们作为框架的使用者,不必过分好奇Blazor框架是怎么实现这一点的,只需要知道这么个事就行了。总之就是记住:通常情况下,所有跳转都是无刷新跳转。

从具体行为角度来讲,几乎所有的跳转都是框架在做伪跳转。从书写代码的角度来讲,实现跳转大概有两类方法:

  1. 在页面上放置链接,由用户去触发跳转
  2. 在Blazor WASM程序中书写逻辑,以编程的方式去触发跳转

跳转这个事,从用户角度来看,就是在变更URL,而既然说到URL,就不得不考虑一个重要的事:查询字符串。

比如https://xxx.com/a/b/c,这是只包含路径的URL,而https://xxx.com/a/b/c?key1=value1&key2=value2就是在前者的基础上,向URL中编码了两个k-v,这玩意就是查询字符串。

所以除了跳转本身之外,如何在Blazor WASM程序中读取到查询字符串中的信息,以及如何构造出包含查询字符串的跳转链接,也是相当重要的内容。也会在本文中进行介绍。

接下来,坐稳扶好,我们来开始跳转的旅程。

1.1 链接式跳转:使用<a>标签去做一个超链接

这个就是框架无关的,纯HTML实现的页面跳转。我们先通过命令dotnet new blazorwasm-empty --hosted -o HelloNav新建一个blazorwasm-empty项目,然后在Pages目录中新添加两个页面组件:Page1.razorPage2.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>

运行效果如下:

a_nav

可以看到,在点击链接跳转的过程中,客户端浏览器完全没有发送任何网络请求,浏览器完全没有刷新行为。不光如此,在我们以JS的方式调用window.history.forward()window.history.back()的时候,即点击当前Tab的前进、后退按钮时,页面也没有刷新行为。

一个额外知识点:浏览器如何识别已经访问过的链接的?

你们有没有注意到一个问题:虽然我们这个简单示例中没有涉及任何CSS样式,但根据浏览器的默认行为,一个用户已经访问过的链接,浏览器在默认情况下会把它的颜色显示为灰色,而不是纯蓝色。纯蓝色的链接代表着一个“之前从未访问过的链接”。

而我们上面录制的示例中,无论怎么点击,来回跳转,那三个链接都是蓝色的。

那么,这时,我有一个问题想问大家:请大家猜一下为什么浏览器没有识别出“已经访问过的链接”,以下三个选项请选择一个正确答案:

  1. 因为Blazor框架截断了跳转请求,所以浏览器完全不知道有跳转这回事,自然不知道哪些链接是访问过的
  2. 虽然浏览器并没有感知到页面跳转,但Blazor框架还是通过魔法将跳转历史写进了window.history栈表中,但这些访问记录其实并没有写进浏览器全局的访问历史中去(在chrome内核浏览器中,可以通过在地址栏输入@history来查看浏览器全局存储的访问历史记录),而只是让框架写进了当前Tab的session history中去,即window.history
  3. 因为上面这张Gif图的边框是黑色的,所以这其实并不是一个正常的浏览器窗口,而是一个隐私窗口,浏览器在这种模式下是不记录访问历史的,任何链接它都显示为纯蓝色

正确答案是:#3

这里就要解释一下浏览器是如何识别一个链接是否被访问过。

首先,大家要清楚,当谈及浏览器的历史记录的时候,其实是有两个完全不同的概念的:

  1. 浏览器全局是有一个访问历史记录的,在chrome系浏览器中,通过在地址栏中输入@history就可以打开访问历史管理页面,这里浏览器存储着全局性的、关闭重启浏览器不会清零的,所有URL的访问历史记录
  2. 在一个Tab中,是有一个访问历史记录的,就是window.history,它里面存储着当前Tab的跳转历史,浏览器的“前进”,“后退”功能就是靠着window.history.forward()window.history.back()实现的。前端应用程序通过编程接口其实是可以控制这个栈表中的内容的,这其实也是所有前端SPA框架做无刷新跳转时需要做的一件事:不光页面要渲染,URL地址要改变,还要把跳转前的页面URL放进这个栈表中去

浏览器在检测一个链接是否被访问过时,只是简单的去查看#1中有没有这个URL,如果有,就是被访问过的,如果没有,就没被访问过,就这么简单。

另外,每当Tab中的栈表记录有变更的时候,这个变更其实是会同步到浏览器全局历史记录列表中去的。换句话说,SPA框架在实现无刷新跳转的时候,跳转记录依然会被浏览器全局历史记录列表检测到。除了一种特殊情况:隐私窗口。

当你打开隐私窗口进行网页浏览的时候,浏览器就完全不存储全局历史记录了,所有的链接都是蓝色的。

现在我们在正常浏览器窗口环境下,再重新模拟一下上面的Gif图:

a_nav_accessed

这下,行为符合我们的直觉了吧?

1.2 链接式跳转:使用<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的元素背景色显示为黄色,前景色显示为红色。

这个例子运行起来是下面这样的:

nav_link

看到了吧?效果非常直观,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>

效果如下:

nav_link

1.3 编程跳转:使用NavigationManager

上面提到的<a><NavLink>其实本质上是同一种东西:让最终渲染结果上出现一个<a>,然后Blazor框架内部再拦截对链接的点击,实现无刷新跳转。那么一个非常自然的想法就出现了:既然跳转的具体实现是Blazor框架做的,那我们程序员能不能直接指挥框架,让它去跳转啊,就没必要非得往页面上先造一个<a>了。

这个用来指挥框架做跳转的东西,是一个Blazor框架在启动时就创建好的一个对象,跟HttpClient实例一样,框架启动时就把它初始化在DI池中,它的类型就是NavigationManager。这个类型共有以下几个比较重要的公开成员:

首先是两个重要的公开属性:UriBaseUri: 通过这两个公开的只读属性,我们可以获得当前页面的“完整URI”,以及当前站点的“BaseURI”。

我们之前简单的提到过<head>.<base>标签,这里的“BaseURI”其实取的就是当前文档的<head>.<base>标签里的值,也是浏览器、以及Blazor框架在运行时,从相对路径计算绝对路径的一个基准

其次是最核心的NavigateTo方法,调用这个方法就可以实现页面跳转。这个方法有三个重载,分别是:(string, bool), (string, bool, bool)以及(string, NavigationOptions)

首先是(string, bool)重载,这个重载没有设置默认参数值,两个参数都需要调用方显式填充

  • 第一个参数是目的地,无论是绝对URI还是相对URI都可以
  • 第二个参数名叫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%的情况下,上面两个重载其实都够用了,在实际使用时上面两个重载能组合出三种用法:

  1. 在应用内部跳转,域内跳转的时候,一般使用三参版本的重载,但只给第一个参数赋值,比如:this.navMgr.NavigateTo("/Product/ProductDetail")
  2. 如果想强制让浏览器做刷新重载的话,就使用(string, bool)重载,并置forceLoadtrue
  3. 如果不想让用户通过“退后”按钮回退页面状态的话,就使用(string, bool, bool)重载,并置replacetrue

最后是第三个重载:(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设计上能看出来,有关跳转的三个最重要的元素,分别是:

  1. 目的地
  2. 要不要做无刷新跳转
  3. 跳转允不允许回退(是向session history栈中新加一项,还是替换掉原栈顶)

至此,有关NavigationManager的基本知识就讲完了,在进入示例代码环节之前,还要再说最后一个知识点:域外跳转。

对于域外跳转,即像调用this.navMgr.NavigateTo("https://www.baidu.com")这种调用,框架默认会放弃对跳转的拦截:这也是非常自然的事情。不过在实践中请务必注意:如果你要做域外跳转,那么传入的URI参数一定是要从协议开始的,比如https://www.baidu.com,而不能只从主机名开始,比如baidu.com,对于后者,框架会认为你是想跳转去当前站点的/baidu.com页面,而不是做域外跳转。

接下来我们通过对MainLayout.razor做一点修改,来向大家展示UriBaseUri以及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));
+    }
+}

运行起来,首先是观察普通的页内跳转,即replaceforceLoad都为false时的行为:

navmgr1

其次是我们新开个tab,来观察当forceLoadtrue时的行为,此时框架放弃了对跳转的拦截,转由浏览器去执行最原始的跳转,页面会整个刷新

navmgr2

注意观察,前半段过程我们总共执行了三次跳转:

  1. //NavLinkSample,刷新跳转
  2. /NavLinkSample/NavLinkSample/a,是无刷新跳转,这次跳转我们第二个参数赋值是false
  3. /NavLinkSample/a/NavLinkSample/a/b,刷新跳转

这都没什么,在我们预料之内,但有意思的是,后半段我们开始按浏览器的回退按钮时

  1. /NavLinkSample/a/b退到/NavLinkSample/a,回退时整个页面刷新
  2. /NavLinkSample/a退到/NavLinkSample,回退时页面没有刷新
  3. /NavLinkSample/,回退时整个页面刷新

这就通过实践验证了Blazor框架跳转的一个特性:在跳转时如果做的是无刷新跳转,那么浏览器在回退时,框架也会拦截回退请求,做无刷新回退。

我们这次再来展示replace参数的效果,如下所示:

navmgr3

在上例中我们做了两次跳转,第一次是无刷新跳转,第二次是刷新跳转,两次跳转时的replace参数都置为true。可以看到,无论跳转是不是刷新跳转,当前Tab的session history中都没有新添加任何东西,用户是无法通过浏览器的“后退”按钮回退到跳转前的。

最后,我们来展示一下域外跳转,在下面的例子中,我们传给NavigateTo的URI参数为一个完整的域外URI,但forceLoadreplace都置为false

navmgr4

可以看到,在域外跳转时,forceLoad显然是不会起作用的,并且replace也不会起作用。

1.4 编程式跳转的补充知识:NavigationManager暴露出的几个勾子

NavigateTo只是NavigationManager的一个基础功能,除了以编程的方式直接调用NavigateTo去实现页面跳转外,我们还可以用NavigationManager实现一些其它的功能。这里只介绍两个比较常用和重要的功能:

  1. 在页面跳转时插入更多逻辑,但不对“跳转”本身的执行做任何干涉:订阅LocationChanged事件,将额外逻辑写在事件回调中
  2. 在页面跳转时拦截跳转事件,从而改变“跳转”本身的执行:使用RegisterLocationChangingHandler方法

如果你想在跳转时做一些额外的事情,使用LocationChanged事件

LocationChangedNavigationManager暴露的一个事件,它的具体签名如下:

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;
    }
}

它的运行效果如下所示:

location_changed

我们来说明一下运行效果Gif图中出现的四次跳转:

  1. 第一次跳转,点击页面上的链接,从主页跳转到/NavLinkSample/a
    • 控制台明确的打印出了跳转的目的地,以及IsNavigationIntercepted == true,这表示我们可以从监听函数里得知,本次跳转是由用户在浏览器触发的
  2. 第二次跳转,点击页面上的按钮,按钮的事件回调调用NavigateTo方法,以代码的方式跳转到了/
    • 控制台打印出了IsNavigationIntercepted == False,这表示我们可以从监听函数里得知,本次跳转是代码触发的
    • 另外,控制台还打印出了代码跳转时,附加的自定义数据
  3. 第三次跳转,再次点击页面上的链接,跳转到了/NavLinkSample/a,行为和第一次跳转一致,没什么可说的
  4. 第四次跳转,是点击浏览器的后退按钮实现的,相当于回退到第二次跳转的结果里
    • 控制台打印出了IsNavigationIntercepted == False: 这里有点意思,按我们的直觉来说,无论是用户点击页面上的链接,还是浏览器的“前进”,“后退”按钮,跳转这件事本身,都应该先是浏览器去处理,再有必要的话由Blazor拦截。但实际情况是,“前进”和“后退”按钮,都是在最初就被代码接管了的,然后“直接”发送给Blazor框架(实际实现要复杂得多,但你可以这样简单理解)。
    • 或者换个角度来理解这事,你可以简单的把IsNavigationIntercepted的语义理解为:“跳转是否是由用户点击链接产生的”。当它为false时,只能说明跳转不是由点击链接触发的,但并不能说明跳转就一定是NavigateTo的调用触发的,也有可能是浏览器的“前进”或“后退”按钮触发的
    • 控制台还打印出了,当初第二次跳转时,NavigateTo附加的那句自定义数据

这个例子里有三个关键点:

  1. 通常情况下的最佳实践,是把挂载监听函数的行为放在OnInitialized{Async}生命周期函数中,并且一定要记得实现IDisposable接口,并在Dispose()方法的实现中,对事件监听函数进行解绑
  2. 在监听LocationChanged的监听函数中,HistoryEntryState的值可能来自两种场合
    1. NavigateTo跳转时,直接能拿到NavigateTo时附加的NavigationOptions.HistoryEntryState
    2. 在浏览器回退至某个页面时,能拿到之前跳转时附加给这个栈项的自定义数据
  3. IsNavigationIntercepted == False时,只能说明跳转不是由点击链接触发,而不能说明跳转一定是由NavigateTo的调用触发的
    • 实际上我们上面这种解释也不是100%正确的,但你可以姑且这样理解

另外还需要注意的一点是:当你在一个组件的OnInitialized中注册LocationChanged的监听函数时,这个监听函数就只会在无刷新跳转场合正常工作,比如我们对上面的代码做一点点小的修改,如下:

             new NavigationOptions
                 {
+                    ForceLoad = true,
                     HistoryEntryState = $"mouse click client x == {args.ClientX}, mouse click client y == {args.ClientY}"
                 });

此时,你会发现,点击按钮跳转后,OnLocationChanged方法就不会被执行,如下:

location_changed

这背后的原因其实很简单:因为对于无刷新跳转来说,跳转后,相当于客户端浏览器重新完整的加载我们的前端代码。这时候,是跳转先发生,然后才有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();
    }
}

运行效果如下:

register_location_changing_handler

注意点有以下几个:

  1. RegisterLocationChangingHandler是一个方法,接收一个函数指针作为参数,函数指针的签名为ValueTask (LocationChangingContext)
    • 从签名上你也能看得出来,函数指针可以是async函数。注册多个异步函数后,各函数的执行顺序、cancel行为等是一个细小又艰深的知识点,有兴趣的话你自己研究吧。
  2. RegisterLocationChangingHandler返回一个注册回执,这个回执是用以在Dispose方法中解除注册的。所以跟LocationChanged事件一样:有注册,就要有解绑,当然组件也要实现IDisposable接口
  3. 通过调用LocationChangingContext.PreventNavigation()就可以停止本次跳转
  4. LocationChanged一样,它也只能影响无刷新跳转,背后的原因也是一样的。

好了,有关RegisterLocationChangingHandler我们就浅浅的介绍在这里,知道有这么回事就行了,实际开发中很少会用到这个接口,真正有需求用到的话再查文档也不迟。

1.5 编程式跳转的补充知识: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();
        }
    }
}

代码很简单,只做了下面几件事:

  1. 页面上写了两个checkbox,并把checkbox打勾与否的状态,与私有字段this.allowInternalNavigationthis.confirmExternalNavigation绑定起来

  2. 调用了组件NavigationLock,并向它传递了两个参数

    1. ConfirmExternalNavigation,默认值为false,而当它为true时,会在页面向外部站点跳转时,向用户弹出一个提示框。类似于:“你所做的修改即将丢失,确定跳转吗?”

      注意,这个参数的值,并不影响站内跳转

    2. OnBeforeInternalNavigation,这是一个EventCallback<LocationChangingContext>事件回调,它会拦截所有的站内跳转,在这个回调函数中,我们就可以禁止站内跳转

      注意,这个回调函数,并不拦截站外跳转

这个程序运行起来是这样的:

当两个勾都打上,即“允许站内跳转”且“需要用户确认站外跳转”时,效果如下:

navlock_true_true

当只打第一个勾,即“允许站内跳转”且“站外跳转不需要确认”时,效果如下:

navlock_true_false

当两个勾都不打,即“不允许站内跳转”且“站外跳转不需要确认”时,效果如下:

navlock_false_false

效果非常直观,非常容易理解。。

NavigationLock组件与RegisterLocationChangingHanlder方法的比较

我们在上面小节的标题其实也说了,NavigationLock其实就是RegisterLocationChangingHandler的语法糖,二者基本是等价的,虽然功能是等价的,但命名上显然有着不同的引导暗示:

  1. 如果你拦截跳转行为的目的,只是想基于一些条件的判断,来决定要不要“禁止/取消掉本次跳转”,那么你就应该使用NavigationLock组件
  2. 如果你拦截跳转行为的目的更复杂,比如想改写跳转的目的地之类的,你其实应当使用RegisterLocationChangingHandler方法

我为什么说这是“引导暗示”呢?因为这俩玩意,其实真的大差不差

  • NavigationLock暴露的是事件,事件回调类型是EventCallback<LocationChangingContext>
  • RegisterLocationChangingHandler需要的则是一个函数指针,类型为Func<LocationChangingContext, ValueTask>

其实没什么区别,只不过在实际使用中,使用NavigationLock是不需要向当前页面注入@inject NavigationManager navMgr的,而使用RegisterLocationChangingHandler就必须拿到NavigationManager对象了。

比起NavigationManager对象,LocationChangingContext提供的功能相当有限,对跳转的干涉方式只有一种:PreventNavigation即取消/阻止本次跳转。

所以我才说命名上的取向只是“引导暗示”,程序员其实完全可以在当前页面注入了NavigationManager对象的情况下,在NavigationLockOnBeforeInternalNavigation事件回调函数中,使用NavigationManager去对跳转做更多花活。

同一个页面上,可以存在两个NavigationLock组件吗?

作为一个视觉隐形,但又有具体实际功能的特殊组件,你或许会和我一样,在第一次接触它时,问出一个非常自然的问题:如果同一个页面渲染了多个NavigationLock,会有什么行为?

我做了一些实验,我试图在同一个页面上渲染多个NavigationLock并观察它们的行为,并总结出规律出来,但我的观察结果是:确实有规律,但很难用语言描述。

换句话说,当一个页面上渲染了多个NavigationLock组件时,是很难用语言讲清楚具体执行时会发生什么的。。你可以简单的把这种行为称为“未定义行为” -- 但实际并不是,你只是可以这样去理解。

在开发中应当避免这种事情的发生,也就是说,我们上面把NavigationLock写在布局组件的行为,是一种非常蠢的行为,不要效仿。

在实际开发中,请确保仅在页面组件中使用NavigationLock组件,并保证仅使用了一次。

1.6 处理URL中的查询字符串

上面我们讲了怎么实现跳转,怎么拦截跳转。学会这两板斧基本就能胜任工作中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();
        }
    }
}

执行效果如下所示:

parse_query_parameter

几个注意点:

  • 在net 8.0之前,SupplyParameterFromQuery需要和Parameter一起同时使用,框架才会正确的解析查询字符串中的值,单独使用SupplyParameterFromQuery是不生效的。net8.0修正了这个"Bug"
  • key的匹配不区分大小宝,对数据的解析转换基本符合直觉
  • 支持的基础数据类型包括:
    1. bool, DateTime, decimal, double, float, Guid, int, long, string以及这些基础类型的nullable版本,比如string?double?
      • 参数缺失的情况下,默认值为default:对于基本类型来说就是0,对于nullable类型来说就是null
    2. 支持上述类型的数组
      • 参数缺失的情况下,默认值是空数组,而不是null
  • 类型转换失败是会抛出异常Crash掉当前页面的。目前是没有公开文档让程序员去替换类型转换逻辑的,所以最稳妥的办法是统一解析成stringstring[],然后在下游再写数据类型转换逻辑
  • 建议像上例那样,使用可空类型

安全的构造带查询字符串的跳转链接

你可以选择自己手动拼字符串,然后把拼出来的字符串放在<a><NavLink>中去,或者喂给NavigateTo方法,没问题,但多少还是有点注入风险的。

稳妥起见,你应该调用NavigationManager.GetUriWithQueryParameter[s](...)系列方法去构造链接,这个系列方法有着巨多的重载,它以**当前页面的URL(包括查询字符串)**为基准,为你安全的生成各种跳转链接字符串。

这里举几个例子:

以当前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 参数已经存在,则覆盖之

以当前URL为基准,向其从其中删除一个查询参数

string target = Navigation.GetUriWithQueryParameter("full name", (string)null);
如果当前URL长这样 那么上面的调用返回的字符串就长这样 备注
/path?gender=female /path?gender=female 参数不存在,则什么也不做
/path?full%20name=Davy%20Jones /path 参数已经存在,则移除之

以当前URL为基准,一次性批量增删多个查询参数

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被覆盖

Blazor目前没有为锚点参数做工具类

这里补充一个知识点:锚点链接。

在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>
}

它理想中的运行效果应当如下图所示:

anchor_work

但是,非常不幸的是:以上的效果仅在net8.0环境下才生效。

如果整个项目是面向net7.0编译生成的话,页内跳转则不会生效,如下图所示:

anchor_notwork

这个问题最早在2019年被社区发现,四年后在2023年才正式得到修复,并在这个PR得到了修复。

坦白讲,我是看不懂那个PR都写了些什么玩意的,但我大概能猜到这个问题背后的原因出在哪里,以下是我的猜测:

锚点链接的页内跳转,本身是由浏览器本身负责的:即页面已经加载完毕后,用户点击了一个锚点链接,浏览器会将用户的页面滚动至对应的元素处。不过Blazor的实现里,框架拦截了所有跳转,导致在旧版本的Blazor WASM程序里,用户点击一个锚点链接后,这个“跳转”被Blazor框架代码拦截了,框架代码没有做到“待页面渲染完成后,将浏览器页面滚动至目标元素处”这件事。

那么,如果你现在正在用的就是net8.0之前的旧版本,有什么workaround的办法呢?有是有的,在上面社区提的issue页面,提主本人就提到了一种workaround,

  1. index.html中,用JS写一个函数,来将页面滚动至ID为指定值的元素处。
  2. 在页面组件的生命周期函数里,比如OnAfterRender里,通过NavigationManager读出锚点里元素的id,然后调用JS实现页面滚动

只不过可惜的是,我们现在还没有介绍如何在Blazor WASM代码中调用JavaScript脚本。

把话题拉回来,上面我们介绍了什么是锚点链接,以及锚点链接在旧版本blazor中不work的现状,现在来说正事:既然对于查询字符串,有GetUriWithQueryParameter[s](...)工具方法,来为我们安全的生成URL。那么框架提供了安全的生成锚点链接的工具方法了吗?

答案是:没有,锚点链接这个东西,你得自己手动拼字符串。原因也很简单:

  • 锚点链接的执行逻辑是不牵涉前后端交互的,锚点里的元素ID是不会传递到服务端的

    无论是纯天然的浏览器负责的页内跳转,还是net8.0后Blazor框架实现的页内跳转,都不会把锚点信息传递给服务端,没有注入风险

  • 锚点链接里的内容比较简单,就是一个元素ID而已,自己拼字符串也没什么大毛病

1.7 小总结

有关路由、跳转方面的其它知识其实很多,甚至于NavigationManager本身还有很多知识可以去学习,但这些知识大多都是实际开发中使用频率很低的知识。我不建议你去仔细追究、学习有关路由和页面跳转的一切知识 -- 不是很有必要。

掌握这些基础的、入门的基本知识足够应付日常开发了,对于开发过程中的一些棘手问题,你应当在遇到的时候再去查阅文档与互联网,而不是在初次学习框架的时候就试图把所有知识都掌握掉 -- 说实话你也做不到。

2. 动态按需加载

看到这个章节的标题,是不是有点懵?我们不是在说路由的事吗?你先别急,我们确实是在说路由的一个小知识点,只不过啊,小知识点只是与路由有关而已。

我们先来复习一下,怎么搞一个独立的组件类库

2.1 复习一下怎么创建一个组件类库

我们在上上一篇文章中,提到过一个小事情,即我们可以自己写一些公用组件,把这些公用组件从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"/>

如下图所示:

component_lib

一切都很完美,没有任何毛病。

这时,如果你查看浏览器的缓存数据,会发现这个组件库的dll已经被缓存起来了

cached_comp_lib

而如果你清空这个缓存,重新发起一次请求的话,会发现首屏加载的过程中,这个dll文件也是被囊括进首屏加载列表中的:

comp_lib_been_loaded

这也是没什么问题的,但接下来,如果我们从Client项目的Pages/Index.razor中删除对Rect的调用,你会发现,浏览器还是会向服务端去请求这个组件库dll

 @page "/"
 
 <h1>Hello, world!</h1>
-
-<HelloNavigateAsync.Components.Rect WidthInPixel="300" HeightInPixel="300" BackgroundColor="Blue"/>

comp_lib_still_been_loaded_even_not_necessary

如果你还对我们之前讲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

tree_shaking_not_working

你可能会说:“那既然我们都没有实际用到组件类库,那为何不直接从Client项目文件中,解除对Components项目的引用呢?”

你说的对,确实,解除引用后,组件类库文件就不会被加载了。但我举这个例子并不是为了让大家手动管理依赖,从而达到控制首屏加载列表的。。而是,以这个例子为引子,请大家思考下面这两个问题:

  1. Client项目虽然引用了Components组件,并没有实际使用到Components中的组件的情况下,如何避免浏览器加载这个没用的HelloNavigateAsync.Components.dll文件

  2. 更进一步的:在首页并没有使用Components中的组件的时候,如何避免在首页加载的时候,浏览器要载入HelloNavigateAsync.Components.dll

    这句话的言外之意是:虽然在Pages/Index.razor中没有使用到Rect组件,但可能在其它页面中使用了Rect组件

这两个问题总结起来其实就是一句话:对于组件类库,如何做到按需加载

说了那么多,终于入活了!!

2.2 走马观花的看一遍如何实现动态按需加载

在具体介绍步骤之前,我们先对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>

然后开始动态加载的改造,再强调一遍我们改造的目的:

  1. 在首次访问首页的时候,浏览器不加载HelloNavigateAsync.Components.dll
  2. 在首次访问/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的时候”。

  • 这个时刻对浏览器前的用户来说,是鼠标点击链接,或者手动输入地址栏后按回车键的时刻。
  • 这个时刻对运行在浏览器中的Blazor WASM程序来说,其实是路由组件Router干活的时刻

我们再在脑海中捋一遍Router组件的工作流程,或者说前后端一把梭的Blazor WASM项目的运行过程

  1. 用户在浏览器打开网址,Server项目将托管的Blazor WASM程序连同所有的依赖dll,都返回给用户的浏览器。用户的浏览器接收到这些内容后,JS触发dotnet runtime的运行,并在其上运行Blazor WASM程序
  2. Blazor WASM程序开始运行,App被渲染,而App的内部其实就是一个Router组件
  3. <Router AppAssembly="@typeof(App).Assembly">中对参数AppAssembly进行了赋值,所以在Router组件初始化的过程中,它就会扫描H.Client.dll中的所有组件,读取所有组件类脑门上的@page指令,从而知道面对用户请求时,应当渲染哪个页面组件
  4. 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,则什么也不做。

所以程序的运行结果就会如下动图所示:

  • 在清空缓存后的首次加载时,确实有HTTP请求被发出
  • 在后续链接跳转的过程中,没有HTTP请求被发出,但日志还是正常打印,代表if分支依然被匹配,LoadAssembliesAsync依然被执行,只是没有什么实际作用

navigate_will_not_trigger_assembly_reload

但是,对于刷新跳转或者用户主动刷新页面,LoadAssembliesAsync则会100%的发送网络请求,就如下图所示:

refresh_will_trigger_assembly_load

在上图中,我们把浏览器调试窗口的preserve log选项的勾给去掉了,去掉这个勾后,每次页面刷新,network栏中的内容就会被清空,更直观的能感受到:

  1. 清空缓存后初次请求时,框架与dotnet类库文件,和H.Components.dll一样,都会走网络请求下载到本地
  2. blazor和dotnet的框架类库文件,在后续刷新过程中,是不会再次请求的。浏览器会直接复用缓存中已经存在的类库文件
  3. 而每次刷新后H.Components.dll都会有一个网络请求去下载它,由于我们在浏览器调试窗口勾选了Disable cache,所以这个请求切切实实的会被发送到服务端

而即便我们将disable cache选项去掉勾选,网络请求依然会被发出,只不过服务端不会再传输库文件的主体,而是回复一个304,如下所示:

refresh_still_trigger_load_just_304

现在就有个小问题:为什么我们的H.Components.dll做不到像框架系统库文件那样,在刷新跳转时,将这个没必要的HTTP请求优化掉?明明文件就在用户的浏览器缓存中啊!

这其实是两个问题:

  1. 为什么框架和系统类库文件可以横跨刷新跳转,不需要在后续的页面请求过程中通过HTTP请求远程加载?
  2. 为什么H.Components.dll做不到这一点?

为了回答这两个问题,我们专门来开个小节,简单的介绍一点额外知识

简单的提一下blazor wasm与浏览器缓存

我首先叠个甲:我要开始胡说八道了啊,以下有关浏览器缓存机制的介绍充斥着大量的错误。但是呢,我觉得如果你之前并不熟悉浏览器的工作细节,或者说你只是一个新手程序员,或者之前从未接触过前端开发,我觉得你可以不妨按我的错误思路去理解浏览器的缓存机制。

缓存机制有两层:

  • 一层是浏览器、HTTP协议、服务端共同设计实现的,通用缓存机制,在这里我称之为第一层缓存机制
  • 一层是各网站的前端逻辑自己实现的,个性化的缓存机制,在这里我称之为第二层缓存机制

第一层缓存机制

这层机制由以下要素构成:

  1. 浏览器发送了HTTP请求首次获得了各种数据后,都会尽量把它缓存在自己肚子里。

    比如用户通过浏览器访问https://***/funny.gif

    1. 浏览器向***/funny.gif发送GET请求
    2. 服务端接收请求,将这张图片包裹在一个HTTP Response中,返回给浏览器。这个HTTP Response的返回码一般是200
    3. 浏览器一边将这个图片展示在显示器上,另一方面,偷偷把这个funny.gif存在自己肚子里,并且标记着:“这张图片是从***/funny.gif拿到的”
  2. 当用户再次请求某个之前已经缓存过的资源时,浏览器依然会向服务端发送HTTP请求,但服务端会告诉浏览器:你用缓存渲染吧

    比如用户再次刷新页面访问https://***/funny.gif

    1. 浏览器依然还是会向***/funny.gif发送一个GET请求,只不过,这一次附加上了一些额外的信息,大致意思就是:

      “哥,这张图片其实我本地就有,我在5分钟前跟你要的”

    2. 服务端接收请求,会看到上面的额外信息,如果在这5分钟期间,这张图片在服务端并没有被更改、替换的话,服务端会生成一个特殊的HTTP Response,告诉浏览器

      “老弟,行,我知道了,图片没变,我也就不给你再传一遍了,没必要,你就用缓存吧”

      这个HTTP Response的状态码就是304

    3. 浏览器收到304后,安心的用本地缓存里的图片数据渲染页面

这套机制是在HTTP协议层级规范、实现的,对网站开发者来说是完全透明的。而我们在浏览器调试窗口,打的那个disable cache的勾的效果,就相当于禁止浏览器向服务端发送那句“哥,我有,五分钟前拿的”额外信息。

这样服务端每次都会把数据完整的传递一遍。

从开发调试角度来说,这一层缓存机制最大的特点就是:这些缓存对程序员来说,是不可见的。你无法在浏览器的调试窗口找到一个地方去浏览这些缓存,你无法清除这些缓存,无法改写这些缓存的内容。

第二层缓存机制

第二层缓存机制则不是通用的,每个网站的开发者需要自己实现缓存逻辑,它大致包括下图的内容:

browser_local_cache

这一层缓存有多种类型,从cookie,到local storage,到cache storage,有很多具体实现。有些需要HTTP协议配合,比如cookie,有些则不需要。但不管它们的形态如何,怎么实现,将它们归在同一类的原因在于,它们有着以下共同特点:

  1. 缓存的数据是以域名隔离的,浏览器为每个不同的域名维护着各自独立的缓存数据
  2. 在浏览器中运行的前端代码,是可以通过编程的形式访问到这些数据的
  3. 在调试过程中,程序员是可以在浏览器调试窗口直接访问、修改这些数据的
  4. 缓存数据的策略是需要开发者自行指定实现的,或者换句话说,浏览器提供的这些东西,不应该叫“缓存”,而仅仅是“客户端的独立沙箱存储”

回到两个问题上

如果你之前仔细阅读了我们介绍Blazor WASM运行原理的内容,应该还记得,Blazor WASM程序从加载到运行大致有以下几步:

  1. 浏览器加载index.html

  2. index.html中夹带了一个JS文件_framework/blazor.webassembly.js,根据浏览器的运行机制,这个JS文件会被自动执行,而也正是它

    1. 再加载了blazor.boot.json,并按照里面的内容从服务端下载各种dll与dotnet.wasm文件
    2. 然后dotnet.wasm运行:即浏览器上的dotnet runtime开始运行
    3. 再然后我们写的Client.dll开始执行,就像其它dotnet程序运行在dotnet runtime上一样

回头再看这个流程,其实Blazor框架应当不需要做什么额外工作,第一层缓存机制就可以生效并节省大量网络带宽。只是,按我们的猜测,在以blazor.webassembly.js起头的一系列魔法中,Blazor框架肯定写了额外的逻辑:

  1. 将所有需要用到的dll文件都存储在第二层缓存机制中去,具体来说,就是cache storage
  2. 然后blazor.webassembly.js中必定有逻辑去检查cache storage中是否已经存在了需要用到的文件,如果有的话,就不需要发送HTTP请求了

通过这样的进一步优化,在后续访问时,浏览器甚至都不需要向服务端发送HTTP请求了。总结一下,这部分的缓存机制是如下运行的:

  1. 在编译发布时,blazor.boot.json文件中就写死了首次加载时所需要的所有内容
  2. 在用户的浏览器中,每次访问页面都其实是在运行blazor.webassembly.js,这个JS文件会根据blazor.boot.json的内容,以及cache storage中的内容,来决定是否需要动态从服务端加载dll
  3. 而即便用户手动将cache storage中的内容都清空,也仅仅是让blazor.webassembly.js为所有的dll都创建HTTP请求而已,而浏览器的第一层缓存机制会保证传输在网线上的不过仅仅是304 http response而已

看起来,第一层缓存机制只起个兜底作用。

而我们动态加载H.Components.dll则不同,这个按需加载的触发,并不是由blazor.webassembly.js触发的,而是:

  1. blazor.boot.json中的文件都已经加载了,运行了,跑起来之后,要渲染Router组件的时候,程序才发现:“靠,我得加载一个叫H.Components.dll的文件”
  2. 按道理来讲,加载这个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而已,确实是框架的实现偷懒了。

那么知道这个知识点有什么用处呢?我遗憾的告诉你,其实没有什么用处,你也没必要纠结这个细节。

2.3 将页面组件写在动态按需加载的类库中去

上面讲的动态加载,理解了原理后,你大概能举一反三出来:动态加载其实并不是一个只适用于组件类库的特性,其实它也适用于普通类库,就像Shared项目,也可以把它做成动态加载式的。

那么,我们发散一下思维:我们能不能把页面组件放在一个独立的类库中,然后让这个dll按需加载呢?

我提前剧透一下答案:可以,但不是以上面的方式去实现的。

这就牵涉到页面组件的特殊之处了。在编译器的角度来看,其实无论是普通的C#类,还是页面组件,还是布局组件,还是普通的组件,本质上都是一个个的类。组件虽然是用razor模板语言书写的,但经过razor引擎转译后,其实也是C#代码。

但在框架角度来看,页面组件是一种特殊的类,它特殊就特殊在:Router组件在初始化过程中,会扫描所有页面组件,以建立路由表。

这就是如下这行代码的功效:

<Router AppAssembly="@typeof(App).Assembly">

如果我们把页面渲染的过程表示成以下几个步骤:

  1. Router初始化,扫描所有页面组件,建立路由表。
  2. Router分析用户请求路径,并查找对应的页面组件。
  3. 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这个请求路径对应的页面组件在哪,也就无法渲染,如下所示:

page_not_found

但实际上H.Pages.dll已经被下载到浏览器缓存中去了,原因也很简单:因为我们的Client项目直接引用了Pages项目,而目前我们对H.Pages.dll没有做任何特殊处理,没有向框架做任何“这是一个需要按需加载的dll”的暗示,所以它会随同dotnet系统类库与Blazor框架类库一起在初次访问时,被下载到客户端浏览器中去。

page_not_found_but_dll_loaded

第二步:让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页面了,如下:

special_page

但现在我们只是正确配置了追加路由项目,还没有达成“按需加载”的目的。

第三步:配置按需加载

原理和我们之前配置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);
    }
    // ...
}

2.4 小总结

动态按需加载是个好特性,但我不建议你滥用它,因为大多数情况下,动态按需加载其实是没必要的。我这系列文章的目标受众其实是,需要发挥Blazor框架开发效率高的优势的全栈程序员。而这样的程序员所写的项目规模,本身就不可能太大。也就是说,在中小规模项目里,这个特性是一个你应该用不上的屠龙技。

什么时候才需要考虑应用动态按需加载呢?我认为至少要满足以下两个条件之一:

  1. 你的项目规模和复杂度已经大到一个非常恐怖的级别,导致如果不使用动态按需加载的话,首屏加载速度已经影响到用户体验了
  2. 你的项目虽然并不复杂,但部署上线后的访问量非常恐怖,而动态按需加载能为你节约一笔非常可观的服务器带宽费用

除此之外,我不建议你使用动态按需加载。

虽然这个特性略显鸡肋,但毕竟是一个有关路由组件Router的重要知识点,所以,讲还是要讲的