【问题标题】:Component creation order组件创建顺序
【发布时间】:2019-11-25 12:49:12
【问题描述】:

我正在尝试创建一个 Tab 组件,我可以将其数据绑定到单例模型对象。

所以我有一个状态对象

public class AppState
{
    private List<TabValue> _tabValues = new List<TabValue>();
    private TabValue _selectedValue = null;

    public AppState()
    {
        Add(new TabValue { Title = "Tab 0", Contents = "Contents 0" });
        Add(new TabValue { Title = "Tab 1", Contents = "Contents 1" });
        Add(new TabValue { Title = "Tab 2", Contents = "Contents 2" });
    }

    public TabValue[] TabValues { get { return _tabValues.ToArray(); } }

    public void Add(TabValue tabValue)
    {
        this._tabValues.Add(tabValue);
        if (_selectedValue == null)
            _selectedValue = tabValue;
        OnChanged();
    }

    public TabValue SelectedValue
    {
        get { return _selectedValue; }
        set { _selectedValue = value; OnChanged(); }
    }

    public event Action Changed;
    protected virtual void OnChanged() { if (Changed != null) Changed(); }
}

public class TabValue
{
    public string Title { get; set; }
    public string Contents { get; set; }
}

我想将此呈现为 Tab 控件,并且我想要 2 路绑定,即当我更改 AppState 对象时,UI 会反映它,而当 UI Tab 控件中的活动选项卡发生更改时,AppState 中的状态为更新了。

@inject AppState MyAppState

@{Debug.WriteLine("Page.BuildRenderTree " + MyAppState.TabValues.Count());}
<TabControl TabPageChanged="@MyTabIndexChanged">
    <ChildContent>
        @foreach (var tabData in MyAppState.TabValues)
        {
            Debug.WriteLine("Page.BuildRenderTree TabPage: " + tabData.Title);
            <TabPage @key="@tabData" Text="@tabData.Title" Selected="@(MyAppState.SelectedValue == tabData)">
                @tabData.Contents
            </TabPage>
        }
    </ChildContent>
</TabControl>

@code {
    private void MyTabIndexChanged(int tabIndex)
    {
        MyAppState.SelectedValue = MyAppState.TabValues[tabIndex];
        this.StateHasChanged();
    }

    protected override void OnInitialized()
    {
        MyAppState.Changed += this.StateHasChanged;
    }

    public void Dispose()
    {
        MyAppState.Changed -= this.StateHasChanged;
    }
}

我已将此代码添加到默认 VS 生成项目的“计数器”页面中。

TabControl 代码基于 Blazor-Univerity 代码。

@{Debug.WriteLine("TabControl.BuildRenderTree " + Pages.Count);}
<div class="btn-group" role="group">
    @foreach (TabPage tabPage in Pages)
    {
        Debug.WriteLine("TabControl.BuildRenderTree TabHeader " + tabPage.Text);
        <button type="button"
                class="btn @GetButtonClass(tabPage)"
                @onclick="@(() => OnTabPageChanged(tabPage))">
            @tabPage.Text
        </button>
    }
</div>
<CascadingValue Value="this">
    @{Debug.WriteLine("TabControl.BuildRenderTree ChildContent " + Pages.Count);}
    @ChildContent
</CascadingValue>

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

    [Parameter]
    public EventCallback<int> TabPageChanged { get; set; }

    List<TabPage> Pages = new List<TabPage>();


    public TabPage ActivePage
    {
        get { return this.Pages.Where(t => t.Selected).FirstOrDefault() ?? this.Pages.FirstOrDefault(); }
    }

    protected virtual void OnTabPageChanged(TabPage tabPage)
    {
        TabPageChanged.InvokeAsync(Pages.IndexOf(tabPage));
        this.StateHasChanged();
    }


    internal void AddPage(TabPage tabPage)
    {
        Pages.Add(tabPage);
    }

    private string GetButtonClass(TabPage page)
    {
        return page.Selected ? "btn-primary" : "btn-secondary";
    }

    protected override void OnInitialized()
    {
        Debug.WriteLine("TabControl.OnInitialized " + Pages.Count);
        base.OnInitialized();
    }
}

最后是 TabPage

@{Debug.WriteLine($"TabPage.BuildRenderTree {Text}"); }
@if (Parent.ActivePage == this)
{
    <div>
        @ChildContent
    </div>
}

@code {
    private bool _Selected = false;

    [CascadingParameter]
    private TabControl Parent { get; set; }

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

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

    [Parameter]
    public bool Selected
    {
        get { return _Selected; }
        set
        {
            if (_Selected == value)
                return;
            _Selected = value;
            StateHasChanged();
        }
    }

    protected override void OnInitialized()
    {
        if (Parent == null)
            throw new ArgumentNullException(nameof(Parent), "TabPage must exist within a TabControl");

        Debug.WriteLine($"TabPage.OnInitialized {Text}");
        base.OnInitialized();
        Parent.AddPage(this);
    }
}

所以当我运行它时,它不会显示我的选项卡控件,但在跟踪的帮助下,我添加了明显的原因

Page.BuildRenderTree 3
TabControl.OnInitialized 0    <-- NO TABS
TabControl.BuildRenderTree 0
TabControl.BuildRenderTree ChildContent 0
Page.BuildRenderTree TabPage: Tab 0
Page.BuildRenderTree TabPage: Tab 1
Page.BuildRenderTree TabPage: Tab 2
TabPage.OnInitialized Tab 0
TabPage.OnInitialized Tab 1
TabPage.OnInitialized Tab 2
TabPage.BuildRenderTree Tab 0
TabPage.BuildRenderTree Tab 1
TabPage.BuildRenderTree Tab 2

我似乎在呈现 TabControl 时还不知道它的 TabPages。

我不知道如何解决这个问题......

我已经尝试了很多次迭代和一些第 3 方选项卡控件,我的 UI 似乎总是在更新后刷新(注意按下“计数器”按钮会导致页面按预期刷新)。

所以我的问题是如何确保在呈现 TabControl 时初始化子 TabPages。

目前正在运行 blazor-server-side。

加载时呈现的内容

使用计数器“点击我”按钮强制刷新后呈现的内容

【问题讨论】:

  • 如果我仔细阅读了代码,您在单击按钮时设置了页面的Selected 属性,但在TabPage 中您测试了Parent.ActivePage。但这并没有改变。在 TabPageSelected 属性字段的 _Selected 上设置测试
  • 抱歉,我错过了挂钩 MyAppState.Changed 事件的代码(所以它应该是这样的 - TabPage 按钮单击设置 MyAppState.SelectedValue,它会触发 MyAppState.Changed 事件,从而导致重新渲染),但最初的问题是 TabControl 是在为其提供选项卡之前呈现的,因此它呈现为空。
  • 根据您的日志,呈现了 3 个选项卡。
  • 但是在呈现 TabControl 的地方,它没有选项卡,因此没有呈现选项卡按钮 (TabControl.BuildRenderTree 0)。然后要求选项卡本身呈现,以便呈现活动选项卡内容,但没有标题按钮。
  • 旁注,但“我可以将数据绑定到单例的选项卡组件”是否意味着当您拥有超过 1 个用户时,您对谁控制选项卡有疑问?还有比赛条件?

标签: data-binding blazor blazor-server-side


【解决方案1】:

基本上我所有的问题都归结为事物的创建顺序。 直到渲染之后才创建选项卡,为了选择 ActiveTab,我们(可能)需要了解所有选项卡,所以这是先有鸡还是先有蛋的问题。

因此,我们需要重新编写代码,使选项卡在呈现之前不需要。

TabSet.razor

<!-- Display the tab headers -->
<CascadingValue Value=this>
    <ul class="nav nav-tabs">
        @ChildContent
    </ul>

    <!-- Display body for only the active tab -->
    <div class="nav-tabs-body p-4">
        @if (HasActiveTab)
        {
            @ActiveTab?.ChildContent
        }
    </div>
</CascadingValue>
@code {
    [Parameter]
    public RenderFragment ChildContent { get; set; }

    [Parameter]
    public EventCallback<int> TabChanged { get; set; }

    [Parameter]
    public int SelectedIndex { get; set; }


    public List<Tab> Tabs = new List<Tab>();

    public void AddTab(Tab tab)
    {
        Tabs.Add(tab);
        StateHasChanged();
    }

    public void RemoveTab(Tab tab)
    {
        Tabs.Remove(tab);
    }

    public bool HasActiveTab { get { return SelectedIndex >= 0 && SelectedIndex < Tabs.Count; } }

    public bool IsActiveTab(Tab tab)
    {
        if (!HasActiveTab)
            return false;

        // NOTE : We may not have all the tabs loaded at this point
        int tabIndex = Tabs.IndexOf(tab);
        return tabIndex == SelectedIndex;
    }

    // Note : unsafe to call before the parent has rendered all its children (i.e. ChildContent)
    // instead use IsActiveTab
    public Tab ActiveTab
    {
        get
        {
            return Tabs[SelectedIndex];
        }
        set
        {
            SelectedIndex = this.Tabs.IndexOf(value);
            TabChanged.InvokeAsync(SelectedIndex);
            StateHasChanged();
        }
    }
}

Tab.razor

@implements IDisposable

<li>
    <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
        @Title
    </a>
</li>

@code {
    [CascadingParameter]
    public TabSet ContainerTabSet { get; set; }

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

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

    // NOTE: use IsActiveTab within the Tab, NOT ActiveTab
    private string TitleCssClass => ContainerTabSet.IsActiveTab(this) ? "active" : null;

    private void Activate()
    {
        ContainerTabSet.ActiveTab = this;
    }

    // attach the tab to the tabset
    protected override void OnInitialized()
    {
        base.OnInitialized();
        ContainerTabSet.AddTab(this);
    }

    // detach the tab to the tabset
    public void Dispose()
    {
        this.ContainerTabSet.RemoveTab(this);
    }
}

下面显示了渲染的顺序。请注意,选项卡现在仅呈现其标题部分。 TabSet 负责呈现选定的选项卡内容。 Tab 和 TabSet 之间的交互工作正常,但只知道当前 Tab 和已经渲染的 Tab(未知数量的 Tab 尚未渲染,只要我们不编写假设它们存在的代码,一切都很好)。

// TabSet.Tabs is empty
TabSet            <ul class="nav nav-tabs">
TabSet                 @ChildContent
TabSet             </ul>

// TabSet.Tabs is empty
Tab0               <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
Tab0                   @Title
Tab0               </a>

Tab0.OnInitialized ContainerTabSet.AddTab(this);

// TabSet.Tabs contains (Tab0)
Tab1               <a @onclick="Activate" class="nav-link @TitleCssClass" role="button">
Tab1                   @Title
Tab1               </a>

Tab1.OnInitialized ContainerTabSet.AddTab(this);

// TabSet.Tabs contains (Tab0, Tab1)
More Tabs ...

// TabSet.Tabs is complete and contains (Tab0, Tab1, ...)
TabSet                 @if (HasActiveTab)
TabSet                 {
TabSet                     @ActiveTab?.ChildContent
TabSet                 }

仍有许多潜在问题。当一个 Tab 标题被渲染时,我们需要知道它是否被选中,因为样式会相应地改变。那么让我们看看 TitleCssClass。

private string TitleCssClass => ContainerTabSet.IsActiveTab(this) ? "active" : null;

这看起来很简单,但我们需要更详细地查看 IsActiveTab。

public bool IsActiveTab(Tab tab)
{
    if (!HasActiveTab)
        return false;

    // NOTE : We may not have all the tabs loaded at this point
    int tabIndex = Tabs.IndexOf(tab);
    return tabIndex == SelectedIndex;
}

这是一种无需加载所有选项卡即可确定给定选项卡是否为选定选项卡的安全方法。如果我们使用了ActiveTab == this,那么我们就会遇到麻烦,因为 ActiveTab 的代码看起来像这样 'Tabs[SelectedIndex]',如果 Tabs 只是部分加载,那么我们最终可能会得到一个超出范围的索引。

另外一点是,当一个标签被移除时,我们必须通知标签集它已经消失了。我们在 Tab.Dispose 方法中执行此操作。这里假设框架会在适当的时候调用 Dispose。

一个奇怪的地方是 TabSet 剃须刀代码中需要 @if (HasActiveTab) 保护。似乎第一次运行它要么没有标签,要么没有呈现子标签。守卫解决了问题,在第一次加载后不再需要。如果有人能对此有所了解,我会很感兴趣。

【讨论】:

  • 我还让子组件调用父组件,让它知道它存在;在该注册期间,我对父级进行了更新,导致它重新渲染(尽管可能要等到所有子级都注册后)。它运行相当流畅,并且使管理生命周期更简单。
  • @VictorThomasWilcoxJr。我假设孩子们在 OnInitialized() 期间注册。我们对注册发生的顺序有任何保证吗(具体来说,我们可以保证这将按照子组件在标记中出现的顺序发生吗?)
  • @Pancake 你是对的,我将父接口级联并在 OnInitialized 中链接到它。对组件生命周期的广泛搜索并没有确认它们的初始化顺序,我可以找到......我通常不按索引引用项目,但我的菜单控件对弹出窗口中的第一个和最后一个项目使用特殊的 css -- 它们似乎总是以正确的顺序正确呈现,但如果发现这种行为记录并得到保证,那就太好了。
  • @VictorThomasWilcoxJr。自从发表我的评论以来,我能够确认 OnInitialized() 实际上会根据它们在渲染队列中的位置在组件上被调用,这反映了它们在 DOM 中的位置。渲染过程同步开始,在这个初始同步阶段调用 OnInitialized()。
猜你喜欢
  • 1970-01-01
  • 2011-07-10
  • 1970-01-01
  • 2020-04-24
  • 1970-01-01
  • 2021-09-11
  • 2011-07-19
  • 2018-09-27
  • 1970-01-01
相关资源
最近更新 更多