Blazor教程 第六课:组件的中级知识:组件间的数据通信

Blazor

我们上篇文章介绍了组件的基础知识,如何创建一个可复用的组件,以及如何定义、传递组件参数。在上篇文章中,我们花了很多篇幅去把组件类比为程序设计语言中的函数:它们都是可利用的代码片断、它们都能接受参数。

而参数的传递其实就是数据的传递,这一点和函数调用也是类似的:调用方/父组件方通过“传参”,将数据传递给被调函数/子组件。

但目前我们掌握的知识里,有个别扭的地方在于:在函数调用中,子函数也可以通过“返回值”向调用方传递数据,但在组件调用的时候,我们现在还不知道能有什么方式,能把数据从子组件传递到父组件里。

1. 把数据从子组件传递到父组件

你可能现在就要问:为什么会需要把数据从子组件传递出来呢?好像一时半会想不到什么场景下需要用到这种功能。。

来,我给你造个场景:我们上篇文章不是写了个消息提示框嘛,如果我们在Pages/Index.razor中把MessageBox调用了10次,那么页面上就会有10个消息提示框。假如,现在我们需要在Pages/Index.razor中展示:一共有几个提示框,以及用户叉掉了多少个提示框,怎么办?

自然的,“一共有几个提示框”这个信息,只能存储在调用方,即Pages/Index.razor里:因为只能调用者才清楚需要调用几次MessageBox,这很显然。同理,如果我们要记录“用户叉掉了几个提示框”的话,这个信息也只能记录在Pages/Index.razor中。

那就是说,我们在Pages/Index.razor中要设置两个计数:TotalMessageBoxCountDismissedMessageBoxCount。现在核心问题就是:如何在叉掉一个MessageBox的时候,让DismissedMessageBoxCount减一?或者换个说法,这个要解决的问题其实是:如何把“用户点击了叉按钮”这个事本身,作为数据,从MessageBox传递到Index中。

因为处理叉按钮的事件逻辑写在MessageBox内部,这就意味着只有在MessageBox内部上下文中,我们才能得知按钮是否被按下。到底怎么才能把这个消息传递出去呢?UI组件又没有“返回值”这个概念,怎么搞?

诶,这个时候就要发挥一点想象力了:我们可以把DismissedMessageBoxCount++封装成一个函数,然后以传递参数的形式传递给MessageBox,然后在MessageBox的事件处理回调里,调用这个函数,就可以实现这个效果了。逻辑示意图如下:

pass_action_to_sub_comp.png

从表面上来看:有两个数据被从父组件传递到子组件里:

  1. 提示消息本身
  2. 一个函数

而在第二层,当子组件收到这个函数后,执行这个函数时,会改写父组件里的状态。这就相当于,数据迂回的从子组件传递到了父组件里。

我们的MessageBox的代码就可以如下写:

@if(!this.CloseButtonHadBeenClicked)
{
    <div style="vertical-align:bottom; display:inline-block; min-width:100px; min-height:50px; border: 1px solid red; position: relative; padding: 5px">
            @this.ChildContent
       <button style="position: absolute; top: 5px; right: 5px" @onclick=@this.HandleCloseButtonClick>x</button>
    </div>
}

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

    [Parameter]
    public Action OnClose { get; set; } = default!;

    private bool CloseButtonHadBeenClicked = false;

    private void HandleCloseButtonClick()
    {
        this.CloseButtonHadBeenClicked = true;
        if(this.OnClose)
        {
            this.OnClose();
        }
    }
}

然后我们的Index.razor可以如下写:

@page "/"

@using HelloComponents.Client.Components
@using Microsoft.AspNetCore.Components.Rendering;

<div>
    <p>Total Message Box Count == @this.totalMessageBoxCount</p>
    <p>Dismissed Message Box Count == @this.dismissedMessageBoxCount</p>
</div>

@for(int i = 0; i < this.totalMessageBoxCount; ++i)
{
    <MessageBox OnClose=@this.increaseDismissedMessageBoxCount>
        <p>Message: #@i</p>
    </MessageBox>
}

@code {
    private int totalMessageBoxCount = 10;
    private int dismissedMessageBoxCount = 0;

    private void increaseDismissedMessageBoxCount()
    {
        this.dismissedMessageBoxCount++;

        Console.WriteLine($"Total == {this.totalMessageBoxCount}, Dismissed == {this.dismissedMessageBoxCount}");
    }
}

运行起来后的效果如下所示:

parent_not_refreshed

等等,好像有哪里有点不对劲!!

  1. 好消息:控制台日志已经打印出了dismissedMessageBoxCount的值已经变了,说明我们确实通过这种迂回的方式,把“数据”从子组件传递到了“父组件”
  2. 坏消息:父组件的UI并没有更新。

这是为什么呢?如何你仔细看过我们之前介绍数据渲染与事件处理的文章,就会知道,组件的重新渲染,默认情况下仅在事件处理后,由框架驱动发生。如果没有事件发生,那么组件就不会重新渲染/重绘。

而这个例子中,响应事件的是MessageBox组件,所以自然而然的,在用户按下叉按钮后,MessageBox得到了重绘:也就是消失了。但父组件Index中,没有任何事件被处理,自然框架就不会重新渲染它,即便Index实例在内存中的字段已经发生了变更。

而要如何改正这个程序呢?简单来说,我们需要在increaseDismissedMessageBoxCount()方法中,主动的,非常明确的告诉框架:“嗨,我内部状态有了变化,请重新渲染我”

即如下这样:

 ...
 ...
 @code {
     private void increaseDismissedMessageBoxCount()
     {
         this.dismissedMessageBoxCount++;
 
         Console.WriteLine($"Total == {this.totalMessageBoxCount}, Dismissed == {this.dismissedMessageBoxCount}");
 
+        this.StateHasChanged();
     }
 }
 ...
 ...

方法StateHasChanged()的意思就是告诉框架:“我的状态发生了变更”。暗含的意思就是:“请重新渲染我”

state_has_changed

总结起来,两个知识点:

  1. 数据从子组件传递到父组件,需要通过函数参数,迂回一下
  2. 如果没有事件处理,那么当前组件就不会重新渲染。可以通过StateHasChanged()方法主动触发重新渲染。

2. 父子组件之间的双向数据绑定

数据从父到子,传递参数是比较符合直觉的。数据从子到父,需要通过“函数指针”迂回一下,不是非常直观,但捏着鼻子也凑合能用。

为了改善“迂回很麻烦”这件事,Blazor提供了另外一种父子组件之间传递数据的方式:双向数据绑定。不过在介绍双向数据绑定之前,请明确以下几点:

  1. 这里我们说的双向数据绑定,不是我们之前提到的,整合了“数据渲染+事件处理”的双向数据绑定,而是另外一个概念,用来在父子组件之间传递数据
  2. 坦白讲,双向数据绑定写起来并不简洁,甚至还挺麻烦。

为了更好的解释说明“双向数据绑定”的功能和作用,我们这里新建一个组件:输入框。简单来说它就是一个文本框,用来收集用户键入的文本。父组件通过一个参数可以来订制文本框前面展示的提示字符串的内容。

新建文件Components/InputBox.razor,最简陋的代码实现如下:

<div style="display:inline-block; vertical-align: bottom">
    @this.Name : <input type="text"/>
</div>

@code {
    [Parameter]
    public string Name { get; set; } = "";
}

在上面这个实现中,我们甚至没有添加事件处理逻辑,来保存用户键入的文本。只是简单的写了个形状而已。

在调用方Pages/Index.razor中呢,我们分别以First Name, Second Name, Nick Name为参数,把InputBox调用三次。同时又有以下需求:

  1. Index需要收集到这三个文本框中用户输入的数据,存储在自身的上下文中。。为了便于演示,我们干脆把这三个值展示 在Index
  2. Index页面上有一个按钮,按下后,随机生成三个值,分别填入三个文本框中。

我们先简单的写个Pages/Index.razor的形状,先不实现上面的两个需求:

@page "/"

@using HelloComponents.Client.Components

<p>First Name: @this.firstName</p>
<p>Second Name: @this.secondName</p>
<p>Nick Name: @this.nickName</p>

<hr/>

<InputBox Name="First Name" />
<InputBox Name="Last Name" />
<InputBox Name="Nick Name" />

<hr/>

<button>Generate Random Names</button>

@code {
    private string firstName = "";
    private string secondName = "";
    private string nickName = "";
}

程序运行起来长下面这个样子,注意目前我们没有实现任何逻辑,所以页面空有外形

only_the_shape

下一步,我们来分析需求:

  • 首先是InputBox组件内部,用户的输入要传递出去,传递到Index组件的firstName, secondName, nickName三个字段中
  • 其次是在Index页面上,点击按钮后,要随机的为firstName, secondName, nickName进行赋值,之后还要把这三个值传递给三个InputBox中,在InputBox内部,要将这三个值显示在文本框中

而在InputBox组件中,我们目前并没有存储用户输入的文本,这显然是不太合适的,即在InputBox内部,应当有个Value属性/字段,来存储着用户输入的内容。

在这点基础上,再次仔细分析需求,会发现,这其实是三个地方的数据的联动同步需求,如下图所示:

bind_chart

内存与页面间的数据绑定我们在之前的文章已有介绍,这部分比较好实现,只需要将Components/InputBox.razor的代码改成如下的样子即可:

 <div style="display:inline-block; vertical-align: bottom">
-    @this.Name : <input type="text"/>
+    @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
 </div>
 
 @this.Value
 
 @code {
     [Parameter]
     public string Name { get; set; } = "";
+
+    public string Value { get; set; } = "";
 }

搞定了这部分,就剩下“父子组件之间的双向绑定”了,即是Index中的三个字段/属性,与三个InputBox实例里的Value字段的双向绑定。这是今天要介绍的主菜。页面上有三个框,我们先只用一个框来做例子,一步步的展示逻辑。

首先,我们得把Index中的字段,改成属性,背后的原因我们后面就会看到,先不要纠结

 ...
-    private string firstName = "";
+    private string FirstName { get; set; } = "";
 ...

其次,我们要告诉框架:“我想把Index.FirstNameInputBox.Value关联起来”

 ...
-<InputBox Name="FirstName" />
+<InputBox Name="First Name" @bind-Value=@this.FirstName />

注意,在@[email protected]这个属性赋值语句中,@bind-后面的Value,指的是InputBox.Value属性,等号后边的@this.FirstName,显然指的就是Index.FirstName属性。强调这点主要是强调,@bind-Value并不是一个固定搭配,而是取决于子组件中的属性叫什么名字,如果子组件中的字段名不叫Value,而叫Content的话,这个属性就得写成@bind-Content

如此更改后,项目已经可以编译了,但运行时会报错,页面也无法正确渲染InputBox组件,报错信息如下所示:

err_msg

报错信息从字面上理解,说的是:“InputBox组件内部缺少一个名为ValueChanged的属性”,这是什么意思呢?

简单解释起来就是:

父子组件之间的双向数据绑定,Blazor框架的实现并不优雅。双向数据绑定依然是语法糖,只是框架给程序员做了一些繁琐的工作而已。双向数据绑定其实就是“父向子传递参数+子向父通过迂回的方式传递数据”,所谓的双向数据绑定的底层实现,依然是这么干的。

现在我们只谈数据从子组件流向父组件:框架在看到@[email protected]后,知道了要把InputBox.Value的值传递给调用它的父组件Index,并且内部逻辑其实是把InputBox.Value的值拷贝给Index.FirstName。这时框架就会在Index组件内部生成一个如下的“迂回函数”,然后把这个“迂回函数”传递给InputBox组件中一个叫ValueChanged函数指针组件参数

如果你打开转译后的C#文件,就会看到,框架把@[email protected]转译成了如下的代码:

// ...
    __builder.AddAttribute(15, "ValueChanged", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => this.FirstName = __value, this.FirstName)));
// ...

当然,转译后的这行代码依然不太好阅读,但你可以大致的把它看作是如下实现:

...

<InputBox Name="FirstName" ValueChanged=@this.__AssignValueToFirstName />

@code {
    private __AssignValueToFirstName(string Value)
    {
        this.FirstName = Value;
    }
}

那么按照常理,框架应当在InputBox中自动为我们声明一个名为ValueChanged的参数,并把它的类型设置为Action<string>,如下所示:

...

@code {
    [Parameter]
    public Action<string> ValueChanged { get; set; } = default!
}
...

更进一步的,框架甚至应当为我们写好ValueChanged函数的调用:即在InputBox.Value的值发生改变时,自动调用ValueChanged,即如下把InputBox.Value属性的定义改写成下面这样:

...
@code {
    //...
    private string value;
    public string Value
    {
        get => this.value;
        set
        {
            if(value != this.value)
            {
                this.value = value;
                this.ValueChanged?.Invoke();
            }
        }
    }
}
...

但可惜的是,框架并没有帮我们做这部分工作,不光没有为我们定义InputBox.ValueChanged这个参数,也没有为我们改写InputBox.Value属性的定义,这一切都得我们手动来。

想清楚了这些逻辑,接下来就让我们把缺失的内容补充上吧,把Components/InputBox.razor改写成下面这样:


 <div style="display:inline-block; vertical-align: bottom">
     @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
 </div>
 
 @code {
     [Parameter]
     public string Name { get; set; } = "";
 
-    public string Value { get; set; } = "";
-
+    [Parameter]
+    public Action<string> ValueChanged { get; set; } = null;
+
+    private string value = "";
+    [Parameter]
+    public string Value
+    {
+        get => this.value;
+        set
+        {
+            if(value != this.value)
+            {
+                this.value = value;
+                if(this.ValueChanged is not null)
+                {
+                    this.ValueChanged(this.value);
+                }
+            }
+        }
+    }
 }

改动里除了我们提到的两点:新增ValueChanged参数,和改写Value属性的实现之外,我们还额外的把Value属性也声明成了一个组件参数。这是为什么呢?

上面我们讨论的都是:“数据从子组件流向父组件,框架替我们写了一半代码,我们自己得完成另外一半代码”。现在考虑另一个方向,即“数据从父组件流向子组件”的话,就需要子组件对应的属性是一个组件参数,这样我们就能以传参的方式实现数据流动了。

但是,当我们如上改写代码后,运行程序,InputBox也能被正确渲染了,但是:输入在文本框中的内容,并没有在页面上显示出来,如下所示:

parent_not_refresh_again

这是我们第二次遇到这种症状了,你应当能立即反应过来:这是由于页面上三行属于父组件Index,虽然父组件内部的FirstName等三个字段的值已经通过双向绑定,被子组件改变了,但由于没有事件处理的触发,所以页面并没有重新渲染。

可恶啊,还真是麻烦呢,为了解决这个问题,我们还需要重写Index组件里的FirstName等三个属性的定义,以如下的方式,让属性的值发生变化时,调用StateHasChanged()去触发页面重新渲染:

 @page "/"
 
 @using HelloComponents.Client.Components
 
 <p>First Name: @this.FirstName</p>
 <p>Second Name: @this.SecondName</p>
 <p>Nick Name: @this.NickName</p>
 
 <hr/>
 
 <InputBox Name="First Name" @bind-Value=@this.FirstName />
 <InputBox Name="Last Name" @bind-Value=@this.SecondName />
 <InputBox Name="Nick Name" @bind-Value=@this.NickName />
 
 <hr/>
 
 <button>Generate Random Names</button>
 
 @code {
-    private string FirstName { get; set; } = "";
-    private string SecondName { get; set; } = "";
-    private string NickName { get; set; } = "";

+    private string firstName = "";
+    private string secondName = "";
+    private string nickName = "";
+
+    private void ChangeFieldAndRefreshPage(string value, ref string field)
+    {
+        if(value != field)
+        {
+            field = value;
+            this.StateHasChanged();
+        }
+    }
+
+    public string FirstName
+    {
+        get => this.firstName;
+        set => this.ChangeFieldAndRefreshPage(value, ref this.firstName);
+    }
+    public string SecondName
+    {
+        get => this.secondName;
+        set => this.ChangeFieldAndRefreshPage(value, ref this.secondName);
+    }
+    public string NickName
+    {
+        get => this.nickName;
+        set => this.ChangeFieldAndRefreshPage(value, ref this.nickName);
+    }
 }

这样就完活了,看一下运行效果:

state_has_changed_again

其实到目前为止,父子组件间的双向数据绑定就介绍完毕了,我们已经可以写一点有关父子组件间数据绑定的总结了:

  1. @[email protected]语法即是父子数据绑定。不过框架背后只为我们生成了一小半代码,即“父组件内部迂回函数的定义”
  2. 我们需要在子组件内部做两件事:
    • 定义接收迂回函数的函数指针组件参数
    • 把参与绑定的属性,也定义为组件参数,并改写其内部实现,在内部调用传入的迂回函数
  3. 我们需要在父组件内部做一件事:
    • 改写参与数据绑定的属性定义,使其在值改变的情况下主动调用this.StateHasChanged()。当然,如果被绑定的数据并不在页面上参与渲染,就不需要这一步操作。

不过,在讨论下一个话题之前,我们需要完成我们的这个小组件的最后一个需求:点击Index上的按钮,随机生成三个值。并且以此来验证双向数据绑定中,数据是否能真的从父组件流向子组件,最终再流向页面。

我们可以如下改写Index.razor

...
-<button>Generate Random Names</button>
+<button @onclick=@this.GenerateRandomNames>Generate Random Names</button>
...
 @code {
     ...
     ...
+    private static readonly string[] FirstNames = new string[] { "Layne", "Jaxtyn", "Riley", "Kody" };
+    private static readonly string[] SecondNames = new string[] { "Leach", "Kim", "Potts", "Glover" };
+    private static readonly string[] NickNames = new string[] { "HoleyMole", "OculusVision", "Bibliokiller", "AnarKiss" };
+    private void GenerateRandomNames()
+    {
+        this.FirstName = FirstNames[new Random().Next(4)];
+        this.SecondName = SecondNames[new Random().Next(4)];
+        this.NickName = NickNames[new Random().Next(4)];
+    }
 }

运行效果如下:

all_works

故事到这里就几乎告一段落了。。但请再等一等。

相信你看到这里,一定会有一个疑问:为什么微软把父子组件间的数据绑定设计成这个残废样子?这东西听起来这么麻烦,用起来那么痛苦,难道微软就没想着把这玩意改进一下吗?

答案是:是有改进,但还没改进完全。或者准确一点的说,不能说是Blazor框架对这玩意有改进,而只能说是有另外一个小工具能缓解我们的痛苦。而如果我们要诚心圆的话,这个事还是能圆得回来的:

  • 一方面,框架作为框架,并不能假定被绑定的两个属性的类型是一样的,所以给你自己实现子组件中属性的内部实现,可以做一些数据类型转换的工作,甚至可以写更多的逻辑在里面,非常灵活
  • 另一方面,框架作为框架,并不能假定父组件中参与绑定的数据一定就是与页面渲染相关的。比如我们上面的例子中,Index页面在现实开发中,完全没必要把用户名、昵称等信息在页面上再展示一遍,自然也就没必要实现页面的自动重新渲染了。

改进是改进了上面的第二点:如果我们使用EventCallback<T>来定义迂回函数的类型的话,框架就会帮我们自动的调用StateHasChanged()。即我们的子组件如下改动:


 <div style="display:inline-block; vertical-align: bottom">
     @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
 </div>
 
 @code {
     [Parameter]
     public string Name { get; set; } = "";
 
     [Parameter]
-    public Action<string> ValueChanged { get; set; } = null;
+    public EventCallback<string>? ValueChanged { get; set; } = null;
 
     private string value = "";
     [Parameter]
     public string Value
     {
         get => this.value;
         set
         {
             if(value != this.value)
             {
                 this.value = value;
                 if(this.ValueChanged is not null)
                 {
-                    this.ValueChanged(this.value);
+                    this.ValueChanged?.InvokeAsync(this.value);
                 }
             }
         }
     }
 }

EventCallback<T>Action<T>有以下几个不同点:

  1. EventCallback<T>是值类型,即天然是不能为null的。这就是我们为什么把参数定义成EventCallback<string>?类型的原因
  2. EventCallback<T>作为一个深度包装的函数指针,没法以调用函数的语法直接调用,而需要使用InvokeAsync(...)的方式去调用
  3. EventCallback<T>内部是会调用StateHasChanged()方法的。但请注意:
    • EventCallback<T>作为参数,定义在InputBox中,但运行时,它的值是调用方传来的,也就是实例是在Index组件中创建的,它调用的自然是Index组件的StateHasChanged()

我们不需要自行调用StateHasChanged()后,Pages/Index.razor的代码就可以简化成下面这样:

 @page "/"
 
 @using HelloComponents.Client.Components
 
 <p>First Name: @this.FirstName</p>
 <p>Second Name: @this.SecondName</p>
 <p>Nick Name: @this.NickName</p>
 
 <hr/>
 
 <InputBox Name="First Name" @bind-Value=@this.FirstName />
 <InputBox Name="Last Name" @bind-Value=@this.SecondName />
 <InputBox Name="Nick Name" @bind-Value=@this.NickName />
 
 <hr/>
 
 <button @onclick=@this.GenerateRandomNames>Generate Random Names</button>
 
 @code {
+    public string FirstName = "";
+    public string SecondName = "";
+    public string NickName = "";
+
-    private string firstName = "";
-    private string secondName = "";
-    private string nickName = "";
-
-    private void ChangeFieldAndRefreshPage(string value, ref string field)
-    {
-        if(value != field)
-        {
-            field = value;
-            this.StateHasChanged();
-        }
-    }
-
-    public string FirstName
-    {
-        get => this.firstName;
-        set => this.ChangeFieldAndRefreshPage(value, ref this.firstName);
-    }
-    public string SecondName
-    {
-        get => this.secondName;
-        set => this.ChangeFieldAndRefreshPage(value, ref this.secondName);
-    }
-    public string NickName
-    {
-        get => this.nickName;
-        set => this.ChangeFieldAndRefreshPage(value, ref this.nickName);
-    }
-
     private static readonly string[] FirstNames = new string[] { "Layne", "Jaxtyn", "Riley", "Kody" };
     private static readonly string[] SecondNames = new string[] { "Leach", "Kim", "Potts", "Glover" };
     private static readonly string[] NickNames = new string[] { "HoleyMole", "OculusVision", "Bibliokiller", "AnarKiss" };
     private void GenerateRandomNames()
     {
         this.FirstName = FirstNames[new Random().Next(4)];
         this.SecondName = SecondNames[new Random().Next(4)];
         this.NickName = NickNames[new Random().Next(4)];
     }
 }

虽说我们改了参数的类型,但迂回函数还是框架为我们生成的,以上的例子能正常运行,也就是说明,框架会根据参数的类型,去采用不同的迂回函数的实现,而事实也确实如此。在使用了EventCallback<T>后,框架把@[email protected]转译成了下面这样:

    __builder.AddAttribute(15, "ValueChanged", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.TypeCheck<global::Microsoft.AspNetCore.Components.EventCallback<System.String>?>(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => this.FirstName = __value, this.FirstName))));

而如果我们把参数类型设置为Action<string>的话,转译结果是下面这样的:

    __builder.AddAttribute(15, "ValueChanged", (object)((global::System.Action<System.String>)(__value => this.FirstName = __value)));

而如果我们没有声明ValueChanged参数的话,转译结果默认是下面这样的:

    __builder.AddAttribute(15, "ValueChanged", (object)(global::Microsoft.AspNetCore.Components.CompilerServices.RuntimeHelpers.CreateInferredEventCallback(this, __value => this.FirstName = __value, this.FirstName)));

怎么说呢,挺长一玩意,看着也脑袋疼,但不重要,知道有这么个事就行了,作为框架的使用者,没必要钻研这玩意。

而且事实上,EventCallback<T>并不是为了“优化双向数据绑定”而被发明出来的一个玩意,眼界要宽阔一点:它内部是可以自动调用StateHasChanged()的,可以使用普通的函数指针或lambda表达式去初始化它,通过它,我们可以把我们之前写的好几个用来阐述StateHasChanged()的示例程序改写的更简洁!

简单一句话总结EventCallaback<T>,哦对了它还有个非模板版本EventCallback:任何一个函数被包装在EventCallback中后,被调用时,都会去调用当初自己缔造者的StateHasChanged()方法。

3. 别通信了,别迂回了,拿来吧你!

UI组件里的通信,最让初学者难以适应的就是,从子组件向父组件传递消息时,需要使用函数指针参数迂回这么一下。其次一个大家都能适应,但实际写起代码来非常繁琐的操作就是:在组件树中,太爷爷组件要传递一个消息给重孙子的话,需要一层一层的通过参数传递下来,写起来太麻烦了。

坏消息是:这几乎是现代前端框架的基础设计,你就算不学Blazor而转头去搞React,你会发现,React也是这么设计的。 好消息是:不爽的不光是你,不光是初学者,久经沙场的程序员也不爽。

怎么办呢?一个流行大方向是把“数据通信”这个概念扔了,造出一个“状态管理”的概念:把组件内部的属性/字段/状态等,存储在一个公共的地方,通过数据共享的方式,让父子甚至是爷爷孙子都能访问,以共享数据访问的形式来解决“通信”问题。然后再辅佐以一定的监控逻辑,让共享存储内部的状态改变时,能自动的重新渲染相关的参与者。

这就是React那边比较流行的mobx库的理念,Blaozr这边也有类似的东西。这种东西的好处就是把程序员的脑力从“迂回函数”和“裹脚布传参”里解放了出来,坏处也是非常显而易见的:有比较大的性能开销。

用状态管理库来替代组件通信,属于一种比较高阶的降维打击操作。我们的系列文章目前暂时没有计划去介绍这类东西,这不是说我嫌这类库不好,或者说我觉得它们有什么缺点,并不是,我只是单纯的想把系列文章涉猎的领域收缩一下,让大家能以最少的知识去上手做项目。状态管理库,其实是值得单独开个系列文章去单独介绍的,它的应用也不局限于UI框架里,它几乎可以去砸所有“数据通信”模型的饭碗。

如果我们不去砸“数据通信”这个模型的饭碗,那么有什么途径能减缓一下我们在父子组件,乃至组件树中传递消息时的痛苦呢?有是有的。简单来说,两方面:

  1. 在子组件向父组件传递消息方面,框架提供给了我们另外一个粗暴的解决方案:我们可以在父组件中直接拿到子组件实例的指针,或者叫引用,这种情况下还传什么消息?儿子都被你拎在手里了,爹直接把它裤子扒了就行了,想看什么看什么,想干什么干什么。
  2. 在爷爷传参数到孙子这方面,框架提供给了我们Cascading Parameters这个工具,让我们在写代码时,能直接把数据从爷爷传递给孙子,不需要一层一层的传递。

现在我们来分别介绍这两个额外知识点:

3.1 拿到子组件实例的指针

较真来说,这个特性并不是为了解决“数据通信”问题,它有更多的应用场景与理由。我们把这个知识点放在这里介绍,只是因为它是一种能粗暴替代“迂回函数”的方案而已。

回想我们之前创建的两个组件,MessageBoxInputBox,它们都是在自身体内,在自身的上下文中处理事件。而他们的父组件,也就是Index,是完全不知道子组件内部的细节的:不知道子组件是否处理了用户的某些事件,不知道子组件内部使用了哪些HTML元素,对子组件的实现细节一无所知。父组件能知道的,只不过是子组件声明出来的组件参数而已。

InputBox为例,即便用了所谓的双向数据绑定,Index能以数据绑定的方式获取到用户在文本框中输入的内容。但本质上来讲,Index压根不知道,也不应该知道,这些文本/字符串是怎么来的:是用户输入的吗?是子组件自己生成的吗?是子组件在内部调用了外部网络API获取的吗?父组件不知道,也不想知道。父组件只想知道结果,不想知道过程。

就跟你的老板一样:手一甩,就知道要结果,完全不管你工作的时候痛苦不痛苦,幸福不幸福,完全不指导你的工作。

这种甩手行为有好处就是所谓的逻辑隔离,和函数一样,作为调用方,没有必要,也不应该去依赖被调用函数的具体实现细节。

这种甩手行为的坏处在函数调用里还不明显,因为函数调用的“返回值”这个做法是符合程序员直觉的。但在UI组件这里,获取子组件内部的信息需要“迂回”,需要“双向绑定”,比较麻烦。

这时,父组件就有一个选择:我也不想迂回了,我也不想写双向绑定了,太麻烦了,我想直接访问你内部那个<input>元素,直接拿到<input>里用户输入的文本内容:不用你向我报告,不用迂回的通信,不要什么劳什子数据绑定,你就站在那里别动,我直接把手伸进你裤裆把东西掏出来。

也就是说:父组件选择直接访问子组件的实例,直接拿到子组件的公开属性/字段/方法,甚至内部的任何细节。

具体怎么做呢?我们把Pages/Index.razor改写成下面这样:

@page "/"

@using HelloComponents.Client.Components

<p>First Name: @this.firstNameInputBox?.Value</p>
<p>Second Name: @this.secondNameInputBox?.Value</p>
<p>Nick Name: @this.nickNameInputBox?.Value</p>

<hr/>

<InputBox Name="First Name" @ref=@this.firstNameInputBox/>
<InputBox Name="Last Name" @ref=@this.secondNameInputBox/>
<InputBox Name="Nick Name" @ref=@this.nickNameInputBox />

<hr/>

<button @onclick=@this.GenerateRandomNames>Generate Random Names</button>

@code {
    private InputBox firstNameInputBox = default!;
    private InputBox secondNameInputBox = default!;
    private InputBox nickNameInputBox = default!;

    private static readonly string[] FirstNames = new string[] { "Layne", "Jaxtyn", "Riley", "Kody" };
    private static readonly string[] SecondNames = new string[] { "Leach", "Kim", "Potts", "Glover" };
    private static readonly string[] NickNames = new string[] { "HoleyMole", "OculusVision", "Bibliokiller", "AnarKiss" };
    private void GenerateRandomNames()
    {
        this.firstNameInputBox.Value = FirstNames[new Random().Next(4)];
        this.secondNameInputBox.Value = SecondNames[new Random().Next(4)];
        this.nickNameInputBox.Value = NickNames[new Random().Next(4)];
    }
}

在上面的代码中,我们在使用子组件的时候,给一个特殊的directive attribute@ref赋值,值即为父组件里的私有字段。这样在组件初始化后的某个时间点,子组件的指针就会被框架赋值给三个私有字段。我们就可以通过this.firstNameInputBox.Value来访问子组件中的公开属性了。

这里有个关键点,即我们在父组件中写出@[email protected]的时候,并不是指针赋值的那个时刻,这时只是我们在告诉框架:“嗨,请把这个子组件的指针,放在我的私有字段this.firstNameInputBox中”。但到底什么时候框架才执行这个指针赋值操作呢?我们后面会解答,但需要肯定的是,在“父组件实例初始化之后”,和“三个私有字段被框架赋值”之前,是有一段时间差的。

所以就需要我们在渲染值的时候,使用?语法,写出<p>First Name: @this.firstNameInputBox?.Value这样的代码,否则程序运行过程中会抛出类似空指针异常的错误。如下所示:

null_exception

然后把我们的Components/InputBox.razor改写成下面这样:


<div style="display:inline-block; vertical-align: bottom">
    @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
</div>

@code {
    [Parameter]
    public string Name { get; set; } = "";

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

但这个程序运行起来后有点问题的,它运行起来后的效果如下所示:

not_quite_working

  1. 在文本框里输入文本时,父组件没有重新渲染:很容易理解,因为事件发生在子组件中,所以只有子组件会被框架重新渲染。(子组件中的@[email protected]背后也是事件处理,我们之前说过,内存与页面间的双向数据绑定本质上也是语法糖)
  2. 点击按钮后,子组件没有重新渲染:也很容易理解,因为事件发生在父组件中,所以只有父组件部分被框架重新渲染,但子组件并没有被重新渲染。

第二点是不是有点颠覆你的认知?是的,事件处理触发的组件重新渲染,只会渲染组件自身,并不会包括组件内部嵌套的子孙组件。事实上,嵌套子孙组件被重新渲染的条件并不父组件被重新渲染,还包括父组件传递给子孙组件的参数发生了变更

即在组件树中,重新渲染的触发规则,按目前我们了解到的知识,可以总结出两条铁律:

  1. 事件处理一定会触发自身重新渲染
  2. 若在自身重新渲染后,自身传递给子组件的参数相较之前发生了变更,那么该子组件才会被重新渲染。否则子组件不会被重新渲染

要解决第二个问题,按我们之前的经验,只需要如下修改即可:

 <div style="display:inline-block; vertical-align: bottom">
     @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
 </div>
 
 @code {
     [Parameter]
     public string Name { get; set; } = "";
 
-    [Parameter]
-    public string Value{ get; set; }
- 
+    private string value;
+    [Parameter]
+    public string Value
+    {
+        get => this.value;
+        set
+        {
+            this.value = value;
+            this.StateHasChanged();
+        }
+    }
 }

但要解决第一个问题,就其实没什么好办法了。我下面会说一个方法去解决第一个问题,但在介绍那个方法之前,让我们先暂停一下下,思考一下下,第一个需求,是不是非做不可呢?

其实多数真实的工作场景下,“父组件想从子组件中获取数据”是很常见的,在这个常见需求之下,“父组件想在子组件的数据变更时得到通知”这个需求是不是刚需就不一定了。以输入控件相关的子组件为例,大多数情况下,是由子组件收集用户输入的各种数据,然后父组件将这些数据整合起来,拼接起来,然后发送网络请求执行业务逻辑。在这种类似场景下,只需要保证:“父组件取数据时,取到的数据一定是最新的”就好了,父组件并不需要确切的知道“数据变更是什么时候发生的”。

真实世界中,有相当一部分所谓的“子组件传递数据到父组件”的需求,其实都是这种“父组件要查看子组件数据”的需求。

好,虽然根据我的经验,相当一部分父组件并不需要时刻关心子组件中的状态变更,但我不能否认这种需求存在,那么接着说我们的解决方法。在我开口之前,如果你会举一反三的话,可能会提出一个想法:“那我们有没有什么途径能在子组件内部拿到父组件的指针,然后直接调用父组件的StateHasChanged()呢?”

Brilliant idea! 这也恰好就是我要说的方法:但框架本身并没有提供类似于@ref之类的内置途径,我们需要自己把Index的指针通过参数传递给InputBox,我们需要把InputBox改造成下面这样:

 <div style="display:inline-block; vertical-align: bottom">
     @this.Name : <input type="text" @bind=@this.Value @bind:event="oninput"/>
 </div>
  
 @code {
     [Parameter]
     public string Name { get; set; } = "";
 
+    [Parameter]
+    public HelloComponents.Client.Pages.Index Parent { get; set; } = default!;
+
     private string value;
     [Parameter]
     public string Value
     {
         get => this.value;
         set
         {
             this.value = value;
             this.StateHasChanged();
+            this.Parent.Refresh();
         }
     }
 }

简单来说就是我们声明了一个参数,用来让父组件把自身的this指针传进来,这样我们在Index内部就能访问到父组件了。然后在文本框的值有更新的时候,再调用父组件的StateHasChanged()方法去触发父组件重新渲染。但这里有个框架限制,即StateHasChanged()的访问修饰符是protected的,我们无法在InputBox类中直接调用Index.StateHasChanged(),所以我们需要用一个叫Refresh()的方法包装一下。

然后我们把我们的Pages/Index.razor改造成下面这样:

 @page "/"
 
 @using HelloComponents.Client.Components
 
 <p>First Name: @this.firstNameInputBox?.Value</p>
 <p>Second Name: @this.secondNameInputBox?.Value</p>
 <p>Nick Name: @this.nickNameInputBox?.Value</p>
 
 <hr/>
 
-<InputBox Name="First Name" @ref=@this.firstNameInputBox/>
-<InputBox Name="Last Name" @ref=@this.secondNameInputBox/>
-<InputBox Name="Nick Name" @ref=@this.nickNameInputBox />
+<InputBox Name="First Name" Parent=@this @ref=@this.firstNameInputBox />
+<InputBox Name="Last Name" Parent=@this @ref=@this.secondNameInputBox />
+<InputBox Name="Nick Name" Parent=@this @ref=@this.nickNameInputBox />
 
 <hr/>
 
 <button @onclick=@this.GenerateRandomNames>Generate Random Names</button>
 
 @code {
     private InputBox firstNameInputBox = default!;
     private InputBox secondNameInputBox = default!;
     private InputBox nickNameInputBox = default!;
 
     private static readonly string[] FirstNames = new string[] { "Layne", "Jaxtyn", "Riley", "Kody" };
     private static readonly string[] SecondNames = new string[] { "Leach", "Kim", "Potts", "Glover" };
     private static readonly string[] NickNames = new string[] { "HoleyMole", "OculusVision", "Bibliokiller", "AnarKiss" };
     private void GenerateRandomNames()
     {
         this.firstNameInputBox.Value = FirstNames[new Random().Next(4)];
         this.secondNameInputBox.Value = SecondNames[new Random().Next(4)];
         this.nickNameInputBox.Value = NickNames[new Random().Next(4)];
     }
+
+    public void Refresh() => this.StateHasChanged();
 }

这样就修好了,效果如下:

working_again

看着挺好是吧?但我要在这里强调:这种代码是非常糟糕的代码,不要学,不要写,具体为什么糟糕的理由我这里就不过多分析了,其实也不用过多分析,从逻辑上感性理解,我们也能知道:

  1. @ref其实是Blazor框架提供给程序员的一个比较危险的工具。这个工具本身并不危险,但过多使用它,打破了组件封装的初衷:子组件的实现细节天生就是应该向外部隐藏的,本身爹翻看儿子的日记本已经属于不道德行为了
  2. this指针传递给子组件更是一个作死的操作,子组件通过这个指针可以间接的使父组件重新渲染,而父组件的重新渲染在满足条件的情况下又会导致子组件重新渲染,这种代码非常容易写出无限渲染的bug出来。另一方面,如果一个组件在设计运行的时候,需要访问它父组件的指针,要查看它父组件的实现细节,那就直接说明了这个组件和父组件就不应该被拆分成两个组件然后互相调用!!这个抽象本身就是错的,代码写得再花哨,厨艺再精湛,也是在厕所里烹饪,你最终得到的,也只能是屎。

总之,@ref本身要慎用,而将this指针传递给子组件,我只能说:祝你好运。

3.2 Cascading参数

Cascade这个单词,作动词,有倾泻、连续传递之意,作名词,有瀑布、一连串的东西、连绵不绝的事情之意。中文中没有一个词语能比较合适的契合这个单词的翻译。

当我们讨论参数从祖宗组件传递给重孙子组件的这个过程时,即一层层的参数传递,这玩意就叫Cascade:像是一滴露珠从一颗树的顶端缓缓向下流,流过多个枝桠,最终停在一片它要去的叶子上面。

Blazor为了简化参数层层传递,缓解程序员的痛苦,设计的露珠就是“Cascading参数”,我们以露珠来类似这个特性,是因为:露珠是自上而下流动的,在某个组件身上放一颗露珠,那么它下面的所有的枝桠组件都能看到这颗露珠

Cascading参数这个特点有两个关键点:

  1. 参数是在某个较高层次的组件上被实例化、然后声明为露珠
    • 使用框架内置组件<CascadingValue>来声明露珠,并通过NameValue两个属性分别指定露珠的名字与值
  2. 接收参数的组件,位于较低层次上的那些组件,需要为接收露珠做一点准备:露珠不是普通参数,接收露珠的组件需要做额外的工作。
    • 露珠参数与普通参数不互相兼容,露珠参数需要使用[CascadingParameter]来修饰,并且建议添加参数Name来指定露珠的名字

下面是一个简单的例子,我们创建三个组件:爷爷、爹与儿子,你别是Components/GrandPa.razor, Components/Father.razor, Components/Son.razor,代码分别如下:

<div style="height:400px;width:400px;margin:auto;border:solid red 1px">
    <p>GrandPa's Name: @this.Name</p>
    @this.ChildContent
</div>


@code {
    [CascadingParameter(Name="GrandPaName")]
    public string Name{ get; set; }

    [Parameter]
    public RenderFragment ChildContent{ get; set; }
}
<div style="height:300px;width:300px;margin:auto;border:solid green 1px">
    <p>Father's Name: @this.Name</p>
    @this.ChildContent
</div>


@code {
    [CascadingParameter(Name="FatherName")]
    public string Name{ get; set; }

    [Parameter]
    public RenderFragment ChildContent{ get; set; }
}
<div style="height:200px;width:200px;margin:auto;border:solid blue 1px">
    <p>Son's Name: @this.Name</p>
    @this.ChildContent
</div>


@code {
    [CascadingParameter(Name="SonName")]
    public string Name{ get; set; }

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

然后在Pages/Index.razor如下使用:

@page "/"

@using HelloComponents.Client.Components

<CascadingValue Name="SonName" Value=@("Must Smith")>
    <CascadingValue Name="FatherName" Value=@("Will Smith")>
        <CascadingValue Name="GrandPaName" Value=@("Should Smith")>
            <GrandPa>
                <Father>
                    <Son>
                        <p>Content inside Must Smith</p>
                    </Son>
                </Father>
            </GrandPa>
        </CascadingValue>
    </CascadingValue>
</CascadingValue>

运行效果就如下所示:

cascading_param

上面只是一个基本的例子,直观的介绍一下Cascading参数的用法,还有一些额外的知识点需要大家注意:

  1. 如果在子树中,有两个组件声明了同名的cascading参数,那么同一个对象会被这两个子孙组件都拿到
  2. 如果在子树中,组件声明[CascadingParameter]的时候不提供Name参数,那么顶层的参数在向下传递时,是按“类型”优先匹配的。
    • 换句话说,如果在组件声明CascadingParameter的时候不提供Name,是没有所谓的“默认Name”这个东西存在的
  3. [CascadingParameter][Parameter]是完全不兼容的两个东西,这一点非常差评

总的来说,cascading参数这个特性并不是一个完美解决“参数层层传递”的方案,主要是因为上面提到的第3点。

不过,如果你思路打开,你会发现,cascading参数这个特性,特别适合当成简易版的依赖注入来使用:在顶层使用<CascadingValue>来声明一些子组件可能会用到的小工具对象,然后在子组件中有选择性的去使用:如果子组件需要,那么就声明对应的[CascadingParameter]去获取。

比如保存用户登录状态的对象、在某小范围子树内需要共享的对象等等。