上文,我们通过剖析一个最简单的 Blazor WASM 项目,讲明白了 Razor 文件是什么,以及它被转译成 C#后长什么样子。也介绍了 Razor 中最简单的一个语法:Razor Expression
,也就是 Razor 表达式
本文将介绍两个内容:
- 首先我们将书接上文,再介绍一丁点 Razor 语法
- 然后我们创建一个对等的 Blazor Server 项目看看事情有哪些变化,
另外请注意:上一篇文章中有很严重的错误,没有及时看到订正与更正的同学麻烦回去看一眼。
目录
- 目录
- 1. 基础的 Razor 语法之二
- 1.1 简单的代码块 : 变量声明与赋值
- 1.2 恶心的代码块:面里加水水里加面无穷加下去
- 1.3 在代码块中书写函数
- 1.4 代码块若是循环和控制块,可以有简便写法
- 1.5 注释
- 关于注释的无用知识点
- 1.6 指令 Directives
- 1.6.1
@namespace
、@using
、@attribute
指令与@page
指令 - 1.6.2
@implements
与@inherits
指令 - 1.6.3
@code
指令与@functions
指令 - 1.6.4 其它指令
- 1.6.1
- 1.7 指令属性 Directive Attributes
- 1.8 小小的总结
- 2. 徒手搓一个 Blazor Server 项目
- 2.1 在创建项目之前需要进行的思考
- 2.2 创建项目文件与入口类
- 2.3 创建
pages/_Host.cshtml
- 2.4 创建 Blazor 组件
- 2.5 Blazor Server 真正的运行逻辑
- 2.5.1 第一次 HTTP 请求
- 2.5.2 第二次 HTTP 请求
- 2.5.3 第三、四次 HTTP 请求
- 2.5.4 websocket 连接
- 2.5.5
blazor.server.js
到底在哪里?
1. 基础的 Razor 语法之二
这里我们接着上一篇文章的第三小节,继续讲一些 Razor 的基本语法。讲解试验过程依然使用上一篇文章创建的 Blazor WASM 项目演示。
这里再提前做个免责声明:我们上一篇文章中提到过,Razor 作为一门标记语言,早在 Blazor 出现 之前就被 ASP .NET 使用,作为服务端渲染框架里用来描述 UI 的标记语言使用着。其历史地位与 JSP 类似。如今又被 Blazor 框架拿来作 UI 描述语言。
但实际上,Razor 中会有一些使用细节,有些功能仅在 Blazor 下可用,有些功能又仅在 ASP .NET 场景下可用,也就是说,随着文件后缀从*.cshtml
改成了*.razor
,背后很多东西都发生了变化,最明显的就是背后转译的 C#差异非常大:这很好理解,服务端渲染是要生成一个 HTML 文档,而 Blazor 下是要生成一个类似于 V-Dom 的数据结构。
但我的系列文章并不会去介绍这些差异,我只保证:我所介绍的 Razor 语法、用法,一定在 Blazor 框架下是可用的。毕竟这是一个介绍 Blazor 框架的系列文章。
1.1 简单的代码块 : 变量声明与赋值
上篇文章我们介绍了Razor Expression
,也就是表达式,那么再渐进一点:我们这次从表达式,升级到多行代码,也就是代码块上.
代码块的语法非常简单,就是把一行或多行代码放在一个@{ }
中去即可,比如我们把Index.razor
改成下面这样:
@page "/"
Hello, Razor!
@{
var quote = "Today a reader, tomorrow a leader";
}
@quote
@{
quote = "Always desire to learn something useful";
}
@quote
效果如下:
它背后的 C#类则变成了这样:
public class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
string quote = "Today a reader, tomorrow a leader"; // !!!!
__builder.OpenElement(1, "h2");
__builder.AddContent(2, quote);
__builder.CloseElement();
quote = "Always desire to learn something useful"; // !!!!
__builder.OpenElement(3, "h2");
__builder.AddContent(4, quote);
__builder.CloseElement();
}
}
这非常明显:我们通过两个代码块在Index.razor
中掺入的 C#代码,是被原封不动的插在了Index
类的BuildRenderTree
方法中去了。
1.2 恶心的代码块:面里加水水里加面无穷加下去
我在上一篇文章开篇的时候就喷过 Razor 是一门丑陋的语言,那么现在,就请允许我向你们介绍第一个屎点:在 Razor 的代码块中,可以直接写标记语言!比如我们把Index.razor
写成下面这样:
@page "/"
Hello, Razor!
@{
var quote = "Today a reader, tomorrow a leader";
Here is a famous quote
}
@quote
@{
quote = "Always desire to learn something useful";
Here is another famous quote
}
@quote
它的效果如下:
是不是很震惊?上面的代码中,我们先是在标记语言中通过@{ }
的方式向其中掺了 C#,然后在本应当全是 C#的地方,又掺了两句标记语言。。相当于水里加面再加水了属于是。
虽然看着很恶心,但这个语法规则非常简单:
- 标记语言内部要掺 C#,必须用
@{}
括起来 - C#内部要掺标记语言,直接写就行了,Parser 主要是靠检测 HTML 标签判断这是 C#代码还是 HTML 代码的
- 以上是可以嵌套的
在展示嵌套,也就是水里加面再加水再加面无穷尽也之前,我们先看一下上面的 Razor 代码被转译成了什么样子:
public class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
string quote = "Today a reader, tomorrow a leader"; // !!!
__builder.AddMarkupContent(1, "Here is a famous quote
"); // !!!
__builder.OpenElement(2, "h2");
__builder.AddContent(3, quote);
__builder.CloseElement();
quote = "Always desire to learn something useful"; // !!!
__builder.AddMarkupContent(4, "Here is another famous quote
"); // !!!
__builder.OpenElement(5, "h2");
__builder.AddContent(6, quote);
__builder.CloseElement();
}
}
也就是说,在代码块中掺入的标记语言,在转译时其实整行是被转译成了AddMarkupContent
现在!!请全体起立,观看下面的代码:
@page "/"
Hello, Razor!
@{
var quote = "Today a reader, tomorrow a leader";
Here is a famous quote:
@{
quote = "Always desire to learn something useful";
@quote
}
}
这就很让人无语,像 JSX 和 TSX 虽然大家也是掺着写,但能这样掺着写的,也就 Razor 独一家了。
它的效果如下:
上面的代码被转译成了下面这样:
public class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
string quote = "Today a reader, tomorrow a leader";
__builder.OpenElement(1, "h1");
__builder.AddMarkupContent(2, "\r\n Here is a famous quote: \r\n");
quote = "Always desire to learn something useful";
__builder.OpenElement(3, "p");
__builder.AddContent(4, quote);
__builder.CloseElement();
__builder.CloseElement();
}
}
我为什么之前在上一篇文章里说,要理解 Razor 的语法,就需要看一眼转译后的 C#代码,原因就在这里:通过对比 Razor 和 C#代码,我们能观察出一个非常有意思的现象:在 Razor 代码中,我们书写上写出了一种嵌套的效果,但实际上转译后的 C#代码中,并没有这种嵌套 的效果。
也就是说,@{xxx}
这种语法形式,虽然写出来有一对大括号,但这个大括号并不代表程序逻辑上有任何嵌套或者递归调用,这种语法形式仅仅起到了提醒 Parser 的作用
你仔细想这个道理,如果你是 Razor 语言 Parser 的作者,要挑出掺在 C#代码中的标记语言是非常简单的:
- 解析过程中碰到
,然后其中的xxx
正好是一个合法的 HTML 元素,后续看下去还存在一个对等的,那么肯定没跑了,这一段就是标记语言,直接转换成
AddMarkupContent()
就行了
但想要挑出掺在标记语言中的 C#代码,在没有特殊标记的前提下,是不可能的事情。而@{ }
这种语法,这一对括号,仅仅就是提供给 Parser 看的一个记号、一个标记,用来提示 Razor 引擎:这里写的是 C#代码。
再强调一点:@{ }
这一对括号,并不意味着代码逻辑上有任何嵌套或调用关系,它仅仅是一个标记而已。
另外再有一个小知识点:我们上面说了,从 C#代码里分辨掺进去的标记语言代码,不依靠任何外部标记,仅凭借标记语言自身的 XML Tag 就可以分辨。但,如果,你就真的想单纯的想让一行字符串以标记语言被 Parser 识别的话,怎么做?
这时候就必须引入一个外部标记了,Razor 提出的解决办法是:
- 以
@:
为标记,从这个标记开始直到这一行行尾,Parser 将会强行将这部分内容识别为标记语言 - 如果需要标识多行,则每一行都必须添加
@:
这个标记
作为一门 UI 描述语言,Razor 这样设计好不好,轮不到我评价,我也没这个资格。但是,对于 Razor 的学习者而言,如果理解不到上面几段话表达的观点,那么,他将很难理解、阅读这种互相掺来掺去的代码。。而很不幸的是,Razor 的学习者中,能去观察转译后 C#代码的人,非常少。
1.3 在代码块中书写函数
在介绍这个 Razor 特性之前,我先要介绍一下 C#中的一个语言特性,叫local function
,简单来说,C#允许你在方法或函数内部创建一个临时函数,一个简单的例子如下:
public class Program
{
public static void Main(string[] args)
{
void Print(string str)
{
Console.WriteLine(str);
}
Print("Foo");
Print("Bar");
}
}
这段代码会在控制台输出两行:
Foo
Bar
看到这里你可能觉得没什么神奇的,还不如写成var Print = (string str) => {Console.WriteLine(str);};
这样来得方便,但 local function 出彩的地方在于,它的声明和定义是会被自动提前的,所以下面的代码也是合法的:
public class Program
{
public static void Main(string[] args)
{
Print("Foo");
Print("Bar");
return;
void Print(string str)
{
Console.WriteLine(str);
}
}
}
你甚至可以把 local function 定义在return
语句之后。
但其实说穿了,截至现在,依然没什么特别神奇的地方,你可能会想:哈,这有什么卵用?纯纯的语法糖?就提前声明吗?那不还是 lambda 表达式嘛!
你的想法是正确的,普通的 local function 也可以捕获变量,确实就是 Lambda 表达式+提前声明。
但是,local function 可以添加 static 关键字,加了 static 关键字之后,local function 就不会捕获任何变量了,就变成了一个纯纯的函数,纯的像你大二学 C 语言时写的函数一样。
常用 Lambda 的人基本都遇到过,Lambda 函数体内因为不小心捕获了一个不该被捕获的变量,从而写出了一个非常难以排查的 Bug。这种情况下,你就应当使用 static local function
现在聊回 Razor 来:在 Razor 的@{}
代码块中,你也可以定义函数,有时候你定义的函数会被转译成普通的 local function,有时会被转译成 static local function。
当你写的函数就单纯的是一个函数时,它会被转译成 static local function。比如下例
@page "/"
Hello, Razor!
@{
string ConvertToUpperCase(string str)
{
return str.ToUpper();
}
var quote = "Today a reader, tomorrow a leader";
quote = ConvertToUpperCase(quote);
}
@quote
转译后长这样:
public class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
string quote = "Today a reader, tomorrow a leader";
quote = ConvertToUpperCase(quote);
__builder.OpenElement(1, "p");
__builder.AddContent(2, quote);
__builder.CloseElement();
static string ConvertToUpperCase(string str)
{
return str.ToUpper();
}
}
}
而如果你写的函数,内部掺了标记语言的话,就会被转译成一个普通的函数,因为这种情况下你写的函数需要捕获外部变量__builder
,比如下面这个例子:
@page "/"
Hello, Razor!
@{
void ConvertToUpperCase(string str)
{
@str.ToUpper()
}
var quote = "Today a reader, tomorrow a leader";
ConvertToUpperCase(quote);
quote = "Always desire to learn something useful";
ConvertToUpperCase(quote);
}
它被转译后变是一个普通的 local function,如下:
public class Index : ComponentBase
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
string quote = "Today a reader, tomorrow a leader";
ConvertToUpperCase(quote);
quote = "Always desire to learn something useful";
ConvertToUpperCase(quote);
void ConvertToUpperCase(string str)
{
__builder.OpenElement(1, "p");
__builder.AddContent(2, str);
__builder.CloseElement();
}
}
}
local function 和 Razor 结合在一起,可以允许我们在局部小范围内创建出一些 util 函数,来非常容易的描述诸如列表、表格这种视觉效果,这个知识点在正式开发生产中会非常频繁的被用到。
要真正理解掌握这个知识点,前提是要理解 local function,再就是要理解标记语言和 C#互相掺着写的真相。
1.4 代码块若是循环和控制块,可以有简便写法
我们上面讲了,代码块的起止标记是@{
与 }
,也理解了这一对括号其实仅起个标记作用。也就是说要给 Parser 一种能方便的分辨当前代码到底是标记语言还是 C#代码的办法。
明确以上这个关键知识点,我们现在来看一个for
循环块,假如我们把Index.razor
改写为如下:
@page "/"
Hello, Razor!
@{
void ConvertToUpperCase(string str)
{
@str.ToUpper()
}
string[] quotes = {
"Today a reader, tomorrow a leader",
"Always desire to learn something useful"
};
}
Here are some quotes:
@{
for(int i = 0; i < quotes.Length; ++i)
{
ConvertToUpperCase(quotes[i]);
}
}
具体什么效果就不用我多说了,代码非常简单易懂。我们主要来看上面代码中的for
循环块:它是一个由@{}
围起来的代码块,它内部仅包含一个for
循环。
对于这种内部仅包含一个循环、或逻辑判断代码的代码块而言,它们有简便写法,如下所示:
@page "/"
Hello, Razor!
@{
void ConvertToUpperCase(string str)
{
@str.ToUpper()
}
string[] quotes = {
"Today a reader, tomorrow a leader",
"Always desire to learn something useful"
};
}
Here are some quotes:
@for(int i = 0; i < quotes.Length; ++i)
{
ConvertToUpperCase(quotes[i]);
}
之所以这样行得通,是因为 Parser 可以通过@for
, @if
, @switch
, @foreach
这些带了标记的关键词,以及这些循环、或逻辑判断代码自身的大括号,就足以分辨出,这是一坨 C#代码了。
同理,下面都是合法的代码块
@page "/"
@foreach(...)
{
}
@for(...)
{
}
@if(...)
{
}
else if(...)
{
}
else
{
}
@switch()
{
case xx:
...
break;
case yy:
...
break;
default:
...
break;
}
实际上,除了循环、逻辑判断代码块可以有这种简便写法,还包括using 块, try-catch块,lock块等,满足一个关键字+一对大括号形式的 C#代码块,均可以使用上述简便写法
1.5 注释
标记语言有注释,格式是。C#也有注释,要么是
// comment
形式的单行注释,要么是/* comments */
形式的多行注释。
Razor 则是标记语言和 C#的结合体,它依然符合直觉:
- 在标记语言区域写
注释,没毛病
- 在 C#语言区域写 C#注释,也没毛病
这看起来没什么问题,但有一个挺蛋疼的问题:假如你在开发过程中,想通过注释的方式临时删除一大块代码区域的话。而又恰巧这一大块代码区域既包括标记语言,也包括 C#代码的话。
就不太好做。
所以 Razor 提供了第三种注释方式:使用@* comments *@
形式的多行注释。
虽然我们并不知道 Razor 引擎内部的实现细节,但我建议你以下面的逻辑来理解这三种注释:
- Razor 引擎在 Parser 工作之前,会先把
@* comments *@
形式的注释移除掉 - 在 Parser 工作中,当解析到标记语言时,再去移除标记语言注释。当解析到 C#代码时,再去移除 C#代码注释
上面的描述也可能是错的,上面的理解也其实没有任何实际意义。。上面也仅是我的个人建议。。
关于注释的无用知识点
- 在传统的 ASP .NET Core 应用中,Razor Page 经服务端渲染后会携带标记语言注释。也就是说标记语言注释并不会被 Razor 引擎移除
- 无论是服务端渲染的 Razor Page,还是 Blazor 应用中的 Razor Page,其实 C#代码注释也不会被 Razor 引擎移除 -- 记得我们之前说的,Razor Page 文件会被转译成一个 C#文件吗?如果你将.Net 版本降低到 5.0 及其以下,你会在
obj\Debug\net5.0\Razor
目录下看到转译后的 C#文件,里面完整的保留了所有 C#注释 - 以上两种行为,随着.NET 版本的变迁,都有可能发生变化。毕竟,无论是 HTML 文档中保留着标记语言注释,还是转译后的 C#代码文件携带注释,这些注释都最终不会出现在浏览器的视觉渲染效果里。
- 但可以非常确定的是,
@* comments *@
这种注释,是一定会被 Razor 引擎移除掉的,你绝对不会在除了源代码文件之外的任何地方再看到它
1.6 指令 Directives
前面我们介绍了代码块和表达式,它们的语法分别是@{...}
和@(...)
(括号在无歧义的情况下可省略)。现在我们要介绍另外一种特殊的东西,它被称为指令。指令通常用会改变 Razor 引擎对整个*.razor
文件的处理方式或细节
换句话说,*.razor
本质上是一个 C#类,指令则是对这整个 C#类的一些额外补充:这里要特别强调,它作用的受体,是整个 C#类,是整个*.razor
文件
指令的语法也比较简单,还是由猴头符号@
开头,然后跟着一个特定的关键字,比如attribute
或code
,不同的关键字代表不同的指令,再然后,按不同的指令,可能会附加一个由{}
括起来的多行代码块,或直接附加单行内容
1.6.1 @namespace
、@using
、@attribute
指令与@page
指令
这四个都是单行指令。
@namespace
指令很容易理解,就是给 C#类声明名称空间。默认情况下*.razor
转译后的类会被存放在项目的
下。
-
额外知识:
我们上节课说过了*.csproj
文件是.Net 项目的编译脚本文件,在这个文件中有个 XML 元素叫做
,这个元素下会定义形形色色的值来向编译器或后续的工具链传递一些信息。比如我们的示例程序中,就定义了一个
属性,其值net6.0
就是在告诉工具链:这是一个面向.NET 6.0 版本的程序,请在编译、链接时使用 6.0 版本的 SDK这些值被称为项目的
Properties
。除了我们显式写出来的
这个 Property,工具链还会自动的声明一些其它的 Property 并赋予它们一些默认值,最重要的值有两个:
与根据名字很容易理解:前者是编译出来的 dll 的名字,后者是该 dll 中的根 namespace 的值。
通常情况下,这两个值都默认为
*.csproj
文件的文件名,比如我们用到的HelloRazor.csproj
,这两个 Property 的值均为HelloRazor
,这意味着:- 编译出来的产物的名字叫
HelloRazor.dll
- 工具链自动生成的一些类,将放置于
HelloRazor
这个 namespace 下
注意:对于显式的,写在
*.cs
文件中的 C#代码直接定义的类,
并不起任何作用。 - 编译出来的产物的名字叫
@using
指令很容易理解,就是一个 C#中的using
语句,它的作用也和 C#中位于脑门上的using
语句一样:引用一个 namespace
@attribute
指令也很简单,它的作用和 C#类定义脑门上的[xxx]
是一样的:给整个类附加一个 Attribute。
@page
指令其实是一个特殊的@attribute
指令,它相当于在 C#类脑门上添加了一个[Route("...")]
,下面这个例子一次性的把三个指令都给大家展示了出来
看下面这个例子,我们把Index.razor
改写成下面这样:
@namespace HelloRazorAgain
@using Microsoft.AspNetCore.Authorization
@page "/"
@attribute [Authorize]
Hello, Razor!
它转译后的 C#类就会长这样:
注意:虽然项目名称是HelloRazor
,但我们通过@namespace
指令将Index
类放在了HelloRazorAgain
这个 namespace 下,注意看App
与Program
还处于HelloRazor
namespace 下,而其中:
Program
处于HelloRazor
下是因为 C#代码中显式的写了namespace HelloRazor;
App
处于HelloRazor
下是因为 Blazor 引擎默认使用
作为 namespace,即是HelloRazor
-
额外知识点:输出查看项目中的 Property 的值
给
*.csproj
中定义一个Target
,然后使用Message
这个 Task 来输出就可以了,如下:然后在控制台使用
dotnet build -t:Log
就可以执行上面定义的名为Log
的 Target。不过由于 dotnet 包裹的 msbuild 默认情况下并不显示普通日志信息,所以需要显式的将输出的 verbosity 指定为 normal 才可见,所以需要使用dotnet build -t:Log -v:normal
,你才会看到如下的输出 :如果你对上面这个额外知识点中描写的内容一头雾水,你有两个选择:忽略它,或者去学习一下有关
MSbuild
的相关知识
1.6.2 @implements
与@inherits
指令
这两个指令也是单行指令,一个用来表达继承接口,一个用来表达继承类,如下例所示:
@implements System.Runtime.Serialization.ISerializable
@implements System.IDisposable
@inherits Microsoft.AspNetCore.Components.ComponentBase
@page "/"
Hello, Razor!
@code {
public void GetObjectData(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context)
{
throw new NotImplementedException();
}
public void Dispose()
{
throw new NotImplementedException();
}
}
我们先忽略掉那个@ code { ... }
代码块,先看指令,上面的代码转译后会变成:
[Route("/")]
public class Index : ComponentBase, ISerializable, IDisposable
{
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor!
");
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
throw new NotImplementedException();
}
public void Dispose()
{
throw new NotImplementedException();
}
}
需要注意的是:
- 这两个指令都是单行指令,且每一行指令后面只能跟一个类,或接口名
- 在一个
*.razor
文件中,@inherits
指令至多只能出现一次,@implements
指令可以出现多次:这很容易理解,因为 C#里一个类只能继承自一个父类,但可以同时实现多个接口
但是这里面有个非常怪的问题:对于 Blazor 组件类来说,必须继承自Microsoft.AspNetCore.Componenets.ComponentBase
,这就意味着正常情况下,@inherits
指令是一句没用的指令,你只有两种选择:
- 不写
@inherits
指令,这样 Razor 引擎会自动让这个类继承于ComponentBase
类 - 写
@inhertis
指令,这样就只能写@inherits ComponentBase
1.6.3 @code
指令与@functions
指令
@code
与@functions
两个指令在 Blazor 语境下,是完全相同的一个东西,你可以理解为同一个指令,有两个不同的名字。当然这背后有一定的历史原因。
@code/functions
是多行指令,它后面跟一对大括号,大括号里写一些字段、属性和方法定义- 这些字段、属性和方法将变成 C#类的成员
这里一定要注意区分@code/functions
与代码块
和表达式
的区别,最大的区别是:前者是在向类添加成员,后者是在向类的BuildRenderTree
方法中添加内容
我们在上面其实已经展示 了@code
的用法与实际效果,这里就不再重复了
1.6.4 其它指令
以上就是开发中最常用到的一些指令,当然不包括全部,我们没有介绍到的指令还包括@preservewhitespace
, @layout
, @inject
等。其中@layout
还是一个非常重要的指令。但我们不在这里介绍这些指令,而是会在后续文章,介绍到相应的知识点时,再去做介绍。
1.7 指令属性 Directive Attributes
上一小节说过了,指令是对整个 C#类进行修饰、变更的一些手段,Razor 引擎会按照指令的指引去处理背后的 C#类。
而这一小节我们要介绍的,另外一个新的语法内容,叫指令属性: directive attributes,其中属性 attribute是本意,指的是标记语言中的attribute,就像标签的
href 属性
。指令 directive是名词性形容词。
也就是说,指令属性是一些要应用在 HTML 元素上的特殊属性: attribute,但这些属性在原生 HTML 规范中是不存在的。并且为了区别于自定义属性,这些特殊的指令属性也是以猴头符号@
做标记的。
指令属性是 Razor Page 在 Blazor 场景下独有的语法特性,今天在这里我们仅介绍一个指令属性:@on{EVENT}
,它几乎是所有指令属性中最重要的一个,而其它的指令属性,我们将在后续碰到的时候再去做介绍。
越说越乱,我们直接来看一个例子:我们先将Index.razor
改写成下面这样:
@page "/"
Hello, Razor! Below is a simple click counter
@code {
public int Count{get;set;} = 0;
}
Count: @Count
有了前面的知识,我们很容易能看懂上面的代码在干什么:
- 我们通过
@code{}
指令给背后的 C#类声明了一个int
类型的属性Count
,并置其初始值为 0 - 我们用标记语言写了一个按钮,但目前这个按钮没有任何交互功能
- 我们通过隐式表达式,将
Count
的值展示在了页面上。显然,这个值在目前恒定的会显示为 0
接下来,我们希望让用户每点击一次按钮,就让属性Count
自增 1,那么就需要做两件事:
- 我们写一个成员方法,每次该成员方法被调用,属性
Count
都会自增 1 - 最关键的是:我们要把这个成员方法,与按钮的点击事件关联起来:这里就是指令属性发光发热的地方
那么,我们把代码改写成下面这样,应该就可以了吗?
@page "/"
Hello, Razor! Below is a simple click counter
@code {
public int Count{get;set;} = 0;
private void IncreaseCount()
{
Count++;
}
}
Count: @Count
- 我们首先是创建了一个方法
- 其次,我们在
标签内,使用
@onclick
指令属性,将回调方法与按钮的点击事件绑定在一起
看起来没有任何问题,但编译、运行,你会发现:点击按钮后,计数依然是 0,没有任何变化
这是怎么回事?我们来翻看一下转译后的 C#代码,如下:
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
[Route("/")]
public class Index : ComponentBase
{
public int Count { get; set; } = 0;
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor! Below is a simple click counter
\r\n\r\n\r\n");
__builder.AddMarkupContent(1, "\r\n\r\n");
__builder.OpenElement(2, "h2");
__builder.AddContent(3, "Count: ");
__builder.AddContent(4, Count);
__builder.CloseElement();
}
private void IncreaseCount()
{
Count++;
}
}
看来,指令属性 @onclick
并没有被正确解析处理,而是直接以文本形式输出到了渲染结果中去,这是怎么回事呢?
原因在于:Blazor 引擎虽然知道@onclick
长的像一个指令属性,但它找不到这个指令属性的定义,于是降级将其视为普通的自定义属性去处理了。那么怎么样才能让 Blazor 引擎找到@onclick
的定义呢?
答案是:要使用@using
指令引入正确的 namespace,下面是正确答案
@using Microsoft.AspNetCore.Components.Web
@page "/"
Hello, Razor! Below is a simple click counter
@code {
public int Count{get;set;} = 0;
private void IncreaseCount()
{
Count++;
}
}
Count: @Count
通过引入Microsoft.AspNetCore.Components.Web
这个名称空间,Blazor 引擎才能找到@onclick
的真身,从而转译后的 C#代码将长下面这样:
using System;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
[Route("/")]
public class Index : ComponentBase
{
public int Count { get; set; } = 0;
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
__builder.AddMarkupContent(0, "Hello, Razor! Below is a simple click counter
\r\n\r\n\r\n");
__builder.OpenElement(1, "button");
__builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, new Action(IncreaseCount)));
__builder.AddContent(3, "Increase Count");
__builder.CloseElement();
__builder.AddMarkupContent(4, "\r\n\r\n");
__builder.OpenElement(5, "h2");
__builder.AddContent(6, "Count: ");
__builder.AddContent(7, Count);
__builder.CloseElement();
}
private void IncreaseCount()
{
Count++;
}
}
引入名称空间前后,转译的 C#代码有了两点变化
- 首先就是引入 了
Microsoft.AspNetCore.Components.Web
这个 namespace - 其次是
@onclick
属性被正确的转译成了__builder.AddAttribute(2, "onclick", EventCallback.Factory.Create
(this.new Action(IncreaseCount)))
现在,表面问题解决了,但有一个疑惑:为什么非要引用M.A.Components.Web
?
我无法向你提供一个完美的解释,我有一个蹩脚的解释,很有可能是错误的:因为click
是一个鼠标事件,而MouseEventArgs
是定义在M.A.Components.Web
这个 namespace 下的。
事件绑定与数据绑定是另外两个非常重要的话题,在这个小节里,我们仅仅是向大家展示了什么叫指令属性,重点在于指令属性,而不在事件绑定上。
后续我们会在介绍事件绑定时介绍更多的细节,包括如何将鼠标事件的参数传递给回调方法等内容
1.8 小小的总结
下表小小的总结回顾了 Razor 的基础语法内容:
语法名称 | 语法 | 备注 |
---|---|---|
表达式 | @xxx 或@(xxx) |
转译后位于BuildRenderTree 方法内部 |
简单代码块 | @{ ... } |
在BuildRenderTree 方法内部声明变量、执行逻辑 |
代码块中掺标记语言 | @{ 或@{ @:xxx } |
被转译成__builder.AddMarkupContent() 要特别理解代码块的括号并不代表嵌套或递归调用,它只是个标记 |
代码块中书写函数 | @{ xxx method(xxx xxx) { ... }} |
被转译成BuildRenderTree 方法内部的 local function |
代码块的简写形式 | @for(xxx){} 或@if() {...} 等 |
只是个语法糖而已,转译后依然处于BuildRenderTree 方法内部 |
注释 | @* xxx *@ |
标记语言注释与 C#注释到底是怎么被处理的,不同版本的.NET 可能行为不一样,但@* xxx *@ 式的注释,是一定会被移除的 |
单行指令 | @namespace , @using , @attribute , @page , @implements , @inherits |
用来修饰整个类 |
多行指令 | @code , @functions |
用来给类中添加成员定义 |
指令属性 | @onxxx |
是一种特殊的属性 attribute,@onxxx 等事件指令属性在使用时需要引入 namespace Microsoft.AspNetCore.Components.Web ,否则 Blazor 引擎不能正确识别指令属性 |
2. 徒手搓一个 Blazor Server 项目
2.1 在创建项目之前需要进行的思考
在创建项目之前,先在心里回顾一下 Blazor Server 的工作方式:
- 服务端渲染,浏览器只是个视觉样式渲染器,Blazor Server 其实是一个 C/S 架构的远程 App
也就是说,Blazor Server App 是一个运行在服务端的 App,这个 App run 起来之后,要做两件事:
- 对于首次用户浏览器访问的 HTTP 请求:Blazor Server App 要处理这个 HTTP 请求,然后返回一些相关的 HTML&JS(或可能还包含其它)文档。而用户的浏览器收到这个回应后,会执行其返回的一些 JS 代码,这些 JS 代码最重要的一个功能是:使浏览器与 Blazor Server App 之间建立一个 SingnalR 长连接。
- 后续用户在浏览器页面中点点按按,凡事涉及交互更新,浏览器中的 JS 代码都会把用户的动作通过 SignalR 传递给服务器,然后服务器会做相应的运算,再把视觉更新的消息传递给浏览器,浏览器按命令更新渲染就可以
这里我们再把这张图贴出来一次:
所以,有以下思路
- 创建一个 Asp .Net Core 应用:
既然用户的首次请求还是传统的 HTTP,并且服务端是要返回一些静态资源的(首次需要返回的 HTML 文档、JS 代码与 CSS 文件),那么在.NET 技术栈中,显然这就是一个典型的 Asp .Net Core 项目应该做的事: - 沿着上面的思路,我们能想出,这个 Asp .Net Core 应用至少应当做两件事:
- 用传统的 Asp .Net 技术给用户的第一次访问返回 HTML 文档与 JS 代码
- 用新的技术接管后续的 SignalR 连接,与处理连接中发送的数据
注意,SignalR 是一个特别面向 Web 前后端的网络连接库,旨在为前后端提供一个可靠的全双工通信链路。它优先使用WebSocket协议,但当 WebSocket 由于种种原因不可用时,也会切换到其它通信协议上去。简而言之,它其实是一个六层协议(通常我们把以太网称为二层网络,IP 称为三层网络,TCP/UDP 称为四层网络协议,HTTP/SMTP/WebSocket 称为五层协议)
为了后面描述方面,也为了降低大家的心智负担,我在这里建议大家,直接将 SignalR 理解为一个平行于 HTTP 协议的网络通信协议,它是全双工的长连接。这个理解是错误的,槽点很多,但还是那句话:是,我知道。我这是在简化问题,先隐去一些细节,目的是之后展示更大的图景。
现在思考的差不多了,我们创建一个名为HelloRazorAgain
的目录,然后开干吧!
2.2 创建项目文件与入口类
有了上面的思路,那么这次的HelloRazorAgain.csproj
就很好写了
net6.0
是的,就这么简洁,没有声明任何一个包的依赖,是因为第一行
已经把 Web 开发全家桶里所有可能涉及到的包都给你引进来了。
然后我们再来创建入口类Program.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace HelloRazorAgain;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
var app = builder.Build();
// configure HTTP request pipeline
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
}
}
这个入口函数较 Blazor WASM 来说稍复杂一些,我们一句一句过:
-
var builder = WebApplication.CreateBuilder(args)
创建了一个
WebApplicationBuilder
实例:这个很好理解,即便我们只需要处理第一次的 HTTP 请求,我们还是需要写一个 Web 应用对于不熟悉.Net 平台的同学来说,这里补充一点额外知识
-
这种创建 HostBuilder,再添加各种 Services,再通过hostBuilder.build()创建 Host,最后再将Host Run 起来的写法,是.Net 平台创建复杂应用的一个标准写法
-
Host 是一个高级抽象概念,简单来说,就是业务逻辑代码的运行环境与周边配套。以开发一个 MVC Web 应用为例来阐述的话,业务逻辑代码指的是各种
Controller
里的方法,以及它们调用的各种其它类中的方法。而运行环境与配套,指的东西就多了去了,包括但不限于:- 处理网络连接、请求的相关代码
- 日志管理模块
- 配置管理模块
- 静态文件管理模块
- 用户登录鉴权的相关模块代码
- 甚至于,将用户 HTTP 请求中的路径,映射到某个 Controller 中某个 Action 的这部分代码,也就是路由,也算是运行环境与配套
-
所以,当我们要写 Web 应用时,我们需要创建一个“WebHost”,当我们需要写一个 7*24 小时运行的后台监控程序时,我们需要创建一个“DaemonHost”。
-
.NET 官方为了广大的程序员不要总重复性的工作,就专门为 Web 开发领域,定义且实现了一个
IHost
的具体类:WebApplication
。而这个类的实例化过程使用了设计模式中的 Builder 模式,所以它配套也有一个WebApplicationBuilder
的定义与实现。随着.Net 版本的变迁,这个特定于 Web 开发场景的IHost
具体类,与之配套的Builder
类的名字也有变化,WebApplication
和WebApplicationBuilder
是进化到.Net 6.0 后官方推荐使用的套件。。我们用脚趾头也能想到,这套官方实现的套件中,默认至少包含三个东西:- 处理网络的相关代码,这里就藏着对 Kestrel 库的使用
- 配置管理相关代码:这就是为什么默认情况下我们能从
app.settings.json
读取配置的原因 - 日志管理相关代码:这就是为什么你一行日志代码都没写,但控制台会输出日志的原因
这些一个个的功能,就是所谓的Service,上面提到的三个功能,是.NET 官方认为“但凡你做 Web 开发就肯定会用到,所以我就给你默认添加进WebApplication
中的功能”,但还有更多的功能,.NET 官方只提供了,但没有默认塞进WebApplication
中
显然,我们也可以在官方提供的各种 Service 不能满足我们需求时,自己写一些个性化
Service
添加进去 -
所以,我们需要先按 Builder 模式,创建一个
WebApplicationBuilder
,然后再按需求添加我们用得到的各种 Services,最后再调用WebApplicationBuilder.Build()
把这个 Host 创建出来:也就是WebApplication
实例
-
-
AddRazorPages()
这是一个历史很悠久的 Service:RazorPage。在 ASP .NET 服务端渲染时代,我们前两篇文章也提到了,那时候.NET 技术栈就在使用 Razor Page,这个 Service 的作用就是在向当前
WebApplication
添加 Razor 引擎相关能力但请注意:这和 Blazor 是没什么关系的,所谓的添加 Razor 引擎相关能力,指的是老式的 ASP .NET 的服务端渲染工作方式:当用户的请求能被匹配到某个
*.cshtml
文件时,服务端要根据*.cshtml
文件中的内容,给客户端生成一个 HTML 文档作为 HTTP 回应。添加这个功能,是为了处理用户的第一次 HTTP 请求。
-
AddServerSideBlazor()
这句代码,才是 Blazor Server 的灵魂。。。的 7 成。
这句代码的作用,是在向当前的
WebApplication
添加:接收、管理客户端通过 JS 代码发起的 SignalR 连接的能力。添加这个功能,是为了处理用户的后续通过 SignalR 发送的数据,并在服务端交互逻辑计算完毕后,再通过 SignalR 连接把 UI 更新的消息传递给用户浏览器 -
var app = builder.Build()
创建了
WebApplication
实例。-
这里再补充一些额外知识,对 ASP .Net Core 基础知识不了解的可以看一看:
我们在上面讲了Host模式,那么截至目前,我们已经创建好了一个
IHost
实例,它的类型是WebApplication
,那么下一步是不是就可以直接调这个实例的Run
方法了呢?不,别着急。对于 Web 应用来说,创建 Host 的整个过程,只不过是注册了可能用到的各种 Services而已。但对 Web 应用来说,更重要的问题是:当一个网络请求来临时,这些 Service 应当在何种组织与调度下去执行,并最终生成一个 HTTP 回应?
你可能脱口而出:那我制定一个顺序就行了,比如先记录日志,再做认证鉴权,再做路由,再处理请求等等。
但你还是想简单了,.NET 的设计者想的比你更深入一层,他们发明了一个概念:中间件(Middleware)
之所以如此设计,是因为Service这个概念其实是一个更细粒度的概念,将 Service 看作是函数的话,简单来说,一个个Service的输入与输入并不是 HTTP 请求和 HTTP 回应。比如日志管理的 Service,它的输入就是字符串,它的“输出”就是把日志写到你配置好的某个地方。
而中间件其实是在这些 Service 之上,一个个更复杂的“函数”,中间件的输入有两种:要么是 HTTP 请求,要么是另外一个中间件的输出结果。中间件的输出有两种:要么是 HTTP 回应,要么是另外一个中间件的输入。
我们要制定的顺序,其实是中间件的执行顺序,如下图所示:
像上图那样,配置好的中间件的顺序,串起来像一条流水线,就叫 pipeline。HTTP 请求严格按照顺序从 pipeline 一个一个中间件的走,如果中途没有错误发生,那么它们将最终走到一个叫
endpiont
的中间件上去,endpoint
中间件将根据 App 的配置,要么去执行某个 Controller 中的某个方法(经典 MVC),要么去渲染某个*.cshtml
(如我们上面的Program.cs
所示)也就是说,下面我们要配置 pipeline 了
-
-
app.UseStaticFiles();
: 处理静态文件请求的中间件 -
app.UseRouting();
: 路由中间件 -
app.MapBlazorHub();
: 可以简单的理解为,建立 SignalR 连接的中间件。这,就是 Blazor Server 的灵魂剩下的那三成。 -
app.MapFallbackToPage("/_Host");
: 渲染pages/_Host.cshtml
这就是一个特殊的
endpoint
中间件,它固定的渲染唯一的一个页面:pages/_Host.cshtml
好了,pipeline 配置结束了,最后,Run 起来
-
app.Run();
现在把代码读完,整体逻辑就非常清晰了:
- 用户的第一次请求,是一个 HTTP 请求,将走完整个 pipeline,干了两件重要的事
- 向浏览器返回了渲染好的
pages/_Host.cshtml
- 服务端与浏览器之间建立了 SignalR 连接
- 向浏览器返回了渲染好的
- 用户的后续点、按、输入,都将通过上面建立好的 Signal 连接与服务端交互数据
这里我先告诉你,上面的描述,是错的,事实没有那么简单,后面介绍完代码后,我们会再捋一次流程。不过就目前为止,请先按上面的简化描述理解着。
2.3 创建pages/_Host.cshtml
我们上面说了,初次要给用户返回一个初始页面,并且在阅读Program.cs
代码时,我们也知道了这个初始页面其实是一个 Razor Page 的服务端渲染结果,下面就是这个pages/_Host.cshtml
的内容:
@page "/ThisIsAMeanlessDirective"
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
Hello Razor Again
我再次重申一遍:这个文件虽然也是 Razor Page,但它和 Blazor 是没有任何关系的。这里是传统的 ASP .NET 服务端渲染场景下的 Razor Page。
上面的代码中有四行需要注意:
-
@page "/ThisIsAMeanlessDirective"
Razor Page 脑门上按规定都得有个
@page
指令,来说明它的路由路径,但鉴于这个文件是在Program.cs
中通过app.MapFallbackToPage("/_Host")
直接指定的,所以这个指令是没有实际意义的。但按规定不写又不行。 -
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
TagHelper 是我们还没有介绍到的一个 Razor 语法中的内容,我们这里先不介绍,可能要等到两三篇文章后我们才会去介绍这个高级特性。但在这里,它的功能是可以让我们使用一个名为
的元素:它不是 HTML 元素,也不是 Blazor 组件。它是魔法 -
这是一行魔法,它的作用是在一个服务端渲染的 Razor Page 中,去引用一个 Blazor 组件。被引用的 Blazor 组件是
HelloRazorAgain.App
类。属性
render-mode
指出了,这个被引用的 Blazor 组件,将在服务端进行渲染成 HTML 文档,再被塞到 HTTP 回包中去 -
引用了一个特殊的 js 文件,这个名为
blazor.server.js
的 js 文件是 Blazor Server 框架自带的一个文件,它里面最重要的内容,是写着浏览器如何向服务端发起一个 SignalR 连接
也就是说,整个文件其实做了两件事:
- 引用了一个 Blazor 组件,我们很快就会看到,这个 Blazor 组件正是上一篇文章中我们遇到过的根组件,做客户端路由的那个组件
- 引用了一个
blazor.server.js
的特殊 JS 文件。这是一个 Blazor Server 框架自带的文件,它用于运行在用户的浏览器上,发起向服务端的 SignalR 连接
2.4 创建 Blazor 组件
和上一篇文章一样,我们要写两个 Blazor 组件,一个是客户端路由组件 App.razor
,一个是有一点点内容的Hello.razor
。为了展示 Blazor Server 渲染的特性(即 UI 要有随着用户输入、点击而重绘的过程),我们在Hello.razor
中加入了一丁点额外内容
首先是App.razor
,没什么可说的,这个文件和上一篇文章中提到的App.razor
一模一样
@using Microsoft.AspNetCore.Components.Routing
Page Not Found
再次是Index.razor
@using Microsoft.AspNetCore.Components.Web
@page "/"
Hello, Razor!
This is a Razor page with a click counter.
Current count: @currentCount
@code {
private int currentCount = 0;
void IncrementCount()
{
currentCount++;
}
}
有了前面 Razor 基础语法的铺垫,这里我们就无需再逐行解释代码了。
现在,所有文件都创建完毕,所有文件与目录结构应当如下图所示:
使用dotnet restore
, dotnet build
, dotnet run
三连,运行起来吧!
2.5 Blazor Server 真正的运行逻辑
我们上面有提到一个错误说法:
1. 用户的第一次请求,是一个 HTTP 请求,将走完整个 pipeline,干了两件重要的事
1. 向浏览器返回了渲染好的`pages/_Host.cshtml`
2. 服务端与浏览器之间建立了 SignalR 连接
2. 用户的后续点、按、输入,都将通过上面建立好的 Signal 连接与服务端交互数据
事实上的逻辑没有那么简单,现在我们来仔细的捋一遍,将项目运行起来,然后点开浏览器的调试窗口,切换到网络选项卡,如下:
可以看到,按时间顺序,浏览器先后发送了四个 HTTP 请求,并建立了一个 WebSocket 连接,我们从头开始看
2.5.1 第一次 HTTP 请求
作为浏览器,用户在地址栏敲入http://localhost:5000
时,第一件要干的是就是向localhost:5000
发送一个 GET 请求。
这个请求被服务端接收到之后,进入了 Asp .Net Core 的 middleware pipeline,按上文的描述,最终 hit 到app.MapFallbackToPage("/_Host")
这一行代码所注册的 middleware 上去
而这行代码,指导着服务端去读取pages/_Host.cshtml
,并使用ASP 场景下的 Razor 引擎,将其渲染成 HTML 文档。
而由于pages/_Host.cshtml
中使用
引用了我们书写的 Blazor 组件,也就是App.razor
,所以服务端会递归的去渲染App.razor
。
而App.razor
其实只是一个路由页面,并没有任何视觉内容,具体要在内部再渲染什么内容,取决于用户访问的 URL 路径。。在本例中,是根目录"/"
。。而又恰巧,这个路径有对应的页面存在,所以 Hit 到了
分支
@using Microsoft.AspNetCore.Components.Routing
Page Not Found
所以,服务端再去递归的渲染Index.Razor
:因为在Index.Razor
脑门上写着:@page "/"
并最终,渲染出了下面的结果:
Hello Razor Again
Hello, Razor!
This is a Razor page with a click counter.
Current count: 0
这样一份渲染结果被作为回包,发回了浏览器,如下所示:
而这份渲染结果中最核心的部分,其实全在_framework/blazor.server.js
中
2.5.2 第二次 HTTP 请求
浏览器在收到 HTML 文档后,对文档进行渲染展示 ,除了视觉元素外,浏览器发现了文档中书写的特殊的一行:
于是,自然的,浏览器发起了第二次 HTTP 请求,而请求的就是这个 JS 文件
在服务端返回了这个 JS 文件后,按照惯例,浏览器开始执行 JS 文件中的代码,在执行过程中,浏览器发起了第三次与第四次 HTTP 请求。
这两次请求都是 JS 代码引导发起的请求
2.5.3 第三、四次 HTTP 请求
第三次 HTTP 请求是 JS 发起的fetch
请求,请求路径为/_blazor/initializers
,请求方法为 GET,而服务端的回应,是一个空的数组。我目前尚不得知这次请求的具体意义是什么。
在第三次请求结束后,JS 继续以fetch
方式发起了第四次 HTTP 请求,本次请求为 POST 请求,请求地址是/_blazor/negotiate
,并通过 URL 携带了一个参数?negotiateVersion=1
服务端的回应是一个 JSON 对象,如下:
虽然我们也不知道具体细节,但从回包的内容上来看,显然这是 SignalR 库在建立连接时的协商。
2.5.4 websocket 连接
在以上四次 HTTP 请求后,浏览器与服务端建立起了一个 websocket 连接,显然,其上就是 SignalR 连接。
这个 websocket 连接建立起来后,通过 Messages 选项卡可以看到,初期双方进行了一些数据交互,具体干了什么我们也不清楚,应该是一些初始化工作。在这部分工作结束后,即使用户在浏览器这边没有执行任何操作,浏览器也会每 5 分钟与服务端通一下心跳,以维持网络连接。
我们可以认为在 websocket 连接完全建立之后,浏览器与服务端的交互,就再和 HTTP 协议没什么关系了:换句话说,和服务端的整条 middleware pipeline 已经没什么关系了。随后在页面上点击按钮,也只会看到 websocket messages 来回交互。
这一切背后的逻辑,在浏览器这边,隐藏在神秘的/_framework/blazor.server.js
中,在服务端那边,隐藏在那句神奇的builder.Services.AddServerSideBlazor();
中
2.5.5 blazor.server.js
到底在哪里?
我们从未写过任何 JS 代码,但服务端显然托管了一个静态文件叫blazor.server.js
中,那么就只有一种可能:这个文件是工具链帮我们生成的,是 Blazor Server 框架的一部分,这很容易求证,我们可以使用下面的命令将整个项目发布在本地目录中
> dotnet publish --output ./dist --configuration Release --runtime win-x64 --self-contained
通过这行命令可以把整个项目“部署”到目录dist
中去,dist
目录中,有一个 dll 名叫Microsoft.AspNetCore.Components.Server.dll
,这个 dll 内部就以 Resource 的形式持有着这个神秘的blazor.server.js
,如下所示: