Blazor組件的new使用方式與動態彈窗

1. 前言

Blazor中的無狀態組件文中,我提到了無狀態組件中,有人提到這個沒有diff,在渲染複雜model時,性能可能會更差。確實,這一點確實是會存在的。以上文的方式來實現無狀態組件,確實只要屬性發生變化,就會渲染。無狀態組件是否渲染,更多的需要依靠父組件來判斷。父組件不用更新,則無狀態組件自然不會發生渲染。此外,有些需求,比如地圖,要做的就是每次拖拽、縮放,整個地圖中都要被渲染,這種純粹用來進行數據展示的組件,使用無狀態組件會更好。如果想要無狀態組件不會每次都渲染,那就可以自己實現一個ShouldRender的函數。

2. 一定要實現IComponent介面嗎?

Blazor中的無狀態組件中,我提到一個組件要想被成功被編譯使用,需要滿足兩個條件:

  1. 實現IComponent介面
  2. 具有一個如下聲明的虛函數:protected virtual void BuildRenderTree(RenderTreeBuilder builder);

那,如果我們把IComponent介面的實現聲明給去掉(即僅刪除: IComponent),能夠使用嗎?顯然不能,VS編譯器都會提示你錯誤找不到這個組件:

RZ10012 Found markup element with unexpected name ‘xx.DisplayCount’. If this is intended to be a component, add a @using directive for its namespace.

但是再想一下,vs會把所有的*.razor文件編譯為一個類,那我們不是可以直接使用這個類,new一個組件嗎?這是當然是沒問題的。

3. 再談Blazor組件的渲染

Blazor中的無狀態組件中,我談到Blazor的渲染,實際上渲染的是組件內生成的RenderFragmentDOM樹。當我們創建一個*.razor文件後,編譯器會自動幫我們將組件中的DOM生成為RenderFragment。因此無論一個*.razor文件是否繼承ComponmentBase類,異或是是否實現IComponent介面,只要滿足上述的第二個條件——具有一個BuildRenderTree的虛函數——就一定能夠將文件內所編輯的DOM轉為RenderFragmentDOM樹。在之前的文章中,無狀態組件StatelessComponentBase基類的聲明大致如下:

public class StatelessComponentBase : IComponent
{
    private RenderFragment _renderFragment;

    public StatelessComponentBase()
    {
        // 設置組件DOM樹(的創建方式)
        _renderFragment = BuildRenderTree;
    }
    
    ...
}

說白了,無非是我們耍了個小聰明,利用編譯器對*.razor的編譯方式,自動生成了RenderFragment。可是,沒人說_renderFragment一定是要私有的,我們完全可以這樣:

public class StatelessComponentBase 
{
    public RenderFragment RenderFragment { get; private set; }

    public StatelessComponentBase()
    {
        RenderFragment = BuildRenderTree;
    }
    ...
}

這樣子,我們就可以在組件外部獲取到RenderFragmentDOM樹。

4. New一個組件

在3中,我們已然將組件中的RenderFragment暴露到了外部,自然也就能夠在new之後,通過實例來獲取到它:

new Componment().RenderFragment

來看一個例子,是基於Counter頁面修改的:

// DisplayCount組件
@inherits StatelessComponentBase

<h3>DisplayCount</h3>
<p role="status">Current count: @Count</p>


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

==================

// index頁面
@page "/"
<PageTitle>Index</PageTitle>

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

@renderFragment

@code {
    RenderFragment? renderFragment = null;
    private int currentCount = 1;

    Components.DisplayCount displayCount = new Components.DisplayCount();
    private void IncrementCount()
    {
        currentCount++;
        displayCount.Count = currentCount;
    }


    public async override Task SetParametersAsync(ParameterView parameters)
    {
        displayCount.Count = currentCount;
        renderFragment = displayCount.RenderFragment;
        await base.SetParametersAsync(parameters);
    }
}

程式碼運行無任何問題:

通過這種小技巧,即可實現Blazor的動態渲染。提到Blazor的動態渲染,有些人可能會講到DynamicComponentDynamicComponent通過接收組件的類型——Type,和組件的參數——Parameters,能夠實現對組件進行動態渲染。這裡提供了一個與DynamicComponent不同的思路。DynamicComponent需要將標籤寫在組件文件中,以實現掛載,而本文則是通過new一個組件來獲取內部的RenderFragment來進行。插個題外話,DynamicComponent也沒有繼承ComponmentBase類,和之前我提出的無狀態組件的結構是相似的。

5. 動態彈窗1

想一想在使用WinForm的時候,我們只需要new和show一下,就可以打開一個窗體。現在有空動態渲染,Blazor中的彈窗組件,new and show不再是夢想。

以上述方法而言,new一個組件後,必然需要在一個組件中進行掛載。沒有掛載點,Blazor組件是無法在頁面上呈現出來的。一想到掛載點,我們自然會想到創建一個全局容器來實現。當我們調用show的時候,在容器組件中將RenderFragment進行渲染即可。

Modal.razor:Modal的基類,為其添加了show和close的方法。因為Modal組件都是new出來的,需要掛載在Blazor中才能夠渲染,這裡通過Container中的靜態屬性ModalContainer來進行Modal的渲染。

@inherits StatelessComponentBase

@code {

    public void Show()
    {
        Container.ModalContainer?.AddModal(this);
    }

    public void Close()
    {
        Container.ModalContainer?.RemoveModal(this);
    }
}

Container.razor:全局容器,用於掛載Modal。

<div style="position: absolute; top:0; width: 100%; height: 100%; z-index: 9999; pointer-events: none;
            display: flex; align-items:center; justify-content:center;">
    @foreach(var modal in Modals)
    {
        <div @key="modal" style="border: 1px solid #efefef; border-radius: 4px;">
            @modal.RenderFragment
        </div>
    }
</div>

@code {
    /// <summary>
    /// 由於需要在每個 new 的 Modal 中能夠獲取到container的實例,
    ///  所以這裡需要用個靜態變數,在組件初始化的時候引用自身
    /// </summary>
    internal static Container? ModalContainer { get; private set; }


    private List<Modal> Modals { get; init; } = new List<Modal>();

    protected override Task OnInitializedAsync()
    {
        ModalContainer = this;
        return base.OnInitializedAsync();
    }

    internal void AddModal(Modal modal)
    {
        if (!Modals.Contains(modal))
        {
            Modals.Add(modal);
            StateHasChanged();
        }
    }

    internal void RemoveModal(Modal modal)
    {
        if (Modals.Contains(modal))
        {
            Modals.Remove(modal);
            StateHasChanged();
        }
    }

}

接下來,當我們需要添加一個Modal組件的時候,我們只需要繼承Modal即可。

// Modal1.razor
@inherits Modal

<h3>Modal1</h3>

而在使用的時候,我們可以new and show即可:

@page "/modalDemo"
@using Instantiation.Components.DyModal1


<PageTitle>Modal Demo</PageTitle>

<button class="btn btn-primary" @onclick="()=>{modal1.Show();}">OpenModal1</button>
<button class="btn btn-primary" @onclick="()=>{modal1.Close();}">CloseModal1</button>

@code {
    Modal modal1 = new Modal1();
}

效果如下:

注意最後的部分,Modal2關閉再打開後,count的計數值是沒有變化的,也就是說這種方式可以保留Modal內部的狀態。但是,Modal中子組件與Modal根組件(例如Modal1.razor)有些交互無法使用,例如**EventCallback** 等。

這部分的程式碼詳見:[Pages/ModalDemo.razor](BlazorTricks/ModalDemo2.razor at main · zxyao145/BlazorTricks (github.com))。

6. 動態彈窗2

當然,使用DynamicComponent也是可以實現的,這樣你就不必使用new and show,可以直接使用泛型Add<T>()來實現,而且可以直接使用ComponentBase,不必將RenderFragment暴露出來。

Container2.razor:同樣需要定義一個全局的容器:


<div style="position: absolute; top:0; width: 100%; height: 100%; z-index: 9999; pointer-events: none;
            display: flex; align-items:center; justify-content:center;">
    @foreach(var modal in Modals)
    {
        <div @key="modal" style="border: 1px solid #efefef; border-radius: 4px; pointer-events: all;">
            <DynamicComponent Type="modal" />
        </div>
    }
</div>

@code {
    /// <summary>
    /// 由於需要在每個 new 的 Modal 中能夠獲取到container的實例,
    ///  所以這裡需要用個靜態變數,在組件初始化的時候引用自身
    /// </summary>
    internal static Container2? ModalContainer { get; private set; }


    private List<Type> Modals { get; init; } = new List<Type>();

    protected override Task OnInitializedAsync()
    {
        ModalContainer = this;
        return base.OnInitializedAsync();
    }

    internal void AddModal<T>()
    {
        var type = typeof(T);
        if (!Modals.Contains(type))
        {
            Modals.Add(type);
            StateHasChanged();
        }
    }

    internal void RemoveModal<T>()
    {
        var type = typeof(T);
        if (Modals.Contains(type))
        {
            Modals.Remove(type);
            StateHasChanged();
        }
    }
}

Modal3.razor:具體的彈窗Modal,注意,不必寫任何繼承

<h3>Modal3</h3>

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

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
        StateHasChanged();
    }
}

使用:

@page "/modalDemo2"
@using Instantiation.Components.DyModal2

<PageTitle>Modal Demo2</PageTitle>

<button class="btn btn-primary" @onclick="()=>{Container2.ModalContainer?.AddModal<Modal3>();}">Open DynamicComponentModalIns</button>
<button class="btn btn-primary" @onclick="()=>{Container2.ModalContainer?.RemoveModal<Modal3>();}">Close DynamicComponentModalIns</button>

效果如下:

注意,這種方式Modal關閉再打開後,count的計數值是重新歸位了0,也就是說這種方式無法保留Modal內部的狀態

這部分的程式碼詳見:[Pages/ModalDemo2.razor](BlazorTricks/ModalDemo2.razor at main · zxyao145/BlazorTricks (github.com))。

7. 總結

本文講述Blazor如何通過new實例化的方式進行使用,繼而引起了動態彈窗的使用。動態彈窗本文寫了兩種方式,一是之前提到的new and show,需要較多的編碼,另外一種是利用Blazor內置的組件DynamicComponent。兩種方式各有缺點。第一種可以保留內部的狀態,但是Modal的跟組件與子組件的交互將有部分功能缺失(不限制於Modal的子孫組件);第二種可以可以保留組件的一切功能,但是Modal在關閉後再次打開無法保留之前內部的狀態(其實這部分是可以解決的,提示:DynamicComponentRender方法)。

示例程式碼:BlazorTricks/02-Instantiation at main · zxyao145/BlazorTricks (github.com)