Blazor 中的無狀態組件

聲明:本文將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 />
    /// and <paramref />.
    /// </summary>
+    /// <param />.</param>
    /// <param >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 ></param>
    void Attach(RenderHandle renderHandle);

    /// <summary>
    /// 用於設置組件的參數(Parameter)
    /// </summary>
    /// <param ></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,無論組件參數是否發生變化。

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

以上僅爲本人的拙見,如有錯誤,敬請諒解和糾正。https://github.com/zxyao145/BlazorTricks/tree/main/01-Stateless

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/lM0LblRzHwLLlTEA98GiPA