深入了解Blazor组件

目录

介绍

什么是组件?

渲染器和渲染树

客户端应用程序

Blazor服务器

Blazor Web Assembly

App.razor

组件

HelloWorld组件

一个简单的IComponent实现

路由组件

组件库

ComponentBase生命周期和事件

渲染过程

SimpleComponent.razor

组件内容

SimpleComponent.razor

SimplePage.razor

组件事件

一些重要的较少记录的信息和经验教训

保持参数属性简单

重写SetParametersAsync

将参数视为不可变的

迭代器

组件编号

构建组件

所有都在一个Razor文件

背后的代码

C# 类

一些观察


介绍

本文着眼于组件的剖析、其生命周期以及Blazor在构建和运行UI时如何使用和管理组件。

对组件的深入理解使开发Blazor应用程序成为一种非常不同的体验。

什么是组件?

微软定义:

组件是用户界面(UI)的自包含部分,具有处理逻辑以启用动态行为。组件可以嵌套、重用、在项目之间共享,以及在MVCRazor Pages应用程序中使用。

组件是在带有.razor文件扩展名的Razor组件文件中使用C#HTML标记的组合实现的。

它做了什么而不是它是什么,并且并非完全正确。

从编程的角度来看,组件只是一个实现IComponent接口的类而已。当它附加到RenderTree时,它就会变得生动起来,使用Renderer构建和更新组件树。UI IComponent接口是`Renderer`,它是用来与组件通信和接收来自组件的通信的接口。

在我们深入研究组件之前,我们需要查看RendererRenderTree,以及应用程序设置。

渲染器和渲染树

RendererRenderTree工作原理的详细描述超出了本文的范围,但您需要基本掌握概念才能理解渲染过程。

RendererRenderTree驻留在WASM的客户端应用程序和服务器的SignalR Hub会话中,即,每个连接的客户端应用程序。

UI——由DOM[文档对象模型]中的HTML代码定义——在应用程序中表示为 RenderTree并由Renderer.管理。可以将其RenderTree视为一棵树,每个分支都附有一个或多个组件。每个组件都是一个实现IComponent接口的C#类。该Renderer有运行的代码更新UIRenderQueue。组件提交RenderFragments以供Renderer运行以更新RenderTreeUIRenderer使用一个不同的进程来检测由RenderTree更新引起的DOM变化,并将这些变化传递给客户端代码,以便在浏览器DOM中实现并更新显示的页面。

下图是开箱即用的Blazor模板的渲染树的直观表示。

深入了解Blazor组件_第1张图片

 

客户端应用程序

Blazor服务器

Blazor Server在初始server/html页面中定义组件。这看起来像这样:


    

type定义路由组件类——在这种情况下,Apprender-mode定义初始服务器端渲染过程的运行方式。你可以在别处读到。唯一需要理解的重要一点是,如果它预渲染,页面在初始加载时会渲染两次——一次由服务器构建页面的静态版本,然后第二次由浏览器客户端代码构建页面的实时版本。

浏览器客户端代码通过以下方式加载:

一旦blazor.server.js加载了,客户端应用程序在浏览器页面,并与服务器建立SignalR连接而运行。为了完成初始加载,客户端应用程序调用Blazor中心会话并请求App组件的完整服务器呈现。然后它将结果DOM更改应用于客户端应用程序DOM——这主要是事件连接。

下图显示了渲染请求如何传递到显示页面:

深入了解Blazor组件_第2张图片

Blazor Web Assembly

Blazor WebAssembly中,浏览器会收到一个HTML页面,其中包含应加载根组件的已定义div占位符:

....

客户端应用程序通过以下方式加载:

加载WASM代码后,它会运行program

builder.RootComponents.Add("#app");

代码告诉RendererApp类组件是RenderTree的根组件,并将其DOM加载到浏览器DOM中的app元素中。

深入了解Blazor组件_第3张图片

从中得出的关键点是,尽管定义和加载根组件的过程不同,但WebAssembly和服务器根组件或任何子组件之间没有区别。您可以使用相同的组件。

App.razor

App.razor标准根组件。它可以是任何IComponent定义的类。

App 看起来像这样:


    
        
    
    
        
            

Sorry, there's nothing at this address.

它是一个Razor组件,定义一个子组件RouterRouter有两个RenderFragments,FoundNotFound。如果Router找到一个路由,因此找到一个IComponent类,它会渲染RouteView组件并将路由类类型和默认Layout类一起传递给它。如果没有找到路由,它会渲染LayoutView并在其Body中渲染定义的内容。

RouteView检查RouteData组件是否定义了特定的布局类。如果是,则使用它,否则使用默认布局。它呈现布局并将组件的类型传递给它以添加到Body RenderFragment中。

组件

所有组件都是普通的DotNetCore类,实现了IComponent接口。

IComponent接口的定义是:

public interface IComponent
{
    void Attach(RenderHandle renderHandle);
    Task SetParametersAsync(ParameterView parameters);
}

我看到这个的第一反应是什么?这里缺少什么。所有这些事件和初始化方法在哪里?” 您阅读的每篇文章都讨论了组件和OnInitialized...不要让它们迷惑您。这些都是ComponentBase的一部分,即IComponent的现成的Blazor实现。ComponentBase没有定义组件。您将在下面看到一个更简单的实现。

让我们看看更详细的定义。Blazor中心会话具有为每个根组件Renderer运行的RenderTree。从技术上讲,您可以拥有多个,但我们将在本次讨论中忽略这一点。引用类文档:

Renderer 提供机制:

  1. 用于呈现IComponent实例的层次结构
  2. 向他们发送事件
  3. 更新用户界面时通知

一个RenderHandle结构:

  1. 允许组件与其渲染器交互。

回到IComponent接口:

  1. RendererIComponent对象附加到RenderTree时调用Attach。它传递组件RenderHandle struct。组件使用此渲染句柄排队RenderFragmentsRendererRenderQueue中。我们很快就会更详细地研究RenderFragement
  2. SetParametersAsync在组件第一次将其附加到RenderTree以及它认为一个或多个组件Parameters发生更改时,由Renderer调用。

请注意,IComponent没有RenderTree的概念。它通过调用SetParametersAsync被触发执行,并通过调用RenderHandle上的方法传递更改。

HelloWorld组件

为了演示IComponent接口,我们将构建一个简单的HelloWorld组件。

我们最简单的Hello World Razor组件如下所示:

@page "/helloworld"
Hello World

这是一个Razor定义的组件。

我们可以将其重构为如下所示:

@page "/helloworld"

@HelloWorld

@code {
    protected RenderFragment HelloWorld => (RenderTreeBuilder builder) =>
    {
        builder.OpenElement(0, "div");
        builder.AddContent(1, "Hello World 2");
        builder.CloseElement();
    };
}

这介绍了RenderFragment。引用微软官方文档。

RenderFragment表示一段UI内容,实现为将内容写入RenderTreeBuilder的委托。

RenderTreeBuilder更简洁:

提供用于构建RenderTreeFrame条目集合的方法。

所以,RenderFragment是一个委托——在Microsoft.AspNetCore.Components中的定义如下:

public delegate void RenderFragment(RenderTreeBuilder builder);

如果您不熟悉委托,请将它们视为模式定义。任何符合RenderFragment委托定义的模式的函数都可以作为RenderFragment

该模式规定您的方法必须:

  1. 有一个且只有一个RenderTreeBuilder类型的参数 
  2. 返回一个 void

回顾上面的代码,我们定义了一个RenderFragment属性并为其分配了一个符合RenderFragment模式的匿名方法。它需要RenderTreeBuilder并且没有返回所以返回void。它使用提供的RenderTreeBuilder对象来构建内容:一个简单的hello world html div。对构建器的每次调用都会添加所谓的RenderTreeFrame 注意每一帧都是按顺序编号的。

了解两点很重要:

  1. 组件本身永远不会运行” RenderFragement。它被传递给调用它的渲染器。
  2. 即使Renderer调用了代码,代码也是在组件的上下文中运行的,并且组件在执行时的状态发生了。

一个简单的IComponent实现

HelloWorld上面的组件继承自ComponentBase。未明确定义继承的Razor组件默认从ComponentBase继承。

我们现在可以将我们的组件构建为一个简单的C#类。

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
using System.Threading.Tasks;

namespace Blazor.HelloWorld.Pages
{
    [RouteAttribute("/helloworld")]
    public class RendererComponent : IComponent
    {
        private RenderHandle _renderHandle;

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

        public Task SetParametersAsync(ParameterView parameters)
        {
            parameters.SetParameterProperties(this);
            this.Render();
            return Task.CompletedTask;
        }

        public void Render()
            => _renderHandle.Render(RenderComponent);

        private void RenderComponent(RenderTreeBuilder builder)
        {
            builder.OpenElement(0, "div");
            builder.AddContent(1, "Hello World 2");
            builder.CloseElement();
        }
    }
}

上面代码中需要注意的地方:

  1. 该类使用自定义属性RouteAttribute来定义路由。
  2. 该类继承自IComponent
  3. 该类实现Attach。传递的对象RenderHandle被分配给本地类字段。
  4. 该类实现了SetParametersAsync,它在组件首次呈现时以及任何Parameters更改时调用。在我们的例子中从来没有——我们没有Parameters定义。它调用类方法Render
  5. 其余代码复制自Razor组件。
  6. 有没有OnInitializedOnAfterRenderStateHasChanged...这些都是ComponentBase的一部分。

当组件附加到渲染树时,该Render方法调用接收RenderHandle.RenderRenderHandle的组件。它将RenderComponent方法作为委托传递。调用Render将传递的委托排到Rendererrender队列中。这是代码实际执行的地方。作为委托,它在拥有对象的上下文中执行。

该组件非常简单,但它演示了基础知识。

路由组件

一切都是一个组件,但并非所有组件都是平等的。路由组件有点特殊。

它们包含@page路由指令和可选的@Layout指令。

@page "/WeatherForecast"
@page "/WeatherForecasts"
@layout MainLayout

您可以直接在类上定义这些:

[LayoutAttribute(typeof(MainLayout))]
[RouteAttribute("/helloworld")]
public class RendererComponent : IComponent {}

路由器使用RouteAttribute在应用程序中查找路由。

不要将路由组件视为页面。这样做似乎很明显,但不要这样做。许多网页属性不适用于路由组件。你会:

  • 当路由组件的行为不像页面时会感到困惑。
  • 尝试编写组件逻辑,就像它是一个网页一样。

组件库

ComponentBaseIComponent标准的开箱即用的Blazor实现。所有.razor文件都继承自它。虽然您可能永远不会走出去ComponentBase,但重要的是要了解它只是IComponent接口的一种实现。它没有定义组件。OnInitialized不是组件生命周期方法,而是ComponentBase生命周期方法。

ComponentBase生命周期和事件

有大量文章重复相同的旧基本生命周期信息。我不会重复它。相反,我将专注于生命周期中某些经常被误解的方面:生命周期还有更多内容,大多数文章中仅涵盖初始组件加载。

我们需要考虑五种类型的事件:

  1. 类的实例化
  2. 组件的初始化
  3. 组件参数更改
  4. 组件事件
  5. 组件处理

有七个公开的事件/方法及其异步等效项:

  1. SetParametersAsync
  2. OnInitialized  OnInitializedAsync
  3. OnParametersSet  OnParametersSetAsync
  4. OnAfterRender  OnAfterRenderAsync
  5. Dispose——如果IDisposable被实现
  6. StateHasChanged
  7. new——经常被遗忘

标准类的实例化方法构建RenderFragmentStateHasChanged传递到Renderer呈现组件。它将两个private类变量设置为false并运行BuildRenderTree

public ComponentBase()
{
    _renderFragment = builder =>
    {
        _hasPendingQueuedRender = false;
        _hasNeverRendered = false;
        BuildRenderTree(builder);
    };
}

SetParametersAsync设置提交参数的属性。它只在初始化时运行RunInitAndSetParametersAsync——因此OnInitialized紧随其OnInitializedAsync后运行。它总是调用CallOnParametersSetAsync.。注意:

  1. CallOnParametersSetAsyncOnInitializedAsync在调用之前等待完成CallOnParametersSetAsync
  2. 如果OnInitializedAsync任务在完成前产生则RunInitAndSetParametersAsync调用StateHasChanged

public virtual Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this);
    if (!_initialized)
    {
        _initialized = true;
        return RunInitAndSetParametersAsync();
    }
    else return CallOnParametersSetAsync();
}

private async Task RunInitAndSetParametersAsync()
{
    OnInitialized();
    var task = OnInitializedAsync();
    if (task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled)
    {
        StateHasChanged();
        try { await task;}
        catch { if (!task.IsCanceled) throw; }
    }
    await CallOnParametersSetAsync();

CallOnParametersSetAsync调用OnParametersSet,紧接着OnParametersSetAsync,最后StateHasChanged。如果OnParametersSetAsync()任务产生CallStateHasChangedOnAsyncCompletion等待任务并重新运行StateHasChanged

private Task CallOnParametersSetAsync()
{
    OnParametersSet();
    var task = OnParametersSetAsync();
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;

    StateHasChanged();

    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

private async Task CallStateHasChangedOnAsyncCompletion(Task task)
{
    try { await task; }
    catch 
    {
        if (task.IsCanceled) return;
        throw;
    }
    StateHasChanged();
}

最后,我们来看看StateHasChanged。如果渲染处于挂起状态,即渲染器还没有开始运行排队的渲染请求,它就会关闭——所做的任何更改都将在排队的渲染中被捕获。如果不是,它设置_hasPendingQueuedRender类标志,并调用RenderHandleRender方法。这个队列_renderFragementRenderer RenderQueue。当队列运行时_renderFragment——见上文——它将两个类标志设置为false并运行BuildRenderTree

protected void StateHasChanged()
{
    if (_hasPendingQueuedRender) return;
    if (_hasNeverRendered || ShouldRender())
    {
        _hasPendingQueuedRender = true;
        try { _renderHandle.Render(_renderFragment);}
        catch {
            _hasPendingQueuedRender = false;
            throw;
        }
    }
}

需要注意的一些关键点:

  1. OnInitializedOnInitializedAsync仅在初始化期间被调用。OnInitialized首先运行。当且仅当OnInitializedAsync返回给内部调用方法RunInitAndSetParametersAsync,然后StateHasChanged被调用,提供向用户提供加载信息的机会。OnInitializedAsyncOnParametersSetOnParametersSetAsync被调用之前完成。
  2. OnParametersSetOnParametersSetAsync在父组件更改组件的参数集或捕获的级联参数更改时调用。任何需要响应参数变化的代码都需要在这里。OnParametersSet首先运行。请注意,如果OnParametersSetAsync产生,StateHasChanged则在产生后运行,提供向用户提供加载信息的机会。
  3. StateHasChangedOnParametersSet{async}方法完成后调用以呈现组件。
  4. OnAfterRenderOnAfterRenderAsync在所有四个事件结束时发生。firstRender在组件初始化时仅为true。请注意,在组件重新渲染之前,此处对参数所做的任何更改都不会应用于显示值。
  5. 如果满足上述条件,则在初始化过程中调用StateHasChanged,在OnParametersSet过程之后,以及任何事件回调。除非需要,否则不要在渲染或参数设置过程中显式调用它。如果你真的调用它,你可能做错了什么。

渲染过程

让我们详细了解一个简单的页面和组件是如何呈现的。

SimpleComponent.razor

Loaded

SimplePage.razor

@page "/simple"

SimplePage

@if (loaded) { } else {
Loading.....
} @code { private bool loaded; protected async override Task OnInitializedAsync() { await Task.Delay(2000); loaded = true; } }

下图显示了一个简化的RenderTree,它代表了一个简单的/路径。

深入了解Blazor组件_第4张图片

 

注意NavMenu中的三个NavLink控件的三个节点。

在我们的页面上,渲染树在第一次渲染时看起来像下图——我们有一个 yielding OnInitializedAsync方法,所以StateHasChanged在初始化过程中运行。

深入了解Blazor组件_第5张图片

 

初始化完成后,StateHasChanged将运行第二次。现在Loadedtrue并且SimpleComponent被添加到RenderFragment组件中。当Renderer运行RenderFragment,SimpleComponent被添加到渲染树,实例化和初始化。

深入了解Blazor组件_第6张图片

 

组件内容

更改SimpleComponentSimplePage为:

SimpleComponent.razor

@ChildContent
@code { [Parameter] public RenderFragment ChildContent { get; set; } }

SimplePage.razor

@page "/simple"

SimplePage

@if (loaded) { } else {
Loading.....
} @code { private bool loaded; protected async override Task OnInitializedAsync() { await Task.Delay(2000); loaded = true; } protected void ButtonClick(MouseEventArgs e) { var x = true; } }

现在SimpleComponent中有内容了。当应用程序运行时,该内容将在父组件的上下文中执行。如何?

答案在SimpleComponent中。 SimpleComponent页面中删除[Parameter]属性并运行页面。它错误:

InvalidOperationException: Object of type 'xxx.SimpleComponent' 
has a property matching the name 'ChildContent', 
but it does not have [ParameterAttribute] or [CascadingParameterAttribute] applied.

如果组件具有内容,即开始标记和结束标记之间的标记,Blazor期望在组件中找到命名为ChildContentParameter。标签之间的内容被预编译成一个 RenderFragment,然后添加到组件中。RenderFragment的内容在拥有它——SimplePage——的对象的上下文中运行。

内容也可以这样定义:


    
        
    

该页面也可以重新编写如下,现在谁拥有RenderFragment

@page "/simple"

SimplePage

@if (loaded) { @_childContent } else {
Loading.....
} @code { private bool loaded; protected async override Task OnInitializedAsync() { await Task.Delay(2000); loaded = true; } protected void ButtonClick(MouseEventArgs e) { var x = true; } private RenderFragment _childContent => (builder) => { builder.OpenElement(0, "button"); builder.AddAttribute(1, "class", "btn btn-primary"); builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, ButtonClick)); builder.AddContent(3, "Click Me"); builder.CloseElement(); }; }

一个组件不限于单个RenderFragment。表组件可能如下所示:


    
...
...
...

组件事件

了解组件事件的最重要的一点是它们不是一劳永逸的。默认情况下,所有事件都是异步的,如下所示:

await calltheeventmethod
StateHasChanged();

因此以下代码不会按预期执行:

void async ButtonClick(MouseEventArgs e) 
{
  await Task.Delay(2000);
  UpdateADisplayProperty();
}

DisplayProperty不显示当前值,直到另一个StateHasChanged的事件发生。为什么?ButtonClick不返回Task,因此事件处理程序无需等待。它在UpdateADisplayProperty完成之前运行StateHasChanged

这是一个创可贴修复——这是不好的做法。

void async ButtonClick(MouseEventArgs e) 
{
  await Task.Delay(2000);
  UpdateADisplayProperty();
  StateHasChanged();
}

正确的解决办法是:

Task async ButtonClick(MouseEventArgs e) 
{
  await Task.Delay(2000);
  UpdateADisplayProperty();
}

现在事件句柄有一个Task等待并且在完成StateHasChanged之前不会执行ButtonClick

一些重要的较少记录的信息和经验教训

保持参数属性简单

您的参数声明应如下所示:

[Parameter] MyClass myClass {get; set;}

不要gettersetter添加代码。为什么?任何setter都必须作为渲染过程的一部分运行,并且会对渲染速度和组件状态产生重大影响。

重写SetParametersAsync

如果您重写SetParametersAsync,您的方法应如下所示:

public override Task SetParametersAsync(ParameterView parameters)
{
    // always call first
    parameters.SetParameterProperties(this);
    // Your Code
    .....
    // pass an empty ParameterView, not parameters
    return base.SetParametersAsync(ParameterView.Empty);
}

在第一行设置参数并调用传递ParameterView.Empty的基本方法。不要试图传递parameters——你会得到一个错误。

将参数视为不可变的

切勿在代码中设置参数。如果要进行或跟踪更改,请执行以下操作:

[Parameter] public int MyParameter { get; set; }
private int _MyParameter;
public event EventHandler MyParameterChanged;

public async override Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this);
    if (!_MyParameter.Equals(MyParameter))
    {
        _MyParameter = MyParameter;
        MyParameterChanged?.Invoke(_MyParameter, EventArgs.Empty);
    }
    await base.SetParametersAsync(ParameterView.Empty);
}

迭代器

当使用For迭代器循环遍历集合以构建select或数据表时,会出现一个常见问题。一个典型的例子如下所示:

@for (var counter = 0; counter < this.myList.Count; counter++)
{
    
}
@for (var counter = 0; counter < this.myList.Count; counter++)
{
    
}
Value = @this.value
@code { private List myList => new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; private int value; private Task ButtonClick(int value) { this.value = value; return Task.CompletedTask; } }

如果您单击第一行中的按钮,您将收到Index was out of range错误。单击第二行中的按钮,值始终为10。原因是在您单击按钮之前,迭代器已经完成,此时counter10

要解决此问题,请在循环中设置一个局部变量,如下所示:

@for (var counter = 0; counter < this.myList.Count; counter++)
{
    var item = this.myList[counter];
    
}
@for (var counter = 0; counter < this.myList.Count; counter++)
{
    var item = this.myList[counter];
    var thiscount = counter;
    
}

最好的解决方案是使用ForEach

@foreach  (var item in this.myList)
{
    
}

组件编号

使用迭代器来自动化组件元素的编号似乎是合乎逻辑的。别这么做。比较引擎使用编号系统来决定DOM的哪些位需要更新,哪些位不需要。RenderFragment中的编号必须是一致的。您可以使用OpenRegionCloseRegion来定义具有自己的数字空间的区域。有关更详细的解释,请参阅此要点

构建组件

可以通过三种方式定义组件:

  1. 作为在@code块内带有代码的.razor文件。
  2. 作为.razor文件和.razor.cs文件背后的代码。
  3. 作为继承自ComponentBaseComponentBase继承类的纯.cs类文件,或实现IComponent

所有都在一个Razor文件

HelloWorld.razor

@HelloWorld
@code { [Parameter] public string HelloWorld {get; set;} = "Hello?"; }

背后的代码

HelloWorld.razor

@inherits ComponentBase
@namespace CEC.Blazor.Server.Pages

@HelloWorld

HelloWorld.razor.cs

namespace CEC.Blazor.Server.Pages
{
    public partial class HelloWorld : ComponentBase
    {
        [Parameter]
        public string HelloWorld {get; set;} = "Hello?";
    }
}

C#

HelloWorld.cs

namespace CEC.Blazor.Server.Pages
{
    public class HelloWorld : ComponentBase
    {
        [Parameter]
        public string HelloWorld {get; set;} = "Hello?";

        protected override void BuildRenderTree(RenderTreeBuilder builder)
        {
            builder.OpenElement(0, "div");
            builder.AddContent(1, (MarkupString)this._Content);
            builder.CloseElement();
        }
    }
}

一些观察

  1. 有一种倾向是在OnInitializedOnInitializedAsync中堆积太多的代码,然后使用事件来驱动组件树的StateHasChanged更新。将相关代码放入生命周期中的正确位置,您就不需要事件了。
  2. 有一种诱惑是从非异步版本开始(因为它们更容易实现),并且仅在必须时才使用异步版本,而情况恰恰相反。大多数基于Web的活动本质上是异步的。我从不使用非异步版本——我的工作原则是在某些时候,我需要添加异步行为。
  3. StateHasChanged被频繁调用,通常是因为代码在组件生命周期中的位置错误,或者事件编码不正确。问自己一个具有挑战性的为什么?” 当您键入StateHasChanged
  4. 组件在UI中未得到充分利用。重复使用相同的代码/标记块。与C#代码相同的规则适用于代码/标记块。
  5. 一旦您真正地、真正地理解了组件,编写Blazor代码就会成为一种完全不同的体验。

https://www.codeproject.com/Articles/5277618/A-Dive-into-Blazor-Components

你可能感兴趣的:(ASP.NET,CORE,架构及框架,Blazor组件)