Blazor 003 : Razor的基礎語法

上文,我們通過剖析一個最簡單的 Blazor WASM 項目,講明白了 Razor 文件是什麼,以及它被轉譯成 C#後長什麼樣子。也介紹了 Razor 中最簡單的一個語法:Razor Expression,也就是 Razor 表達式

本文將介紹兩個內容:

  • 首先我們將書接上文,再介紹一丁點 Razor 語法
  • 然後我們創建一個對等的 Blazor Server 項目看看事情有哪些變化,

另外請注意:上一篇文章中有很嚴重的錯誤,沒有及時看到訂正與更正的同學麻煩回去看一眼。

目錄

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 "/"

<h1>Hello, Razor!</h1>

@{
    var quote = "Today a reader, tomorrow a leader";
}

<h2>@quote</h2>

@{
    quote = "Always desire to learn something useful";
}

<h2>@quote</h2>

效果如下:

image

它背後的 C#類則變成了這樣:

public class Index : ComponentBase
{
  protected override void BuildRenderTree(RenderTreeBuilder __builder)
  {
    __builder.AddMarkupContent(0, "<h1>Hello, Razor!</h1>");
    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 "/"

<h1>Hello, Razor!</h1>

@{
    var quote = "Today a reader, tomorrow a leader";

    <h1>Here is a famous quote</h1>
}

<h2>@quote</h2>

@{
    quote = "Always desire to learn something useful";

    <h1>Here is another famous quote</h1>
}

<h2>@quote</h2>

它的效果如下:

image

是不是很震驚?上面的代碼中,我們先是在標記語言中通過@{ }的方式向其中摻了 C#,然後在本應當全是 C#的地方,又摻了兩句標記語言。。相當於水里加面再加水了屬於是。

雖然看着很噁心,但這個語法規則非常簡單:

  1. 標記語言內部要摻 C#,必須用@{}括起來
  2. C#內部要摻標記語言,直接寫就行了,Parser 主要是靠檢測 HTML 標籤判斷這是 C#代碼還是 HTML 代碼的
  3. 以上是可以嵌套的

在展示嵌套,也就是水裡加面再加水再加面無窮盡也之前,我們先看一下上面的 Razor 代碼被轉譯成了什麼樣子:

public class Index : ComponentBase
{
  protected override void BuildRenderTree(RenderTreeBuilder __builder)
  {
    __builder.AddMarkupContent(0, "<h1>Hello, Razor!</h1>");
    string quote = "Today a reader, tomorrow a leader";                     // !!!
    __builder.AddMarkupContent(1, "<h1>Here is a famous quote</h1>");       // !!!
    __builder.OpenElement(2, "h2");
    __builder.AddContent(3, quote);
    __builder.CloseElement();
    quote = "Always desire to learn something useful";                      // !!!
    __builder.AddMarkupContent(4, "<h1>Here is another famous quote</h1>"); // !!!
    __builder.OpenElement(5, "h2");
    __builder.AddContent(6, quote);
    __builder.CloseElement();
  }
}

也就是說,在代碼塊中摻入的標記語言,在轉譯時其實整行是被轉譯成了AddMarkupContent

現在!!請全體起立,觀看下面的代碼:

@page "/"

<h1>Hello, Razor!</h1>

@{
    var quote = "Today a reader, tomorrow a leader";

    <h1>
        Here is a famous quote:
        @{
            quote = "Always desire to learn something useful";
            <p>@quote</p>
        }
    </h1>
}

這就很讓人無語,像 JSX 和 TSX 雖然大家也是摻着寫,但能這樣摻着寫的,也就 Razor 獨一家了。

它的效果如下:

image

上面的代碼被轉譯成了下面這樣:

public class Index : ComponentBase
{
  protected override void BuildRenderTree(RenderTreeBuilder __builder)
  {
    __builder.AddMarkupContent(0, "<h1>Hello, Razor!</h1>");
    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>,然後其中的xxx正好是一個合法的 HTML 元素,後續看下去還存在一個對等的</xxx>,那麼肯定沒跑了,這一段就是標記語言,直接轉換成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 "/"

<h1>Hello, Razor!</h1>

@{
    string ConvertToUpperCase(string str)
    {
        return str.ToUpper();
    }

    var quote = "Today a reader, tomorrow a leader";
    quote = ConvertToUpperCase(quote);
}

<p>@quote</p>

轉譯後長這樣:

public class Index : ComponentBase
{
  protected override void BuildRenderTree(RenderTreeBuilder __builder)
  {
    __builder.AddMarkupContent(0, "<h1>Hello, Razor!</h1>");
    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 "/"

<h1>Hello, Razor!</h1>

@{
    void ConvertToUpperCase(string str)
    {
        <p>@str.ToUpper()</p>
    }

    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, "<h1>Hello, Razor!</h1>");
    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 "/"

<h1>Hello, Razor!</h1>

@{
    void ConvertToUpperCase(string str)
    {
        <p>@str.ToUpper()</p>
    }

    string[] quotes = {
        "Today a reader, tomorrow a leader",
        "Always desire to learn something useful"
    };
}

<h2>Here are some quotes:</h2>

@{
    for(int i = 0; i < quotes.Length; ++i)
    {
        ConvertToUpperCase(quotes[i]);
    }
}

具體什麼效果就不用我多說了,代碼非常簡單易懂。我們主要來看上面代碼中的for循環塊:它是一個由@{}圍起來的代碼塊,它內部僅包含一個for循環。

對於這種內部僅包含一個循環、或邏輯判斷代碼的代碼塊而言,它們有簡便寫法,如下所示:

@page "/"

<h1>Hello, Razor!</h1>

@{
    void ConvertToUpperCase(string str)
    {
        <p>@str.ToUpper()</p>
    }

    string[] quotes = {
        "Today a reader, tomorrow a leader",
        "Always desire to learn something useful"
    };
}

<h2>Here are some quotes:</h2>

@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 注釋

標記語言有注釋,格式是<!-- comment -->。C#也有注釋,要麼是// comment形式的單行注釋,要麼是/* comments */形式的多行注釋。

Razor 則是標記語言和 C#的結合體,它依然符合直覺:

  • 在標記語言區域寫<!-- comment -->注釋,沒毛病
  • 在 C#語言區域寫 C#注釋,也沒毛病

這看起來沒什麼問題,但有一個挺蛋疼的問題:假如你在開發過程中,想通過注釋的方式臨時刪除一大塊代碼區域的話。而又恰巧這一大塊代碼區域既包括標記語言,也包括 C#代碼的話。

就不太好做。

所以 Razor 提供了第三種注釋方式:使用@* comments *@形式的多行注釋。

雖然我們並不知道 Razor 引擎內部的實現細節,但我建議你以下面的邏輯來理解這三種注釋:

  1. Razor 引擎在 Parser 工作之前,會先把@* comments *@形式的注釋移除掉
  2. 在 Parser 工作中,當解析到標記語言時,再去移除標記語言注釋。當解析到 C#代碼時,再去移除 C#代碼注釋

上面的描述也可能是錯的,上面的理解也其實沒有任何實際意義。。上面也僅是我的個人建議。。

關於注釋的無用知識點

  1. 在傳統的 ASP .NET Core 應用中,Razor Page 經服務端渲染後會攜帶標記語言注釋。也就是說標記語言注釋並不會被 Razor 引擎移除
  2. 無論是服務端渲染的 Razor Page,還是 Blazor 應用中的 Razor Page,其實 C#代碼注釋也不會被 Razor 引擎移除 — 記得我們之前說的,Razor Page 文件會被轉譯成一個 C#文件嗎?如果你將.Net 版本降低到 5.0 及其以下,你會在obj\Debug\net5.0\Razor目錄下看到轉譯後的 C#文件,裏面完整的保留了所有 C#注釋
  3. 以上兩種行為,隨着.NET 版本的變遷,都有可能發生變化。畢竟,無論是 HTML 文檔中保留着標記語言注釋,還是轉譯後的 C#代碼文件攜帶注釋,這些注釋都最終不會出現在瀏覽器的視覺渲染效果里。
  4. 但可以非常確定的是,@* comments *@這種注釋,是一定會被 Razor 引擎移除掉的,你絕對不會在除了源代碼文件之外的任何地方再看到它

1.6 指令 Directives

前面我們介紹了代碼塊和表達式,它們的語法分別是@{...}@(...)(括號在無歧義的情況下可省略)。現在我們要介紹另外一種特殊的東西,它被稱為指令。指令通常用會改變 Razor 引擎對整個*.razor文件的處理方式或細節

換句話說,*.razor本質上是一個 C#類,指令則是對這整個 C#類的一些額外補充:這裡要特彆強調,它作用的受體,是整個 C#類,是整個*.razor文件

指令的語法也比較簡單,還是由猴頭符號@開頭,然後跟着一個特定的關鍵字,比如attributecode,不同的關鍵字代表不同的指令,再然後,按不同的指令,可能會附加一個由{}括起來的多行代碼塊,或直接附加單行內容

1.6.1 @namespace@using@attribute指令與@page指令

這四個都是單行指令。

@namespace指令很容易理解,就是給 C#類聲明名稱空間。默認情況下*.razor轉譯後的類會被存放在項目的<RootNamespace>下。

  • 額外知識:
    我們上節課說過了*.csproj文件是.Net 項目的編譯腳本文件,在這個文件中有個 XML 元素叫做<PropertyGroup>,這個元素下會定義形形色色的值來向編譯器或後續的工具鏈傳遞一些信息。

    比如我們的示例程序中,就定義了一個<TargetFramework>屬性,其值net6.0就是在告訴工具鏈:這是一個面向.NET 6.0 版本的程序,請在編譯、鏈接時使用 6.0 版本的 SDK

    這些值被稱為項目的Properties。除了我們顯式寫出來的<TargetFramework>這個 Property,工具鏈還會自動的聲明一些其它的 Property 並賦予它們一些默認值,最重要的值有兩個:<AssemblyName><RootNamespace>

    根據名字很容易理解:前者是編譯出來的 dll 的名字,後者是該 dll 中的根 namespace 的值。

    通常情況下,這兩個值都默認為*.csproj文件的文件名,比如我們用到的HelloRazor.csproj,這兩個 Property 的值均為HelloRazor,這意味着:

    1. 編譯出來的產物的名字叫HelloRazor.dll
    2. 工具鏈自動生成的一些類,將放置於HelloRazor這個 namespace 下

    注意:對於顯式的,寫在*.cs文件中的 C#代碼直接定義的類,<RootNamespace>並不起任何作用。

@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]

<h1>Hello, Razor!</h1>

它轉譯後的 C#類就會長這樣:

image

注意:雖然項目名稱是HelloRazor,但我們通過@namespace指令將Index類放在了HelloRazorAgain這個 namespace 下,注意看AppProgram還處於HelloRazor namespace 下,而其中:

  1. Program處於HelloRazor下是因為 C#代碼中顯式的寫了namespace HelloRazor;
  2. App處於HelloRazor下是因為 Blazor 引擎默認使用<RootNamespace>作為 namespace,即是HelloRazor
  • 額外知識點:輸出查看項目中的 Property 的值

    *.csproj中定義一個Target,然後使用Message這個 Task 來輸出就可以了,如下:

    <Target Name="Log">
      <Message Text="RootNamespace = $(RootNamespace)" />
      <Message Text="AssemblyName = $(AssemblyName)" />
    </Target>
    

    然後在控制台使用dotnet build -t:Log就可以執行上面定義的名為Log的 Target。不過由於 dotnet 包裹的 msbuild 默認情況下並不顯示普通日誌信息,所以需要顯式的將輸出的 verbosity 指定為 normal 才可見,所以需要使用dotnet build -t:Log -v:normal,你才會看到如下的輸出 :

    image

    如果你對上面這個額外知識點中描寫的內容一頭霧水,你有兩個選擇:忽略它,或者去學習一下有關MSbuild的相關知識

1.6.2 @implements@inherits指令

這兩個指令也是單行指令,一個用來表達繼承接口,一個用來表達繼承類,如下例所示:

@implements System.Runtime.Serialization.ISerializable
@implements System.IDisposable
@inherits Microsoft.AspNetCore.Components.ComponentBase

@page "/"

<h1>Hello, Razor!</h1>

@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, "<h1>Hello, Razor!</h1>");
  }

  public void GetObjectData(SerializationInfo info, StreamingContext context)
  {
    throw new NotImplementedException();
  }

  public void Dispose()
  {
    throw new NotImplementedException();
  }
}

需要注意的是:

  1. 這兩個指令都是單行指令,且每一行指令後面只能跟一個類,或接口名
  2. 在一個*.razor文件中,@inherits指令至多只能出現一次,@implements指令可以出現多次:這很容易理解,因為 C#里一個類只能繼承自一個父類,但可以同時實現多個接口

但是這裏面有個非常怪的問題:對於 Blazor 組件類來說,必須繼承自Microsoft.AspNetCore.Componenets.ComponentBase,這就意味着正常情況下,@inherits指令是一句沒用的指令,你只有兩種選擇:

  1. 不寫 @inherits指令,這樣 Razor 引擎會自動讓這個類繼承於ComponentBase
  2. @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,就像<a>標籤的href 屬性指令 directive是名詞性形容詞。

也就是說,指令屬性是一些要應用在 HTML 元素上的特殊屬性: attribute,但這些屬性在原生 HTML 規範中是不存在的。並且為了區別於自定義屬性,這些特殊的指令屬性也是以猴頭符號@做標記的。

指令屬性是 Razor Page 在 Blazor 場景下獨有的語法特性,今天在這裡我們僅介紹一個指令屬性:@on{EVENT},它幾乎是所有指令屬性中最重要的一個,而其它的指令屬性,我們將在後續碰到的時候再去做介紹。

越說越亂,我們直接來看一個例子:我們先將Index.razor改寫成下面這樣:

@page "/"

<h1>Hello, Razor! Below is a simple click counter</h1>

@code {
    public int Count{get;set;} = 0;
}

<button>Increase Count</button>

<h2>Count: @Count</h2>

有了前面的知識,我們很容易能看懂上面的代碼在幹什麼:

  1. 我們通過@code{}指令給背後的 C#類聲明了一個int類型的屬性Count,並置其初始值為 0
  2. 我們用標記語言寫了一個按鈕,但目前這個按鈕沒有任何交互功能
  3. 我們通過隱式表達式,將Count的值展示在了頁面上。顯然,這個值在目前恆定的會顯示為 0

接下來,我們希望讓用戶每點擊一次按鈕,就讓屬性Count自增 1,那麼就需要做兩件事:

  1. 我們寫一個成員方法,每次該成員方法被調用,屬性Count都會自增 1
  2. 最關鍵的是:我們要把這個成員方法,與按鈕的點擊事件關聯起來:這裡就是指令屬性發光發熱的地方

那麼,我們把代碼改寫成下面這樣,應該就可以了嗎?

@page "/"

<h1>Hello, Razor! Below is a simple click counter</h1>

@code {
    public int Count{get;set;} = 0;

    private void IncreaseCount()
    {
        Count++;
    }
}

<button @onclick="IncreaseCount">Increase Count</button>

<h2>Count: @Count</h2>
  1. 我們首先是創建了一個方法
  2. 其次,我們在<button>標籤內,使用@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, "<h1>Hello, Razor! Below is a simple click counter</h1>\r\n\r\n\r\n");
    __builder.AddMarkupContent(1, "<button @onclick=\"IncreaseCount\">Increase Count</button>\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 "/"

<h1>Hello, Razor! Below is a simple click counter</h1>

@code {
    public int Count{get;set;} = 0;

    private void IncreaseCount()
    {
        Count++;
    }
}

<button @onclick="IncreaseCount">Increase Count</button>

<h2>Count: @Count</h2>

通過引入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, "<h1>Hello, Razor! Below is a simple click counter</h1>\r\n\r\n\r\n");
    __builder.OpenElement(1, "button");
    __builder.AddAttribute(2, "onclick", EventCallback.Factory.Create<MouseEventArgs>(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#代碼有了兩點變化

  1. 首先就是引入 了Microsoft.AspNetCore.Components.Web 這個 namespace
  2. 其次是@onclick屬性被正確的轉譯成了__builder.AddAttribute(2, "onclick", EventCallback.Factory.Create<MouseEventArgs>(this.new Action(IncreaseCount)))

現在,表面問題解決了,但有一個疑惑:為什麼非要引用M.A.Components.Web

我無法向你提供一個完美的解釋,我有一個蹩腳的解釋,很有可能是錯誤的:因為click是一個鼠標事件,而MouseEventArgs是定義在M.A.Components.Web這個 namespace 下的。

事件綁定與數據綁定是另外兩個非常重要的話題,在這個小節里,我們僅僅是向大家展示了什麼叫指令屬性,重點在於指令屬性,而不在事件綁定上。

後續我們會在介紹事件綁定時介紹更多的細節,包括如何將鼠標事件的參數傳遞給回調方法等內容

1.8 小小的總結

下表小小的總結回顧了 Razor 的基礎語法內容:

語法名稱 語法 備註
表達式 @xxx@(xxx) 轉譯後位於BuildRenderTree方法內部
簡單代碼塊 @{ ... } BuildRenderTree方法內部聲明變量、執行邏輯
代碼塊中摻標記語言 @{ <tag>...</tag>}@{ @: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 起來之後,要做兩件事:

  1. 對於首次用戶瀏覽器訪問的 HTTP 請求:Blazor Server App 要處理這個 HTTP 請求,然後返回一些相關的 HTML&JS(或可能還包含其它)文檔。而用戶的瀏覽器收到這個回應後,會執行其返回的一些 JS 代碼,這些 JS 代碼最重要的一個功能是:使瀏覽器與 Blazor Server App 之間建立一個 SingnalR 長連接。
  2. 後續用戶在瀏覽器頁面中點點按按,凡事涉及交互更新,瀏覽器中的 JS 代碼都會把用戶的動作通過 SignalR 傳遞給服務器,然後服務器會做相應的運算,再把視覺更新的消息傳遞給瀏覽器,瀏覽器按命令更新渲染就可以

這裡我們再把這張圖貼出來一次:

blazor_server

所以,有以下思路

  1. 創建一個 Asp .Net Core 應用:
    既然用戶的首次請求還是傳統的 HTTP,並且服務端是要返回一些靜態資源的(首次需要返回的 HTML 文檔、JS 代碼與 CSS 文件),那麼在.NET 技術棧中,顯然這就是一個典型的 Asp .Net Core 項目應該做的事:
  2. 沿着上面的思路,我們能想出,這個 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就很好寫了

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

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

</Project>

是的,就這麼簡潔,沒有聲明任何一個包的依賴,是因為第一行<Project Sdk="Microsoft.NET.Sdk.Web">已經把 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 來說稍複雜一些,我們一句一句過:

  1. 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類的名字也有變化,WebApplicationWebApplicationBuilder是進化到.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 回應,要麼是另外一個中間件的輸入。

      我們要制定的順序,其實是中間件的執行順序,如下圖所示:

      image

      像上圖那樣,配置好的中間件的順序,串起來像一條流水線,就叫 pipeline。HTTP 請求嚴格按照順序從 pipeline 一個一個中間件的走,如果中途沒有錯誤發生,那麼它們將最終走到一個叫endpiont的中間件上去,endpoint中間件將根據 App 的配置,要麼去執行某個 Controller 中的某個方法(經典 MVC),要麼去渲染某個*.cshtml(如我們上面的Program.cs所示)

      image

      也就是說,下面我們要配置 pipeline 了

  • app.UseStaticFiles(); : 處理靜態文件請求的中間件

  • app.UseRouting(); : 路由中間件

  • app.MapBlazorHub(); : 可以簡單的理解為,建立 SignalR 連接的中間件。這,就是 Blazor Server 的靈魂剩下的那三成。

  • app.MapFallbackToPage("/_Host"); : 渲染pages/_Host.cshtml

    這就是一個特殊的endpoint中間件,它固定的渲染唯一的一個頁面:pages/_Host.cshtml

    好了,pipeline 配置結束了,最後,Run 起來

  • app.Run();

現在把代碼讀完,整體邏輯就非常清晰了:

  1. 用戶的第一次請求,是一個 HTTP 請求,將走完整個 pipeline,幹了兩件重要的事
    1. 向瀏覽器返回了渲染好的pages/_Host.cshtml
    2. 服務端與瀏覽器之間建立了 SignalR 連接
  2. 用戶的後續點、按、輸入,都將通過上面建立好的 Signal 連接與服務端交互數據

這裡我先告訴你,上面的描述,是錯的,事實沒有那麼簡單,後面介紹完代碼後,我們會再捋一次流程。不過就目前為止,請先按上面的簡化描述理解着。

2.3 創建pages/_Host.cshtml

我們上面說了,初次要給用戶返回一個初始頁面,並且在閱讀Program.cs代碼時,我們也知道了這個初始頁面其實是一個 Razor Page 的服務端渲染結果,下面就是這個pages/_Host.cshtml的內容:

@page "/ThisIsAMeanlessDirective"

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>Hello Razor Again</title>
    <base href="/" />
</head>
<body>
    <component type="typeof(HelloRazorAgain.App)" render-mode="ServerPrerendered" />

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

我再次重申一遍:這個文件雖然也是 Razor Page,但它和 Blazor 是沒有任何關係的。這裡是傳統的 ASP .NET 服務端渲染場景下的 Razor Page。

上面的代碼中有四行需要注意:

  • @page "/ThisIsAMeanlessDirective"

    Razor Page 腦門上按規定都得有個@page指令,來說明它的路由路徑,但鑒於這個文件是在Program.cs中通過app.MapFallbackToPage("/_Host")直接指定的,所以這個指令是沒有實際意義的。但按規定不寫又不行。

  • @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

    TagHelper 是我們還沒有介紹到的一個 Razor 語法中的內容,我們這裡先不介紹,可能要等到兩三篇文章後我們才會去介紹這個高級特性。但在這裡,它的功能是可以讓我們使用一個名為<component>的元素:它不是 HTML 元素,也不是 Blazor 組件。它是魔法

  • <component type="typeof(HelloRazorAgain.App)" render-mode="ServerPrerendered" />

    這是一行魔法,它的作用是在一個服務端渲染的 Razor Page 中,去引用一個 Blazor 組件。被引用的 Blazor 組件是HelloRazorAgain.App類。

    屬性render-mode指出了,這個被引用的 Blazor 組件,將在服務端進行渲染成 HTML 文檔,再被塞到 HTTP 回包中去

  • <script src="_framework/blazor.server.js"></script>

    引用了一個特殊的 js 文件,這個名為blazor.server.js的 js 文件是 Blazor Server 框架自帶的一個文件,它裏面最重要的內容,是寫着瀏覽器如何向服務端發起一個 SignalR 連接

也就是說,整個文件其實做了兩件事:

  1. 引用了一個 Blazor 組件,我們很快就會看到,這個 Blazor 組件正是上一篇文章中我們遇到過的根組件,做客戶端路由的那個組件
  2. 引用了一個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

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" />
    </Found>
    <NotFound>
        <h1>Page Not Found</h1>
    </NotFound>
</Router>

再次是Index.razor

@using Microsoft.AspNetCore.Components.Web

@page "/"

<h1>Hello, Razor!</h1>

<p>This is a Razor page with a click counter.</p>

<button @onclick="IncrementCount" >Click to Increase Count</button>

<p>Current count: @currentCount</p>

@code {
    private int currentCount = 0;

    void IncrementCount()
    {
        currentCount++;
    }
}

有了前面 Razor 基礎語法的鋪墊,這裡我們就無需再逐行解釋代碼了。

現在,所有文件都創建完畢,所有文件與目錄結構應當如下圖所示:

image

使用dotnet restore, dotnet build, dotnet run三連,運行起來吧!

image

2.5 Blazor Server 真正的運行邏輯

我們上面有提到一個錯誤說法:

1. 用戶的第一次請求,是一個 HTTP 請求,將走完整個 pipeline,幹了兩件重要的事
   1. 向瀏覽器返回了渲染好的`pages/_Host.cshtml`
   2. 服務端與瀏覽器之間建立了 SignalR 連接
2. 用戶的後續點、按、輸入,都將通過上面建立好的 Signal 連接與服務端交互數據

事實上的邏輯沒有那麼簡單,現在我們來仔細的捋一遍,將項目運行起來,然後點開瀏覽器的調試窗口,切換到網絡選項卡,如下:

image

可以看到,按時間順序,瀏覽器先後發送了四個 HTTP 請求,並建立了一個 WebSocket 連接,我們從頭開始看

2.5.1 第一次 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中使用<component>引用了我們書寫的 Blazor 組件,也就是App.razor,所以服務端會遞歸的去渲染App.razor

App.razor其實只是一個路由頁面,並沒有任何視覺內容,具體要在內部再渲染什麼內容,取決於用戶訪問的 URL 路徑。。在本例中,是根目錄"/"。。而又恰巧,這個路徑有對應的頁面存在,所以 Hit 到了<Found>分支

@using Microsoft.AspNetCore.Components.Routing

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">   <!-- Hit到了這裡 -->
        <RouteView RouteData="@routeData" />
    </Found>
    <NotFound>
        <h1>Page Not Found</h1>
    </NotFound>
</Router>

所以,服務端再去遞歸的渲染Index.Razor:因為在Index.Razor腦門上寫着:@page "/"

並最終,渲染出了下面的結果:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Hello Razor Again</title>
    <base href="/" />
  </head>
  <body>
    <!--Blazor:{"sequence":0,"type":"server","prerenderId":"78805629b3074d71b41621d864452c45","descriptor":"CfDJ8AcZjzqSCIlPjImWeXvfgvlW/O0dYX5WiF1HEjp3wnxX/BkueyBXrtymzLPIbZc3vdyUADpIR\u002Bx/wo6ZKqas8Mjy2puxTmZJHOxG1wwn/18E7\u002BloFQlW3/GyhZT2UQW47UHISsIvZsovrJ5VAAb69cA9Lv2M9mS\u002BsbXAH6uFqHPVDAJXEMBKR2tSrjCHlAYnLz\u002B0Yh0/ZhRYQP8mWvgR5pgQdjp6lUHznNgQ53C8b1DGeoJgzA5TbtwIcA1g6NVQy6JaQmb1TK0DNX0LBOd5c0E5Ilsf4HWHXc1JxQUI3DeGTcnA8PNTuTarVnsDeDsF\u002B6WqcEzc\u002BAAe/V/woXRg0\u002BpicpzMfaCS832wksRg/5g6"}-->
    <h1>Hello, Razor!</h1>

    <p>This is a Razor page with a click counter.</p>

    <button>Click to Increase Count</button>

    <p>Current count: 0</p>
    <!--Blazor:{"prerenderId":"78805629b3074d71b41621d864452c45"}-->

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

這樣一份渲染結果被作為回包,發回了瀏覽器,如下所示:

image

而這份渲染結果中最核心的部分,其實全在_framework/blazor.server.js

2.5.2 第二次 HTTP 請求

瀏覽器在收到 HTML 文檔後,對文檔進行渲染展示 ,除了視覺元素外,瀏覽器發現了文檔中書寫的特殊的一行:

<script src="_framework/blazor.server.js"></script>

於是,自然的,瀏覽器發起了第二次 HTTP 請求,而請求的就是這個 JS 文件

在服務端返回了這個 JS 文件後,按照慣例,瀏覽器開始執行 JS 文件中的代碼,在執行過程中,瀏覽器發起了第三次與第四次 HTTP 請求。

這兩次請求都是 JS 代碼引導發起的請求

2.5.3 第三、四次 HTTP 請求

第三次 HTTP 請求是 JS 發起的fetch請求,請求路徑為/_blazor/initializers,請求方法為 GET,而服務端的回應,是一個空的數組。我目前尚不得知這次請求的具體意義是什麼。

image

image

在第三次請求結束後,JS 繼續以fetch方式發起了第四次 HTTP 請求,本次請求為 POST 請求,請求地址是/_blazor/negotiate,並通過 URL 攜帶了一個參數?negotiateVersion=1

image

服務端的回應是一個 JSON 對象,如下:

image

雖然我們也不知道具體細節,但從回包的內容上來看,顯然這是 SignalR 庫在建立連接時的協商。

2.5.4 websocket 連接

在以上四次 HTTP 請求後,瀏覽器與服務端建立起了一個 websocket 連接,顯然,其上就是 SignalR 連接。

image

這個 websocket 連接建立起來後,通過 Messages 選項卡可以看到,初期雙方進行了一些數據交互,具體幹了什麼我們也不清楚,應該是一些初始化工作。在這部分工作結束後,即使用戶在瀏覽器這邊沒有執行任何操作,瀏覽器也會每 5 分鐘與服務端通一下心跳,以維持網絡連接。

image

我們可以認為在 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,如下所示:

image

Tags: