《進擊吧!Blazor!》第一章 5.組件開發

《進擊吧!Blazor!》是本人與張善友老師合作的Blazor零基礎入門系列視頻,此系列能讓一個從未接觸過Blazor的程序員掌握開發Blazor應用的能力。
視頻地址://space.bilibili.com/483888821/channel/detail?cid=151273
本系列文章是基於《進擊吧!Blazor!》直播內容編寫,升級.Net5,改進問題,講解更全面。因為篇幅有限,文章中省略了部分代碼,完整示例代碼://github.com/TimChen44/Blazor-ToDo

作者:陳超超
Ant Design Blazor 項目貢獻者,擁有十多年從業經驗,長期基於.Net技術棧進行架構與開發產品的工作,現就職於正泰集團。
郵箱:[email protected]
歡迎各位讀者有任何問題聯繫我,我們共同進步。

這次分享我么要聊聊Blazor的精髓,也是我個人認為Blazor框架體系中最優秀的特性——組件。

組件

組件(Component)是對數據和方法的簡單封裝。幾乎所有UI相關的框架都有組件(控件)的概念。

在這裡插入圖片描述
早期的Delphi組件叫做VCL(Visual Component Library),它採用自身嵌套的方式組合成所需的用戶界面,並提供屬性,方法,事件與組件外部進行交互,自身有着獨立的生命周期,在必要的時候進行銷毀。

之後.Net的WinForms和WPF組件相對於Delphi雖然設計實現上完全不同,但是對組件的定義和用途上幾乎一致。

現在Web前端框架Angular中也採用了組件的概念,整體理念依舊相似。

有些框架根據是否可見將組件分為,組件(Component)不可見,控件(Control)可見,比如Delphi,WinForms

縱觀這些框架的組件設計,可以提煉出組件包含以下特性。
在這裡插入圖片描述
Blazor應用也是使用組件構建的。組件是自包含的用戶界面 (UI) 塊,例如頁、對話框或窗體。 組件包含插入數據或響應 UI 事件所需的 HTML 標記和處理邏輯。 組件非常靈活且輕量。 可在項目之間嵌套、重複使用和共享。

1.參數(屬性)

提供組件外部向組件內部傳遞數據的方式。

在Blazor中我們稱組件的屬性(Property)叫參數(Parameter),參數本身就是一個屬性,但是為了讓Blazor框架能區分兩者,所以我們在屬性上增加 [Parameter]特性來聲明屬性為組件的參數。

[Parameter]
public string Text { get; set; }

組件參數

組件參數可以接收來在razor頁面中給與的值,支持簡單類型,也可以支持複雜類型。

<!--組件代碼-->
<h1>Blazor is @Text!</h1>
@code {
    [Parameter]
    public string Text { get; set; }
}
<!--組件使用-->
<Component Title="Superior">

上例就是將Superior通過參數傳入組件,組件中就會輸出Blazor is Superior!

路由參數

組件可以接收來自 @page 指令所提供的路由模板的路由參數。 路由器使用路由參數來填充相應的組件參數。參數類型受限於路由規則,只支持幾個基本類型。

<!--頁面代碼-->
@page "/RouteParameter/{text}"
<h1>Blazor is @Text!</h1>
@code {
    [Parameter]
    public string Text { get; set; }
}

當使用/RouteParameter/Superior地址進行路由時,跳轉到上例中的頁面,並且頁面輸出Blazor is Superior!

級聯參數

在某些情況下,使用組件參數將數據從祖先組件流向子代組件不太方便,尤其是在有多個組件層時。 級聯值和參數提供了一種方便的方法,使祖先組件為其所有子代組件提供值,從而解決了此問題。

祖先組件中使用CascadingValue設定需要向下傳遞的級聯值,子代組件中使用 [CascadingParameter] 特性來聲明級聯參數用於接收級聯值。

本文後續會有詳細的Demo來講解此特性,此處暫不展開了。

2.事件

事件是一種由組件內部發起,由組件外部處理的一種機制。

對於原始的Html元素與Razor組件在事件的使用上有一些細微差別,下面分開介紹。

Html 元素

對HTML 元素的事件採用@on{EVENT}格式(例如 @onclick)處理事件,Razor 組件將此屬性的值視為事件處理程序。

<h1>Blazor is @Text!</h1>
<button @onclick="OnClick">Button</button>
@code
{
    private string Text { get; set; }
    void OnClick(MouseEventArgs e)
    {
        Text = "Superior";
    }
}

點擊Button按鈕後就觸發@onclick事件,然後設置Text的值,最後組件輸出Blazor is Superior!
每一個事件都會返回一個參數,@onclick事件返回MouseEventArgs參數,更多詳見事件參數類型

Razor 組件

跨組件公開事件,可以使用 EventCallback。父組件可向子組件的 EventCallback 分配回調方法,由子組件完成調用。

<!--子組件-->
<button @onclick="OnBtnClick">Button</button>
@code {
    [Parameter]
    public EventCallback<string> OnClick { get; set; }

    void OnBtnClick(MouseEventArgs e)
    {
        if (OnClick.HasDelegate)
            OnClick.InvokeAsync("Superior");
    }
}
<!--父組件-->
<h1>Blazor is @Text!</h1>
<Component OnClick="OnClick"></Component>
@code
{
    private string Text { get; set; }
    void OnClick(string e)
    {
        Text = e;
    }
}

在這裡插入圖片描述
EventCallback<string> OnClick 定義了一個名為OnClick的事件,EventCallback的泛型參數就是事件的參數類型。
OnClick.InvokeAsync("Superior") 調用這個事件,讓註冊的方法執行,注意事件調用前通過OnClick.HasDelegate判斷事件是否有被註冊,如果沒有任何方法註冊此事件,那麼調用會發生異常。
OnClick="OnClick"OnClick方法註冊給事件。

3.方法

組件對外暴露的方法,提供外部組件調用。

<!--組件代碼-->
<h1>Blazor is @Text!</h1>
@code
{ 
    private string Text { get; set; }
    public void SetText(string text)
    {
        Text = text;
        StateHasChanged();
    } 
}
<!--組件使用-->
<Component @ref="@component"></Component>
<button @onclick="OnClick">Button</button>
@code
{
    private Component component;
    void OnClick(MouseEventArgs e)
    {
        component.SetText("Superior");
    }
}

當點擊Button按鈕觸發@onclick事件,通過Component組件的SetText方法設置組件的Text值,組件就輸出Blazor is Superior!
@ref 想要獲得某個組件的實例,可以使用@ref特性,在這裡他會把Component組件的實例填充到component變量中。此處注意,@ref的應用只有在組件完成呈現後才完成。

4.數據綁定

參數只提供了外部組件向組件單向賦值,數據綁定就是雙向賦值。

對於原始的Html元素與Razor組件在數據綁定的使用上有一些細微差別,下面分開介紹。

Html 元素

使用通過名為 @bind 的 Html 元素特性提供了數據綁定功能。

<h4>Blazor is @Text!</h4>
<input @bind="Text" />
@code
{
    private string Text;
}

在這裡插入圖片描述
Text變量綁定到input組件,當input中完成輸入且離開焦點後輸出Blazor is Superior!

如果我們想要輸入時立即顯示輸入的內容,我們可以通過帶有 event 參數的 @bind:event 屬性將綁定指向 oninput 事件。

<h4>Blazor is @Text!</h4>
<input @bind="Text" @bind:event="oninput"/>
@code
{
    private string Text;
}

在這裡插入圖片描述
Html元素綁定實現原理
Html元素本身並不支持雙向屬性綁定機制,當我們使用@bind後,Blazor幫我們生成了value="@Text"實現向Html元素賦值,再生成@onchange事件實現Html元素向綁定變量賦值。

<input value="@Text"
    @onchange="@((ChangeEventArgs __e) => Text = __e.Value.ToString())" />

@code {
    private string Text { get; set; }
}

5.嵌套

組件嵌套就是允許一個組件成為另一組件的容器,通過父與子的層層嵌套實現各種複雜的界面,在這過程中我們也能提煉出相似的組件,加以重複使用和共享。

下面是「我的一天」界面的代碼以及他們組件的嵌套結構
在這裡插入圖片描述

子內容

組件可以設置自己的某一個位置插入其他組件的內容。

<!--組件代碼-->
<h1>Blazor is @ChildContent</h1>
@code{
    [Parameter] public RenderFragment ChildContent { get; set; }
}
<!--組件使用-->
<Component>
    <strong>Superior!</strong>
</Component>

在這裡插入圖片描述
Component具有一個類型為 RenderFragmentChildContent 屬性,RenderFragment表示要呈現的 UI 段。
ChildContent 的值是從父組件接收的UI段。
在組件中需要呈現ChildContent內容的地方放置@ChildContent標記。
ChildContent屬性命名為固定名字,下例是完整寫法,上面是簡略寫法。

<Component>
    <ChildContent>
        <strong>Superior!</strong>
    </ChildContent>
</Component>

模板

可以通過指定一個或多個 RenderFragment 類型的組件參數來接收多個UI段。

<!--組件代碼-->
<h1>@Title is @Quality</h1>

@code{
    [Parameter] public RenderFragment Title { get; set; }
    [Parameter] public RenderFragment Quality { get; set; }
}
<!--組件使用-->
<Component>
    <Title>
        <strong>Blazor</strong>
    </Title>
    <Quality>
        <strong>Superior!</strong>
    </Quality>
</Component>

模板參數

可以定義 RenderFragment<TValue> 類型的組件參數來定義支持參數的模板。

<!--組件代碼-->
@foreach (var item in Items)
{
    <h4>@Title(item) is Superior!</h4>
}
@code{
    [Parameter] public RenderFragment<string> Title { get; set; }
    [Parameter] public IReadOnlyList<string> Items { get; set; }
}
<!--組件使用-->
<Component Items="items">
    <Title Context="item">
        <strong>@item</strong>
    </Title>
</Component>
@code{
    List<string> items = new List<string> { ".Net", "C#", "Blazor" };
}

在這裡插入圖片描述
組件使用時通過IReadOnlyList<string> Items屬性將內容傳入組件,組件內部使用@foreach (var item in Items)將集合循環呈現,@Title(item)確定了插入位置,且給模板傳入item的值,再外部通過Context="item"接收參數,最終實現模板的呈現。

6.生命周期

Blazor 框架包括同步和異步生命周期方法。一般情況下同步方法會先與異步方法執行。
我們可以重寫生命周期方法的,以在組件初始化和呈現期間對組件執行其他操作。

組件初始化

在這裡插入圖片描述

組件狀態改變

在這裡插入圖片描述

組件銷毀

在這裡插入圖片描述

ToDo應用組件化改造

任務信息

重要任務不論是否是今天,我們都需要便捷的查看,所以我們需要做一個「重要任務」的頁面。
這個頁面顯示內容和「我的一天」非常相似,所以我們可以抽象出一個TaskItem.razor組件,組件的Html以及樣式基本是從ToDay.razor組件遷移過來。

<Card Bordered="true" Size="small" Class="task-card">
    <div class="task-card-item">
        @{
            var finishClass = new ClassMapper().Add("finish").If("unfinish", () => Item.IsFinish == false);
        }
        <div class="@(finishClass.ToString())" @onclick="OnFinishClick">
            <Icon Type="check" Theme="outline" />
        </div>
        <div class="title" @onclick="OnCardClick">

            @if (TitleTemplate != null)
            {
                @TitleTemplate
            }
            else
            {
                <AntDesign.Text Strong> @Item.Title</AntDesign.Text>
                <br />
                <AntDesign.Text Type="@TextElementType.Secondary">
                    @Item.Description
                </AntDesign.Text>
            }
        </div>
        <div class="del" @onclick="OnDelClick">
            <Icon Type="rest" Theme="outline" />
        </div>
        <div class="date">
            @Item.PlanTime.ToShortDateString()
            <br />
            @{
                int? days = (int?)Item.Deadline?.Subtract(DateTime.Now.Date).TotalDays;
            }
            <span style="color:@(days switch { _ when days > 3 => "#ccc", _ when days > 0 => "#ffd800", _ => "#ff0000" })">
                @Item.Deadline?.ToShortDateString()
            </span>
        </div>
        @if (ShowStar)
        {
            <div class="star" @onclick="OnStarClick">
                <Icon Type="star" Theme="@(Item.IsImportant ? "fill" : "outline")" />
            </div>
        }
    </div>
</Card>
public partial class TaskItem
{
    //任務內容
    [Parameter] public TaskDto Item { get; set; }

    //完成圖標事件
    [Parameter] public EventCallback<TaskDto> OnFinish { get; set; }
    public async void OnFinishClick()
    {
        if (OnFinish.HasDelegate)
            await OnFinish.InvokeAsync(Item);
    }

    //條目點擊事件
    [Parameter] public EventCallback<TaskDto> OnCard { get; set; }
    public async void OnCardClick()
    {
        if (OnCard.HasDelegate)
            await OnCard.InvokeAsync(Item);
    }

    //刪除圖標事件
    [Parameter] public EventCallback<TaskDto> OnDel { get; set; }
    public async void OnDelClick()
    {
        if (OnDel.HasDelegate)
            await OnDel.InvokeAsync(Item);
    }

    //重要圖標事件
    [Parameter] public EventCallback<TaskDto> OnStar { get; set; }
    public async void OnStarClick()
    {
        if (OnStar.HasDelegate)
            await OnStar.InvokeAsync(Item);
    }

    //是否相似重要圖標
    [Parameter] public bool ShowStar { get; set; } = true;

    //支持標題模板
    [Parameter] public RenderFragment TitleTemplate { get; set; }
}

@if (TitleTemplate != null) 如果外部傳入了模板,那麼就是顯示模板,否則就使用默認格式顯示。

新建任務

在「重要任務」和「我的一天」中均有添加任務的功能,我們也將他們抽象成NewTask.razor組件。

<Divider Text="新任務"></Divider>
@if (newTask != null)
{
    <Spin Spinning="isNewLoading">
        <div class="task-input">
            <DatePicker Picker="@DatePickerType.Date" @bind-Value="@newTask.PlanTime" />
            <Input @bind-Value="@newTask.Title" OnkeyUp="OnInsertKey" />
            @if(ChildContent!=null )
            {
                @ChildContent(newTask)
            }
        </div>
    </Spin>
}
public partial class NewTask
{
    [Inject] public MessageService MsgSrv { get; set; }
    [Inject] public HttpClient Http { get; set; }

    [Parameter] public EventCallback<TaskDto> OnInserted { get; set; }
    [Parameter] public Func<TaskDto> NewTaskFunc { get; set; }
    [Parameter] public RenderFragment<TaskDto> ChildContent { get; set; }

    //新的任務
    TaskDto newTask { get; set; }
    private bool isNewLoading { get; set; }

    protected override void OnInitialized()
    {
        newTask = NewTaskFunc?.Invoke();
        base.OnInitialized();
    }

    async void OnInsertKey(KeyboardEventArgs e)
    {
        if (e.Code == "Enter")
        {
            if (string.IsNullOrWhiteSpace(newTask.Title))
            {
                MsgSrv.Error($"標題必須填寫");
                return;
            }
            isNewLoading = true;
            var result = await Http.PostAsJsonAsync<TaskDto>($"api/Task/SaveTask", newTask);
            if (result.IsSuccessStatusCode)
            {
                newTask.TaskId = await result.Content.ReadFromJsonAsync<Guid>();
                await Task.Delay(1000);
                if (OnInserted.HasDelegate) await OnInserted.InvokeAsync(newTask);

                newTask = NewTaskFunc?.Invoke();
            }
            else
            {
                MsgSrv.Error($"請求發生錯誤 {result.StatusCode}");
            }
            isNewLoading = false;
            StateHasChanged();
        }
    }
}

EventCallback<TaskDto> OnInserted 不同場景下插入後需要做的事情可能不同,所以通過這個事件由外部進行處理。
Func<TaskDto> NewTaskFunc 不同場景下對TaskDto初始化要求不同,所以用這個函數來調用初始化。
RenderFragment<TaskDto> ChildContent 使用模板實現額外的表單進行擴展輸入內容。

重要任務

創建Star.razor文件作為重要任務的頁面文件,代碼如下

@page "/star"

<PageHeader Title="@("重要的任務")" Subtitle="@($"數量:{taskDtos?.Count}")"></PageHeader>

<Spin Spinning="@isLoading">
    @foreach (var item in taskDtos)
    {
        <TaskItem  Item="item" OnFinish="OnFinish" OnCard="OnCardClick" OnDel="OnDel" OnStar="OnStar" ShowStar="false">
        </TaskItem>
    }
    <NewTask OnInserted="OnInsert" NewTaskFunc="() => new TaskDto() { PlanTime = DateTime.Now.Date, IsImportant = true  }"></NewTask>
</Spin>
public partial class Star
{
    // 1、	列出當天的所有代辦工作
    [Inject] public HttpClient Http { get; set; }
    
    bool isLoading = true;
    private List<TaskDto> taskDtos = new List<TaskDto>();
    protected async override Task OnInitializedAsync()
    {
        isLoading = true;
        taskDtos = await Http.GetFromJsonAsync<List<TaskDto>>("api/Task/GetStarTask");
        isLoading = false;
        await base.OnInitializedAsync();
    }

    //2、	添加代辦
    public MessageService MsgSrv { get; set; }
    async void OnInsert(TaskDto item)
    {
        taskDtos.Add(item);
    }

    //3、	編輯抽屜
    [Inject] public TaskDetailServices TaskSrv { get; set; }
    async void OnCardClick(TaskDto task)
    {
        TaskSrv.EditTask(task, taskDtos);
        await InvokeAsync(StateHasChanged);
    }

    //4、	修改重要程度
    private async void OnStar(TaskDto task)
    {
        var req = new SetImportantReq()
        {
            TaskId = task.TaskId,
            IsImportant = !task.IsImportant,
        };

        var result = await Http.PostAsJsonAsync<SetImportantReq>("api/Task/SetImportant", req);
        if (result.IsSuccessStatusCode)
        {
            task.IsImportant = req.IsImportant;
            StateHasChanged();
        }
    }

    //5、	修改完成與否
    private async void OnFinish(TaskDto task)
    {
        var req = new SetFinishReq()
        {
            TaskId = task.TaskId,
            IsFinish = !task.IsFinish,
        };

        var result = await Http.PostAsJsonAsync<SetFinishReq>("api/Task/SetFinish", req);
        if (result.IsSuccessStatusCode)
        {
            task.IsFinish = req.IsFinish;
            StateHasChanged();
        }
    }

    //6、	刪除代辦
    [Inject] public ConfirmService ConfirmSrv { get; set; }

    public async Task OnDel(TaskDto task)
    {
        if (await ConfirmSrv.Show($"是否刪除任務 {task.Title}", "刪除", ConfirmButtons.YesNo, ConfirmIcon.Info) == ConfirmResult.Yes)
        {
            taskDtos.Remove(task);
        }
    }
}

在這裡插入圖片描述
TaskItem
OnFinish="OnFinish" OnCard="OnCardClick" OnDel="OnDel" OnStar="OnStar" 綁定不同的操作函數

 此處完全可以使用上一節介紹服務將這些方法提取到一個獨立的服務中,這裡我就偷懶不改了。

ShowStar="false" 不顯示重要圖標

NewTask
NewTaskFunc="() => new TaskDto() { PlanTime = DateTime.Now.Date, IsImportant = true }" 重要初始化時默認將IsImportant設置成true

我的一天

我們將「我的一天」也進行適當改造

@page "/today"

<PageHeader Title="@("我的一天")" Subtitle="@DateTime.Now.ToString("yyyy年MM月dd日")"></PageHeader>

<Spin Spinning="@isLoading">
    @foreach (var item in taskDtos)
    {
        <TaskItem @key="item.TaskId" Item="item" OnFinish="OnFinish" OnCard="OnCardClick" OnDel="OnDel" OnStar="OnStar">
            <TitleTemplate>
                <AntDesign.Text Strong Style="@(item.IsFinish?"text-decoration: line-through;color:silver;":"")"> @item.Title</AntDesign.Text>
                <br />
                <AntDesign.Text Type="@TextElementType.Secondary">
                    @item.Description
                </AntDesign.Text>
            </TitleTemplate>
        </TaskItem>
    }

    <NewTask OnInserted="OnInsert" NewTaskFunc="()=>  new TaskDto() {PlanTime=DateTime.Now.Date }">
        <ChildContent Context="newTask">
            <RadioGroup @bind-Value="newTask.IsImportant">
                <Radio RadioButton Value="true">重要</Radio>
                <Radio RadioButton Value="false">普通</Radio>
            </RadioGroup>
        </ChildContent>
    </NewTask>
</Spin>

C#代碼因為變化很小,所以不再此處貼出

在這裡插入圖片描述
TaskItem
TitleTemplate 通過模板重寫了標題的顯示方式,支持當完成後標題增加刪除線

NewTask
ChildContent 重寫了子內容,提供了重要度的選擇。

次回預告

自己的待辦當然只有自己能看了啦,所以登錄,權限啥的都給安排上,請關注下一節——安全

學習資料

更多關於Blazor學習資料://aka.ms/LearnBlazor