2026年4月29日 星期三

Blazor 10新功能介紹 - 1

 


.NET 10版中的Blazor是微軟網頁UI 框架又一次重大的飛躍。在這一系列的文章中,我們將探索.NET 10版本中Blazor一些重要的新功能,這篇文章我們將介紹效能的提升、持久狀態管理(persistent state management)、增強的WebAssembly 的熱重載(Hot Reload)功能、表單驗證原始碼產生器(Form Validation Source Generator)這些新特性。

效能提升

.NET 10版的Blazor Web應用程式進行幾項變動來提高執行效能。Blazor Web 應用程式核心的「blazor.web.js」檔案大變身,在.NET 9版時,檔案約有183 KB大小;而在.NET 10版則縮小到約43 KB,大幅瘦身

此外在.NET 9版時,啟動清單(Boot manifest)是一個獨立檔案,在.NET 10版已經包含在「dotnet.js」檔案之中,這代表用戶端和伺服器之間減少一次HTTP請求的負擔。

此外,資源檔案的傳遞方式改成具指紋fingerprinting的靜態資源,以便提供更好的快取,例如在以往.NET 9版引進「blazor.web.js」檔案時,使用以下語法:

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


.NET 10版則改成以下語法:

  <script src="@Assets["_framework/blazor.web.js"]"></script>


Blazor應用程式執行時,我們可從瀏覽器除錯工具觀察這個檔案的檔名後方會有一個唯一識別碼(指紋),用於偵測伺服端的程式是否有變更,請參考下圖所示:

1:具指紋(fingerprinting)的「blazor.web.js」檔案

 

狀態管理(State management

.NET 10 Blazor用用程式中,狀態管理(State management被認為是這個版本中最具影響力,且能大幅提升開發體驗的新功能之一。它提供了一種極為簡潔的「宣告式模型」- 利用[PersistentState] Attribute,以便解決應用程式預先渲染(Prerendering)與狀態管理的痛點。

你可能會有這樣的操作經驗,當使用者在Blazor Server應用程式表單填寫資料時,網路突然出問題而導致WebSocket斷線。當Blazor Server 應用程式重新連線之後,元件的資料全部消失了!

在過去開發者必須撰寫大量樣板程式碼(boilerplate code)來保留與還原元件狀態(component state),例如:保存文字方塊中輸入的值,尤其是在處理伺服器端渲染(Server-Side RenderingSSR)的情境下。.NET 10版解決了這個問題,大幅簡化Blazor應用程式中的狀態管理(State management)功能,現在Blazor支援使用「PersistentComponentState服務」(service)在預先渲染(Prerendering)期間保存狀態。

.NET 10版之前,當 Blazor 進行預先渲染(Prerendering)時,元件經常會發生「雙重渲染(Double-render)」的問題:元件在伺服器端預先渲染時會呼叫一次 API 獲取資料,當畫面切到用戶端並進入互動模式時,又會再次觸發 API 請求。這不僅會對後端造成不必要的負擔,還會導致畫面有短暫閃爍現象。

過去,為了解決這個問題,開發者必須手動撰寫相當多的程式碼來處理。現在,你只需要在需要保存的屬性上加上 [PersistentState] 標籤即可。執行階段會自動幫你處理所有的狀態保存、還原與清理工作,這個簡單的改動完美消除了預先渲染(Prerendering)與過程中的雙重渲染問題與重複API呼叫的問題。

例如若建立一個Blazor Server專案,並修改預設的「Counter.razor」程式如下,讓這個元件使用InteractiveServer渲染模式執行,同時在預渲染階段先產生一個隨機值:

@page "/counter"

@rendermode InteractiveServer

@inject ILogger<Counter> Logger

 

<PageTitle>Counter</PageTitle>

 

<h1>Counter</h1>

 

<p>目前階段: @renderStage</p>

<p role="status">Current count: @currentCount</p>

 

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

 

@code {

    public int currentCount { get; set; }

 

    private string renderStage { get; set; } = string.Empty;

 

    protected override void OnInitialized()

    {

        renderStage = RendererInfo.IsInteractive ? "互動式渲染階段" : "預渲染階段";

       

        if (!RendererInfo.IsInteractive)

        {

            if (currentCount == 0)

            {

                currentCount = Random.Shared.Next(1000, 9999);

            }

 

            Logger.LogInformation("OnInitialized - 預渲染階段,初始化 currentCount: {CurrentCount}", currentCount);

        }

        else

        {

            Logger.LogInformation("OnInitialized - 互動式渲染階段,收到 currentCount: {CurrentCount}", currentCount);

        }

    }

 

    private void IncrementCount()

    {

        currentCount++;

        Logger.LogInformation("IncrementCount - currentCount: {CurrentCount}", currentCount);

    }

}



這個頁面會在預渲染階段先產生一個隨機值,然後在互動式渲染階段,「currentCount」變數的值會回到預設「0」,從瀏覽器開發工具除錯視窗可以觀察到這個現象,以Visual Studio Code開發工具為例,請參考下圖所示:
                                                         2:「Counter.razor」執行結果

 
修改「Counter.razor」程式碼如下,這次在「currentCount」屬性套用「PersistentStateAttribute

@page "/counter"

@rendermode InteractiveServer

@inject ILogger<Counter> Logger

 

<PageTitle>Counter</PageTitle>

 

<h1>Counter</h1>

 

<p>目前階段: @renderStage</p>

<p role="status">Current count: @currentCount</p>

 

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

 

@code {

    [PersistentState]

    public int currentCount { get; set; }

 

    private string renderStage { get; set; } = string.Empty;

 

    protected override void OnInitialized()

    {

        renderStage = RendererInfo.IsInteractive ? "互動式渲染階段" : "預渲染階段";

       

        if (!RendererInfo.IsInteractive)

        {

            if (currentCount == 0)

            {

                currentCount = Random.Shared.Next(1000, 9999);

            }

 

            Logger.LogInformation("OnInitialized - 預渲染階段,初始化 currentCount: {CurrentCount}", currentCount);

        }

        else

        {

            Logger.LogInformation("OnInitialized - 互動式渲染階段,收到 currentCount: {CurrentCount}", currentCount);

        }

    }

 

    private void IncrementCount()

    {

        currentCount++;

        Logger.LogInformation("IncrementCount - currentCount: {CurrentCount}", currentCount);

    }

}

 

套用PersistentStateAttribute之後執行這個元件進行相同的測試,這次將會在「預渲染」與「互動式渲染」之間保留狀態,在互動式階段會延續同一個值,舉例來說目前的值為「8842」,請參考下圖所示:

3:套用「PersistentStateAttribute後執行將會保留狀態

 

讓我們進行一下測試,開啟瀏覽器除錯視窗,在主控台執行以下函式,強迫與伺服端斷線,然後點選畫面中的「Click me」按鈕:

Blazor._internal.forceCloseConnection()


currentCount」屬性的值不會不見,會自動保留狀態,值會繼續累加,舉例來說,目前的值為「8843」,請參考下圖所示:

4:套用「PersistentStateAttribute後執行將會保留狀態


增強的WebAssembly的熱重載(Hot Reload)功能

在過去的版本開發WebAssembly元件時,熱重載(Hot Reload需要手動進行處理,.NET 10版將 WebAssembly的熱重載(Hot Reload)移轉到底層實作,並由 SDK 自動實現。熱重載(Hot Reload)在除錯模式中預設是啟用的,現在使用Visual Studio 開發工具設計WebAssembly元件時,不需要做任何處理,只要你建立WebAssembly元件,編輯 .razor 檔案、儲存檔案,按下「ALT+10」組合鍵套用,不需要重新建置,也不需要重新整理瀏覽器,就能立即看到更改,請參考下圖所示:

5:增強的WebAssembly 的熱重載(Hot Reload)功能

 

你也可以直接啟用Visual Studio開發工具的「Hot Reload on File Save」項目,這樣只要編輯「.razor」檔案、儲存,就能立即看到更改,不必再按下「ALT+10」組合鍵套用,請參考下圖所示:

 

6:啟用「Hot Reload on File Save」項目


表單驗證原始碼產生器

.NET 10版之前,Blazor 的表單驗證高度依賴「執行階段的反射(Reflection)」機制處理,每次提交表單時,都會透過反射來尋找模型上的屬性並建立驗證器,這不僅速度較慢,對 AOT(預先編譯,Ahead-of-Time)也非常不友善。

.NET 10 之後只要搭配「ValidatableTypeAttribute原始碼產生器會在「編譯時期(Compile time)」直接產生最佳化的驗證程式碼,免去了執行階段的反射負擔,使其執行速度更快且完全相容於 AOT提升了整體效能

此外過去要驗證深層的巢狀物件或集合項目時,往往需要自訂驗證屬性。.NET 10版預設便支援了複雜物件圖(Object graph)與集合的驗證能力。

我們以一個範例來說明,假設在Blazor Server專案有一個「Book模型定義如下

using System.ComponentModel.DataAnnotations;

 

namespace BlazorServerApp.Models {

    public class Book {

        public int Id { get; set; }

        [Required(ErrorMessage = "{0} is required")]

        [StringLength(50, ErrorMessage = "{0} must be less then {1}")]

        public string Title { get; set; } = null!;

        [MaxLength(3, ErrorMessage = "{0} count must be less than or equal to {1}")]

        public List<Author> Authors { get; set; } = [];

    }

}

 


有一個「
Author」模型定義如下:

using System.ComponentModel.DataAnnotations;

 

namespace BlazorServerApp.Models {

    public class Author {

        public int Id { get; set; }

        [Required(ErrorMessage = "{0} is required")]

        [StringLength(50, ErrorMessage = "{0} must be less then {1}")]

        public string AuthorName { get; set; } = null!;

 

    }

}

 

為了方便說明,我們簡化元件的範例程式碼,在以下create元件中,讓一個「Book」物件,固定最多有三個作者(Author):

@page "/create"

@rendermode InteractiveServer

@using BlazorServerApp.Models

<PageTitle>Book Create</PageTitle>

 

<h1>Book Create</h1>

 

<div class="row">

    <div class="col-md-8">

        <EditForm Model="@Book" OnValidSubmit="HandleSubmit" FormName="create" Enhance>

            <DataAnnotationsValidator />

            <ValidationSummary />

            <div class="mb-3">

                <label for="Title" class="form-label">Title</label>

                <InputText id="Title" @bind-Value="Book.Title" class="form-control" />

                <ValidationMessage For="@(() => Book.Title)" />

            </div>

 

            <div class="mb-3">

                <label for="Author1" class="form-label">Author 1</label>

                <InputText id="Author1" @bind-Value="Book.Authors[0].AuthorName" class="form-control" />

            </div>

 

            <div class="mb-3">

                <label for="Author2" class="form-label">Author 2</label>

                <InputText id="Author2" @bind-Value="Book.Authors[1].AuthorName" class="form-control" />

            </div>

 

            <div class="mb-3">

                <label for="Author3" class="form-label">Author 3</label>

                <InputText id="Author3" @bind-Value="Book.Authors[2].AuthorName" class="form-control" />

            </div>

 

            <div class="mb-3">

                <input type="submit" value="Save" class="btn btn-primary" />

                <a href="/booklist" class="btn btn-primary m-1"> Back to List </a>

            </div>

        </EditForm>

    </div>

</div>

 

<hr />

 

<h2>Book List</h2>

 

<table class="table table-striped">

    <thead>

        <tr>

            <th>Id</th>

            <th>Title</th>

            <th>Authors</th>

        </tr>

    </thead>

    <tbody>

        @foreach (var book in Books)

        {

            <tr>

                <td>@book.Id</td>

                <td>@book.Title</td>

                <td>@string.Join(", ", book.Authors.Select(a => a.AuthorName))</td>

            </tr>

        }

    </tbody>

</table>

 

@code {

    // 使用 List<Book> 儲存頁面上的書籍清單

    public List<Book> Books { get; set; } = [];

    // 目前書籍物件(含固定三筆 Author

    public Book Book { get; set; } = CreateNewBook();

 

    // 元件初始化時先建立三筆預設書籍資料

    protected override void OnInitialized() {

        // 指定初始書籍清單

        Books = new List<Book> {

            new Book {

                Id = 1,

                Title = "Clean Code",

                Authors = new List<Author> {

                    new Author { Id = 1, AuthorName = "Robert C. Martin" }

                }

            },

            new Book {

                Id = 2,

                Title = "The Pragmatic Programmer",

                Authors = new List<Author> {

                    new Author { Id = 1, AuthorName = "Andrew Hunt" },

                    new Author { Id = 2, AuthorName = "David Thomas" }

                }

            },

            new Book {

                Id = 3,

                Title = "Domain-Driven Design",

                Authors = new List<Author> {

                    new Author { Id = 1, AuthorName = "Eric Evans" }

                }

            }

        };

    }

 

    // 表單驗證通過後的送出處理

    protected Task HandleSubmit() {

        // 取出三個文字方塊中的作者名稱

        var authorNames = Book.Authors

            // 取作者名稱欄位

            .Select(a => a.AuthorName)

            // 過濾空白或未填的作者

            .Where(x => !string.IsNullOrWhiteSpace(x))

            // 去除前後空白

            .Select(x => x!.Trim())

            // 轉為清單

            .ToList();

 

        // 建立要新增到清單的新書籍物件

        var newBook = new Book {

            // 自動遞增 Id(若清單為空則從 1 開始)

            Id = Books.Count == 0 ? 1 : Books.Max(b => b.Id) + 1,

            // 使用表單輸入的書名並去除空白

            Title = Book.Title.Trim(),

            // 將作者名稱轉成 Author 物件清單

            Authors = authorNames

                // 依序建立 AuthorId 1 開始)

                .Select((name, index) => new Author {

                    // 作者 Id

                    Id = index + 1,

                    // 作者名稱

                    AuthorName = name

                })

                // 轉為清單

                .ToList()

        };

 

        // 將新書加入書籍清單

        Books.Add(newBook);

        // 重設表單資料(維持三個 Author 欄位)

        Book = CreateNewBook();

 

        // 回傳完成的工作

        return Task.CompletedTask;

    }

 

    // 建立新的表單 Book 物件(固定三筆 Author 文字方塊)

    private static Book CreateNewBook() {

        // 回傳初始 Book

        return new Book {

            // 預設空書名

            Title = string.Empty,

            // 預設三位空白作者

            Authors = [new Author(), new Author(), new Author()]

        };

    }

 

}

 

這個元件執行時只會檢查「Book」屬性資料是否有效,不會檢查「Author」物件的屬性,例如當你畫面中的按下「Save」按鈕,只會顯示「Book」模型的錯誤訊息,請參考下圖所示:

7:只驗證「Book」物件


.NET 10版有新的做法,若要使用全新的表單驗證機制,開發者需要遵循以下幾個關鍵步驟:首先,需將模型定義在獨立的一個C#類別檔(.cs)之中,不要將表單模型(Model)直接宣告在 .razor 元件檔的程式碼內。這是因為全新的表單驗證機制和 Razor 編譯器兩者都使用了原始碼產生器,而目前 .NET 不支援將一個原始碼產生器的輸出直接作為另一個原始碼產生器的輸入。

 

接著在最上層(Root)的表單模型類別上加上 [ValidatableType] Attribute,修改「Book」類別程式如下:

using System.ComponentModel.DataAnnotations;

 

namespace BlazorServerApp.Models {

    [ValidatableType]

    public class Book {

        public int Id { get; set; }

        [Required(ErrorMessage = "{0} is required")]

        [StringLength(50, ErrorMessage = "{0} must be less then {1}")]

        public string Title { get; set; } = null!;

        [MaxLength(3, ErrorMessage = "{0} count must be less than or equal to {1}")]

        public List<Author> Authors { get; set; } = [];

    }

}

 

最後在專案中的「Program.cs」檔案中呼叫 AddValidation」方法,向DI容器註冊新的驗證服務:

using BlazorAppServer.Components;

using BlazorAppServer.Data;

using Microsoft.EntityFrameworkCore;

 

var builder = WebApplication.CreateBuilder(args);

 

builder.AddServiceDefaults();

 

// Add services to the container.

builder.Services.AddRazorComponents()

    .AddInteractiveServerComponents();

 

builder.Services.AddValidation();

builder.Services.AddDbContext<ApplicationDbContext>(options =>

    options.UseInMemoryDatabase("BooksDb"));

 

var app = builder.Build();

 

app.MapDefaultEndpoints();

 

// Configure the HTTP request pipeline.

if (!app.Environment.IsDevelopment()) {

    app.UseExceptionHandler("/Error", createScopeForErrors: true);

    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.

    app.UseHsts();

}

app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);

app.UseHttpsRedirection();

 

app.UseAntiforgery();

 

app.MapStaticAssets();

app.MapRazorComponents<App>()

    .AddInteractiveServerRenderMode();

 

app.Run();

 

接著執行Create元件的程式碼測試,這次直接按下「Save」按鈕,會驗證巢狀物件與集合,同時顯示「Book」與「Author」模型的驗證錯誤訊息,請參考下圖所示:

8:驗證巢狀物件與集合


總結

.NET 10 版本的 Blazor 在效能與開發體驗上都有顯著提升。核心檔案「blazor.web.js」大幅縮小,並整合啟動清單以減少 HTTP 請求,同時透過具指紋的靜態資源改善快取效率。在功能方面,新增 [PersistentState] Attribute,讓元件狀態在預渲染與互動階段之間能自動保存與還原;WebAssembly Hot Reload也整合至 SDK,提升開發效率。此外,全新的表單驗證原始碼產生器透過 [ValidatableType] 在編譯階段產生驗證程式碼,不僅提升效能,也支援巢狀物件與集合驗證。整體而言,.NET 10 Blazor 在效能與開發便利性上更進一步。

0 意見:

張貼留言