Blazor教程 第二课:Hello Blazor,用C#写前端

Blazor

本文中的例子、分析均基于.NET Core 7.0 版本,版本不同细节会略有差异

1. 网页应用的几种工作模式

在介绍Blazor之前,需要回顾一下历史,这部分内容在论文里大致相当于“国内外研究现状”这个章节。

上古时期,互联网上出现了HTML格式标准与浏览器,以及HTTP协议,那里甚至没CSS什么事,那时候整个世界还很简单,工作模式也很简单,如下图所示:

web_history_1

那时的服务端其实也很单纯:简单的解析一下HTTP请求,然后几乎就是按服务器文件系统里的目录来安排URL,就是所谓的“静态站点”,也就是这个博客背后的样子。然后慢慢的人民群众对视觉的要求越来越高,就出现了CSS。但CSS的出现对整体工作模式没有什么大的影响。

随着CSS流行起来,大家发现纯静态站点还是不太好用,对于像小型站点来说,网站管理员通过管理服务器文件系统的方式更新网站内容,没有什么大的问题。但对于稍微上点规模的信息展示、门户类网站来说,通过管理文件系统来管理网站内容就变得相当痛苦,一个天然的需求就出现了:要将网站的内容装在数据库中。

web_history_2

根据数据库中的数据来动态的生成HTML文档内容,这就是所谓的“渲染”。这个时期的大流行持续了相当长的时间,甚至可以说直到今天此类网站依然有相当大的保有量。以开发者视角来看,这个时期有很多新技术新框架新思想出现,我认为最主要的变革在于以下两点:

  1. Web服务端程序的功能开始分离:“处理网络连接、HTTP请求的封装与解析”,和“查询数据库,根据用户请求执行业务逻辑”这两部分开始分离,前者现在我们一般把它叫Web Server,典型的就是Nginx,后者我们把它叫Web Application
  2. 普通程序员的工作主要集中在Web Application领域的开发上,诸如像JSP、ASP .NET Core、PHP之类的开发工具和框架基本都是在解决一个核心问题:如何方便快捷的在内存中生成HTML文档。

从工作模式上你大致也能看出来,这个时期的程序员几乎个个都是全栈,他们既要懂如何处理后端数据,也要懂如何书写HTML文档,如何写CSS样式表,甚至于如何剪裁出一张好看的图片。

当然随着时间的发展,随着对工作效率的追求,分工是必然的结果,分工催生出了一个新的岗位:设计师与切图仔。设计师是艺术类工程,完全不参与程序设计,负责从无到有的设计出一个网站应该长什么样子,早期的设计稿甚至就是一张张的PS图片。切图仔是最初的前端工程师,在早期,他们的主要工作内容就是把网页设计稿转换成静态图片,然后通过图片来简单粗暴的实现设计师的效果:大量的视觉效果通过<img>元素与CSS中的background-image去实现。

你可能要问,JavaScript在哪里?马上他就来了:2005年左右的时候,google发布了一系列的网页应用,其中最重磅的要属GMail:整个网页在交互的过程中,完全不存在“刷新加载”这个事。也就是说,google通过JavaScript给整个业界打了个大样:看好,JS是这样用的,不是修修改改DOM的工具脚本。你几乎可以认为GMail就是现代SPA应用的祖师爷。

在随后的十年间,业界一直在追赶GMail这个标杆,这个时期涌现出了很多夹带JS的UI样式库,如果非要给这个时代打一个标签的话,我相信这个标签应该是jQuery。在这十年间,服务端渲染依然是市场中的王者,而赶时髦的一些中型项目,已经演变成了下面这样:

web_history_3

上图的工作模式描述的就是SPA单页应用的运行逻辑,也就是所谓“客户端渲染”:服务端完全扔开HTML与CSS,只提供数据,如何展示这些数据,完全由前端JavaScript说了算,JS代码来将数据包裹成一个个的DOM元素,再传递给浏览器。

这套逻辑一直流行到今天。2015年后,随着现代前端三大框架的陆续发布,前端才算是进化成了真正的前端。虽然此后许多年,技术不断发展演示,标准不断推陈出新,框架版本号一路飞奔,但上图这种工作模式至今未改变。它的核心宗旨就是一句话:设计和视觉来吸引眼球,前端来玩浏览器,后端专注于数据与业务逻辑。

如今,前端领域内的三大框架,其工作模式也基本如下图所示:

vdom

这张图比较朦胧,没有标1/2/3/4来写出工作流程,但它简明扼要的说明了现代的三大框架处理客户端渲染的思路:虚拟DOM树。vdom是前端框架在浏览器内存中存储的一颗“树”,每次对要渲染的“数据”的更改都会导致树生成一个新的版本,同时有一套自动检测机制来检测树是否发生了变动,如果发生了变动,那么就告诉浏览器:诶,页面需要重绘。

那么Blazor又是怎么工作的呢?简单来说:和三大框架没什么本质差别,只是有一些微小的创新。

  • 三大框架均是在用户的浏览器中构建vdom,而Blazor不同:Blazor给了开发者两个选择,vdom既可以构建在服务端(Blazor Server),也可以构建在浏览器上(Blazor WASM)。
    • 当vdom构建在服务端时,vdom以及diff机制都运行在服务端,而“嗨,浏览器,麻烦你重绘一下”这个指令,是通过微软的一个通信库SignalR实现的:它在网络状态好的时候使用WebSocket来把消息从服务端推送至浏览器,在网络状态不好的时候使用Ajax长轮询来实现。
    • 当vdom构建在浏览器上时,Blazor没有使用JavaScript及其运行时,编程语言选择了.NET平台,或者狭隘点说,就是C#,而实际工作时,有两种选择:
      • 微软在WebAssembly运行时上实现了一套.NET Core运行时:vdom及“前端”代码全跑在.NET Core Runtime上,而这个.NET Core Runtime则跑在WebAssembly Runtime上
      • 开发者可以选择将运行期代码全部编译到WebAssembly,即AOT编译方案,直接跑在浏览器的WebAssembly Runtime上

下面这张扒自微软官方的图展示了Blazor Server的工作模式:

blazor-server

下面这张则展示 了Blazor WASM的工作模式:

blazor-webassembly

Blazor的优缺点都相当明显,最大的优点有两个:

  1. 统一了前后端的开发语言,前后端之间能以类库方式来共同引用DTO数据定义。并且用的是世界上最好的Java版本:C#
  2. 在总体渲染指导思想向现代前代框架靠拢的同时,用一种力大砖飞的方式解决了SEO的问题:Blazor Server

而缺点也相当明显:

  1. Blazor Server的服务端负载是非常高的,因为对于每一个活跃用户,服务端都要维护它的vdom。所以blazor server不适合做高访问量的面向消费者的产品
  2. Blazor WASM,不选择AOT的话,用户初次访问需要从服务端下载整个.NET Core Runtime,选择AOT的话,这个初次访问的问题只是缓解了,远没有达到JS前端框架的水准
  3. WebAssembly目前,甚至在可预见的将来,都是不能直接在浏览器里操纵dom元素的,它是通过指挥JS间接实现的,这一点就导致实际的渲染性能,Blazor肯定是没有现代前端三大框架高的

这几个天花板都非常明显,技术选型的时候要慎重考虑,如果确信项目完全触碰不到Blazor的天花板,那么用Blazor是非常合适的。

另外,由于Blazor砍掉了JS,导致这个框架非常适合后端程序员做一些小项目,或者全栈程序员做一些小项目。

2. Blazor Server "Hello World"

2.1 New & Build & Run

新目录,先新建项目文件HelloBlazor.csproj,内容如下:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

和普通的web项目没什么不同,再新建一个Program.cs,内容如下:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

namespace HelloBlazorServer;

public static class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddRazorPages();
        builder.Services.AddServerSideBlazor();

        var app = builder.Build();

        app.UseStaticFiles();
        app.MapBlazorHub();
        app.MapFallbackToPage("/_Host");

        await app.RunAsync();
    }
}

新建一个App.razor,如下:

@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <p role="alert">Sorry, there's nothing at this address.</p>
    </NotFound>
</Router>

新建一个子目录叫Pages,在其下新建Pages/_Host.chtml,如下:

@page "/"

@using Microsoft.AspNetCore.Components.Web
@using HelloBlazorServer
@namespace HelloBlaozorServer.Pages

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <base href="~/" />
</head>
<body>
    <component type="typeof(App)" render-mode="Server" />
    <script src="_framework/blazor.server.js"></script>
</body>
</html>

再新建Pages/Index.razor,如下:

@page "/"

@using Microsoft.AspNetCore.Components.Web

<h1>Hello Blazor Server!</h1>

<input type="button" @onclick=@(this.OnButtonClick) value="Click Me">

<br>

<p>Counter == @(this.counter)</p>

@code {
    int counter = 10;

    void OnButtonClick()
    {
        this.counter++;
    }
}

整体目录结构如下所示:

HelloBlazorServer
   |
   `----Pages
   |     |
   |     `--> _Host.cshtml
   |     `--> Index.razor
   |
   `--> HelloBlazorServer.csproj
   `--> Program.cs
   `--> App.razor

在命令行dotnet run起来,效果如下:

HelloBlazorServer.gif

2.2 运行观察

首先我们看首屏加载时的网络请求,如下:

blazor-server-network-requests

首先先剧透一点:由于运行在本地环境下,不是Release Builder和Production环境,所以请求里会有两个奇怪的请求:

  • 第一行一直在pending的websocket请求
  • 第四行对JS脚本aspnetcore-browser-refresh.js的加载

忽略掉这两个仅在本地调试过程中出现的贵物,我们来一个个的看网络请求。

第一个请求是由我们浏览器发出的,对http://localhost的请求,服务端给出了200回应,回应的HTML文档如下:

blazor-server-request-1

这个HTML文档中包含了对_framework/blazor.server.js的引用,所以浏览器在收到第一个请求的回应后,转而就去请求这个JS文件,也就是请求列表中的第二个请求blazor.server.js

blazor.server.js是一个内容很长的JS文件,在浏览器下载到本地后,开始执行,脚本的执行触发了列表中最后三个请求:

  1. _blazor/initializers,GET请求,响应结果是application/json; charset=utf-8,内容是一个空的JS数组[]。不知道在干什么
  2. _blazor/negotiate?negotiateVersion=1,POST请求,响应结果是application/json; charset=utf-8,内容描述了服务端支持的SignalR底层传输方式。这显然是浏览器与服务端在协商SignalR的通信协议。如下所示:

blazor-server-request-2

  1. ws://localhost:5000/_blazor?id=qD6xxcloR7GF5KwDQJdF3w。这是在建立一个websocket连接。显然上一步的协商结果就是使用websocket来进行通信

blazor-server-request-3

如果你仔细看websocket里传输的内容,会发现内容发两类:

  • 大小为3字节的心跳信息,由客户端发起,服务端回应
  • 从时间15:13:37.913开始,到13:13:37.920结束,是初次渲染的全过程。消息列表如下:
浏览器发送/接收 消息体内容(截取ASCII能解出来的部分可读信息)
发送 StartCircuit http://localhost:5000/ [{"type":"server","sequence":0,"descriptor":"xxxxxxxxxx"}]
接收 JS.BeginInvokeJS Blazor._internal.attachWebRendererInterop.[0,{"__dotNetObject":1},{},{}]
发送 EndInvokeJSFromDotNet [2,true,null]
接收 JS.AttachComponent 0
接收 JS.RenderBatch AppAssembly.Found.NotFound.RouteData.Layout.ChildContent!<h1>Hello Blazor Server!</h1> input.type.button.onclick.value.Click Me <br> p.Counter == 10
发送 OnRenderCompleted
接收 xxxxxxxxxxx
接收 JS.BeginInvokeJS Blazor._internal.navigationManager.enableNavigationInterception
发送 EndInvokeJSFromDotNet [3,true,null]

虽然我们现在并不太懂websocket里二进制的编码规则,但仅从能ASCII解码出来的信息,我们大致能猜到它的工作模式:服务端发信息,浏览器去执行,然后报告执行结果。

接下来我们来看一下页面上的按钮点击后会发生什么:每点一下按钮,浏览器都会向服务端发送一条websocket消息,如下:

blazor-server-click-btn

每次点击按钮,在websocket里,浏览器和服务端都会通信两次,然后页面上的Counter计数会+1。通信过程大致如下:

浏览器发送/接收 消息体内容(截取ASCII能解出来的部分可读信息)
发送 BeginInvokeDotNetFromJs 1 DispatchEventAsync Y[{"eventHandlerId":1,"eventName":"click","eventFieldInfo":{"componentId":4,"fieldValue":"Click Me"}},{"detail":1,"screenX":797,"screenY":418,"clientX":54,"clientY":88," ... "type":click}]
接收 JS.RenderBatch 11
发送 OnRenderCompleted
接收 JS.EndInvokeDotNet 1

可以看出,在发生事件,且事件有回调函数的时候,浏览器是把事件的详情发送给服务端,由服务端进行计算的

2.3 代码分析

首先是程序入口点,Program.cs,核心代码就下面五行:

         ...
        builder.Services.AddRazorPages();
        builder.Services.AddServerSideBlazor();
        ...
        app.UseStaticFiles();
        app.MapBlazorHub();
        app.MapFallbackToPage("/_Host");

AddRazorPages向DI池里注了一坨用来支持Razor Page的玩意,AddServerSideBlazor向DI池里注了一坨用来支持Blazor Razor Component的玩意。

你可能好奇我们不是在说Blazor吗?怎么还和Razor Page有关系?答案是确实有,但不大,只是一个引子,后面会说。

接下来Pipeline的配置里,配置了三个东西:

  1. UseStaticFiles(),这个用来使Kestrel支持静态文件托管。虽然我们并没有新建wwwroot目录,也没有手动添加任何静态资源,但框架会向wwwroot下放一些JS文件,典型的就是我们之前看到的_framework/blazor.server.js
  2. MapBlazorHub(),这个是与SignalR相关的一个服务,使服务端支持SignalR,不然浏览器与服务端无法进行正常的SignalR通信,即上面提到的websocket连接
  3. MapFallbackToPage(),当一个请求既不是静态文件路径,也不是SignalR连接请求的话,就会由这个pipeline进行兜底:将请求在服务端重定向至名为_Host.cshtml的Razor Page上去

那么很显然,当我们的浏览器向服务端发起请求,即http://localhost:5000时,请求路径是根目录:既不是静态文件,也不是SignalR请求,服务端就会降级到_Host.cshtml上去

再看_Host.cshtml里写了什么,核心代码就两行

    <component type="typeof(App)" render-mode="Server"/>
    <script src="_framework/blazor.server.js"></script>

第一行的Tag,<component>是一个Tag Helper,我们在上一篇文章中讲到过,“Tag Helper”是一个Razor Page专属的功能特性。我们依然不需要去纠结Tag Helper是什么,怎么使用,只需要知道,这里的component指的是:去渲染一个Blazor组件,一个Blazor component,或者叫一个Razor Component。

这个Tag Helper还有一个attribute,render-mode="Server",这涉及到一个稍微深入一点的知识点,我们这里先略过。

总之,_Host.cshtml其实只是一个引子,一个把Blazor引入进来的Razor Page,就像React框架里的index.html一样。总之,现在服务端知道了:哦,我应该去渲染一个叫App的类,这个类应该是一个Blazor Component,它在哪呢?

显然,就是App.razor,它的关键代码如下:

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" />
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <p role="alert">Sorry, there's nothing at this address.</p>
    </NotFound>
</Router>

里面出现了大量我们不认识的HTML Tag,<Router>/<Found>/<NotFound>/<PageTitle>/<RouteView>等,这些Tag都是Blazor框架自带的一些Razor Component,虽然现在我们并不清楚它们的用法与定义,但我们大致从名字上也能猜出来它们的功能是什么:如果用户的请求路径能找到对应的Razor Component,那么就渲染它,否则就渲染<NotFound>里包裹的内容。

现在用户的请求路径是根路径/,那么它对应的Razor Component在哪呢?在Pages/Index.razor,为什么这么说呢?因为这个Razor Component的脑门上有一个@page,如下:

@page "/"

@using Microsoft.AspNetCore.Components.Web

<h1>Hello Blazor Server!</h1>

<input type="button" @onclick=@(this.OnButtonClick) value="Click Me">

<br>

<p>Counter == @(this.counter)</p>

@code {
    int counter = 10;

    void OnButtonClick()
    {
        this.counter++;
    }
}

现在到了最有意思的核心部分:上面的代码里我们做了以下事情,如果你对Razor的语法不熟悉,看到这里有点懵逼,你应当回去看一下上一篇文章。

  1. @page "/"指明了当前Razor Component对应着一个URL请求路径,也就是根路径
  2. @using Microsoft.AspNetCore.Components.Web引用了一个命名空间
  3. 写了一个<h1>标签,没什么可说的
  4. 写了一个<input>控件,这里比较特殊的是使用了directive attribute@onclick来指定它的点击回调函数,这个特殊的directive attribute的值则是用explicit expression指向名为OnButtonClick()的成员方法
  5. 一个<br>标签,没什么可说的
  6. 一个<p>标签,但里面的内容使用了explicit expression来将类中的this.counter字段进行求值,并将结果渲染在页面上
  7. 一个@code{}代码块,为该类补充了一个字段,一个方法

这里有一个小坑需要注意:directive attribute虽然说起来是一个由Razor引擎提供的功能,特性,但是,在使用到directive attribute的场合,要保证当前类引用了Microsoft.AspNetCore.Components.Web命名空间,不然回调函数无法工作。

这背后的原因是,虽然在Razor代码中,我们的回调函数并没有使用到事件参数,但Razor引擎在转译的时候,会把@onclick=@(this.OnButtonClick)转译成下面的样子:

   __builder.AddAttribute(
      3,
      "onclick", 
      global::Microsoft.AspNetCore.Components.EventCallback.Factory.Create<global::Microsoft.AspNetCore.Components.Web.MouseEventArgs>(
         this, 
         this.OnButtonClick)
   );

而如果我们没有为类引用M.A.Components.Web命名空间,Razor引擎在转译的时候就不会认为@onclick是一个有效的directive attribute,就会转译成下面这样:

   __builder.AddAttribute(3, "@onclick", this.OnButtonClick);

实际上完全没起效

总之,Index.razor会被Razor引擎转译成一个类,然后在框架编译的时候Blazor Server有意思的地方就来了:这个类,被编译打包在服务端。它的渲染结果,会通过SignalR传输到浏览器,再由浏览器本地的blazor.server.js中的相关代码进行浏览器渲染,而浏览器上的任何事件响应,都又会通过SignalR回传到服务端,服务端再重新渲染,对比vdom差异,如果发现需要浏览器重绘,再把有关重绘的信息发送回浏览器。

2.4 小总结

Blazor Server是一个很奇葩的框架,很有性格,但用脚趾头也知道它不适合高并发的网站应用去使用。另外在部署时,它也依赖.NET Core内置的Kestrel Web Server。

但它有一个非常鲜明的特点,不好说是优点还是缺点:一旦客户端断网,SignalR断开连接,整个网页应用就彻底停摆,因为它所有的计算负载都在服务端。

感觉非常适合做一些安全管控非常严格的专用应用。

3. Blazor WASM "Hello World"

3.1 将Blazor Server项目改造成Blazor WASM项目

我们上面已经讲了Blazor Server项目是怎么创建的,这一节我们不从0开始创建Blazor WASM项目,而是把上面的Blazor Server项目改造成一个Blazor WASM项目。这样做有个好处:就是向大家展示Blazor的两种模式的差异与共性。

我们先把Blazor Server的项目结构再重温一遍,改造前后的对比如下:

- HelloBlazorServer
+ HelloBlazorWASM
+    |
+    `----wwwroot
+    |     |
+    |     `--> index.html
     |
     `----Pages
     |     |
-    |     `--> _Host.cshtml
     |     `--> Index.razor
     |
-    `--> HelloBlazorServer.csproj
+    `--> HelloBlazorWASM.csproj
     `--> Program.cs
     `--> App.razor

从项目结构上看,改动主要包括两部分:

  1. 重新对项目进行命名:包括项目目录与项目文件。
  2. 移除掉原先的Pages/_Host.cshtml,取而代之的是wwwroot/index.html

项目文件除了文件名的变动,内容也有稍许变动,如下:

- <Project Sdk="Microsoft.NET.Sdk.Web">
+ <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
    <PropertyGroup>
      <TargetFramework>net7.0</TargetFramework>
      <Nullable>enable</Nullable>
      <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    </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" />
+   </ItemGroup>

  </Project>

变动有两点:

  1. <Project SdkM.NET.Sdk.Web变更成了M.NET.Sdk.BlazorWebAssembly
  2. 新增了两个Nuget包的引用:M.A.Components.WebAssembly是Blazor WASM开发必须的基础类库包,M.A.Components.WebAssembly.DevServer则是仅在开发环境下需要用到的一个工具
    • 我们上面讲过了,Blazor WASM与现代前端的三大框架的工作模式是非常类似的,这意味着Blazor WASM项目的构建成果其实是个纯前端项目,需要被托管在某种Web Server下,比如Nginx下。这个*.DevServer包就是微软提供的一个Web Server,仅供开发人员调试使用,类比起来类似于Node工具链中的webpack dev server。

然后再看程序入口点,这个改动就非常大了,完全就是重写,重写后的样子如下:

using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace HelloBlazorWASM;

public static class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);

        builder.RootComponents.Add<App>("#app");

        var app = builder.Build();

        await app.RunAsync();
    }
}

Blazor Server本质上还是个ASP .NET Core应用程序,只不过我们使用Razor Page作为引子引出了Blazor Razor Component而已,所以它依然具有ASP .NET Core那老一套:DI池,pipeline等。

但在Blazor WASM这边,就完全跟ASP .NET Core没关系了,它使用的是WebAssemblyHostBuilder去创建Host Builder,这里面有DI池,但没有所谓的pipeline的概念。上面的代码主要只做了一件事:

  • 告诉框架,在index.html中,占位的HTML元素是哪个。代码中使用id选择器告诉了框架:“id为app的那个元素就是点位元素”

与此概念紧密相连的就是下面我们要讲的知识点:

既然Blazor WASM并是ASP .NET Core程序了,也既然不需要Pages/_Host.cshtml来引出Blazor Razor Component。它的工作模式其实和React之类的前端框架一样:浏览器先下载一个index.html,然后在这个HTML文档中仅有一个“占位元素”,这个“占位元素”后续会被框架代码替换成真正要被渲染的内容。这就是所谓的“使用wwwroot/index.html替换掉了Pages/_Host.cshtml”,index.html的内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <base href="/" />
    <title>HelloBlazorWASM</title>
</head>
<body>
    <div id="app">Loading...</div>

    <script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

这里有一个小知识点需要注意:这里的<base href="/" />和之前Blazor Server项目中,Pages/_Host.cshtml中的<base href="~/" />不是一个东西:

  • Pages/_Host.cshtml中的<base>元素,并不是原生的HTML标签,而是一个TagHelper。赋予这个TagHelperherfattribute的值是会被TagHelper内部逻辑处理的,我们在代码中书写的"~/"会最终被求值成"/",所以最终页面上渲染出来的内容其实是<base href="/" />
  • 在Blazor WASM项目中,wwwroot/index.html则是一个实打实的静态资源,它是真正的会直接被返回到客户端浏览器上的,所以这里的<base>元素是一个实打实的HTML标签,它的值也就必须写成"/"

再补充一点小知识:如果你对上面的小知识点觉得比较懵逼,不知道<base>元素到底有啥用的话,我这里简单介绍一下:

  • <base>元素指定的是,当前HTML文档中所有相对路径的起始点,它的值必须是一个绝对路径。比如我们下面写了<script src="_framework/blazor.webassembly.js"></script>,这个script标签里的路径是相对路径,那么浏览器在解析这个脚本的下载地址时,会先去找文档中是否存在<base>元素,如果存在,就用<base>中的路径,再和相对路径拼起来,组成完整的路径,即"/_framework/blazor.webassembly.js"
  • 在多数简单项目中,我们设定<base href="/" />就行,即路径起始点就是根目录。但在一些特殊的部署环境中,就不是这样了

至此,所有改造均完毕:原来的Pages/Index.razor不需要变动,App.razor也不需要变动。不过呢,在Index.razor中,我们需要改一句话,如下:

...
- <h1>Hello Blazor Server!</h1>
+ <h1>Hello Blazor WASM!</h1>
...

虽然文件内容不需要变动,但还是有一点需要注意:这两个类的命名空间其实发生了变化。

一般情况下,除了有特殊的理由,否则我们是不会给Razor Component手动指定@namespace的,那么Razor引擎就会按照代码文件的目录位置来为类生成命名空间

在上面的Blazor Server项目中,Pages/Index.razor转译后的类,其全名其实是HelloBlazorServer.Pages.IndexApp.razor转译后的类的全名是HelloBlazorServer.App。同样的代码,放在现在Blazor WASM环境下去转译编译,两个类的全名就会变成HelloBlazorWASM.Pages.IndexHelloBlazorWASM.App

命令行使用dotnet run把项目跑起来,效果如下:

HelloBlazorWASM

和Blazor Server基本没什么区别,唯一能肉眼感知的区别就是刷新页面会有一个短暂的Loading字样。

3.2 运行观察

先去浏览器调试窗口,把“Application”选项卡下,“Cache Storage”里的东西清空:

blazor-wasm-clear-cache-storage

然后转到Network选项卡,刷新页面,会拿到一张特别长的网络请求列表:

blazor-wasm-first-load-requests

这个看着就非常吓人了,从上图可以看到,即便是在本地调试时,这些网络请求全部完成也花了四百毫秒以上。

这个巨长的网络请求列表在第二次刷新页面时就会简化成下图这样:

blazor-wasm-second-load-requests

我们先来分析首次加载时的请求列表,这个长列表虽然看起来吓人,但整体还是比较清晰的。

第一步,显然是浏览器向http://localhost:5000发送GET请求,服务端,也就是DevServer,将我们的index.html直接返回。这就是web server在老实的返回静态资源而已,没什么特殊的。

第二步,我们的index.html中引用了/_framework/blazor.webassembly.js,然后浏览器就去下载这个脚本,依然是静态资源访问。

第三步,浏览器在拿到blazor.webassembly.js后,这个脚本开始执行,执行的第一步,就是fetch了一个JSON文件:blazor.boot.json

blazor-wasm-download-blazor-boot-json-1

blazor-wasm-download-blazor-boot-json-2

这个JSON文件里写着下一步应当下载的文件列表,要下载的文件主要分为以下几类:

字段 文件
resources.pdb HelloBlazorWASM的调试符号表
resources.runtime 里面包含了一个核心文件,就是dotnet.wasm,可以将它理解为“用webassembly实现的dotnet runtime”
resources.assembly 上图没有展开的一个列表,里面内容非常多,是一个完整的dotnet基础类库

第四步,blazor.webassembly.js在知道了要下载哪些文件后,就开始一个个的下载这些文件,构成了网络请求列表中最长的那一坨。

然后就没有然后了。

我们再来分析二次刷新页面时的网络请求列表:

blazor-wasm-second-load-requests

二次刷新页面时,依然是先访问index.html,再下载blazor.webassembly.js,再fetchblazor.boot.json,不过在拿到blazor.boot.json后,由于上一次已经把几乎所有文件都缓存在本地了,所以就不需要再次下载了。

但还是有一个文件dotnet.7.0.11.xdhx50mrap.js被重复下载了,为什么呢?这是因为这个文件是JavaScript脚本,其缓存机制是由浏览器控制的,而不是写在blazor.webassembly.js中。

而为什么我们的浏览器没有缓存dotnet.7.0.11.xdhx50mrap.js呢?很简单,因为我在调试器上勾选了Disable cache这个选项,禁用了浏览器的缓存。而如果不禁用缓存的话,页面的请求会长下面这样:

blazor-wasm-second-load-requests-enable-cache

OK,现在问题来了:浏览器拿到的是index.html,但浏览器上展示的内容是从哪来的?

答案:在浏览器拿到了blazor.wasm以及其它一些必要文件,还拿到一坨dotnet系统类库dll后,一个完整的dotnet runtime就可以跑起来了。然后在这个dotnet runtime上,我们编写编译的HelloBlazorWASM.dll开始运行,在这个dll内部,程序逻辑开始运行,替换掉了index.html中的<div id="app">,更改在浏览器DOM,将页面渲染成我们想要的样子。

我们再来看页面按钮点击的效果:

blazor-wasm-click-btn

可以看到,完全和服务端没有任何交互,没有任何网络请求。这是因为事件回调函数被编译在HelloBlazorWASM.dll中,而这个dotnet assembly现在是完全跑在浏览器里的!

3.3 将程序部署在Nginx上

虽然Blazor WASM从运行逻辑上来看,和React之类的前端SPA框架十分相似,但那个长长长长长到爆的首屏加载请求列表还是太吓人了。

不过呢,实际情况并没有你想的那么吓人,因为:

  1. 缓存机制起来后,后续二次请求就正常了
  2. 在Debug模式编译时,工具链会把完整的dotnet runtime打包起来,所以一些你完全没有用到的系统类库也会被打包在产出列表中,不过呢,当工具链运行在Release模式下时,会使用tree shaking来筛选出仅必须的dll列表,首屏加载时所需要下载的文件会大幅度减少

现在我们使用命令dotnet publish -c Release打包编译一个Release包,打包结果默认会放在项目目录的bin/Release/net7.0/publish目录下。分为一个wwwroot目录,一个web.config配置文件。

其中web.config配置文件是写给IIS之类微软出品的web server看的,我们就忽略掉它,wwwroot才是真正的内容主体。

将整个wwwroot目录放在nginx目录的html子目录中,然后修改nginx的配置文件,使网站根目录变成html/wwwroot,一个示例如下:

...
server {
    listen      80;
    server_name localhost;

    location / {
        root    html/wwwroot;
        index   index.html;
    }

    ...
}
...

此时我们在浏览器中访问本机,我们的首屏加载网络请求列表就会优化成下面这个样子:

blazor-wasm-nginx

虽然还是稍微有点长,但比起Debug模式下DevServer里的表现来说,已经好太多了。本地加载时间也优化到了六十多毫秒,虽然本地加载时间没什么大的参考意义,也不是非常但从优化前的四百多毫秒,到nginx里的六十多毫秒,至少能说明,优化裁剪能使首屏加载的速度提升一个数量级。

客观的说,这个加载速度,比起React等纯前端框架来说,肯定还是有差距,但这个差距怎么说呢,可以接受。

做这个实验主要是说明两件事:

  1. 生产环境的Release产出,是可以裁剪下载列表的
  2. 这是一个纯前端项目,是可以脱离开ASP .NET Core,被任何一个Web Server托管起来的,不光是Nginx,httpd也可以。甚至你要玩叛逆,你可以用golang写个web server,然后把内容托管在里面

3.4 代码分析

首先是程序入口点,非常简单易懂,核心代码就只有一句话:

        builder.RootComponents.Add<App>("#app");

它的意思类似于React里的createRoot(document.getElementById('root')).render(<App />)。即是在说:“找到index.html中那个ID为app的元素,然后用App的渲染结果取代它”。

App显然指的就是App.razor这个类,它内部的逻辑和我们说Blazor Server时一模一样,这里就不再次赘述了,简单提一下:App.razor里主要写了路由查找逻辑:找到了对应的component就渲染,找不到就渲染一行出错信息。

实际的页面内容Pages/Index.razor也与我们分析Blazor Server时没有任何差别。

完了,就这些,没有了,就这么简单。

4. 总结与一点题外话

看完了上面的长篇大论,我们再回头看一眼Blazor Server和Blazor WASM的工作模式示意图,相信你会有深刻的理解:

Blazor Server blazor-server
Blazor WASM blazor-webassembly

怎么说呢,各有各的特点,整体上,Blazor无论哪种工作模式,特点都非常鲜明,很有性格,在React/Angular/Vue三大框架之外给了我们另外一种选择。整体上对中小型项目与全栈开发者非常友好。

最近.NET 8.0开始preview了,在.NET 8.0中,Blazor有了一个新的混合模式:首屏时使用Blazor Server提升用户体验,同时后台默默为WASM模式做准备,然后在适时的时候切换过去。

怎么说呢,idea非常好,希望微软在将来不要砍掉这个项目。真的非常非常非常有想象力。