Blazor 002 : 一種開歷史倒車的UI描述語言 -- Razor

張少博發表於2022-03-23

Razor是一門相當怪異醜陋的標記語言,但在實際使用中卻十分高效靈活。本文主要介紹了Razor是什麼,以及Razor引擎的一些淺薄的背後機理。

寫文章前我本想一口氣把Razor的基本語法,以及Blazor Server App的編譯過程都介紹出來的,奈何文章到了這個長度部落格園的Markdown編輯器實在不堪重負了。就只能將這些零碎的、無聊的基礎語法知識,Blazor Server App與Blazor WASM App 編譯過程的差別,放在下一篇文章再去講了。

1. 什麼是 Razor,它和 Blazor 有什麼關係?

我們上文提到了 Web UI 框架三大重點:

  1. 調 DOM API
  2. 描述互動邏輯
  3. 呼叫服務端函式或 API

我們也介紹了 Blazor 的兩種工作方式:Blazor Server 和 Blazor WebAssembly。雖然 Blazor 有兩套工作方式,但都逃不脫一個問題:如何用程式碼描述視覺和互動邏輯。

描述互動邏輯,就必然要用一種程式設計語言去表達這些邏輯。

主流前端框架選擇了 JavaScript,這出於兩點考慮:

  1. 因為瀏覽器天然的有 JS 的執行環境。
  2. 因為互動邏輯要放在瀏覽器中執行

Blazor 選擇了 C#,由於瀏覽器不支援 C#的執行環境,所以 Blazor 有以下妥協

  1. 其中一條路就是把 C#編譯成 WebAssembly,這就是 Blazor Assembly 工作方式
  2. 另一條路就是,既然瀏覽器沒有 C#的執行環境,那就不要把互動邏輯放在瀏覽器中執行,直接放在服務端算了,這就是 Blazor Server

視覺和互動之間最好要互相融合起來,這樣框架的使用者用起來會更直觀

在 React/Angular/Vue 之前,HTML 天然的就支援在其內部引用 JavaScript 程式碼

在服務端渲染流行的年代,JSP 和 ASP .NET 這種技術,就是發明了一種四不像的語言,用來在 HTML 文件中嵌入 Java 程式碼段或 C#程式碼段,處理過程分兩部分

  1. 第一步,編譯時將這種四不像文件轉譯成 Java 或 C#的類,
  2. 第二步,瀏覽器的每次請求其實都是在呼叫這種類中的一個方法,這個方法會給客戶端返回生成的 HTML+CSS 文件

主流前端框架摒棄了服務端渲染,進一步融合了 JavaScript 或 TypeScript,HTML 和 CSS,典型的就是 React 主推的*.jsx*.tsx。這些特殊的指令碼並不能直接跑在瀏覽器中,最終會被工具鏈轉換成 HTML 文件、JS 程式碼檔案和 CSS 檔案

Blazor 則開了一點歷史的倒車:它把服務端渲染的那套四不像的東西又拉出來了,就是 Razor。

  1. 第一步依然是相同的,Blazor 依然會把這種四不像指令碼語言先轉譯成一個 C#的類
  2. 第二步是不同的
    1. Blazor WebAssembly 會將這個 C#類進一步轉譯成 WebAssembly 程式碼跑在瀏覽器上
    2. Blazor Server 雖然會在服務端像 ASP .NET 一樣直接跑類中的方法,但最終返回給客戶端的並不是渲染好的全新的 HTML+CSS 文件,而是傳送更新 UI 的指令

而 Blazor 使用的這套,將視覺和互動邏輯融合起來的四不像指令碼語言,就叫 Razor。我們上面也說了,Razor 其實是在服務端渲染時代就存在的一個東西,這個東西其實就倆使命:

  1. 把 HTML&CSS 和 C#嵌合在一起,使用上更像是在 HTML&CSS 中嵌 C#,而不像現在的前端框架,在 JS/TS 中嵌 HTML 標籤
  2. 它最終會被轉譯成一個類。換句話說,Razor 雖然寫著像是標記語言,像是在 HTML&CSS 中嵌了一些 C#程式碼,但實際上它是一個 C#類

Razor 指令碼的歷史其實很長,ASP .NET 時代它就是 UI 描述語言,那時候大家用*.cshtml來做指令碼檔案的字尾,也很好理解嘛,把 html 和 CSharp 結合在一起,叫*.cshtml是非常河鯉的。最近,特別是在 Blazor 框架下,大概是微軟的人覺得用*.cshtml太土了,所以又啟用了一個新的檔案字尾,就叫*.razor,其實就是喵叫了個咪,沒有什麼本質區別。

最重要的要謹記以下兩點:

  1. Razor 是一門四不像語言,在 HTML 中摻 C#
  2. Razor 檔案雖然看起來像是 HTML,但其實是個 C#的類

特別是第二點,不清晰的認識到第二點,就很難理解 Razor 語法中很多奇怪的地方

2. Razor 是怎麼被轉譯成 C#類的?

上面我們介紹了什麼是 Razor,按常理來說,我們接下來應該介紹 Razor 怎麼寫,即 Razor 的語法。但我覺得有必要,在講解 Razor 的語法之前,探究一下 Razor 檔案是怎麼被轉譯成一個 C#類 的這個過程。

雖然從框架的使用者的視角來說,並沒有必要去了解、理解框架的工作方式,只需要掌握使用方法就行了。但 Razor 太擰巴了,就像上面說的,這是一門四不像的標記語言,如果不瞭解、理解它背後的工作原理,那麼 Razor 中很多奇怪的語法、用法,使用者就無法理解。並且當程式碼出錯時,就完全沒有除錯糾錯的思路。

而更要命的是,上一篇文章我們僅是走馬觀花的介紹了,使用預設的.Net Core 專案模板建立出來的 BlazorWASM 和 BlazorServer 專案,如果你回過頭去看上一篇文章我們介紹的專案中的目錄與檔案明細,會發現很多不明所以的內容(特別是對之前完全不瞭解.Net 框架的人來說)。所以這裡還得先給大家介紹,如何一步步的純手動的建立一個 Blazor 專案。

所以這個小節有兩個主要任務:

  1. 介紹如何以最原始的方式建立一個最簡單的 BlazorWASM 專案。
  2. 再介紹如何從命令列編譯這個專案,以及編譯的過程中都發生了什麼,以及最終這個專案是怎麼 run 起來的。

在上面兩部分內容介紹完畢後,我們會再簡短的介紹一下如何建立一個類似的 BlazorServer 專案

2.1 徒手建立並執行一個 BlazorWASM 專案

現在,讓我們拋開dotnet new這個命令列工具,我們直接徒手開始搓一個專案。這裡你不需要用到 Visual Studio,甚至不需要用到 VSCode,你需要的只是一個文字編輯器。

step 1 : 新建目錄,建立csproj檔案

顯然,我們需要先建立一個目錄(資料夾,我在系列文章中將使用目錄這個術語,後續不再說明),我們開啟 powershell,或者如果你在 Linux 平臺或 Mac OS 平臺,開啟 Terminal,如下使用mkdir命令建立一個目錄:

>> mkdir HelloRazor

首先,所有的.Net 專案都由一個*.csproj檔案宣告,這個檔案裡,以 XML 的形式描述了這個專案的型別、結構、包含多少原始碼,你可以把這個檔案理解為專案的宣告檔案+專案的編譯指令碼。一般來說,像 Visual Studio,以及dotnet new這種工具,建立專案時會為你自動生成一個*.csproj檔案,但這裡我們決定,在HelloRazor目錄下新建一個名為HelloRazor.csproj的檔案,其內容如下:

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

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

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="6.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="6.0.3" PrivateAssets="all" />
  </ItemGroup>

</Project>

上面的檔案內容主要說了三件事:

  1. 這個專案面向.Net 6.0
  2. 這個專案依賴兩個包:M.A.C.WebAssemblyM.A.C.WebAssembly.DevServer
  3. 雖然沒有明說,但這個專案,會把當前目錄下的所有*.cs檔案視為專案的原始碼檔案,即所有的*.cs檔案都會參與編譯

step 2 : 建立入口類

如所有程式設計語言一樣,.Net 專案也需要一個入口類,一個入口函式。這種函式在 C 語言中叫int main(int argc, char ** argv),在 C#中叫static void Main(string[] args)。現在我們將在HelloRazor目錄下與HelloRazor.csproj平齊,再建立一個檔案Program.cs,它的內容如下:

using System;
using System.Net.Http;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace HelloRazor;

public class Program
{
    public static async Task Main(string[] args)
    {
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");

        builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

        await builder.Build().RunAsync();
    }
}

在上一篇文章中我們講過,Blazor WebAssembly 的工作方式和 Angular、React、Vue 是類似的。那麼類比一下:

  • 一個 React 的前端專案:
    • 開發人員要在本地把它 run 起來一邊開發一邊除錯,就需要把它塞給 Webpack dev server,也就是一個本地的 Web Server
    • 而實際部署到生產環境時,打包後的前端編譯產物會被拷貝到 Nginx 中託管起來,此時 Nginx 充當了 Web Server 的角色
  • 一個 Blazor WebAssembly 專案:
    • 開發人員要在本地把它 run 起來一邊開發一邊除錯,就需要把它塞給一個類似於 Webpack dev server 的東西中去。
    • 而實際部署到生產環境時,編譯,或者叫打包後的產物,也一樣是會被拷貝到 Nginx 中託管起來

.Net 工具鏈中,有沒有一個類似於 webpack dev server,或者 Nginx 的東西呢?答案是:有,也沒有

webpack dev server 和 Nginx,以及其它的 Web Server 軟體,它們本質上都是一個現成的、可執行的二進位制,然後通過配置檔案中的資訊去尋找應當如何處理 Http 請求。

.Net 中沒有這樣現成的、可執行的二進位制,但有一個庫,叫 Kestrel,這個庫的作用,就是用來處理Http 請求裡有關網路收發的繁雜工作:比如網路層的連線管理、將 TCP 解析為 HTTP Request,再將 HTTP Request 解析為.Net 技術棧中相應的物件,以及在回包時,將.Net 技術棧中相應的物件,再翻譯成 HTTP 回應報文,再通過網路層發回去。

在.Net 技術棧中,把這個名為 Kestrel 的,也稱為 Web Server,但它和 Nginx、Apache、以及 webpack dev server 有著本質的區別:

  • Kestrel 只是一個庫,你需要進行編譯、編譯、連結後才能得到一個可執行的二進位制
  • 相較於 webpack dev server, Nginx, Apache 這種只能直接託管靜態資源的 Web Server,.Net 的開發人員可以在 Kestrel 庫的基礎上,自己編寫能動態處理 HTTP 請求的應用程式 -- 這,其實就是 ASP .NET Core 整個技術棧的工作方式

所以現在回過頭再去看上面Program.cs的程式碼,以下的註釋你就能稍微理解了:

        // 建立一個hostBuilder,它用來建立一個Host例項,這個Host
        // 1. 在服務端會打包一個叫 `App` 的Blazor Component,類似於React中的根元件,以WebAssembly的方式返回給瀏覽器
        // 2. 而這個 `App` 的Blazor Component在渲染完成之後,會用渲染成果替換掉HTML文件中那個叫 `app` 的元素
        var builder = WebAssemblyHostBuilder.CreateDefault(args);
        builder.RootComponents.Add<App>("#app");

        builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

        await builder.Build().RunAsync();

上面這段程式碼,和 React 的下面這段程式碼有異曲同工之妙,但工作方式完全不同:

ReactDOM.render(<App />, document.getElementById("app"));

step 3 : 編寫根 Blazor Component :App

和 React 一樣的是,Blazor 專案都由一個根元件一層層巢狀渲染起來。Blazor 的根元件,一般是一個前端路由器。現在,我們在HelloRazor目錄中再建立一個名為App.razor的檔案,其內容如下:

@using Microsoft.AspNetCore.Components.Routing

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

目前我們還沒有學習 Razor 中的語法以及特殊元素,所以上面的程式碼我們並看不懂。但根據單詞基本能猜個大概,這個前端路由器的功能是:

  1. 如果使用者訪問的路徑能被路由表匹配,那麼就去渲染@routeData,也就是渲染對應的子 Blazor Component
  2. 如果不能,則渲染<h1>Page Not Found</h1>

step 4 : 編寫一個 Hello 頁面

上面我們說了,根元件(後續文章中,Component元件將頻繁出現,其實它倆是同一個意思)其實就是個前端路由器,我們當然不能只寫個路由器,不然使用者訪問哪都是<h1>Page Not Found</h1>,我們現在就來編寫一個真正意義的元件,一個真正意義上的Razor Page

HelloBlazor目錄中新建一個檔案叫Index.Razor,其內容如下:

@page "/"

<h1>Hello, Razor!</h1>

<p>This is a Razor page, but only contains standard HTML code.</p>

這個檔案包含兩部分內容:

  1. 腦門上的@page "/",其實是路由宣告:宣告這個元件僅匹配路由路徑"/",也就是根目錄
  2. 餘下兩行就是標準的 HTML 程式碼,沒有任何魔法

step 5 : 編寫一個預設 HTML 文件

如同 React 一樣,前端渲染框架都要有一個預設的 HTML 文件,這個文件中一般有一個 ID 為rootapp<div>元素,它其實就是前端框架渲染結果的佔位符。在部署的 Web 伺服器中,這個文件是純純的靜態檔案

Blazor 也一樣,我們依然也需要建立這樣一個預設文件。而這個文件也是一個純純的靜態檔案,而 Kestrel 庫預設情況下,會把專案目錄下一個名為wwwroot的子目錄中的所有東西都託管為靜態資源的。所以,這次,我們要在HelloRazor目錄下建立一個名為wwwroot的子目錄,然後在子目錄下建立一個名為index.html的文件。

鑑於我們在Program.cs中已經寫明瞭,那個佔位符元素的 ID 是app,所以這個index.html的內容應當如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>HelloRazor</title>
    <base href="/" />
  </head>

  <body>
    <div id="app">Loading...</div>

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

可以看到除了那個 ID 為app的空<div>,還有一行引用了_framework/blazor.webassembly.js這個檔案:這其實就是 Blazor 元件打包後生成的檔案

step 6 : 編譯、啟動專案

現在,你的HelloRazor目錄中的檔案結構應當如下所示:

project_layout

現在,開啟命令列,在HelloRazor目錄中,執行以下命令:

>> dotnet restore
...
...
>> dotnet build
...
...
>> dotnet run
...
...

其中

  • dotnet restore 是下載專案編譯所需要的依賴包
  • dotnet build 是編譯專案
  • dotnet run 是執行專案

效果大致如下:

build_and_run

然後在瀏覽器中開啟http://localhost:5000https://localhost:5001即可看到如下效果:

hello_razor

2.2 Blazor WASM 編譯、執行背後的一些淺層機理

現在我們已經手動,from scratch 的建立了一個 Blazor 專案,簡單的總結一下:

  1. 我們編寫了兩個 Blazor 元件:
    1. App.razor: 沒有視覺,是一個前端路由器
    2. Index.razor : 一個視覺元件,繫結在路徑"/",即根目錄上
  2. 我們通過編寫程式碼,編寫了一個 Web Server,這個 Web Server 做了兩件事:
    1. 託管了靜態文件wwwroot/index.html,這個文件內部有兩個重點
      1. 存在一個<div id="app">用來當 Blazor 元件渲染的佔位符
      2. 引用了一個 js 檔案_framework/blazor.webassembly.js,這實際上是 Blazor 元件編譯打包後的產物
    2. 通過Program.cs中的幾行程式碼,讓這個 Web Server 對 Blazor 元件進行打包,也就是生成上面所謂的blazor.webassembly.js

實際專案執行時,我們執行的是編譯連結後的 Web Server,瀏覽器訪問localhost:5000時,同時下載了index.html和服務端生成的blazor.webassembly.js。之後,瀏覽器執行blazor.webassembly.js渲染了兩個元件,並最終將瀏覽器中的<div id="app">替換為渲染成果。

整體流程故事就是這樣,但這裡有一個核心點需要我們關注:服務端是怎麼生成blazor.webassembly.js的?

過程分如下三步走

  1. 在專案編譯期,所有 Razor 頁面,也就是 Blazor 元件,也就是*.razor檔案,都被轉譯成了 C#檔案,然後進一步的,編譯成了 dll 中的 IL 程式碼。也就是一個名為HelloRazor.dll可執行二進位制
    • 是的,你沒有看錯,.Net Core 專案的可執行二進位制的字尾依然是*.dll。。
      • 這種可執行比較怪,或許不應該叫“可執行二進位制”:對於一般的 console application,由於不牽涉額外的執行時依賴的特殊類庫,可以直接用dotnet *.dll方式執行,但對於一些複雜應用,比如 Blazor App,直接以dotnet *.dll試圖執行時可能會找不到對應的執行時依賴庫
    • 如果你想得到 Windows 下標準的 PE 可執行二進位制檔案(*.exe),或 Linux 下標準的 ELF 可執行二進位制,需要進一步的使用dotnet publish命令進行釋出
    • 釋出的過程,其實就是把編譯產生的*.dll檔案包裝成了一個 PE 檔案或 ELF 檔案,就是套了一層殼子,外加把依賴庫集中放置在身邊 -- 或者self-contained形式的話整體打成一個二進位制
  2. 在專案編譯期,上一步編譯出來的 Blazor 元件類,被打包轉譯成了 Web Assembly 程式碼。再然後連同.Net 的一些類庫也被打包成 Web Assembly 程式碼,最後全捏在一起,形成了一個blazor.webassembly.js,並放置在執行目錄的wwwroot子目錄
  3. 在專案執行期,上一步生成的blazor.webassembly.js就變成了一個在wwwroot目錄下託管的普通靜態資源,變得與index.html別無二致

這其中我們要重點關注第一步,即要關注一個我們從上一篇文章就強調的概念:Blazor 元件,其實就是 C#類,只不過書寫成了*.razor這種形式。我們要重點關注,這個從*.razor*.cs的轉譯過程中,發生了什麼。瞭解這背後的機理,有助於我們理解*.razor中一些奇怪的語法。

至於第二步,我們作為框架的使用者,沒必要去過分關心。有兩個理由:

  1. 在 Blazor WASM(WASM 即是 Web Assembly 的縮寫,後續文章不再說明)工作方式中,互動邏輯才需要被轉譯成 WASM 程式碼下發給瀏覽器。而在 Blazor Server 工作方式中,互動邏輯直接在 Web Server 端執行,不需要轉譯。過分關注從 C#到 WASM 的轉譯過程只對 Blazor WASM 工作方式的應用有用,對 Blazor Server App 是沒有意義的
  2. 這部分知識過於艱深晦澀,並且對業務開發幾乎沒有任何意義

在執行了dotnet build後,專案目錄下就會預設的生成兩個子目錄:objbin,這兩個目錄下有海茫茫的檔案與目錄,目錄你可以這樣簡單的理解這兩個目錄:

  • obj: 存放編譯產物與中間產物,包括編譯前必要的一些準備工作所需的臨時檔案等
  • bin: 可執行二進位制。比如在bin目錄下除了會有可執行二進位制外,還會有wwwroot目錄儲存著執行時需要託管的靜態資源(包括生成的blazor.webassembly.js,以及執行時所需的相關庫檔案)

對於我們這個專案來說,有兩個編譯產物需要關注

  1. obj/Debug/net6.0/HelloRazor.dllbin/Debug/net6.0/HelloRazor.dll,可以認為這兩個檔案是同一個檔案。後續文章將直接以HelloRazor.dll來描述
  2. bin/Debug/net6.0/wwwroot/_framework/blazor.webassembly.js。這個是最終生成的 Web Assembly 程式碼

我們上面說了,*.razor檔案會被先轉成*.cs,然後再編進二進位制中。但在 Blazor WASM 工作模式下,中間那一步是不可見的,即obj目錄下是沒有App.csIndex.cs這樣的中間檔案的:它們被一步到位的編譯進了HelloRazor.dll

Razor 程式碼與 IL 程式碼

沒有中間的*.cs檔案,意味著我們沒法直接觀察中間的*.cs長什麼樣子。但有個比較曲折的方式:我們可以使用反編譯工具ILSpy,去檢視由 IL 程式碼反編譯生成的 C#程式碼長什麼樣。

下面是HelloRazor.dll的反編譯結果:

首先是整個 dll 中包含了三個類:Program是我們寫的入口類,AppIndex則是 Razor 程式碼生成的類:

ilspy_overview

App

以下是App.razor的內容

@using Microsoft.AspNetCore.Components.Routing

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

下面是反編譯的App的類

// HelloRazor.App
using HelloRazor;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.CompilerServices;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Routing;

public class App : ComponentBase
{
	protected override void BuildRenderTree(RenderTreeBuilder __builder)
	{
		__builder.OpenComponent<Router>(0);
		__builder.AddAttribute(1, "AppAssembly", RuntimeHelpers.TypeCheck(typeof(App).Assembly));
		__builder.AddAttribute(2, "Found", (RenderFragment<RouteData>)((RouteData routeData) => delegate(RenderTreeBuilder __builder2)
		{
			__builder2.OpenComponent<RouteView>(3);
			__builder2.AddAttribute(4, "RouteData", RuntimeHelpers.TypeCheck(routeData));
			__builder2.CloseComponent();
		}));
		__builder.AddAttribute(5, "NotFound", (RenderFragment)delegate(RenderTreeBuilder __builder2)
		{
			__builder2.AddMarkupContent(6, "<h1>Page Not Found</h1>");
		});
		__builder.CloseComponent();
	}
}

通過對比,不難看出一些一一對應的行。我們也可以簡單的總結一些規律

App.razor是一個前端路由器,雖然書寫上均是 XML 元素+屬性的形式,但用到的元素和屬性都不屬於 HTML 的範疇。這裡,我們可以把非 HTML 範疇的 XML 元素標籤或屬性簡單的理解為Blazor 框架內部為我們已經實現的元件

比如很明顯的:

  • <Router>就對應著OpenComponent<Router>,我們可以理解為 Blazor 框架內部為我們實現了一個名為Router的元件
  • <Found>和<NotFound>雖然也是 XML 元素,但對應的 C#程式碼其實是Router元件中的一個屬性
  • <RouteView>對應著OpenComponent<RouteView>,可以理解這是一個名為RouteView的元件

除了這種元件,還有一行特別矚目:

  • <h1>Page Not Found</h1>被轉譯成了AddMarkupContent(.., "<h1>Page Not Found</h1>")

根據這個,我們目前可以簡單的認為,*.razor中原生的 HTML 程式碼其實是被直接以字串的形式轉譯過去的。

關於這一點,我們可以在 Index.razor中得到驗證

Index

以下是Index.razor的內容:

@page "/"

<h1>Hello, Razor!</h1>

<p>This is a Razor page, but only contains standard HTML code.</p>

以下是反編譯的Index類的程式碼:

// HelloRazor.Index
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

[Route("/")]
public class Index : ComponentBase
{
	protected override void BuildRenderTree(RenderTreeBuilder __builder)
	{
		__builder.AddMarkupContent(0, "<h1>Hello, Razor!</h1>\r\n\r\n");
		__builder.AddMarkupContent(1, "<p>This is a Razor page, but only contains standard HTML code.</p>");
	}
}

這就簡直沒什麼懸念了,我們可以暫時總結出下面三條規律:

  1. 原生的 HTML 程式碼會被轉譯成AddMarkupContent,以字串的形式餵給__builder
  2. Blazor 框架已經為我們實現了一些元件,這些元件均會被轉譯成OpenComponent<XXX>
  3. 元件中的屬性,一部分以*.razor中以 XML 屬性的形式出現,一部分以子元素的形式出現

另外,雖然我們不清楚__builder的具體實現,也沒必要去過分糾結,但有一點可以肯定的是:它內部是一個樹型的資料結構,它就是對標於 React 框架中的 Virtual Dom 概念的一個東西,它最終的渲染結果,其實就是代表著最終視覺效果的 DOM

3. 基礎的 Razor 語法

在瞭解了一些淺薄的 Razor -> C#的知識後,我們終於可以開始介紹 Razor 這套標記語言的語法了。本小節將在上一小節的示例專案的基礎上,循序漸進的講解一些基礎的 Razor 語法

3.1 Razor 表示式 = 在 Razor 頁面中寫 C#表示式 : @xxx@(xxx)

在 Razor 頁面中可以書寫 C#表示式,最終呈現的渲染結果將對錶達式進行求值,比如我們可以把Index.razor改寫成如下模樣:

@page "/"

<h1>Hello, Razor!</h1>

<p>Current Time Is @DateTime.Now.ToString()</p>

最終呈現效果如下:

expression

語法很簡單,就是在一個@後加一個合法的 C#表示式,即可。

表示式最終會被隱式的呼叫ToString()轉成字串(也就是說上面顯式的呼叫ToString()是不必要的),並且為了避免注入,也會對字串進行轉義處理。這都沒什麼好說的,比較容易理解。

而除過記住這個語法,更重要的是去理解,這種語法在 C#類那邊,被轉譯成了什麼樣子。下面是更改後,對應的 C#類在 ILSpy 中的樣子(using語句已略,後方不再說明):

[Route("/")]
public class Index : ComponentBase
{
	protected override void BuildRenderTree(RenderTreeBuilder __builder)
	{
		__builder.AddMarkupContent(1, "<h1>Hello, Razor!</h1>\r\n\r\n");
		__builder.OpenElement(1, "p");
		__builder.AddContent(2, "Current Time Is ");
		__builder.AddContent(3, DateTime.Now.ToString());
		__builder.CloseElement();
	}
}

這裡我們接觸到了新的方法:OpenElement,顯然它是用來轉譯 HTML 原生元素用的。而 C#表示式則被轉譯成了AddContent。如果你仔細閱讀了上一章節的內容,這裡你應該相當豁然開朗,甚至如果你有相關編譯原理知識的功底,知道如何寫一個 Parser 的話,你大致已經有了一個“自己寫一個 Razor 引擎轉譯器”的思路了

一些額外知識點:

  1. 有些人會把 Razor 檔案轉譯器叫做 Razor 引擎,而有些人會把從轉譯到整個渲染執行的所有相關的類庫加在一起,叫Razor 引擎,我可能在後續文章中不會特別區別,可能會混著叫,大家按上下文自行甄別
  2. @代表著 Razor 引擎會把後續當作是一個 C#表示式去處理。而如果你真的想輸入一個@字元的話,連續寫兩個@@就可以了
  3. Razor 引擎也並不是簡單無腦的把所有@字元後面的後續當成 C#表示式去處理,一些場景它會智慧分析,比如像<a href="mailto:damn@wtf.com">damn@wtf.com</a>這種情形,它就能自動分析出來這是電子郵件地址,而不做表示式求值
  4. 表示式中間是不能有空格的,比如<p>@DateTime. Now. ToString()</p>是非法的,引擎僅會把DateTime當成表示式,而由於這是一個型別,不是一個合法的表示式,編譯期就會把這種錯誤檢查出來。 但有時候恰巧加個空格導致一個殘疾的表示式在語法上是合法的,這種錯誤可能就只能等到執行期才可能報錯了。
  5. 要使用複雜的、包含空格或者其它雜技的表示式,一個很簡單的方法:加括號,比如<p>@(DateTime. Now. ToString())</p>就是合法的。這種由@(xxx)將表示式整個括起來的寫法,被稱為Explicit Razor Expression,我將稱其為顯式表示式,而不加括號的簡便寫法,叫Implicit Razor Expressions,我將稱其為隱式表示式
  6. 隱式表示式是無法使用泛型的,典型的就是呼叫泛型方法。比如<p>@GenericMethod<int>()</p>,這是非法的。這是由於 Razor 引擎無法區分泛型表示式中的尖括號,與 HTML 元素、Blazor 元件的尖括號。這時你只能使用顯式表示式,如<p>@(GenericMethod<int>())</p>
  7. 預設情況下,表示式求值後,會呼叫ToString()轉成字串,再被脫敏進行防注入。意味著<p>@("<h1>Header?</h1>")</p>最終求值的結果其實是"&lt;h1&gt;Header?&lt;/h1&gt;"。而如果你真的想作大死,就是要輸出 HTML 標籤,那麼你可以使用@((MarkupString)("<h1>Header?</h1>"))這種方式。其中MarkupString是一個型別,全名為Microsoft.AspNetCore.Components.MarkupString,其實就是加一個強制型別轉換。。但強烈建議不要這麼作死。

3.2 待續

鑑於部落格園的markdown編輯器已經開始卡頓了,並且這篇文章已經足夠長了,我們就把其它Razor基礎語法放在下一篇文章中再介紹吧。

有好奇心的同學其實已經可以順著這個思路去官網查文件學習Razor Syntax了。沒必要非得等我寫教程

相關文章