یکی از نکات جالب رندر کامپوننتها در Blazor، امکان فراخوانی بازگشتی آنها است؛ یعنی یک کامپوننت میتواند خودش را نیز فراخوانی کند. از همین قابلیت میتوان جهت نمایش ساختارهای درختی، مانند
مدلهای خود ارجاع دهندهی EF استفاده کرد.
مدل برنامه، جهت تامین دادههای خود ارجاع دهنده و درختی
فرض کنید قصد داریم لیستی از کامنتهای تو در تو را مدل سازی کنیم که در آن هر کامنت، میتواند چندین کامنت تا بینهایت سطح تو در تو را داشته باشد:
namespace BlazorTreeView.ViewModels;
public class Comment
{
public IList<Comment> Comments = new List<Comment>();
public string? Text { set; get; }
}
برای نمونه بر اساس این مدل، منبع دادهی فرضی زیر را تهیه میکنیم:
using BlazorTreeView.ViewModels;
namespace BlazorTreeView.Pages;
public partial class TreeView
{
private IReadOnlyDictionary<string, object> ChildrenHtmlAttributes { get; } =
new Dictionary<string, object>(StringComparer.Ordinal)
{
{ "style", "list-style: none;" },
};
private IList<Comment> Comments { get; } =
new List<Comment>
{
new()
{
Text = "پاسخ یک",
},
new()
{
Text = "پاسخ دو",
Comments =
new List<Comment>
{
new()
{
Text = "پاسخ اول به پاسخ دو",
Comments =
new List<Comment>
{
new()
{
Text = "پاسخی به پاسخ اول پاسخ دو",
},
},
},
new()
{
Text = "پاسخ دوم به پاسخ دو",
},
},
},
new()
{
Text = "پاسخ سوم",
},
};
}
این قطعه کد partial class که مربوط به فایل TreeView.razor.cs برنامهاست، در حقیقت کدهای پشت صحنهی کامپوننت مثال TreeView.razor است که در ادامه آنرا توسعه خواهیم داد. در نهایت قرار است بتوانیم آنرا به صورت زیر رندر کنیم:
طراحی کامپوننت DntTreeView
برای اینکه بتوانیم به یک کامپوننت با قابلیت استفادهی مجدد بررسیم، کدهای نمایش اطلاعات تو در تو و درختی را توسط کامپوننت سفارشی DntTreeView پیاده سازی خواهیم کرد. پیشنیازهای آن نیز به صورت زیر است:
- این کامپوننت باید جنریک باشد؛ یعنی باید به صورت زیر شروع شود:
/// <summary>
/// A custom DntTreeView
/// </summary>
public partial class DntTreeView<TRecord>
{
چون باید بتوان یک لیست جنریک <IEnumerable<TRecord را به آن، جهت رندر ارسال کرد و قرار نیست این کامپوننت، تنها به شیء سفارشی Comment مثال جاری ما وابسته باشد. بنابراین اولین خاصیت آن، شیء جنریک Items است که لیست کامنتها/عناصر را دریافت میکند:
/// <summary>
/// The treeview's self-referencing items
/// </summary>
[Parameter]
public IEnumerable<TRecord>? Items { set; get; }
- هنگام رندر هر آیتم کامنت باید بتوان یک قالب سفارشی را از کاربر دریافت کرد. نمیخواهیم صرفا برای مثال Text شیء Comment فوق را به صورت متنی و ساده نمایش دهیم. میخواهیم در حین رندر، کل شیء TRecord جاری را به مصرف کننده ارسال و یک قالب سفارشی را از آن دریافت کنیم. یعنی باید یک RenderFragment جنریک را به صورت زیر نیز داشته باشیم تا مصرف کننده بتواند TRecord در حال رندر را دریافت و قالب Htmlای خودش را بازگشت دهد:
/// <summary>
/// The treeview item's template
/// </summary>
[Parameter]
public RenderFragment<TRecord>? ItemTemplate { set; get; }
- همچنین همیشه باید به فکر عدم وجود اطلاعاتی برای نمایش نیز بود. به همین جهت بهتر است قالب دیگری را نیز از مصرف کننده برای اینکار درخواست کنیم و نحوهی رندر سفارشی این قسمت را نیز به مصرف کننده واگذار کنیم:
/// <summary>
/// The content displayed if the list is empty
/// </summary>
[Parameter]
public RenderFragment? EmptyContentTemplate { set; get; }
- زمانیکه با شیء از پیش تعریف شدهی Comment این مثال کار میکنیم، کاملا مشخص است که خاصیت Comments آن تو در تو است:
public class Comment
{
public IList<Comment> Comments = new List<Comment>();
public string? Text { set; get; }
}
اما زمانیکه با یک کامپوننت جنریک کار میکنیم، نیاز است از مصرف کننده، نام این خاصیت تو در تو را به نحو واضحی دریافت کنیم؛ به صورت زیر:
/// <summary>
/// The property which returns the children items
/// </summary>
[Parameter]
public Expression<Func<TRecord, IEnumerable<TRecord>>>? ChildrenSelector { set; get; }
دلیل استفاده از Expression Funcها را در مطلب «
static reflection» میتوانید مطالعه کنید. زمانیکه قرار است از کامپوننت DntTreeView استفاده کنیم، ابتدا نوع جنریک آنرا مشخص میکنیم، سپس لیست اشیاء ارسالی به آنرا و در ادامه با استفاده از ChildrenSelector به صورت زیر، مشخص میکنیم که خاصیت Comments است که به همراه Children میباشد و تو در تو است:
<DntTreeView
TRecord="Comment"
Items="Comments"
ChildrenSelector="m => m.Comments"
و مرسوم است جهت بالابردن کارآیی Expression Funcها، آنها را کامپایل و کش کنیم که نمونهای از روش آنرا به صورت زیر مشاهده میکنید:
public partial class DntTreeView<TRecord>
{
private Expression? _lastCompiledExpression;
internal Func<TRecord, IEnumerable<TRecord>>? CompiledChildrenSelector { private set; get; }
// ...
protected override void OnParametersSet()
{
if (_lastCompiledExpression != ChildrenSelector)
{
CompiledChildrenSelector = ChildrenSelector?.Compile();
_lastCompiledExpression = ChildrenSelector;
}
}
}
تا اینجا ساختار کدهای پشت صحنهی DntTreeView.razor.cs مشخص شد. اکنون UI این کامپوننت را به صورت زیر تکمیل میکنیم:
@namespace BlazorTreeView.Pages.Components
@typeparam TRecord
@if (Items is null || !Items.Any())
{
@EmptyContentTemplate
}
else
{
<CascadingValue Value="this">
<ul @attributes="AdditionalAttributes">
@foreach (var item in Items)
{
<DntTreeViewChildrenItem TRecord="TRecord" ParentItem="item"/>
}
</ul>
</CascadingValue>
}
در ابتدای کار، اگر آیتمی برای نمایش وجود نداشته باشد، EmptyContentTemplate دریافتی از استفاده کننده را رندر میکنیم. در غیراینصورت، حلقهای را بر روی لیست Items ایجاد کرده و آنها را یکی نمایش میدهیم. این نمایش، نکات زیر را به همراه دارد:
- نمایش توسط کامپوننت دومی به نام DntTreeViewChildrenItem انجام میشود که آنهم جنریک است و شیء item جاری را توسط خاصیت ParentItem دریافت میکند.
- در اینجا یک CascadingValue اشاره کننده به شیء this را هم مشاهده میکنید. این روش، یکی از روشهای اجازه دادن دسترسی به خواص و امکانات یک کامپوننت والد، در کامپوننتهای فرزند است که در ادامه از آن استفاده خواهیم کرد.
تکمیل کامپوننت بازگشتی DntTreeViewChildrenItem.razor
اگر به حلقهی foreach (var item in Items) در کامپوننت DntTreeView.razor دقت کنید، یک سطح را بیشتر پوشش نمیدهد؛ اما کامنتهای ما چندسطحی و تو در تو هستند و عمق آنها هم مشخص نیست. به همین جهت نیاز است به نحوی بتوان یک طراحی recursive و بازگشتی را در کامپوننتهای Blazor داشت که خوشبختانه این مورد پیشبینی شدهاست و هر کامپوننت Blazor، میتواند خودش را نیز فراخوانی کند:
@namespace BlazorTreeView.Pages.Components
@typeparam TRecord
<li @attributes="@SafeOwnerTreeView.ChildrenHtmlAttributes" @key="ParentItem?.GetHashCode()">
@if (SafeOwnerTreeView.ItemTemplate is not null && ParentItem is not null)
{
@SafeOwnerTreeView.ItemTemplate(ParentItem)
}
@if (Children is not null)
{
<ul>
@foreach (var item in Children)
{
<DntTreeViewChildrenItem TRecord="TRecord" ParentItem="item"/>
}
</ul>
}
</li>
اینها کدهای DntTreeViewChildrenItem.razor هستند که در آن، ابتدا ItemTemplate دریافتی از والد یا همان DntTreeView.razor رندر میشود. سپس به کمک CompiledChildrenSelector ای که عنوان شد، یک شیء Children را تشکیل داده و آنرا به خودش (فراخوانی مجدد DntTreeViewChildrenItem در اینجا)، ارسال میکند. این فراخوانی بازگشتی، سبب رندر تمام سطوح تو در توی شیء جاری میشود.
کدهای پشت صحنهی این کامپوننت یعنی فایل DntTreeViewChildrenItem.razor.cs به صورت زیر است:
/// <summary>
/// A custom DntTreeView
/// </summary>
public partial class DntTreeViewChildrenItem<TRecord>
{
/// <summary>
/// Defines the owner of this component.
/// </summary>
[CascadingParameter]
public DntTreeView<TRecord>? OwnerTreeView { get; set; }
private DntTreeView<TRecord> SafeOwnerTreeView =>
OwnerTreeView ??
throw new InvalidOperationException("`DntTreeViewChildrenItem` should be placed inside of a `DntTreeView`.");
/// <summary>
/// Nested parent item to display
/// </summary>
[Parameter]
public TRecord? ParentItem { set; get; }
private IEnumerable<TRecord>? Children =>
ParentItem is null || SafeOwnerTreeView.CompiledChildrenSelector is null
? null
: SafeOwnerTreeView.CompiledChildrenSelector(ParentItem);
}
با استفاده از یک پارامتر از نوع CascadingParameter، میتوان به اطلاعات شیء CascadingValue ای که در کامپوننت والد DntTreeView.razor قرا دادیم، دسترسی پیدا کنیم. سپس یکبار هم بررسی میکنیم که آیا نال هست یا خیر. یعنی قرار نیست که این کامپوننت فرزند، درجائی به صورت مستقیم استفاده شود. فقط قرار است داخل کامپوننت والد فراخوانی شود. به همین جهت اگر این CascadingParameter نال بود، یعنی این کامپوننت فرزند، به اشتباه فراخوانی شده و با صدور استثنائی این مساله را گوشزد میکنیم. اکنون که به SafeOwnerTreeView یا همان نمونهای از شیء والد دسترسی پیدا کردیم، میتوانیم پارامتر CompiledChildrenSelector آنرا نیز فراخوانی کرده و توسط آن، به شیء تو در توی جدیدی در صورت وجود، جهت رندر بازگشتی آن رسید.
یعنی این کامپوننت ابتدا ParentItem، یا اولین سطح ممکن و در دسترس را رندر میکند. سپس با استفاده از Expression Func مهیای در کامپوننت والد، شیء فرزند را در صورت وجود یافته و سپس به صورت بازگشتی آنرا
با فراخوانی مجدد خودش ، رندر میکند.
روش استفاده از کامپوننت DntTreeView
اکنون که کار توسعهی کامپوننت جنریک DntTreeView پایان یافت، روش استفادهی از آن به صورت زیر است:
<div class="card" dir="rtl">
<div class="card-header">
DntTreeView
</div>
<div class="card-body">
<DntTreeView
TRecord="Comment"
Items="Comments"
ChildrenSelector="m => m.Comments"
style="list-style: none;"
ChildrenHtmlAttributes="ChildrenHtmlAttributes">
<ItemTemplate Context="record">
<div class="card mb-1">
<div class="card-body">
<span>@record.Text</span>
</div>
</div>
</ItemTemplate>
<EmptyContentTemplate>
<div class="alert alert-warning">
There is no item to display!
</div>
</EmptyContentTemplate>
</DntTreeView>
</div>
</div>
همانطور که مشاهده میکنید، چون کامپوننت جنریک است، باید نوع TRecord را که در مثال ما، شیء Comment است، مشخص کرد. سپس لیست نظرات، خاصیت تو در تو، قالب سفارشی نمایش Text نظرات (با توجه به Context دریافتی که امکان دسترسی به شیء جاری در حال رندر را میسر میکند) و همچنین قالب سفارشی نبود اطلاعاتی برای نمایش را تعریف میکنیم.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: BlazorTreeView.zip
کامپوننت توسعه یافتهی در اینجا در هر دو حالت Blazor WASM و Blazor Server کار میکند.