Blazor教程 第五课:组件的初级知识:定义组件、传递参数

Blazor

1. 什么是组件

通过前面四节课的学习,现在你可以创建一个Hosted Blazor WASM项目了,也能在Pages/Index.razor中添加内容画出个网页了,如果你仔细看过之前的文章的话,你甚至懂得如何在页面上调用后端API了。

如果你恰巧天资聪颖善于举一反三,你甚至可以在Pages目录下添加更多的其它xxx.razor文件,并在各个文件脑门上写上不同的@page指令,写出多个页面了。

而如果你恰巧骨骼精奇是万中无一的代码奇才,你甚至可以在各个页面之间,使用<a>标签将它们串连起来,实现页面间的互相跳转,这样,一个初具雏形的网站也能搭建起来。

但这里有个大问题:我们没有复用代码。这是什么意思呢?我来以我自己的这个博客网站举个例子:

下面这个页面,是根路径,是网站的首页,如果这个博客网站是用上面的思路实现的话,这个页面对应的Razor模板文件应当是Pages/Index.razor

shaobozhang_index

下面这个页面,访问路径是/tag/blazor/,这个页面对应的Razor模板文件应当是Pages/Tag/Blazor/Index.razor,或者是Pages/Tag/Blazor.razor,或者甚至直接就是Pages/Blazor.razor,文件是怎么组织在目录里的不是很重要,只需要脑门上的@page指令的值是/tag/blazor/就行了

shaobozhang_blazor

可以非常明显的看到,这两个页面几乎是一模一样的:除了页面上展示的介绍信息文本,与具体的文章列表不一样。

而如果我们没有代码复用的意识和能力,那么我们就几乎要在Pages/Index.razorPages/Tag/Blazor/Index.razor两个文件中,把90%相似的代码写两遍:怎么想这都是一个非常愚蠢的实现方式。

正确的实现方式应当是:把页面中可重复使用的碎片,包装成组件,然后在不同的页面Razor模板文件上,去调用它们。

这是什么意思呢?我们来分析上面两个页面,第一眼看上去,页面可以被分为三部分:脑门、简介与文章列表。我们专业一点,就分别叫它们Header, Abstract, PostList吧。(这里叫Abstract好像也不是太合适,但我贫乏的英语词汇也就找出个abstract)

shaobozhang_split

在这种指导思想的指引下,我们可以把可复用的这三部分包装成组件,然后分别在Pages/Index.razorPages/Tag/Blazor/Index.razor中去调用它们。我们项目的目录结构大致如下:

...
 |
 |-- Components
 |      |
 |      \--> Header.razor
 |      \--> Abstract.razor
 |      \--> PostList.razor
 |
 \-- Pages
        |
        |-- Tag
        |    \--> Blazor
        |           \--> Index.razor
        |
        \--> Index.razor
...
...

你会看到,无论是组件,还是页面,都是*.razor文件。

这就顺带引出了第一个知识点:组件和页面,本质上都是Razor Component,可以说页面是特殊的组件。页面只是多了@page指令在脑门上而已

我们先不关心组件应当如何定义,先关心它应当如何使用。在组件定义好之后,页面代码可以大致如下写:(注意以下是伪代码)

<Header />
<Abstract />
<PostList />

但如上这样写又存在一个问题:两个页面虽然使用了相同的组件,但实际上两个页面除了Header是完全一样的之外,简介和文章列表的内部内容都是不同的,怎么办?

很简单:让组件有接收参数的功能,我们把简介的标题、内容,文章列表中的内容,以参数形式传递给<Abstract/><PostList/>组件就可以了。

我们先不关心参数怎么去定义,先关心它应当如何使用。我们在页面中使用组件的时候,需要以类似HTML Element Attribute的方式去传递参数。如下(伪代码)所示:

<Header />
<Abstract Title=@this.abstractTitle Detail=@this.abstractDetail />
<PostList List=@this.postList />

这就引出了第二个知识点:组件就像函数一样,可以有参数

到这一步,我们基本从概念上讲清楚了组件是什么玩意,但接下来,我们还要介绍一个重要概念:组件的嵌套。

仔细观察文章列表区域,你会发现文章列表区域其实是由多个小的文章的标题、标签、创建日期和预览内容组成的,那么我们能不能进一步抽象复用呢?可以的,我们可以把一个个的这种小方块叫Feed

shaobozhang_nested

然后在<PostList/>的实现内部,我们可以调用<Feed/>组件多次。这就是嵌套。

这就是第三个知识点:组件就像函数一样,可以嵌套调用

所以暂时总结一下:

  1. 所有的*.razor文件,本质上都是组件。组件其实就是UI片段。只是有一些组件比较特殊,通过@page指令指定了访问路径,我们把这部分特殊的组件叫页面组件
  2. 组件就是UI函数:它代表的是一段可复用的、可通过参数定制具体行为的UI片段,所以它有两个重要特征:
    1. 可以定义参数
    2. 可以嵌套调用

以上,就是组件的概念。

2. 书写一个可复用的组件

按照常理来说呢,上个章节我们用博客网站举了例子,这个章节就应该实现一个上面提到的<Header />, <Abstract/>, <PostList/><Feed/>组件了。但是,为了不要让文章过于拖沓,我决定还是把例子搞得再简单一点:我们来实现一个提示框组件吧。

什么是提示框呢?简单来说,就是:

  1. 一个框,里面可以展示提示文字
  2. 右上角有个X按钮,用户点击之后可以让提示框从页面上消失

那么很显然的,我们来从头捋一下刚才学到的知识:

  • 提示框要在各种场合、各种页面下复用的话,那么内部的提示文字肯定是需要使用方去自定义的,那么显然,提示文字就应当被定义成参数
  • 框本身的行为比较简单:用户点击了x按钮,提示框就消失,这个很容易实现,有两种实现思路,
    • 第一种是在按钮的事件回调中,让整体提示框隐藏掉。这个可以通过CSS来实现,给整个提示框的样式属性加上display: nonevisibility:hidden即可。
    • 第二种就是在按钮的事件回调中,让提示框不要渲染任何内容,即在按钮的事件回调之后,提示框的BuildRenderTree方法返回空白内容。换个角度来说,就是组件渲染时需要判断按钮是否被按过一次:如果是,那么就不渲染任何内容,否则渲染提示信息。

下面,我们一步步的来实现它

2.1 首先先新建一个项目,我们来聊聊项目布局

这次我们不手搓项目了,直接使用dotnet new blazorwasm-empty --hosted -o HelloComponents从官方模板创建一个Hosted 的Blazor WASM项目。

项目创建结束后,我们需要在HelloComponents.Client项目下面新建一个名为Components的目录。我们即将在这个目录下放置我们的提示框组件,整个解决方案的结构如下图所示:

empty_solution

官方模板与我们之前手搓的解决方案的区别在于:

  1. ClientServer项目均有默认的Properties/launchSettings.json文件
  2. Client项目中,在wwwroot目录下,除了必要的index.html,还有一个简单的css文件,里面写了一些样式,主要用来修饰在异常发生的情况下,App.razor无法正确渲染时,在页面上展示一个稍微漂亮点的错误提示
  3. Client项目中有一个_Imports.razor文件,里面写满了@using指令,其中就有我们之前多次强调过的@using Microsoft.AspNetCore.Components.Web,用以引入诸如@onclick之类的directive attribute,来让Razor引擎能正确的识别事件回调声明
  4. Client项目中有一个MainLayout.razor文件,这个是一个布局组件,有关这部分知识我们后续会介绍,现在请暂时忽视它
  5. Client项目的App.razor的内容也比我们手搓的版本要丰富一些,里面的内容我们后续会介绍,现在也请暂时忽视它

这些区别对于目前我们来说,都可以暂时忽视,让我们先把注意力集中到如何实现一个组件身上。知识点来了:按约定俗成的规则,

  • Razor写的页面,我们一般按@page上的路由路径,把它们组织在Pages目录下。
  • Razor写的组件,我们一般按功能,把它们组织在Components目录下

如此约定俗成的背后其实还有一个不太受人注意的知识点:Razor引擎在处理组件或页面文件时,是按照目录路径来定义名称空间的。

比如,我们现在新建的这个整个解决方案,叫HelloComponents,这个解决方案下会有三个子项目,分别是HelloComponents.Client, HelloComponents.Server, HelloComponents.Shared

HelloComponents.Client项目中:

  • 默认的名称空间就是HelloComponents.Client,即为csproj文件的文件名。这意味着默认情况下,Program.cs编译出的Program类就是位于这个名称空间下的,同时,也意味着像_Imports.razor, App.razor, MainLayout.razor这些Razor文件,经过Razor引擎处理后转译出来的C#文件,也是包裹在这个默认名称空间下的
  • 而像上图中的Pages/Index.razorComponents.MessageBox.razor,它们转译后的C#文件,其所属的名称空间还要追加上路径信息,比如前者编译后会是一个名为HelloComponents.Client.Pages.Index的类,后者会是一个HelloComponents.Client.Components.MessageBox的类。也就是说,在Razor模板文件中没有@namespace指令存在的情况下,该模板文件对应的类,所属的名称空间,就是项目默认名称空间 + 目录结构

所以说,良好的项目布局,不光是在文件层级结构上看起来让人心情舒畅,更重要的是目录路径信息,也是Razor类的名称空间。

2.2 抛开Blazor框架与Razor引擎不谈,我们应当如何用HTML+CSS实现一个提示框呢?

首先,它得是个框,所以用div来实现它一点毛病没有,更贴心一点,我们假定提示框的大小是固定尺寸的,比如宽400像素,高100像素,那么就会如下:

<div style="height:100px; width:400px">
</div>

另外,默认的div是没有边框的,这样视觉上我们看不出来这个框的边界,所以我们最好给它加个框线:

<div style="height:100px; width:400px; border: 1px solid red">
</div>

然后框内部得有提示信息,也就是字符串

<div style="height:100px; width:400px; border: 1px solid red">
   <p><em>This is the message!</em></p>
</div>

最后,框得有个按钮,点了后能关闭。。不过现在我们只讨论这个按钮本身,而不涉及按钮背后的逻辑:

<div style="height:100px; width:400px; border: 1px solid red">
   <p><em>This is the message!</em></p>
   <button>x</button>
</div>

这样写的话,框里第一行是字符串,第二行是按钮,显然太丑了,我们想把叉按钮放在框的右上角,怎么做呢?这里就要补充一点CSS小知识了:让父元素的position样式为relative,让叉按钮的position样式为absolute,再通过topleft属性指定叉的位置

<div style="height:100px; width:400px; border: 1px solid red; position: relative">
   <p><em>This is the message!</em></p>
   <button style="position: absolute; top: 5px; right: 5px">x</button>
</div>

再接下来,我们会发现文字和框贴得太近了,我们给框本身加个小小的padding吧,就基本完活了:

<div style="height:100px; width:400px; border: 1px solid red; position: relative; padding: 5px">
   <p><em>This is the message!</em></p>
   <button style="position: absolute; top: 5px; right: 5px">x</button>
</div>

效果如下:

messagebox_html

丑是丑了点,但有那么点意思了。这就是我们Components/MessageBox.razor文件的雏形

2.3 定义参数、调用组件

我们上面已经通过纯HTML+CSS写出了Components.MessageBox.razor的雏形,第一个问题:如何调用它?

我们可以在Pages/Index.razor中,以使用HTML元素类似的方式,去调用这个组件,如下所示:

@page "/"

<HelloComponents.Client.Components.MessageBox />

所以,第一个知识点:

  • 组件作为UI函数,调用时的语法,就是以类名为元素名,假装自己是一个HTML元素

当然如果你嫌类的全名太冗长,可以如下改善:

@page "/"

@using HelloComponents.Client.Components

<MessageBox />

现在如此操作,启动项目,你已经能在首页看到我们写的这个提示框了,但问题来了:提示信息现在是写死的"This is the message!",如何把它写成参数呢?

谨记两板斧:

  1. 在组件内部,参数首先需要是一个公开属性
  2. 在组件内部,其次,参数需要有一个特定的修饰,即为[Parameter]

所以,声明参数,就是在组件内部,声明一个带[Parameter]修饰的公开属性。现在,让我们为MessageBox.razor加上参数声明:

<div style="height:100px; width:400px; border: 1px solid red; position: relative; padding: 5px">
   <p><em>This is the message!</em></p>
   <button style="position: absolute; top: 5px; right: 5px">x</button>
</div>

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

我们还贴心的给这个属性声明了一个默认值,即为空字符串。

那么如此情况下,使用这个属性的值来替换写死的提示消息就非常显然了,如下:

 <div style="height:100px; width:400px; border: 1px solid red; position: relative; padding: 5px">
-   <p><em>This is the message!</em></p>
+   <p><em>@this.Message</em></p>
    <button style="position: absolute; top: 5px; right: 5px">x</button>
 </div>
 
 @code {
    [Parameter]
    public string Message {get; set; } = "";
 }

现在再次启动项目,你会看到,提示框里毛都没有了,为什么呢?显然,是因为我们虽然定义了参数,但在Pages/Index.razor中使用它时,并没有为这个参数传值。所以在渲染MessageBox时,使用的是默认的空字符串值作为提示消息的。

那么下个问题就是:如何给参数传递值?也简单:以参数名为属性名,假装自己是一个HTML属性,如下:

@page "/"

@using HelloComponents.Client.Components

<MessageBox Message="Hello Components!"/>

所以总结起来看,所谓的组件本质上就是函数调用,无非是:

  1. 调用组件时,假装组件是一个HTML元素
  2. 传递参数时,假装参数是一个HTML属性

2.4 添加事件处理逻辑,实现关闭功能

我们上面说了,实现提示框的关闭有两种思路

  1. 有选择性的让MessageBoxBuildRenderTree选择是否渲染内容
  2. 通过CSS样式来实现
    • 可以通过visibility:hidden来实现
    • 还可以通过display:none来实现

现在我们分别实现这两种思路:

有选择性的渲染内容

这个实现途径的一个核心点在于:我们要用一个字段去标记,用户是否已经点击过关闭按钮了,想通这点后,实现起来就非常容易,如下:

@if(!this.CloseButtonHadBeenClicked)
{
    <div style="height:100px; width:400px; border: 1px solid red; position: relative; padding: 5px">
       <p><em>@this.Message</em></p>
       <button style="position: absolute; top: 5px; right: 5px" @onclick=@this.HandleCloseButtonClick>x</button>
    </div>
}

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

    private bool CloseButtonHadBeenClicked = false;

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

通过CSS来实现

这里就要捡起一个数据渲染方面的一个知识点了:数据渲染不光可以将变量/属性渲染成可视的元素内容,还可以用来渲染属性值。想通这一点,我们就可以写出如下的代码:

<div style=@this.outerDivStyle>
   <p><em>@this.Message</em></p>
   <button style="position: absolute; top: 5px; right: 5px" @onclick=@this.HandleCloseButtonClick>x</button>
</div>

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

    private string outerDivStyle = "height:100px; width:400px; border: 1px solid red; position: relative; padding: 5px;";

    private void HandleCloseButtonClick()
    {
        this.outerDivStyle += "display:none";
    }
}

当然,我们也可以把display:none换成visibility:hidden来实现类似的功能

这三者有什么区别吗?

显然,这三种实现途径是有区别的。

首先我们先来对比条件渲染与CSS实现的区别:如果你仔细阅读过之前的文章,完全了解了事件处理+组件重新渲染的逻辑,你就会明白:

  • 在条件渲染实现方式中,当我们按下按钮后,MessageBox组件会重新被渲染,而新渲染的结果是空的,意味着此刻在浏览器的DOM树中,已经没有相关的结点了
  • 在CSS渲染实现方式中,当我们按下按钮后,MessageBox组件也会被重新渲染,但渲染的结果并不是空的,即在浏览器的DOM树中,提示框的那个<div>以及嵌套的一个<p><button>还是被渲染到了DOM树中,只是在浏览器展示DOM树的时候,根据CSS规则,没有将提示框展示在屏幕上而已

其次,对于两种CSS的实现途径,它们的行为其实也是不一致的:

  • display:none是在浏览器排版的时候,把<div>元素直接移除了,删除了,相当于虽然DOM里有这个<div>,但浏览器视觉化的时候,把这个结点删了
  • visibility:hidden是浏览器排版的时候,把<div>元素给透明化了,虽然不显示这个元素,但在排版系统中,这个元素依然存在,依然有它自己的位置

为了更好的展示这三种途径的差异,我在Pages/Index.razor中交提示框组件调用了三次,如下:

@page "/"

@using HelloComponents.Client.Components

<MessageBox Message="Hello Components!"/>
<MessageBox Message="Hello Components! #2"/>
<MessageBox Message="Hello Components! #3"/>

再用表格来总结一下:

实现途径 依次点击删除按钮的页面效果 依次点击删除按钮的DOM行为
条件渲染 cond_render_1 cond_render_2
display:none display_none_1 display_none_2
visibility:hidden visi_hidden_1 visi_hidden_2

那到底哪种实现方式好呢?其实没有绝对的好坏标准,但我个人更倾向于使用条件渲染的试,理由如下:

  1. 条件渲染从浏览器的DOM树中移除了不必要的元素,浏览器本身的渲染压力会小一点。。不过坦白讲,这点性能差异完全不应当,也不值得程序员去注意。
  2. 条件渲染使用C#实现了UI交互逻辑,而不必要去了解HTML+CSS方面的“前端”知识。换句话说,我假使默认使用Blazor的开发人员的技能栈以后端知识为主,普遍前端知识匮乏,那么在这种情况下,能使用C#解决的问题,就没必要去使用前端知识去解决了。

3. 书写一个可嵌套的组件

上面我们已经实现了一个最简单的组件,并且通过介绍如何实现它,介绍了很多概念性的知识。你会意识到,组件的本质其实就是函数,和其它程序没什么不同。

比如上面我们在Pages/Index.razor中将MessageBox调用了三遍,这里面的逻辑如果用函数调用的思想,可以描述为以下伪代码:

MessageBox(string message) {
   //...
   //...
   return a_message_box;
}

Index() {
   var res;
   res += MessageBox("Hello Components!");
   res += MessageBox("Hello Components! #2");
   res += MessageBox("Hello Components! #3");
   return res;
}

函数自然也可以嵌套调用,显然我们也可以在MessageBox内部去调用其它组件,如下所示:

OtherTinyComponent() {
   //...
   //...
   return something;
}

MessageBox(string message) {
   var res;
   //...
   //...
   res += OtherTinyComponent();
   //...
   //...
   return res;
}

Index() {
   var res;
   res += MessageBox("Hello Components!");
   res += MessageBox("Hello Components! #2");
   res += MessageBox("Hello Components! #3");
   return res;
}

这非常平平无奇,没什么可说的。但还有一种玩法,叫做:把函数指针当成参数传递给函数。这是什么意思呢?我换个说法来表达:

我们现在的提示框,接受的参数是字符串类型的,那么作为使用者,调用者,唯一能自定义提示框的,就是提示字符串的内容。我们在提示框内部使用<p><em>@this.Message</em></p>将提示字符串渲染出来。

但要是用户不想使用<p><em>呢?假设用户想让字号大一点,颜色变成红色呢?再假如用户想渲染的提示信息并不是字符串,而是一个图片呢?

这个时候就需要把原有的“字符串参数”,升级为一个“函数指针”:这个函数指针,是调用者用来告诉MessageBox:以何种方式,渲染提示内容的。如果将这个思想写成伪码,将变成如下这样:

MessageBox(*renderFunc()) {
   var res;
   //...
   res += renderFunc();
   //...
   return res;
}

RichTextWarningContent() {
   var res;
   // bold font
   // background color red
   // insert an icon
   // etc...
   return res;
}

Index() {
   var res;
   res += MessageBox(RichTextWarningContent);
   return res;
}

回到标记语言处:你有没有想过,我们之前定义的MessageBox,看起来像是个HTML标签,还有自己的属性Message,但你有没有觉得它有哪点还不够HTML吗?

答案是:我们之前定义的MessageBox是一个自闭的HTML元素,它不支持下面的操作:

<MessageBox Message="Hello Components">
   <p>some other content</>
   <div>
      // nested elements
   </div>
</MessageBox>

而上面的操作,其实就是在MessageBox的内部,再给它嵌套了一段UI片段。如果把UI片段看做是组件的就地写法,那么上面的写法其实描述的就是将函数指针传递给函数

有点绕口了,再换个说法:假如用户不希望通过Message参数来自定义提示信息,而是希望通过嵌套的UI片断来自定义提示信息的话,作为调用方,就可能 写出如下代码:

<MessageBox>
   <p>some other content</>
   <div>
      // nested elements
   </div>
</MessageBox>

终于圆回来了。所以现在的问题是:如何声明一个“函数指针参数”呢?

答案是:

  1. “函数指针参数”,或者叫“UI片段参数”,本质上依然是个组件参数,所以:
    • 它是一个公开的属性
    • 它脑门上要有[Parameter]修饰
  2. 但特殊的是它的类型:既然是“UI片段”,那么它的类型叫RenderFragment也就非常合理了
  3. 每个组件理论上可以声明无数个RenderFragment类型的参数,但只有其中的一个参数,可以通过HTML元素嵌套,或者叫XML嵌套的方式传递值,这个参数的名字必须是ChildContent

#1和#2都好理解,但#3是什么意思呢?它的意思是,假如我们如下声明了MessageBox

<div>
   @this.p1
   @this.ChildContent
   @this.p3
</div>

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

然后又如下调用它:

<MessageBox>
   <NestedContent>
      // ...
   </NestedContent>
</MessageBox>

那么<NestedContent>元素包裹起来的UI片段,会被自动的传递给参数ChildContent。另外两个参数,属于“没有传值”的状态。

当然,反过来说,我们确实可以通过attribute的方式给参数传值,即使是ChildContent,也是一点毛病都没有的。事实上我们可以通过attribute的方式向任何参数传值,不过这个话题等会再补充再说。

好,理论与概念上的东西已经翻过来翻过去说得够多了,现在我们来将我们之前的,基于条件渲染实现的MessageBox,改造成“自定义内部内容”的状态

  • 由于我们把主要内容的渲染权力移交了出去,所以这时再限制提示框的宽高就不太合理了,但制定一个最小高度和最小宽度还是有必要有,以防提示框的大小不能包裹叉按钮。
  • 我们这次支持提示框出现在行内,所以也把外部divdisplay样式改为inline-block
  • 由于一个特殊的原因,我们需要在外部div上再加上一个特殊的样式:vertical-align:bottom。这个特殊的原因你可以参考这个stackoverflow上的回答
  • 然后移除原先的Message参数,新添加ChildContent参数
@if(!this.CloseButtonHadBeenClicked)
{
   <div style="vertical-align: bottom; display:inline-block; min-height:50px; min-width:100px; 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!;

   private bool CloseButtonHadBeenClicked = false;

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

Pages/Index.razor中可以如下使用:

@page "/"

@using HelloComponents.Client.Components

<MessageBox>
    <p>MessageBox about paragraph</p>
</MessageBox>

<MessageBox >
    <h1>MessageBox about an article</h1>
    <p>...</p>
    <p>...</p>
    <p>...</p>
    <p>...</p>
</MessageBox>

<MessageBox />

效果如下:

child_content

OK,看到这里相信你也了解了什么是ChildContent,以及它的“函数指针”本质,接下来是几个无关紧要的额外知识点:

3.1 所有的参数都可以以attribute的方式传值,即使是ChildContent

Razor引擎将嵌套的UI片段传递给ChildContent的行为可以看作是一个特殊的语法糖,事实上,组件内声明的任何参数都可以以attribute的形式传值。

这里有就两个问题:如果我们要向ChildContent以attribute的语法来传值

  1. RenderFragment类型到底是什么东西?我们要向一个类型为RenderFragment类型的参数传值,那么至少应当知道以下二者之一:
    • 这个类型如何初始化一个实例
    • 这个类型的字面常量怎么书写
  2. 我们之前说过,在Razor模板语言中,使用@(xxx)的语法,是将变量/属性/字段进行“渲染”,我们也说过,所谓的“渲染”就是对变量/字段/属性进行ToString()求值,然后把求出来的字符串值放置在原地。
    • 这个说法到底正确不正确?如果100%正确的话,我们通过<MessageBox ChildContent=@xxx/>传递参数的过程中,岂不是意味着引擎会对@xxx进行ToString()求值吗?

我先来回答第二个问题:在参数传递场景下,@(xxx)并不会无脑的对xxx.ToString()进行求值,99.9%的情况下,它会按照最符合直觉的逻辑进行工作。比如<MessageBox ChildContent=@xxx />, 在这个场合,假如xxx是一个类型为RenderFragment的变量/字段/属性,那么会直接把这个变量/字段/属性的值,传递给ChildContent参数

并且,在处理事件回调时我们也写过<button @[email protected]>click me</button>这样的代码,在这种场合下,@this.HandleButtonClick也不会去求ToString()。总之,在attribute赋值场景下,至少目前而言,对于组件参数赋值,以及directive attribute赋值,是不会有ToString()求值行为的!

再回头来回答第一个问题:RenderFragment到底是什么?

简单来说,RenderFragment是一个函数指针:是的,没错,它的真实类型其实是一个delegate:

public delegate void RenderFragment(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder builder);

而如果你恰巧之前有看过*.razor文件转译后的C#文件的话,就会知道,这个函数指针的签名,和我们在前几篇文章中一直讲的BuildRenderTree方法是一模一样的!

我们可以简单的将RenderTreeBuilder这个参数,看作是Blazor框架传入的、用来给v-dom添加枝桠的一个句柄。比如我们把Pages.Index.razor简化成下面这样:

@page "/"

<h1>Hello Blazor!</h1>

经过Razor引擎转译后,生成的C#文件如下:

namespace HelloComponents.Client.Pages
{
   [global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
   public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
   {
      protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
      {
         __builder.AddMarkupContent(0, "<h1>Hello Blazor!</h1>");
      }
   }
}

这里的BuilderRenderTree方法就完全契合RenderFragment的签名,也从侧面验证了我们之前的逻辑理论:Razor组件/页面、UI片段、RenderFragment参数等等不同的说法,本质上都是函数指针

那么明白了这一点,至少目前我们能以最原始的方式写出RenderFragment的值了,于是乎我们可以将我们之前的Index.razor改写成如下的样式:

@page "/"

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


<MessageBox ChildContent=@this.p1/>
<MessageBox ChildContent=@this.p2/>
<MessageBox />

@code{
    void p1(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<p>MessageBox about paragraph</p>");
    }

    private void p2(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<h1>MessageBox about an article</h1>");
        builder.AddMarkupContent(2, "<p>..</p>");
        builder.AddMarkupContent(3, "<p>..</p>");
        builder.AddMarkupContent(4, "<p>..</p>");
        builder.AddMarkupContent(5, "<p>..</p>");
    }
}

但这样写,实在是太麻烦了,有没有方便一点的方法呢?有的,这里就要补充一个Razor模板语法了:

我们在第一篇文章中就介绍过,Razor模板文件中:

  1. 默认是标记语言状态,@(xxx)是临时转为C#状态对表达式求值并渲染(上面我们也补充了,在一些特殊情况下并不会ToString()字符串化)
  2. @{}@code{}都是切换状态,进入C#状态,前者是在BuildRenderTree方法的上下文中,为方法补充语句,后者是在类的上下文中,为类补充成员
  3. @{}状态中,即BuildRenderTree上下文的C#状态中,有以下几种方式可以就地向__builder添加标记语言,即临时切换为标记语言状态:
    • <p>xxx</p>: 看起来像HTML的一整行,会被当成HTML渲染
    • <text>xxx</text>: 将一整行用<text>这个假元素包裹起来,内部内容会被当成标记语言就地渲染,最终<text>会被移除
    • @:xxx: 在行首用@:开头,该行的剩余内容会被就地当成标记语言渲染
    • @开头,写下的诸如@<p>you have a pet named <strong>@item.Name</strong></p>这样的表达式,会被引擎转序成一个local lambda表达式,其类型为Func<dynamic, object>,这个特性叫Templated Razor delegates。这里面的item非常关键
      • 它的特点是有一个固定名称的参数叫item,用于小范围的复用代码
    • @xxx(xx, xx, xxx)形式书写的表达式,会被引擎认为是对templated razor delegates的调用。尽管以templated razor delegates语法定义的local lambda表达式只能有一个固定参数item,但可以在@code{}块中显式定义类似的方法,然后在BuildRenderTree上下文中以@xxx(xx, xx, xxx)形式调用

这里,今天,再补充一个知识点:

无论是在@{}状态中,即BuildRenderTree上下文中,还是在@code{}状态中,即类上下文中,都可以以类似于templated razor delegates的语法写一个表达式,但是,内部不使用@item,这样的表达式会被引擎转译成一个RenderFragment

换句话说,上面的示例代码可以简化成如下模样:

@page "/"

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


<MessageBox ChildContent=@this.p1/>
<MessageBox ChildContent=@this.p2/>
<MessageBox />

@code{
    private RenderFragment p1 =@<p>MessageBox about paragraph</p>;
    private RenderFragment p2 =
    @<text>
        <h1>MessageBox about an article</h1>
        <p>..</p>
        <p>..</p>
        <p>..</p>
        <p>..</p>
    </text>;
}

上面的例子中,我们把p1p2写在了类上下文中,即写成了类的成员,以p1为例,它事实上被引擎转译成了下面的样子:

namespace HelloComponents.Client.Pages
{
   [global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
   public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
   {
      // ...

      private RenderFragment p1 = 
         (__builder2) => {
            __builder2.AddMarkupContent(7, "<p>MessageBox about paragraph</p>");
         }

      // ...
   }
}

我们也可以把p1p2写在BuildRenderTree上下文中,如下:

@page "/"

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

@{
    RenderFragment p1 =@<p>MessageBox about paragraph</p>;
    RenderFragment p2 =
    @<text>
        <h1>MessageBox about an article</h1>
        <p>..</p>
        <p>..</p>
        <p>..</p>
        <p>..</p>
    </text>;
}

<MessageBox ChildContent=@p1/>
<MessageBox ChildContent=@p2/>
<MessageBox />

此时引擎的转译结果类似,只不过是把p1声明成了BuildRenderTree方法里的local lambda variable

namespace HelloComponents.Client.Pages
{
   [global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
   public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
   {
      protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
      {
         // ...
         RenderFragment p1 = 
            (__builder2) => {
                __builder2.AddMarkupContent(0, "<p>MessageBox about paragraph</p>");
            }
         // ...
         __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(7);
         __builder.AddAttribute(8, "ChildContent", (object)((global::Microsoft.AspNetCore.Components.RenderFragment)(
            p1
         )));
         // ...
      }
   }
}

3.2 给类型为RenderFragment的组件参数传值的几种方式

RenderFragment是相当特殊的一种组件参数:从概念角度来看,它代表的是UI片段,从本质角度来看,它其实是一个函数指针。从使用角度来看,它与其它普通组件参数不一样:它的花活很多。

我们上面已经接触了两种传递RenderFragment组件参数的方式,这个小节做个归纳,并额外再介绍两种

第一种:ChildContent传值

上面我们说了,当一个组件参数类型是RenderFragment且参数名为ChildContent的时候,引擎会把调用方写在XML元素中的内容打包传递给ChildContent

这种传值方式只适用于参数名为ChildContent的情况。

但这里有个知识点需要讲清楚一点。

假如我们定义了一个空组件:不接受参数,如下,就叫它Components/Blank.razor

<h3>Just a blank component</h3>

然后在调用方如下调用:

<Blank />

那么在调用方,上面这行代码会被引擎转译为BuildRenderTree方法中的如下语句:

   //...
   __builder.OpenComponent<Blank>(0);
   __builder.CloseComponent();
   //...

而如果我们定义一个ChildContent参数,如下改动Blank.razor的话:

<h3>Just a blank component</h3>
@this.ChildContent

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

在调用方如下调用的话:

<Blank>
   <p>some content</p>
</Blank>

那么在调用方,上面这三行代码会被转译为BuildRenderTree方法中的如下语句:

   __builder.OpenComponent<Blank>(0);
   __builder.AddAttribute(
      1, 
      "ChildContent", 
      (RenderFragment)(
         (__builder2) => {
            __builder2.AddMarkupContent(2, "<p>some content</p>");
         }
      )
   );
   __builder.CloseComponent();

也就是说,实际上在调用方,我们在组件内部写的东西,会被转译成两个东西:

  1. 会被转译成对ChildContent参数的赋值
  2. 内部内容,会被转译成一个local lambda表达式,并通过强制类型转换转换成RenderFragment类型

第二种:通过Attribute传值,通过定义方法的形式去构造值

这也是上面已经提到的,通过attribute的方式,像普通组件参数一样传递值。这种方式里,比较痛苦的是如何在调用方构造一个RenderFragment的值,有两种选择:

  1. 使用特殊语法直接写一个字面量,如下:
    RenderFragment p1 =@<p>MessageBox about paragraph</p>;

这种写法既可以写在BuildRenderTree上下文中,即@{}代码块中,也可以写在类上下文中,即@code{}代码块中

  1. 直接在类上下文,即@code{}代码块中,按RenderFragment的本质,写成一个方法,如下:
    void p1(RenderTreeBuilder builder)
    {
        builder.AddMarkupContent(1, "<p>MessageBox about paragraph</p>");
    }

第三种: 依然通过Attribute传值,但通过lambda表达式的方式去构造值

这种传值方式从“调用子组件”的角度来说,和第二种方式一样,也是通过attribute去传递参数值,不同的是我们如何去构造参数值本身。上面说了因为RenderFragment是一个delegate,所以天然的可以把函数指针当成RenderFragment类型的值。而这里,我们则可以使用lambda表达式来当作RenderFragment的值,最朴素的实现如下:

   RenderFragment p1 = (builder) => builder.AddMarkupContent(1, "<p>MessageBox about paragraph</p>");

以上写法是纯C#语法,既可以写在@code{}代码块中当字段,也可以写在@{}代码块中当本地变量。都行。

不过这样写lambda表达式,其实并不比直接按#2的方式写成方法形式方便。而如果你还记得,Razor引擎在@{}代码块中,有一种语法,叫:如果一行代码看着像是标记语言,那么它就会被就地渲染。你可能会写出下面的代码:

   RenderFragment p1 = (builder) => 
   {
      <p>MessageBox about paragraph</p>
   };

恭喜你,踩到了一个坑里,上面的代码不能通过编译,但下面的代码可以:

   RenderFragment p1 = (__builder) => 
   {
      <p>MessageBox about paragraph</p>
   };

我们马上就会解释这个智障的坑。稍安勿躁。

正确简化Lambda表达式的方式,我们上面也提到过,是如下的写法:

   RenderFragment p1 = @<p>MessageBox about paragraph</p>;

它相当于是无参版本的templated razor delegates。

第四种:通过XML子元素的方式去传递值

我们前面说的第一种传值方式,写出来长下面这样:

<MessageBox>
   <p>content will be pass to ChildContent</p>
</MessageBox>

它其实是一种简写,完全体长下面这样:

<MessageBox>
   <ChildContent>
      <p>content will be pass to ChildContent</p>
   </ChildContent>
</MessageBox>

即,RenderFragment传值,可以以参数名为XML子元素,然后参数值以标记语言写在XML子元素内部,这样的方式去传值。这个语法在子组件定义了多个RenderFragment组件参数时特别有用:比如我们定义了一个子组件,叫Components/Flow.razor吧,它接受三个参数,如下:(以下是简略代码)


<div>@this.Header</div>
<div>@this.ChildContent</div>
<div>@this.Footer</div>

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

    [Parameter]
    public RenderFragment Footer { get; set; } = default!;

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

调用方当然可以使用attribute传值的方式去调用这个子组件,但很明显没有下面的写法更自然:

<Flow>
    <Header>
        <h1>this is header</h1>
    </Header>
    <ChildContent>
        <h1>this is content</h1>
        <p>content contains several paragraph..</p>
        <p>content contains several paragraph..</p>
        <p>content contains several paragraph..</p>
    </ChildContent>
    <Footer>
        <h1>this is footer</h1>
    </Footer>
</Flow>

与我们上面剖析ChildContent转译结果一样,实际上上面的代码会被转换成三次参数传递,三个XML元素内部的内容也被转译成了三个lambda表达式,如下:

   __builder.OpenComponent<Flow>(0);
   __builder.AddAttribute(1, "Header", (RenderFragment)((__builder2) => {
         __builder2.AddMarkupContent(2, "<h1>this is header</h1>");
   }
   ));
   __builder.AddAttribute(3, "ChildContent", (RenderFragment)((__builder2) => {
         __builder2.AddMarkupContent(4, "<h1>this is content</h1>\r\n        ");
         __builder2.AddMarkupContent(5, "<p>content contains several paragraph..</p>\r\n        ");
         __builder2.AddMarkupContent(6, "<p>content contains several paragraph..</p>\r\n        ");
         __builder2.AddMarkupContent(7, "<p>content contains several paragraph..</p>");
   }
   ));
   __builder.AddAttribute(8, "Footer", (RenderFragment)((__builder2) => {
         __builder2.AddMarkupContent(9, "<h1>this is footer</h1>");
   }
   ));
   __builder.CloseComponent();

需要注意的是,但凡有一个参数使用了XML子元素的方式去传递值,就没法省略<ChildContent>子元素了。即这种传值方式无法与我们讲的第一种传值方式共存,比如下面的代码就是无法通过编译的:

<Flow>
    <Header>
        <h1>this is header</h1>
    </Header>
    <Footer>
        <h1>this is footer</h1>
    </Footer>

        <h1>this is content</h1>
        <p>content contains several paragraph..</p>
        <p>content contains several paragraph..</p>
        <p>content contains several paragraph..</p>
</Flow>

3.3 Razor引擎其实没有你想象的那么智能

这里,我们来看一个有意思的例子,其实在讲四种传值方式的时候,我们已经提了一嘴了。

下面这份代码是能通过编译,也能正常执行的:

@page "/"

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

@code{
    void p1(RenderTreeBuilder __builder)
    {
        <p>MessageBox about paragraph</p>;
    }

    private void p2(RenderTreeBuilder __builder)
    {
        <h1>MessageBox about an article</h1>
        <p>..</p>
        <p>..</p>
        <p>..</p>
        <p>..</p>
    }
}

<MessageBox ChildContent=@this.p1/>
<MessageBox ChildContent=@this.p2/>
<MessageBox />

但下面这份代码就不能通过编译:

 @page "/"
 
 @using HelloComponents.Client.Components
 @using Microsoft.AspNetCore.Components.Rendering;
 
 @code{
-    void p1(RenderTreeBuilder __builder)
+    void p1(RenderTreeBuilder builder)
     {
         <p>MessageBox about paragraph</p>;
     }
 
-    private void p2(RenderTreeBuilder __builder)
+    private void p2(RenderTreeBuilder builder)
     {
         <h1>MessageBox about an article</h1>
         <p>..</p>
         <p>..</p>
         <p>..</p>
         <p>..</p>
     }
 }
 
 <MessageBox ChildContent=@this.p1/>
 <MessageBox ChildContent=@this.p2/>
 <MessageBox />

编译器给的错误信息如下:

compile_error

这背后的故事非常智障。不过在讲最终答案之前,我们先梳理一下能编译通过的那份代码的逻辑:

与我们之前写的例子不同,这次,p1p2虽然是以方法定义的,但在方法内部,又使用了Razor引擎的“单行HTML就地渲染特性”,这个特性按文档,只能应用于@{}范围内,即BuildRenderTree上下文中:

如果当前行代码看起来像是一行HTML代码,那么就把这行HTML代码就地渲染。

这本来是方便程序员在BuildRenderTree上下文中定义UI片段的一个特性,但我们这里把这个特性用在了另外一个方法的上下文中。

配合上Razor引擎一个非常僵硬的转译逻辑:转译此类单行HTML代码时,只就机械的将<>...</>转译成__builder.AddMarkupContent("<>...</>"),导致了一个非常僵硬的转译结果,如下:

namespace HelloComponents.Client.Pages
{
   [global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
   public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
   {
      protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
      {
         __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(0);
         __builder.AddAttribute(1, "ChildContent", (object)((global::Microsoft.AspNetCore.Components.RenderFragment)(
            this.p1
         )));
         __builder.CloseComponent();
         __builder.AddMarkupContent(2, "\r\n");
         __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(3);
         __builder.AddAttribute(4, "ChildContent", (object)((global::Microsoft.AspNetCore.Components.RenderFragment)(
            this.p2
         )));
         __builder.CloseComponent();
         __builder.AddMarkupContent(5, "\r\n");
         __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(6);
         __builder.CloseComponent();
      }

      void p1(RenderTreeBuilder __builder)
      {
         __builder.AddMarkupContent(7, "<p>MessageBox about paragraph</p>");
      }

      private void p2(RenderTreeBuilder __builder)
      {
         __builder.AddMarkupContent(8, "<h1>MessageBox about an article</h1>\r\n        ");
         __builder.AddMarkupContent(9, "<p>..</p>\r\n        ");
         __builder.AddMarkupContent(10, "<p>..</p>\r\n        ");
         __builder.AddMarkupContent(11, "<p>..</p>\r\n        ");
         __builder.AddMarkupContent(12, "<p>..</p>");
      }
   }
}

这个事情蠢就蠢在,如果我们在代码中,把p1p2的参数名改为builder的话,转译结果就会变成下面这样:

 namespace HelloComponents.Client.Pages
 {
    [global::Microsoft.AspNetCore.Components.RouteAttribute("/")]
    public partial class Index : global::Microsoft.AspNetCore.Components.ComponentBase
    {
       protected override void BuildRenderTree(global::Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder)
       {
          __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(0);
          __builder.AddAttribute(1, "ChildContent", (object)((global::Microsoft.AspNetCore.Components.RenderFragment)(
             this.p1
          )));
          __builder.CloseComponent();
          __builder.AddMarkupContent(2, "\r\n");
          __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(3);
          __builder.AddAttribute(4, "ChildContent", (object)((global::Microsoft.AspNetCore.Components.RenderFragment)(
             this.p2
          )));
          __builder.CloseComponent();
          __builder.AddMarkupContent(5, "\r\n");
          __builder.OpenComponent<global::HelloComponents.Client.Components.MessageBox>(6);
          __builder.CloseComponent();
       }

-      void p1(RenderTreeBuilder __builder)
+      void p1(RenderTreeBuilder builder)
       {
          __builder.AddMarkupContent(7, "<p>MessageBox about paragraph</p>");
       }

-      private void p2(RenderTreeBuilder __builder)
+      private void p2(RenderTreeBuilder builder)
       {
          __builder.AddMarkupContent(8, "<h1>MessageBox about an article</h1>\r\n        ");
          __builder.AddMarkupContent(9, "<p>..</p>\r\n        ");
          __builder.AddMarkupContent(10, "<p>..</p>\r\n        ");
          __builder.AddMarkupContent(11, "<p>..</p>\r\n        ");
          __builder.AddMarkupContent(12, "<p>..</p>");
       }
    }
 }

总结:

  1. 尽量不要在非BuildRenderTree上下文中使用各种“从C#转到标记语言,再从标记语言转到C#”之类的花活,如果非要用,确保你切实理解引擎转译的行为
  2. 这种程序虽然能正确编译运行,但显然Razor引擎的这种工作逻辑是错误的
    • Razor引擎按理应当检测此类特殊语法的书写位置是否在BuildRenderTree上下文中,但事实上,并没有,或者说,至少在dotnet 7.0版本中,没有
    • 我们可以看到,__builder.AddMarkupContent()方法的第一个参数是某种"sequence",而在我们“恰好能正常运行”的版本中,这个“sequence”的值即使在p1p2中也在自增。这显然是一个错误逻辑,有风险会制造出一些奇怪的Bug