Blazor中的無狀態元件

半野發表於2021-12-20

宣告:本文將RenderFragment稱之為元件DOM樹或者是元件DOM節點,將*.razor稱之為元件。

1. 什麼是無狀態元件

如果瞭解React,那就應該清楚,React中存在著一種元件,它只接收屬性,並進行渲染,沒有自己的狀態,也沒有所謂的生命週期。寫法大致如下:

var component = (props: IPerson)=>{
    return <div>{prop.name}: {prop.age}</div>;
}

無狀態元件非常適用於僅做資料的展示的DOM樹最底層——或者說是最下層——元件。

2. Blazor的無狀態元件形式

Blazor也可以生命無狀態元件,最常見的用法大概如下:

...

@code {
    RenderFragment<Person> DisplayPerson = props => @<div class="person-info">
        <span class="author">@props.Name</span>: <span class="text">@props.Age</span>
    </div>;
}

其實,RenderFragment就是Blazor在UI中真正需要渲染的元件DOM樹。Blazor的渲染並不是直接渲染元件,而是渲染的元件編譯生成的RenderFragment,執行渲染的入口,就是在renderHandle.Render(renderFragment)函式。而renderHandle則只是對renderer進行的一層封裝,內部邏輯為:renderer.AddToRenderQueue(_componentId, renderFragment);_renderHandle內部私有的_renderer,對於WebAssembly來說,具體就是指WebAssemblyRenderer,它將會在webAssemblyHost.RunAsync()進行建立。

以上方式,固然能夠宣告一個Blazor的無狀態元件,但是這種標籤式的寫法是有限制的,只能寫在*.razor檔案的@code程式碼塊中。如果寫在*.cs檔案中就比較複雜,形式大概如下:

RenderFragment<Person> DisplayPerson = props => (__builder2) =>
    {
        __builder2.OpenElement(7, "div");
        __builder2.AddAttribute(8, "class", "person-info");
        __builder2.OpenElement(9, "span");
        __builder2.AddAttribute(10, "class", "author");
        __builder2.AddContent(11, props.Name);
        __builder2.CloseElement();
        __builder2.AddContent(12, ": ");
        __builder2.OpenElement(13, "span");
        __builder2.AddAttribute(14, "class", "text");
        __builder2.AddContent(15, props.Age);
        __builder2.CloseElement();
        __builder2.CloseElement();
    };

這段程式碼是.NET自動生成的,如果你使用.NET6,需要使用一下命令:

dotnet build /p:EmitCompilerGeneratedFiles=true

或者,在專案檔案中加入一下配置:

  <PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  </PropertyGroup>

然後就能在

"obj\Debug\net6.0\generated\Microsoft.NET.Sdk.Razor.SourceGenerators\Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator"資料夾下看到檔案的生成(.NET5 應該是在 "obj/Debug/net6.0/RazorDeclaration")。

事實上,這和React是類似的,JSX也是ReactReact.createElement()的語法糖。但是,不管怎麼樣,語法糖就是香,而且能夠直觀看到HTML的DOM的大致樣式(因為看不到元件的DOM)。那麼,有沒有一種更加優雅的方式,能夠實現無狀態元件,減少元件的生命週期的呼叫?答案是有的。

3. 面向介面程式設計的Blazor

當我們建立一個*.razor Blazor元件的時候,元件會預設繼承抽象類ComponentBase,Blazor元件所謂的生命週期方法OnInitializedOnAfterRender等等,都是定義在這個抽象類中的。但是,Blazor在進行渲染的時候,元件的基類是ComponentBase並不是強制要求的,只需要實現IComponent介面即可。關於這一點,我並沒有找到具體的原始碼在哪,只是從Blazor掛載的根節點的原始碼中看到的:

/// <summary>
/// Defines a mapping between a root <see cref="IComponent"/> and a DOM element selector.
/// </summary>
public readonly struct RootComponentMapping
{
    /// <summary>
    /// Creates a new instance of <see cref="RootComponentMapping"/> with the provided <paramref name="componentType"/>
    /// and <paramref name="selector"/>.
    /// </summary>
+    /// <param name="componentType">The component type. Must implement <see cref="IComponent"/>.</param>
    /// <param name="selector">The DOM element selector or component registration id for the component.</param>
    public RootComponentMapping([DynamicallyAccessedMembers(Component)] Type componentType, string selector)
    {
        if (componentType is null)
        {
            throw new ArgumentNullException(nameof(componentType));
        }

+        if (!typeof(IComponent).IsAssignableFrom(componentType))
        {
            throw new ArgumentException(
                $"The type '{componentType.Name}' must implement {nameof(IComponent)} to be used as a root component.",
                nameof(componentType));
        }

       // ...
    }
}

那麼,是不在只要Blazor的元件實現了IComponent介面即可?答案是:不是的。因為除了要實現IComponent介面,還有一個隱形的要求是需要有一個虛擬函式BuildRenderTree

protected virtual void BuildRenderTree(RenderTreeBuilder builder);

這是因為,Blazor在編譯後檔案中,會預設重寫這個函式,並在該函式中建立一個具體DOM渲染節點RenderFragmentRenderFragment是一個委託,其宣告如下:

public delegate void RenderFragment(RenderTreeBuilder builder)

BuildRenderTree的作用就相當於是給這個委託賦值。

4. 自定義StatelessComponentBase

既然只要元件類實現IComponent介面即可,那麼我們可以實現一個StatelessComponentBase : IComponent,只要我們以後建立的元件繼承這個基類,即可實現無狀態元件。IComponent介面的宣告非常簡單,其大致作用見註釋。

public interface IComponent
{
    /// <summary>
    /// 用於掛載RenderHandle,以便元件能夠進行渲染
    /// </summary>
    /// <param name="renderHandle"></param>
    void Attach(RenderHandle renderHandle);

    /// <summary>
    /// 用於設定元件的引數(Parameter)
    /// </summary>
    /// <param name="parameters"></param>
    /// <returns></returns>
    Task SetParametersAsync(ParameterView parameters);
}

沒有生命週期的無狀態元件基類:

public class StatelessComponentBase : IComponent
{
    private RenderHandle _renderHandle;
    private RenderFragment renderFragment;

    public StatelessComponentBase()
    {
        // 設定元件DOM樹(的建立方式)
        renderFragment = BuildRenderTree;
    }

    public void Attach(RenderHandle renderHandle)
    {
        _renderHandle = renderHandle;
    }

    public Task SetParametersAsync(ParameterView parameters)
    {
        // 繫結props引數到具體的元件(為[Parameter]設定值)
        parameters.SetParameterProperties(this);

        // 渲染元件
        _renderHandle.Render(renderFragment);
        return Task.CompletedTask;
    }

    protected virtual void BuildRenderTree(RenderTreeBuilder builder)
    {
    }
}

StatelessComponentBaseSetParametersAsync中,通過parameters.SetParameterProperties(this);為子元件進行中的元件引數進行賦值(這是ParameterView類中自帶的),然後即執行_renderHandle.Render(renderFragment),將元件的DOM內容渲染到HTML中。

繼承自StatelessComponentBase的元件,沒有生命週期、無法主動重新整理、無法響應事件(需要繼承IHandleEvent),並且在每次接收元件引數([Parameter])的時候都會更新UI,無論元件引數是否發生變化。無狀態元件既然有這麼多不足,我們為什麼還需要使用它呢?主要原因是:沒有生命週期的方法和狀態,無狀態元件在理論上應具有更好的效能。

5. 使用StatelessComponentBase

Blazor模板預設帶了個Counter.razor元件,現在,我們將count展示的部分抽離為一個單獨DisplayCount無狀態元件,其形式如下:

@inherits StatelessComponentBase

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


@code {
    [Parameter]
    public int Count{ get; set; }
}

counter的形式如下:

@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

+ <Stateless.Components.DisplayCount Count=@currentCount />
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

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

6. 效能測試

StatelessComponentBase新增一個生命週期函式AfterRender,並在渲染後呼叫,則現在其結構如下(注意SetParametersAsync現在是個虛擬函式):

public class StatelessComponentBase : IComponent
{
    private RenderHandle _renderHandle;
    private RenderFragment renderFragment;

    public StatelessComponentBase()
    {
        // 設定元件DOM樹(的建立方式)
        renderFragment = BuildRenderTree;
    }

    public void Attach(RenderHandle renderHandle)
    {
        _renderHandle = renderHandle;
    }

+    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        // 繫結props引數到具體的元件(為[Parameter]設定值)
        parameters.SetParameterProperties(this);

        // 渲染元件
        _renderHandle.Render(renderFragment);
+        AfterRender();
        return Task.CompletedTask;
    }

    protected virtual void BuildRenderTree(RenderTreeBuilder builder)
    {
    }

    protected virtual void AfterRender()
    {
    }
}

修改無狀態元件DisplayCount如下:

@inherits StatelessComponentBase

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


@code {
    [Parameter]
    public int Count{ get; set; }

    long start;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        start = DateTime.Now.Ticks;
        return base.SetParametersAsync(parameters);
    }


    protected override void AfterRender()
    {
        long end = DateTime.Now.Ticks;
        Console.WriteLine($"Stateless DisplayCount: {(end - start) / 1000}");
        base.AfterRender();
    }
}

建立有狀態元件DisplayCountFull

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


@code {
    [Parameter]
    public int Count { get; set; }

    long start;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        start = DateTime.Now.Ticks;
        return base.SetParametersAsync(parameters);
    }

    protected override void OnAfterRender(bool firstRender)
    {
        long end = DateTime.Now.Ticks;
        Console.WriteLine($"DisplayCountFull: {(end - start) / 1000}");
        base.OnAfterRender(firstRender);
    }
}

兩者的區別在於繼承的父類、生命週期函式和輸出的日誌不同。

有趣的是,DisplayCountDisplayCountFull元件的位置的更換,在第一次渲染的時候,會得到兩個完全不一樣的結果,哪個在前,哪個的耗時更短,但是DisplayCount在前的時候,兩者整體耗時之和是最小的。關於這點,我還沒有找到原因是什麼。但是無論那種情況,之後隨著count的變化,DisplayCount的耗時是小於DisplayCountFull的。

7. 總結

本文粗略的探究了Blazor的元件的本質——元件僅僅是對RenderFragment元件DOM樹的包裝和語法糖。通過宣告RenderFragment變數,即可進行無狀態的Blazor的元件渲染。此外,元件不需要繼承ComponentBase類,只需要實現IComponent介面並具備一個protected virtual void BuildRenderTree(RenderTreeBuilder builder)抽象函式即可。

同時,本文提出了Blazor的無狀態元件的實現方式沒,相較於直接宣告RenderFragment更加優雅。儘管無狀態元件有很多缺點:

  1. 沒有生命週期

  2. 無法主動重新整理

  3. 無法響應事件(需要繼承IHandleEvent),

  4. 每次接收元件引數([Parameter])的時候都會更新UI,無論元件引數是否發生變化。

但是通過對無狀態元件的效能進行粗略測試,發現由於無狀態元件沒有生命週期的方法和狀態,總體上具有更好的效能。此外,相較於重寫生命週期的元件,更加直觀。無狀態元件更加適用於純進行資料資料展示的元件。

以上僅為本人的拙見,如有錯誤,敬請諒解和糾正。

程式碼:BlazorTricks/01-Stateless (github.com)

相關文章