65.9K
CodeProject 正在变化。 阅读更多。
Home

Blazor 组件从 RenderFragment 模板进行回调

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2022 年 4 月 16 日

CPOL

6分钟阅读

viewsIcon

21753

downloadIcon

143

Blazor 组件方法从外部 RenderFragment 模板进行回调

问题

我当时正在开发一个 Blazor 组件,该组件需要在页脚模板中支持外部按钮,以与组件中的方法进行交互。

通常,组件会看起来像这样

<div>
    <h1>@HeaderText</h1>
    <p>@BodyText</p>
    @if (FooterTemplate is not null)
    {
        @FooterTemplate
    }
</div>

@code {
    [Parameter]
    public string HeaderText { get; set; }

    [Parameter]
    public string BodyText { get; set; }

    [Parameter]
    public RenderFragment? FooterTemplate { get; set; }

    private void OnClicked()
    {
        // do something here
    }
}

然后我们会这样使用该组件

<ComponentName>
    <FooterTemplate>
        <button @onclick="OnClicked">Close</button>
    </FooterTemplate>
</ComponentName>

这里的问题在于,按钮的 @onclick 会调用一个本地方法,而不是调用组件中的方法。

解决方案

本文重点介绍一种使用 RenderFragment<>EventCallback/EventCallback<> 的解决方案,以实现从外部模板调用组件中的方法。我们还将介绍如何使用相同的解决方案传递参数。

简而言之

可下载代码 解决方案可在文章末尾找到。

EventCallback

  1. 官方定义:已绑定的事件处理程序委托。
  2. Blazor UniversityEventCallback 类是一个特殊的 Blazor 类,可以作为参数公开,以便组件可以轻松地通知使用者发生了感兴趣的事件。在构建具有数据绑定的组件时,我们使用 EventCallback 来通知属性已更改。

RenderFragment

官方定义RenderFragment 表示要渲染的 UI 段。RenderFragment<TValue> 接受一个类型参数,该参数可以在调用渲染片段时指定。

实现

当我们查看 EventCallback 类中的构造函数和 InvokeAsync 方法的代码时,它定义如下:

public EventCallback(IHandleEvent? receiver, MulticastDelegate? @delegate)
{
    this.Receiver = receiver;
    this.Delegate = @delegate;
}

public Task InvokeAsync(object? arg)
    => this.Receiver == null
    ? EventCallbackWorkItem.InvokeAsync<object>(this.Delegate, arg)
    : this.Receiver.HandleEventAsync(
        new EventCallbackWorkItem(this.Delegate), arg);
        
public Task InvokeAsync() => this.InvokeAsync((object) null);

这里让我们感兴趣的是,我们可以在初始化时传递一个方法,并在调用时(可选地)传递参数。

RenderFragment<TValue> 允许我们将对象/类暴露给组件的模板。我们现在可以按如下方式修改上述有问题的代码:

  1. 组件 (Component)
    <div>
        <h1>@HeaderText</h1>
        <p>@BodyText</p>
        @if (FooterTemplate is not null)
        {
            @FooterTemplate(new EventCallback(null, OnCallbackClicked))
        }
    </div>
    
    @code {
        [Parameter]
        public string HeaderText { get; set; }
    
        [Parameter]
        public string BodyText { get; set; }
    
        [Parameter]
        public RenderFragment<EventCallback>? FooterTemplate { get; set; }
    
        private void OnCallbackClicked()
        {
            // do something here
        }
    }
  2. 用法
    <ComponentName>
        <FooterTemplate>
            <button @onclick="async ()
                => await context.InvokeAsync().ConfigureAwait(false)">
                Close
            </button>
        </FooterTemplate>
    </ComponentName>

    我们可以使用方法组来简化此代码

    <ComponentName>
        <FooterTemplate>
            <button @onclick="context">
                Close
            </button>
        </FooterTemplate>
    </ComponentName>

那么它是如何工作的呢?

context 是从组件传递到模板的 EventCallback。当模板中的按钮被按下时,context 上的 InvokeAsync 方法将被调用,并执行组件中的委托 OnCallbackClicked 方法。

通过上面的方法组简化,编译器会自动调用 contextEventCallback)类上的 InvokeAsync,正如 EventCallback 类中定义的那样。

如果我们想将参数传回组件怎么办?

我们使用泛型 EventCallback<T> 来传递一个(1)个或多个参数。为此,我们将使用一个参数类。

  1. 参数
    public interface IElementCallbackArgs
    {
        /* base interface */
    }
    
    public class MessageCallbackArgs : IElementCallbackArgs
    {
        public string? Message { get; set; }
    }
  2. 组件 (Component)
    <div>
        <h1>@HeaderText</h1>
        <p>@BodyText</p>
        @if (FooterTemplate is not null)
        {
            @FooterTemplate(
                new EventCallback<IElementCallbackArgs>(
                null,
                new Action<MessageCallbackArgs> (args => OnCallbackClicked(args))))
        }
    </div>
    
    @code {
        [Parameter]
        public string HeaderText { get; set; }
    
        [Parameter]
        public string BodyText { get; set; }
    
        [Parameter]
        public RenderFragment<EventCallback<IElementCallbackArgs>>?
            FooterTemplate { get; set; }
    
        private void OnCallbackClicked(MessageCallbackArgs args)
        {
            // do something here
        }
    }

    同样,我们可以使用方法组来简化代码

    <div>
        <h1>@HeaderText</h1>
        <p>@BodyText</p>
        @if (FooterTemplate is not null)
        {
            @FooterTemplate(
                new EventCallback<IElementCallbackArgs>(
                null,
                OnCallbackClicked))
        }
    </div>
  3. 用法
    <ComponentName>
        <FooterTemplate>
            <button @onclick="async () => await OnClickedAsync(context)">
                Close
            </button>
        </FooterTemplate>
    </ComponentName>
    
    @code {
        private static async Task OnClickedAsync(
            EventCallback<IElementCallbackArgs> callback)
                => await callback.InvokeAsync(
                new MessageCallbackArgs
                {
                    Message = "message goes here"
                }).ConfigureAwait(false);
    }

    所以,就像第一个回调一样,这里我们执行相同的调用,但是我们传递了特定于事件/按钮按下的数据。

改进

当前的代码按预期工作。我们可以将 EventCallback 封装在包装器接口和类中。下面的代码是一个可以扩展的基础实现。

  1. 定义
    public interface IElementCallback
    {
        EventCallback Execute { get; }
    }
    
    public interface IElementCallback<T>
    {
        EventCallback<T> Execute { get; }
    }
  2. 实现
    public class ElementCallback : IElementCallback
    {
        public ElementCallback(MulticastDelegate  @delegate)
            => Execute = new EventCallback(null, @delegate);
    
        public EventCallback Execute { get; }
    }
    
    public class ElementArgsCallback : IElementArgsCallback
    {
        public ElementArgsCallback(MulticastDelegate  @delegate)
            => Execute = new EventCallback<IElementCallbackArgs>(null, @delegate);
    
        public EventCallback<IElementCallbackArgs> Execute { get; }
    }

示例 1 - 基本

下面的示例在组件内有一个按钮,在模板中有一个按钮。这是为了模拟组件可以有一个预设按钮或允许一个可选的自定义按钮。

  1. 组件:BasicComponent
    <button type="button"
            class="btn btn-primary me-4"
            @onclick="ButtonClicked">
        OK
    </button>
    
    @if (ContentTemplate is not null)
    {
        @ContentTemplate(new ElementCallback(OnCallbackClicked))
    }
    <hr />
    <ul>
        @if (!Messages.Any())
        {
            <li>No buttons clicked...</li>
        }
        else
        {
            @foreach (string message in Messages)
            {
                <li>@message</li>
            }
        }
    </ul>
    <hr />
    
    @code {
        [Parameter]
        public RenderFragment<IElementCallback>? ContentTemplate { get; set; }
    
        private void ButtonClicked()
            => Clicked($"Button clicked in {nameof(BasicComponent)}");
    
        private void OnCallbackClicked()
            => Clicked("External button clicked");
    
        private readonly IList<string> Messages = new List<string>();
    
        private void Clicked(string message)
        {
            Messages.Add(message);
            InvokeAsync(StateHasChanged);
        }
    }
  2. 实现
    <h2>Example 1 - Simple ElementCallback</h2>
    <BasicComponent>
        <ContentTemplate>
            <button type="button" class="btn btn-outline-success"
                    @onclick="context.Execute">
                Template OK
            </button>
        </ContentTemplate>
    </BasicComponent>
  3. 输出

可以在下面的 下载 中找到演示项目 WasmComponentCallback1ServerComponentCallback1

示例 2 - 参数

此示例扩展了第一个示例,并将消息传回给组件。

  1. 组件:BasicComponent
    <button type="button"
            class="btn btn-primary me-4"
            @onclick="ButtonClicked">
        OK
    </button>
    
    @if (ContentTemplate is not null)
    {
        @ContentTemplate(new ElementArgsCallback(OnCallbackClicked))
    }
    <hr />
    <ul>
        @if (!Messages.Any())
        {
            <li>No buttons clicked...</li>
        }
        else
        {
            @foreach (string message in Messages)
            {
                <li>@message</li>
            }
        }
    </ul>
    <hr />
    
    @code {
        [Parameter]
        public RenderFragment<IElementArgsCallback>? ContentTemplate { get; set; }
        
        private void ButtonClicked()
            => Clicked($"Button clicked in {nameof(ArgsComponent)}");
    
        private void OnCallbackClicked(MessageCallbackArgs args)
            => Clicked(args.Message ?? "External button clicked");
    
        private readonly IList<string> Messages = new List<string>();
    
        private void Clicked(string message)
        {
            Messages.Add(message);
            InvokeAsync(StateHasChanged);
        }
    }
  2. 实现
    <h2>Example 2 -  Message ElementCallback</h2>
    <ArgsComponent>
        <ContentTemplate>
            <button type="button" class="btn btn-outline-success"
                    @onclick="@(async () => await ClickedAsync(context.Execute))">
                Template OK
            </button>
        </ContentTemplate>
    </ArgsComponent>
    
    @code {
        private int _count = 1;
    
        private async Task ClickedAsync(
            EventCallback<IElementCallbackArgs> callback)
            => await callback.InvokeAsync(
                new MessageCallbackArgs
                {
                    Message = $"Message > Click # {_count++}"
                }).ConfigureAwait(false);
    }
  3. 输出

可以在下面的 下载 中找到演示项目 WasmComponentCallback2ServerComponentCallback2

建议的替代解决方案

Shaun C Curtis 提出了一种 替代解决方案,其中组件本身被传递到模板,而不是 EventCallback

不被推荐,因为你不仅会失去上下文的意义,还会暴露不应该暴露给模板的属性和方法。意外的使用很可能会破坏你的组件。

一个更好的替代解决方案

如果带有参数的 EventCallback 有所限制,那么公开一个 Model 类来封装和控制外部暴露给模板(RenderFragment)的内容是推荐的。

  1. 模型与接口
    public interface IMessageModel
    {
        void SetText(string Text);
    }
    
    public class MessageModel : IMessageModel
    {
        private string? _text;
    
        [Parameter]
        public string Text
        {
            get => _text ?? "";
            set
            {
                if (_text == value)
                    return;
    
                _text = value;
                TextChanged.InvokeAsync(value);
                OnTextChanged?.Invoke(value);
            }
        }
    
        // for two-way binding inside the component
        [Parameter]
        public EventCallback<string> TextChanged { get; set; }
    
        // for Callback from outside the component
        void IMessageModel.SetText(string text)
            => Text = text;
    
        public event Action<string>? OnTextChanged;
    }
  2. 组件(AltArgsCompponent
     @implements IDisposable
    
    <button type="button" class="btn btn-primary me-4"
            @onclick="ButtonClicked">
        OK
    </button>
    
    @if (ContentTemplate is not null)
    {
        @ContentTemplate(Message)
    }
    <hr />
    <ul>
        @if (!Messages.Any())
        {
            <li>No buttons clicked...</li>
        }
        else
        {
            @foreach (string message in Messages)
            {
                <li>@message</li>
            }
        }
    </ul>
    <hr />
        @if (!string.IsNullOrWhiteSpace(Message.Text))
        {
            <p>@($"{LastMessageTimestamp} > {Message.Text}")</p>
        }
        else
        {
            <p>Waiting for a message...</p>
        }
    <hr />
    
    @code {
    
        [Parameter]
        public RenderFragment<IMessageModel>? ContentTemplate { get; set; }
    
        private readonly MessageModel Message = new();
        private string? LastMessageTimestamp;
    
        private void ButtonClicked()
            => Clicked($"Button clicked in {nameof(AltArgsCompponent)}");
    
        protected override void OnInitialized()
        {
            Message.OnTextChanged += OnMessageModelChanged;
            base.OnInitialized();
        }
    
        private void OnMessageModelChanged(string message)
        {
            LastMessageTimestamp = DateTime.Now.ToLongTimeString();
            Clicked(message);
        }
    
        private readonly IList<string> Messages = new List<string>();
    
        private void Clicked(string message)
        {
            Messages.Add(message);
            InvokeAsync(StateHasChanged);
        }
    
        public void Dispose()
        {
            Message.OnTextChanged -= OnMessageModelChanged;
        }
    }
  3. 用法(Index.razor
     <h1>Alternative Template Callback Demo</h1>
    
    <AltArgsCompponent>
        <ContentTemplate>
            <button class="btn btn-dark"
                    @onclick="_ => context.SetText(GetMessage())">Click Me</button>
        </ContentTemplate>
    </AltArgsCompponent>
    
    @code {
        private int _count = 1;
    
        private string GetMessage()
            => $"Message > Click # {_count++}";
    }
  4. 输出

这展示了当 EventCallback 不适用时,如何正确实现替代解决方案。

这里的代码比我最初的解决方案多一点。MessageModel 同时实现了双向绑定和事件通知。它还使用了一个显式接口,只暴露模板允许使用的方法。

这也可以是一个 MVVM 友好的设计(注意:MVVM 超出了本文的范围)。

可以在下面的 下载 中找到演示项目 WasmComponentCallback3ServerComponentCallback3

另一个替代解决方案

在用更好的替代解决方案更新文章后,Shaun C Curtis 提出了另一种使用 Func<T, Task> 的解决方案。这是一个异步委托,它像上面的示例 2 一样共享 MessageCallbackArgs,而不是 EventCallback。

虽然他的解决方案在下面的评论中,但我在这里的文章中包含了它以确保完整性。

  1. 模型与接口(MessageCallbackArgs & IElementCallbackArgs
    public interface IElementCallbackArgs
    {
        /* base interface */
    }
    
    public class MessageCallbackArgs : IElementCallbackArgs
    {
        public string? Message { get; set; }
    }
  2. 组件(AltArgsCompponent
    <button type="button" class="btn btn-primary me-4"
            @onclick="ButtonClicked">
        OK
    </button>
    
    @if (ContentTemplate is not null)
    {
        @ContentTemplate(CallForward)
    }
    <hr />
    <ul>
        @if (!Messages.Any())
        {
            <li>No buttons clicked...</li>
        }
        else
        {
            @foreach (string message in Messages)
            {
                <li>@message</li>
            }
        }
    </ul>
    <hr />
    
    @code {
    
        [Parameter]
        public RenderFragment<Func<MessageCallbackArgs, Task>>?
            ContentTemplate { get; set; }
    
        [Parameter]
        public string? Message { get; set; }
    
        private Func<MessageCallbackArgs, Task> CallForward => this.ExternalClick;
    
        private void ButtonClicked()
            => Clicked($"Button clicked in {nameof(AltArgsComponent)}");
    
        private Task ExternalClick(MessageCallbackArgs args)
        {
            Clicked(args.Message ?? "External button clicked");
            return Task.CompletedTask;
        }
    
        private readonly IList<string> Messages = new List<string>();
    
        private void Clicked(string message)
        {
            Messages.Add(message);
            InvokeAsync(StateHasChanged);
        }
    }
  3. 用法(Index.razor
    <h1>Alternative 2 Template Callback Demo</h1>
    
    <AltArgsComponent>
        <ContentTemplate>
            <button type="button" class="btn btn-outline-dark"
                    @onclick="(e) => context(message)">
                Template OK
            </button>
        </ContentTemplate>
    </AltArgsComponent>
    
    @code {
        private int _count = 1;
    
        private MessageCallbackArgs message => new()
        {
            Message = $"Message > Click # {_count++}"
        };
    }
  4. 输出

可以在下面的 下载 中找到演示项目 WasmComponentCallback4ServerComponentCallback4

奖励 - Blazor Server 应用(嵌入式 Wasm)

将 Blazor Web Assembly 应用程序嵌入到 Blazor Server 应用程序中

我不喜欢 Blazor Server 应用程序,但是上面的解决方案同时适用于 Blazor WebAssembly 和 Blazor Server 应用程序。因此,对于这次更新,我使用了一个小技巧,将 Blazor Web Assembly 应用程序作为 RCL(Razor 组件库)嵌入到 Blazor Server 应用程序中。

为了使其正常工作,您需要:

  1. 创建 Blazor Server 应用
  2. 引用 Web Assembly 项目
  3. 删除不必要的文件(例如:wwwroot 中的重复文件、未使用的 razor 文件等)
  4. 更新 _Host.cshtml 文件,添加 css 引用并指向 Web Assembly App.razor

新的精简项目结构将如下所示:

_Host.cshtml 文件将如下所示:

@page "/"
@using Microsoft.AspNetCore.Components.Web
@namespace ServerComponentCallback4.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="WasmComponentCallback4.styles.css" rel="stylesheet" />
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
<app>
    <component type="typeof(WasmComponentCallback4.App)"
               render-mode="ServerPrerendered" />
</app>

<div id="blazor-error-ui">
    <environment include="Staging,Production">
        An error has occurred. This application may no longer respond until reloaded.
    </environment>
    <environment include="Development">
        An unhandled exception has occurred. See browser dev tools for details.
    </environment>
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

<script src="_framework/blazor.server.js"></script>
</body>
</html>

关键部分是:

  1. 链接 WebAssembly css 样式表
    <link href="WasmComponentCallback4.styles.css" rel="stylesheet" />
  2. 指向 Web Assembly App razor 类
    <app>
        <component type="typeof(WasmComponentCallback4.App)"
                   render-mode="ServerPrerendered" />
    </app>

以这种方式设置项目的关键好处是,Blazor Web Assembly 和 Blazor Server 应用程序可以共享同一代码库并保持同步。

识别 Web Assembly 应用程序模式

我还包含了一段代码,用于识别 Web Assembly 应用是独立运行,还是作为 Blazor Server 应用中的 RCL 运行。如果您查看上面的两个(2)替代解决方案,您可以看到我在屏幕截图中包含了环境。

这是执行检查的代码:

[Inject]
protected IJSRuntime? ijsRuntime { get; set; }

private string Environment
    => ijsRuntime! is IJSInProcessRuntime ? "WebAssembly" : "Server";

此代码片段可以在 MainLayout.razor 中找到。

工作示例

下面是代码链接,从概念到最终实现,就像我最初研究问题时使用的一样,并在上面的文章中提到。

下载 v1.2 源代码 - 816.2 KB

摘要

启用组件方法回调的最终解决方案,带有可选数据,从外部模板提供了一个干净的实现,可以轻松地扩展以适应任何用例。

尽情享用!

历史

  • v1.0 - 2022 年 4 月 17 日 - 初始发布
  • v1.1 - 2022 年 4 月 19 日 - 添加了替代解决方案
  • v1.2 - 2022 年 4 月 21 日 - 添加了替代解决方案 2 + Blazor Server 应用项目
© . All rights reserved.