您当前的位置: 首页 > 

寒冰屋

暂无认证

  • 1浏览

    0关注

    2286博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

深入了解Blazor组件

寒冰屋 发布时间:2021-12-11 15:49:03 ,浏览量:1

目录

介绍

什么是组件?

渲染器和渲染树

客户端应用程序

Blazor服务器

Blazor Web Assembly

App.razor

组件

HelloWorld组件

一个简单的IComponent实现

路由组件

组件库

ComponentBase生命周期和事件

渲染过程

SimpleComponent.razor

组件内容

SimpleComponent.razor

SimplePage.razor

组件事件

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

保持参数属性简单

重写SetParametersAsync

将参数视为不可变的

迭代器

组件编号

构建组件

所有都在一个Razor文件

背后的代码

C# 类

一些观察

介绍

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

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

什么是组件?

微软定义:

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

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

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

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

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

渲染器和渲染树

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

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

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

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

 

客户端应用程序 Blazor服务器

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


    

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

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

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

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

Blazor Web Assembly

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

....

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

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

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

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

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

App.razor

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

App 看起来像这样:


    
        
    
    
        
            

Sorry, there's nothing at this address.

它是一个Razor组件,定义一个子组件Router。Router有两个RenderFragments,Found和NotFound。如果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. 当Renderer将IComponent对象附加到RenderTree时调用Attach。它传递组件RenderHandle struct。组件使用此渲染句柄排队RenderFragments到Renderer的RenderQueue中。我们很快就会更详细地研究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. 有没有OnInitialized,OnAfterRender,StateHasChanged...这些都是ComponentBase的一部分。

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

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

路由组件

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

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

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

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

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

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

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

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

ComponentBase是IComponent的“标准的”开箱即用的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——经常被遗忘

标准类的实例化方法构建RenderFragment该StateHasChanged传递到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类标志,并调用RenderHandle的Render方法。这个队列_renderFragement到Renderer RenderQueue。当队列运行时_renderFragment——见上文——它将两个类标志设置为false并运行BuildRenderTree。

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

需要注意的一些关键点:

  1. OnInitialized和OnInitializedAsync仅在初始化期间被调用。OnInitialized首先运行。当且仅当OnInitializedAsync返回给内部调用方法RunInitAndSetParametersAsync,然后StateHasChanged被调用,提供向用户提供“加载”信息的机会。OnInitializedAsync在OnParametersSet并OnParametersSetAsync被调用之前完成。
  2. OnParametersSet和OnParametersSetAsync在父组件更改组件的参数集或捕获的级联参数更改时调用。任何需要响应参数变化的代码都需要在这里。OnParametersSet首先运行。请注意,如果OnParametersSetAsync产生,StateHasChanged则在产生后运行,提供向用户提供“加载”信息的机会。
  3. StateHasChanged在OnParametersSet{async}方法完成后调用以呈现组件。
  4. OnAfterRender和OnAfterRenderAsync在所有四个事件结束时发生。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,它代表了一个简单的“/”路径。

 

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

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

 

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

 

组件内容

更改SimpleComponent和SimplePage为:

SimpleComponent.razor

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

SimplePage.razor

@page "/simple"
SimplePage
@if (loaded)
{
    
        Click Me
    
}
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期望在组件中找到命名为ChildContent的Parameter。标签之间的内容被预编译成一个 RenderFragment,然后添加到组件中。RenderFragment的内容在拥有它——SimplePage——的对象的上下文中运行。

内容也可以这样定义:


    
        
            Click Me
        
    

该页面也可以重新编写如下,现在谁拥有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;}

不要向getter或setter添加代码。为什么?任何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++)
{
    @this.myList[counter]
}
@for (var counter = 0; counter < this.myList.Count; counter++)
{
    @this.myList[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。原因是在您单击按钮之前,迭代器已经完成,此时counter为10。

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

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

最好的解决方案是使用ForEach。

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

组件编号

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

构建组件

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

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

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

关注
打赏
1665926880
查看更多评论
立即登录/注册

微信扫码登录

0.0497s