備註
這不是本文的最新版本。 關於目前版本,請參閱 本文的 .NET 10 版本。
警告
此版本的 ASP.NET Core 已不再受支援。 如需詳細資訊,請參閱 .NET 和 .NET Core 支援政策。 如需目前的版本,請參閱 本文的 .NET 9 版本。
將轉譯速度優化,以將轉譯工作負載降到最低,並改善UI回應性,這可產生 十倍以上的 UI轉譯速度改善。
避免不必要的元件子樹渲染
當事件發生時,您可以避免子元件子樹的重新呈現,這樣能減少大部分父元件的渲染消耗。 您應該只擔心略過轉譯子樹,而轉譯成本特別高且造成UI延遲。
在運行時間,元件存在於階層中。 根元件(載入的第一個元件)具有子元件。 接著,根系的子系有自己的子元件等等。 當事件發生時,例如使用者選取按鈕時,以下過程來決定哪些元件需要重新渲染:
- 事件會分派至轉譯事件處理程式的元件。 執行事件處理程序之後,會重新呈現元件。
- 重新呈現元件時,它會為其每個子元件提供參數值的新複本。
- 收到一組新的參數值之後, Blazor 決定是否要重新呈現元件。 如果
ShouldRender傳回true,則元件會重新呈現,這是預設行為,除非被覆寫,且參數值可能已變更,例如,如果它們是可變動的物件。
上述序列的最後兩個步驟會以遞迴的方式逐層深入至元件層級。 在許多情況下,會重新呈現整個子樹。 以高階元件為目標的事件可能會造成昂貴的重新調整,因為高階元件下方的每個元件都必須重新調整。
若要防止將遞迴轉譯成特定子樹,請使用下列其中一種方法:
- 請確定元件參數屬於特定的不可變類型†,例如
string、int、bool和DateTime。 如果不可變的參數值尚未變更,偵測變更的內建邏輯會自動略過重新調整。 如果您使用<Customer CustomerId="item.CustomerId" />轉譯子元件,而CustomerId是int類型,則除非Customer改變,否則item.CustomerId元件不會重新呈現。 - 覆寫
ShouldRender,回傳false:- 當參數是非慣用型別或不支援的不可變型別時†,例如複雜的自定義模型類型或 RenderFragment 值,而且參數值尚未變更時,
- 撰寫僅限於 UI 的元件,該元件在初始渲染後不會因參數值變更而變動。
† 如需詳細資訊,請參閱 參考來源 (Blazor) 中的ChangeDetection.cs變更偵測邏輯。
備註
通常,指向 .NET 參考來源的文件連結會載入存放庫的預設分支,這代表 .NET 下一版本的最新開發進度。 若要選取特定發行版本的標籤,請使用「切換分支或標籤」下拉式清單。 如需詳細資訊,請參閱如何選取 ASP.NET Core 原始程式碼 (dotnet/AspNetCore.Docs #26205) 的版本標籤。
下列航空公司航班搜尋工具範例會使用私人欄位來追蹤必要的資訊來偵測變更。 先前的入境航班識別碼(prevInboundFlightId)和先前的出境航班識別碼(prevOutboundFlightId)用於追蹤下一次潛在元件更新的資訊。 如果其中一個航班識別碼在設定元件的參數時發生變更,則會重新渲染元件,因為 OnParametersSet 設定為 shouldRender。 如果在檢查飛行識別碼後,shouldRender 被評估為 false,則可以避免昂貴的重新渲染。
@code {
private int prevInboundFlightId = 0;
private int prevOutboundFlightId = 0;
private bool shouldRender;
[Parameter]
public FlightInfo? InboundFlight { get; set; }
[Parameter]
public FlightInfo? OutboundFlight { get; set; }
protected override void OnParametersSet()
{
shouldRender = InboundFlight?.FlightId != prevInboundFlightId
|| OutboundFlight?.FlightId != prevOutboundFlightId;
prevInboundFlightId = InboundFlight?.FlightId ?? 0;
prevOutboundFlightId = OutboundFlight?.FlightId ?? 0;
}
protected override bool ShouldRender() => shouldRender;
}
事件處理程式也可以設定 shouldRender 為 true。 針對大部分元件,通常不需要在個別事件處理器層級決定重新呈現。
如需詳細資訊,請參閱下列資源:
虛擬化
例如,在迴圈中轉譯大量的使用者介面時,如具有數千個項目的清單或方格,轉譯作業的數量可能會導致使用者介面轉譯延遲。 由於使用者一次只能看到少量的元素,而需要捲動才能查看更多,因此通常浪費時間來渲染那些目前不可見的元素。
Blazor Virtualize<TItem>提供元件來創建任意大型清單的外觀和捲動行為,同時只渲染當前捲動畫面中的清單項目。 例如,元件可以轉譯具有100,000個項目的清單,但只需支付可見20個項目的轉譯成本。
如需詳細資訊,請參閱 ASP.NET Core Razor 元件虛擬化。
建立輕量型、優化的元件
大部分 Razor 元件不需要積極的優化工作,因為大部分元件不會在UI中重複,而且不會以高頻率重新調整。 例如,具有 @page 指示詞的可路由元件,以及用來呈現UI高階片段的元件,例如對話框或窗體,最有可能一次只出現一個,而且只會重新呈現以回應使用者手勢。 這些元件通常不會建立高轉譯工作負載,因此您可以自由使用任何架構功能組合,而不必擔心轉譯效能。
不過,在某些情況下,元件會大規模重複,而且通常會導致UI效能不佳:
- 具有數百個個別元素的大型巢狀窗體,例如輸入或標籤。
- 具有數百列或數千個儲存格的網格線。
- 具有數百萬個數據點的散佈圖。
如果將每個元素、數據格或數據點模型化為個別的元件實例,其渲染效能通常會變得十分重要。 本節提供讓這類元件變得輕量型的建議,讓UI保持快速且回應性。
避免數千個元件實例
每個元件都是一個獨立的島嶼,可以獨立於其父元件和子元件進行渲染。 藉由選擇如何將 UI 分割成元件階層,您就可以控制 UI 轉譯的數據粒度。 這可能會導致良好或不佳的效能。
藉由將 UI 分割成個別的元件,您可以在事件發生時重新調整 UI 的較小部分。 在具有每個數據列中具有按鈕之許多數據列的數據表中,您可能只能使用子元件來重新調整單一數據列,而不是整個頁面或數據表。 不過,每個元件都需要額外的記憶體和CPU額外負荷,才能處理其獨立狀態和轉譯生命週期。
在 ASP.NET Core 產品單元工程師所執行的測試中,Blazor WebAssembly 應用程式中的每個元件實例觀察到大約 0.06 毫秒的渲染負荷。 測試應用程式轉譯了接受三個參數的簡單元件。 在內部,額外負荷主要是因為從字典擷取每個元件狀態,並傳遞和接收參數。 透過乘法,您可以看到新增 2,000 個額外的元件實例會將 0.12 秒新增至轉譯時間,而 UI 會開始對使用者感到緩慢。
您可以讓元件更輕量,讓您可以擁有更多元件。 不過,更強大的技術通常是避免擁有這麼多元件來轉譯。 下列各節說明您可以採取兩種方法。
如需記憶體管理的詳細資訊,請參閱 管理已部署 ASP.NET Core 伺服器端 Blazor 應用程式中的記憶體。
內嵌子元件至其父元件: 請考慮下列父元件的一部分,它在循環中呈現子元件:
<div class="chat">
@foreach (var message in messages)
{
<ChatMessageDisplay Message="message" />
}
</div>
ChatMessageDisplay.razor:
<div class="chat-message">
<span class="author">@Message.Author</span>
<span class="text">@Message.Text</span>
</div>
@code {
[Parameter]
public ChatMessage? Message { get; set; }
}
如果一次未顯示數千則訊息,則上述範例會執行良好。 若要一次顯示數千則訊息,請考慮 不要 將個別 ChatMessageDisplay 元件分解出來。 相反地,將子元件內嵌至父元件。 以下方法可避免渲染眾多子元件的每個元件額外負擔,代價是失去了獨立重新渲染每個子元件的標記的能力:
<div class="chat">
@foreach (var message in messages)
{
<div class="chat-message">
<span class="author">@message.Author</span>
<span class="text">@message.Text</span>
</div>
}
</div>
在程式碼中定義可重複使用的RenderFragments: 您可能將子元件分解,純粹是為了重複使用其轉譯邏輯。 如果是這種情況,您可以建立可重複使用的轉譯邏輯,而不實作其他元件。 在任何元件的 @code 區塊中,定義 RenderFragment。 根據需要從任何位置渲染片段多次。
@RenderWelcomeInfo
<p>Render the welcome content a second time:</p>
@RenderWelcomeInfo
@code {
private RenderFragment RenderWelcomeInfo = @<p>Welcome to your new app!</p>;
}
若要讓 RenderTreeBuilder 程式代碼在多個元件之間重複使用,請宣告 RenderFragmentpublic 和 static:
public static RenderFragment SayHello = @<h1>Hello!</h1>;
SayHello 在上述範例中,可以從不相關的元件叫用。 這項技術對於建置可重複使用標記代碼段的連結庫很有用,這些代碼段會轉譯而不需要個別元件的額外負荷。
RenderFragment 委派方法可以接受參數。 下列元件會將訊息 (message) 傳遞至 RenderFragment 委派:
<div class="chat">
@foreach (var message in messages)
{
@ChatMessageDisplay(message)
}
</div>
@code {
private RenderFragment<ChatMessage> ChatMessageDisplay = message =>
@<div class="chat-message">
<span class="author">@message.Author</span>
<span class="text">@message.Text</span>
</div>;
}
上述方法會重複使用轉譯邏輯,而不需要每個元件的額外負荷。 不過,此方法無法獨立刷新UI的子樹,也無法在父層呈現時跳過UI子樹的呈現,由於不存在元件邊界。
RenderFragment 元件檔案中僅支援指派給 Razor 委派 (.razor)。
對於欄位初始化表示式無法參考的非靜態欄位、方法或屬性,例如在下列範例中的 TitleTemplate,請使用屬性代替 RenderFragment 的欄位。
protected RenderFragment DisplayTitle =>
@<div>
@TitleTemplate
</div>;
不要接收太多參數
如果元件非常頻繁地重複,例如數百次或數千次,傳遞和接收每個參數的額外負荷就會累積起來。
太多參數很少會嚴重限制效能,但可能是一個因素。 對於在 TableCell 方格內轉譯 4,000 次的元件,傳遞至元件的每個參數都會將大約 15 毫秒新增至總轉譯成本。 傳遞十個參數大約需要 150 毫秒,並導致 UI 轉譯延遲。
若要減少參數負載,請將自定義類別中的多個參數組合在一起。 例如,表格單元格元件可能會接受通用物件。 在下列範例中, Data 每個數據格都不同,但 Options 在所有數據格實例中都是通用的:
@typeparam TItem
...
@code {
[Parameter]
public TItem? Data { get; set; }
[Parameter]
public GridOptions? Options { get; set; }
}
不過,請記住,將基本參數組合成類別並不總是一個優點。 雖然它可以減少參數的數量,但它也會影響變更偵測和渲染的行為。 傳遞非基本參數一律會觸發重新轉譯,因為 Blazor 不知道任意物件是否有內部可變動的狀態,而傳遞基本參數只會在實際變更其值時觸發重新轉譯。
此外,請考慮不使用表格單元格元件可能是一種改進,如上述範例所示,而是將其邏輯直接內嵌到父元件中。
備註
當有多個方法可用來改善效能時,通常需要對方法進行基準檢驗,以判斷哪一種方法會產生最佳結果。
如需泛型型別參數的詳細資訊(@typeparam),請參閱下列資源:
確定串連參數已固定
元件CascadingValue具有選擇性IsFixed參數:
- 如果
IsFixed為false(預設值),則重迭值的每個收件者都會設定訂閱以接收變更通知。 由於訂閱追蹤,每個[CascadingParameter]的成本 都顯著地更昂貴 高於一般[Parameter]。 - 如果
IsFixed是true(例如<CascadingValue Value="someValue" IsFixed="true">),則收件者會收到初始值,但不會設定訂閱以接收更新。 每個[CascadingParameter]都是輕量型,且不比一般[Parameter]更昂貴。
當有大量其他元件接收串聯值時,將 IsFixed 設定為 true 可以改善效能。 在可能的情況下,在串聯值上設定 IsFixed 為 true 。 當提供的值不會隨著時間變更時,您可以設定 IsFixed 為 true 。
當元件傳遞 this 為串聯值時,IsFixed 也可以設定為 true,因為 this 元件生命週期期間永遠不會變更:
<CascadingValue Value="this" IsFixed="true">
<SomeOtherComponents>
</CascadingValue>
如需詳細資訊,請參閱 ASP.NET Core Blazor 串聯值和參數。
避免使用屬性散佈CaptureUnmatchedValues
元件可以選擇使用 CaptureUnmatchedValues 旗標接收「不相符」參數值:
<div @attributes="OtherAttributes">...</div>
@code {
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? OtherAttributes { get; set; }
}
這個方法允許將任意額外的屬性傳遞至元素。 不過,這種方法很昂貴,因為轉譯器必須:
- 比對所有提供的參數與一組已知參數,以建置字典。
- 追蹤相同屬性的多份副本如何彼此覆寫。
在元件轉譯效能不重要的地方使用 CaptureUnmatchedValues,例如不頻繁重複的元件。 對於需要大規模渲染的元件,如大型列表中的每個項目或網格單元中的每個項目,請嘗試避免使用屬性展開技術。
如需詳細資訊,請參閱 ASP.NET Core Blazor 屬性展開和任意參數。
手動實作SetParametersAsync
每個元件轉譯額外負荷的重要來源是將傳入的參數值寫入屬性 [Parameter] 。 轉譯器會使用 反映 來寫入參數值,這可能會導致大規模效能不佳。
在某些極端情況下,您可能想要避免使用反射,並手動設定自己的參數邏輯。 這在下列情況下可能適用:
- 例如,當UI中有數百或數千個元件時,元件會極為頻繁地渲染。
- 元件接受許多參數。
- 您會發現接收參數的額外負荷會對UI回應性造成可觀察的影響。
在極端情況下,您可以覆寫元件的虛擬 SetParametersAsync 方法,並實作您自己的元件特定邏輯。 下列範例會刻意避免字典查閱:
@code {
[Parameter]
public int MessageId { get; set; }
[Parameter]
public string? Text { get; set; }
[Parameter]
public EventCallback<string> TextChanged { get; set; }
[Parameter]
public Theme CurrentTheme { get; set; }
public override Task SetParametersAsync(ParameterView parameters)
{
foreach (var parameter in parameters)
{
switch (parameter.Name)
{
case nameof(MessageId):
MessageId = (int)parameter.Value;
break;
case nameof(Text):
Text = (string)parameter.Value;
break;
case nameof(TextChanged):
TextChanged = (EventCallback<string>)parameter.Value;
break;
case nameof(CurrentTheme):
CurrentTheme = (Theme)parameter.Value;
break;
default:
throw new ArgumentException($"Unknown parameter: {parameter.Name}");
}
}
return base.SetParametersAsync(ParameterView.Empty);
}
}
在上述程式代碼中,傳回基類 SetParametersAsync 會執行一般生命週期方法,而不需再次指派參數。
如您在上述程式代碼中所見,覆寫 SetParametersAsync 和提供自定義邏輯很複雜且費力,因此我們通常不建議採用此方法。 在極端情況下,它可以藉由 20-25%來改善轉譯效能,但您應該只在本節稍早所列的極端案例中考慮此方法。
不要太快速觸發事件
某些瀏覽器事件會極其頻繁地觸發。 例如,onmousemove 和 onscroll 可以每秒發射數十次或數百次。 在大部分情況下,您不需要經常執行UI更新。 如果事件觸發速度過快,您可能會損害 UI 回應性,或耗用過多的 CPU 時間。
與其使用快速觸發的原生事件,不如考慮使用 JS interop 來註冊一個觸發頻率較低的回調。 例如,下列元件會顯示滑鼠的位置,但最多每 500 毫秒更新一次:
@implements IDisposable
@inject IJSRuntime JS
<h1>@message</h1>
<div @ref="mouseMoveElement" style="border:1px dashed red;height:200px;">
Move mouse here
</div>
@code {
private ElementReference mouseMoveElement;
private DotNetObjectReference<MyComponent>? selfReference;
private string message = "Move the mouse in the box";
[JSInvokable]
public void HandleMouseMove(int x, int y)
{
message = $"Mouse move at {x}, {y}";
StateHasChanged();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
selfReference = DotNetObjectReference.Create(this);
var minInterval = 500;
await JS.InvokeVoidAsync("onThrottledMouseMove",
mouseMoveElement, selfReference, minInterval);
}
}
public void Dispose() => selfReference?.Dispose();
}
對應的 JavaScript 程式代碼會註冊 DOM 事件接聽程式以進行滑鼠移動。 在此範例中,事件接聽程式會使用 Lodash的 throttle 函 式來限制調用的速率:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js"></script>
<script>
function onThrottledMouseMove(elem, component, interval) {
elem.addEventListener('mousemove', _.throttle(e => {
component.invokeMethodAsync('HandleMouseMove', e.offsetX, e.offsetY);
}, interval));
}
</script>
避免在不變更狀態的情況下處理事件之後重新呈現
元件繼承自 ComponentBase,其會在叫用元件的事件處理程式之後自動叫 StateHasChanged 用。 在某些情況下,叫用事件處理程序之後,可能會不必要或不想要觸發重新調整。 例如,事件處理程式可能不會修改元件狀態。 在這些情境中,應用程式可以利用 IHandleEvent 介面來控制 Blazor 的事件處理行為。
備註
本節中的方法不會將例外狀況流向 錯誤界限。 如需透過呼叫 ComponentBase.DispatchExceptionAsync支援錯誤界限的詳細資訊和示範程序代碼,請參閱 AsNonRenderingEventHandler + ErrorBoundary = 非預期的行為 (dotnet/aspnetcore#54543)。
若要防止所有元件的事件處理程式重新調整,請實 IHandleEvent 作並提供一項 IHandleEvent.HandleEventAsync 工作,以叫用事件處理程式而不呼叫 StateHasChanged。
在下列範例中,未新增至元件的事件處理程式觸發重新調整,因此 HandleSelect 不會在叫用時產生重新調整。
HandleSelect1.razor:
@page "/handle-select-1"
@using Microsoft.Extensions.Logging
@implements IHandleEvent
@inject ILogger<HandleSelect1> Logger
<p>
Last render DateTime: @dt
</p>
<button @onclick="HandleSelect">
Select me (Avoids Rerender)
</button>
@code {
private DateTime dt = DateTime.Now;
private void HandleSelect()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler doesn't trigger a rerender.");
}
Task IHandleEvent.HandleEventAsync(
EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg);
}
除了可以全域方式在元件中觸發事件處理程式後防止重新呈現外,還可以藉由採用下列工具方法,防止單一事件處理程式之後的重新呈現。
將下列 EventUtil 類別新增至 Blazor 應用程式。 類別頂端的 EventUtil 靜態動作和函式會提供事件處理程序,這些程序能涵蓋 Blazor 在處理事件時使用的多種自變數和傳回型別組合。
EventUtil.cs:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
public static class EventUtil
{
public static Action AsNonRenderingEventHandler(Action callback)
=> new SyncReceiver(callback).Invoke;
public static Action<TValue> AsNonRenderingEventHandler<TValue>(
Action<TValue> callback)
=> new SyncReceiver<TValue>(callback).Invoke;
public static Func<Task> AsNonRenderingEventHandler(Func<Task> callback)
=> new AsyncReceiver(callback).Invoke;
public static Func<TValue, Task> AsNonRenderingEventHandler<TValue>(
Func<TValue, Task> callback)
=> new AsyncReceiver<TValue>(callback).Invoke;
private record SyncReceiver(Action callback)
: ReceiverBase { public void Invoke() => callback(); }
private record SyncReceiver<T>(Action<T> callback)
: ReceiverBase { public void Invoke(T arg) => callback(arg); }
private record AsyncReceiver(Func<Task> callback)
: ReceiverBase { public Task Invoke() => callback(); }
private record AsyncReceiver<T>(Func<T, Task> callback)
: ReceiverBase { public Task Invoke(T arg) => callback(arg); }
private record ReceiverBase : IHandleEvent
{
public Task HandleEventAsync(EventCallbackWorkItem item, object arg) =>
item.InvokeAsync(arg);
}
}
呼叫 EventUtil.AsNonRenderingEventHandler 以呼叫在被調用時不觸發渲染的事件處理程式。
在以下範例中:
- 選取第一個呼叫
HandleClick1的按鈕會觸發重新渲染。 - 選取第二個會呼叫
HandleClick2的按鈕不會觸發重新調整。 - 選取第三個會呼叫
HandleClick3的按鈕不會觸發重新調整,並使用 事件自變數 (MouseEventArgs)。
HandleSelect2.razor:
@page "/handle-select-2"
@using Microsoft.Extensions.Logging
@inject ILogger<HandleSelect2> Logger
<p>
Last render DateTime: @dt
</p>
<button @onclick="HandleClick1">
Select me (Rerenders)
</button>
<button @onclick="EventUtil.AsNonRenderingEventHandler(HandleClick2)">
Select me (Avoids Rerender)
</button>
<button @onclick="EventUtil.AsNonRenderingEventHandler<MouseEventArgs>(HandleClick3)">
Select me (Avoids Rerender and uses <code>MouseEventArgs</code>)
</button>
@code {
private DateTime dt = DateTime.Now;
private void HandleClick1()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler triggers a rerender.");
}
private void HandleClick2()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler doesn't trigger a rerender.");
}
private void HandleClick3(MouseEventArgs args)
{
dt = DateTime.Now;
Logger.LogInformation(
"This event handler doesn't trigger a rerender. " +
"Mouse coordinates: {ScreenX}:{ScreenY}",
args.ScreenX, args.ScreenY);
}
}
除了實作 IHandleEvent 介面之外,利用本文所述的其他最佳做法,也有助於減少處理事件之後不必要的轉譯。 例如,在目標元件的子元件中覆寫 ShouldRender 可用來控制重新呈現。
避免針對許多重複的專案或元件重新建立委派
Blazor在迴圈中重新建立元素或元件的 Lambda 運算式委派 可能會導致效能不佳。
事件 處理文章 中顯示的下列元件會轉譯一組按鈕。 每個按鈕都會將委派指派給其 @onclick 事件,如果需要渲染的按鈕數量不多,則不會有問題。
EventHandlerExample5.razor:
@page "/event-handler-example-5"
<h1>@heading</h1>
@for (var i = 1; i < 4; i++)
{
var buttonNumber = i;
<p>
<button @onclick="@(e => UpdateHeading(e, buttonNumber))">
Button #@i
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private void UpdateHeading(MouseEventArgs e, int buttonNumber)
{
heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
}
}
@page "/event-handler-example-5"
<h1>@heading</h1>
@for (var i = 1; i < 4; i++)
{
var buttonNumber = i;
<p>
<button @onclick="@(e => UpdateHeading(e, buttonNumber))">
Button #@i
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private void UpdateHeading(MouseEventArgs e, int buttonNumber)
{
heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
}
}
如果使用上述方法轉譯大量的按鈕,轉譯速度會受到負面影響,導致用戶體驗不佳。 若要顯示具有點擊事件回呼函式的大量按鈕,下列範例會使用按鈕對象的集合,將每個按鈕的 @onclick 委派指派給 Action。 下列方法不需要 Blazor 在每次轉譯按鈕時重建所有按鈕委派:
LambdaEventPerformance.razor:
@page "/lambda-event-performance"
<h1>@heading</h1>
@foreach (var button in Buttons)
{
<p>
<button @key="button.Id" @onclick="button.Action">
Button #@button.Id
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private List<Button> Buttons { get; set; } = new();
protected override void OnInitialized()
{
for (var i = 0; i < 100; i++)
{
var button = new Button();
button.Id = Guid.NewGuid().ToString();
button.Action = (e) =>
{
UpdateHeading(button, e);
};
Buttons.Add(button);
}
}
private void UpdateHeading(Button button, MouseEventArgs e)
{
heading = $"Selected #{button.Id} at {e.ClientX}:{e.ClientY}";
}
private class Button
{
public string? Id { get; set; }
public Action<MouseEventArgs> Action { get; set; } = e => { };
}
}