本文中的例子、分析均基于.NET Core 7.0 版本,版本不同细节会略有差异
在介绍Blazor之前,需要回顾一下历史,这部分内容在论文里大致相当于“国内外研究现状”这个章节。
上古时期,互联网上出现了HTML格式标准与浏览器,以及HTTP协议,那里甚至没CSS什么事,那时候整个世界还很简单,工作模式也很简单,如下图所示:
那时的服务端其实也很单纯:简单的解析一下HTTP请求,然后几乎就是按服务器文件系统里的目录来安排URL,就是所谓的“静态站点”,也就是这个博客背后的样子。然后慢慢的人民群众对视觉的要求越来越高,就出现了CSS。但CSS的出现对整体工作模式没有什么大的影响。
随着CSS流行起来,大家发现纯静态站点还是不太好用,对于像小型站点来说,网站管理员通过管理服务器文件系统的方式更新网站内容,没有什么大的问题。但对于稍微上点规模的信息展示、门户类网站来说,通过管理文件系统来管理网站内容就变得相当痛苦,一个天然的需求就出现了:要将网站的内容装在数据库中。
根据数据库中的数据来动态的生成HTML文档内容,这就是所谓的“渲染”。这个时期的大流行持续了相当长的时间,甚至可以说直到今天此类网站依然有相当大的保有量。以开发者视角来看,这个时期有很多新技术新框架新思想出现,我认为最主要的变革在于以下两点:
从工作模式上你大致也能看出来,这个时期的程序员几乎个个都是全栈,他们既要懂如何处理后端数据,也要懂如何书写HTML文档,如何写CSS样式表,甚至于如何剪裁出一张好看的图片。
当然随着时间的发展,随着对工作效率的追求,分工是必然的结果,分工催生出了一个新的岗位:设计师与切图仔。设计师是艺术类工程,完全不参与程序设计,负责从无到有的设计出一个网站应该长什么样子,早期的设计稿甚至就是一张张的PS图片。切图仔是最初的前端工程师,在早期,他们的主要工作内容就是把网页设计稿转换成静态图片,然后通过图片来简单粗暴的实现设计师的效果:大量的视觉效果通过<img>
元素与CSS中的background-image
去实现。
你可能要问,JavaScript在哪里?马上他就来了:2005年左右的时候,google发布了一系列的网页应用,其中最重磅的要属GMail:整个网页在交互的过程中,完全不存在“刷新加载”这个事。也就是说,google通过JavaScript给整个业界打了个大样:看好,JS是这样用的,不是修修改改DOM的工具脚本。你几乎可以认为GMail就是现代SPA应用的祖师爷。
在随后的十年间,业界一直在追赶GMail这个标杆,这个时期涌现出了很多夹带JS的UI样式库,如果非要给这个时代打一个标签的话,我相信这个标签应该是jQuery。在这十年间,服务端渲染依然是市场中的王者,而赶时髦的一些中型项目,已经演变成了下面这样:
上图的工作模式描述的就是SPA单页应用的运行逻辑,也就是所谓“客户端渲染”:服务端完全扔开HTML与CSS,只提供数据,如何展示这些数据,完全由前端JavaScript说了算,JS代码来将数据包裹成一个个的DOM元素,再传递给浏览器。
这套逻辑一直流行到今天。2015年后,随着现代前端三大框架的陆续发布,前端才算是进化成了真正的前端。虽然此后许多年,技术不断发展演示,标准不断推陈出新,框架版本号一路飞奔,但上图这种工作模式至今未改变。它的核心宗旨就是一句话:设计和视觉来吸引眼球,前端来玩浏览器,后端专注于数据与业务逻辑。
如今,前端领域内的三大框架,其工作模式也基本如下图所示:
这张图比较朦胧,没有标1/2/3/4来写出工作流程,但它简明扼要的说明了现代的三大框架处理客户端渲染的思路:虚拟DOM树。vdom是前端框架在浏览器内存中存储的一颗“树”,每次对要渲染的“数据”的更改都会导致树生成一个新的版本,同时有一套自动检测机制来检测树是否发生了变动,如果发生了变动,那么就告诉浏览器:诶,页面需要重绘。
那么Blazor又是怎么工作的呢?简单来说:和三大框架没什么本质差别,只是有一些微小的创新。
下面这张扒自微软官方的图展示了Blazor Server的工作模式:
下面这张则展示 了Blazor WASM的工作模式:
Blazor的优缺点都相当明显,最大的优点有两个:
而缺点也相当明显:
这几个天花板都非常明显,技术选型的时候要慎重考虑,如果确信项目完全触碰不到Blazor的天花板,那么用Blazor是非常合适的。
另外,由于Blazor砍掉了JS,导致这个框架非常适合后端程序员做一些小项目,或者全栈程序员做一些小项目。
新目录,先新建项目文件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
起来,效果如下:
首先我们看首屏加载时的网络请求,如下:
首先先剧透一点:由于运行在本地环境下,不是Release Builder和Production环境,所以请求里会有两个奇怪的请求:
aspnetcore-browser-refresh.js
的加载忽略掉这两个仅在本地调试过程中出现的贵物,我们来一个个的看网络请求。
第一个请求是由我们浏览器发出的,对http://localhost
的请求,服务端给出了200回应,回应的HTML文档如下:
这个HTML文档中包含了对_framework/blazor.server.js
的引用,所以浏览器在收到第一个请求的回应后,转而就去请求这个JS文件,也就是请求列表中的第二个请求blazor.server.js
blazor.server.js
是一个内容很长的JS文件,在浏览器下载到本地后,开始执行,脚本的执行触发了列表中最后三个请求:
_blazor/initializers
,GET请求,响应结果是application/json; charset=utf-8
,内容是一个空的JS数组[]
。不知道在干什么_blazor/negotiate?negotiateVersion=1
,POST请求,响应结果是application/json; charset=utf-8
,内容描述了服务端支持的SignalR底层传输方式。这显然是浏览器与服务端在协商SignalR的通信协议。如下所示:ws://localhost:5000/_blazor?id=qD6xxcloR7GF5KwDQJdF3w
。这是在建立一个websocket连接。显然上一步的协商结果就是使用websocket来进行通信如果你仔细看websocket里传输的内容,会发现内容发两类:
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消息,如下:
每次点击按钮,在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 |
可以看出,在发生事件,且事件有回调函数的时候,浏览器是把事件的详情发送给服务端,由服务端进行计算的
首先是程序入口点,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的配置里,配置了三个东西:
UseStaticFiles()
,这个用来使Kestrel支持静态文件托管。虽然我们并没有新建wwwroot
目录,也没有手动添加任何静态资源,但框架会向wwwroot
下放一些JS文件,典型的就是我们之前看到的_framework/blazor.server.js
MapBlazorHub()
,这个是与SignalR相关的一个服务,使服务端支持SignalR,不然浏览器与服务端无法进行正常的SignalR通信,即上面提到的websocket连接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的语法不熟悉,看到这里有点懵逼,你应当回去看一下上一篇文章。
@page "/"
指明了当前Razor Component对应着一个URL请求路径,也就是根路径@using Microsoft.AspNetCore.Components.Web
引用了一个命名空间<h1>
标签,没什么可说的<input>
控件,这里比较特殊的是使用了directive attribute@onclick
来指定它的点击回调函数,这个特殊的directive attribute的值则是用explicit expression指向名为OnButtonClick()
的成员方法<br>
标签,没什么可说的<p>
标签,但里面的内容使用了explicit expression来将类中的this.counter
字段进行求值,并将结果渲染在页面上@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差异,如果发现需要浏览器重绘,再把有关重绘的信息发送回浏览器。
Blazor Server是一个很奇葩的框架,很有性格,但用脚趾头也知道它不适合高并发的网站应用去使用。另外在部署时,它也依赖.NET Core内置的Kestrel Web Server。
但它有一个非常鲜明的特点,不好说是优点还是缺点:一旦客户端断网,SignalR断开连接,整个网页应用就彻底停摆,因为它所有的计算负载都在服务端。
感觉非常适合做一些安全管控非常严格的专用应用。
我们上面已经讲了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
从项目结构上看,改动主要包括两部分:
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>
变动有两点:
<Project Sdk
从M.NET.Sdk.Web
变更成了M.NET.Sdk.BlazorWebAssembly
M.A.Components.WebAssembly
是Blazor WASM开发必须的基础类库包,M.A.Components.WebAssembly.DevServer
则是仅在开发环境下需要用到的一个工具
*.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。赋予这个TagHelperherf
attribute的值是会被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.Index
,App.razor
转译后的类的全名是HelloBlazorServer.App
。同样的代码,放在现在Blazor WASM环境下去转译编译,两个类的全名就会变成HelloBlazorWASM.Pages.Index
和HelloBlazorWASM.App
命令行使用dotnet run
把项目跑起来,效果如下:
和Blazor Server基本没什么区别,唯一能肉眼感知的区别就是刷新页面会有一个短暂的Loading
字样。
先去浏览器调试窗口,把“Application”选项卡下,“Cache Storage”里的东西清空:
然后转到Network选项卡,刷新页面,会拿到一张特别长的网络请求列表:
这个看着就非常吓人了,从上图可以看到,即便是在本地调试时,这些网络请求全部完成也花了四百毫秒以上。
这个巨长的网络请求列表在第二次刷新页面时就会简化成下图这样:
我们先来分析首次加载时的请求列表,这个长列表虽然看起来吓人,但整体还是比较清晰的。
第一步,显然是浏览器向http://localhost:5000
发送GET请求,服务端,也就是DevServer,将我们的index.html
直接返回。这就是web server在老实的返回静态资源而已,没什么特殊的。
第二步,我们的index.html
中引用了/_framework/blazor.webassembly.js
,然后浏览器就去下载这个脚本,依然是静态资源访问。
第三步,浏览器在拿到blazor.webassembly.js
后,这个脚本开始执行,执行的第一步,就是fetch了一个JSON文件:blazor.boot.json
这个JSON文件里写着下一步应当下载的文件列表,要下载的文件主要分为以下几类:
字段 | 文件 |
---|---|
resources.pdb |
HelloBlazorWASM的调试符号表 |
resources.runtime |
里面包含了一个核心文件,就是dotnet.wasm ,可以将它理解为“用webassembly实现的dotnet runtime” |
resources.assembly |
上图没有展开的一个列表,里面内容非常多,是一个完整的dotnet基础类库 |
第四步,blazor.webassembly.js
在知道了要下载哪些文件后,就开始一个个的下载这些文件,构成了网络请求列表中最长的那一坨。
然后就没有然后了。
我们再来分析二次刷新页面时的网络请求列表:
二次刷新页面时,依然是先访问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
这个选项,禁用了浏览器的缓存。而如果不禁用缓存的话,页面的请求会长下面这样:
OK,现在问题来了:浏览器拿到的是index.html
,但浏览器上展示的内容是从哪来的?
答案:在浏览器拿到了blazor.wasm
以及其它一些必要文件,还拿到一坨dotnet系统类库dll后,一个完整的dotnet runtime就可以跑起来了。然后在这个dotnet runtime上,我们编写编译的HelloBlazorWASM.dll
开始运行,在这个dll内部,程序逻辑开始运行,替换掉了index.html
中的<div id="app">
,更改在浏览器DOM,将页面渲染成我们想要的样子。
我们再来看页面按钮点击的效果:
可以看到,完全和服务端没有任何交互,没有任何网络请求。这是因为事件回调函数被编译在HelloBlazorWASM.dll
中,而这个dotnet assembly现在是完全跑在浏览器里的!
虽然Blazor WASM从运行逻辑上来看,和React之类的前端SPA框架十分相似,但那个长长长长长到爆的首屏加载请求列表还是太吓人了。
不过呢,实际情况并没有你想的那么吓人,因为:
现在我们使用命令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;
}
...
}
...
此时我们在浏览器中访问本机,我们的首屏加载网络请求列表就会优化成下面这个样子:
虽然还是稍微有点长,但比起Debug模式下DevServer里的表现来说,已经好太多了。本地加载时间也优化到了六十多毫秒,虽然本地加载时间没什么大的参考意义,也不是非常但从优化前的四百多毫秒,到nginx里的六十多毫秒,至少能说明,优化裁剪能使首屏加载的速度提升一个数量级。
客观的说,这个加载速度,比起React等纯前端框架来说,肯定还是有差距,但这个差距怎么说呢,可以接受。
做这个实验主要是说明两件事:
首先是程序入口点,非常简单易懂,核心代码就只有一句话:
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时没有任何差别。
完了,就这些,没有了,就这么简单。
看完了上面的长篇大论,我们再回头看一眼Blazor Server和Blazor WASM的工作模式示意图,相信你会有深刻的理解:
Blazor Server | |
Blazor WASM |
怎么说呢,各有各的特点,整体上,Blazor无论哪种工作模式,特点都非常鲜明,很有性格,在React/Angular/Vue三大框架之外给了我们另外一种选择。整体上对中小型项目与全栈开发者非常友好。
最近.NET 8.0开始preview了,在.NET 8.0中,Blazor有了一个新的混合模式:首屏时使用Blazor Server提升用户体验,同时后台默默为WASM模式做准备,然后在适时的时候切换过去。
怎么说呢,idea非常好,希望微软在将来不要砍掉这个项目。真的非常非常非常有想象力。