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

MicrosoftReactor發表於2021-02-25

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

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

這次分享我麼要聊聊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學習資料:https://aka.ms/LearnBlazor

相關文章