通过前面四节课的学习,现在你可以创建一个Hosted Blazor WASM项目了,也能在Pages/Index.razor
中添加内容画出个网页了,如果你仔细看过之前的文章的话,你甚至懂得如何在页面上调用后端API了。
如果你恰巧天资聪颖善于举一反三,你甚至可以在Pages
目录下添加更多的其它xxx.razor
文件,并在各个文件脑门上写上不同的@page
指令,写出多个页面了。
而如果你恰巧骨骼精奇是万中无一的代码奇才,你甚至可以在各个页面之间,使用<a>
标签将它们串连起来,实现页面间的互相跳转,这样,一个初具雏形的网站也能搭建起来。
但这里有个大问题:我们没有复用代码。这是什么意思呢?我来以我自己的这个博客网站举个例子:
下面这个页面,是根路径,是网站的首页,如果这个博客网站是用上面的思路实现的话,这个页面对应的Razor模板文件应当是Pages/Index.razor
下面这个页面,访问路径是/tag/blazor/
,这个页面对应的Razor模板文件应当是Pages/Tag/Blazor/Index.razor
,或者是Pages/Tag/Blazor.razor
,或者甚至直接就是Pages/Blazor.razor
,文件是怎么组织在目录里的不是很重要,只需要脑门上的@page
指令的值是/tag/blazor/
就行了
可以非常明显的看到,这两个页面几乎是一模一样的:除了页面上展示的介绍信息文本,与具体的文章列表不一样。
而如果我们没有代码复用的意识和能力,那么我们就几乎要在Pages/Index.razor
和Pages/Tag/Blazor/Index.razor
两个文件中,把90%相似的代码写两遍:怎么想这都是一个非常愚蠢的实现方式。
正确的实现方式应当是:把页面中可重复使用的碎片,包装成组件,然后在不同的页面Razor模板文件上,去调用它们。
这是什么意思呢?我们来分析上面两个页面,第一眼看上去,页面可以被分为三部分:脑门、简介与文章列表。我们专业一点,就分别叫它们Header
, Abstract
, PostList
吧。(这里叫Abstract
好像也不是太合适,但我贫乏的英语词汇也就找出个abstract)
在这种指导思想的指引下,我们可以把可复用的这三部分包装成组件,然后分别在Pages/Index.razor
和Pages/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
然后在<PostList/>
的实现内部,我们可以调用<Feed/>
组件多次。这就是嵌套。
这就是第三个知识点:组件就像函数一样,可以嵌套调用
所以暂时总结一下:
*.razor
文件,本质上都是组件。组件其实就是UI片段。只是有一些组件比较特殊,通过@page
指令指定了访问路径,我们把这部分特殊的组件叫页面组件以上,就是组件的概念。
按照常理来说呢,上个章节我们用博客网站举了例子,这个章节就应该实现一个上面提到的<Header />
, <Abstract/>
, <PostList/>
与<Feed/>
组件了。但是,为了不要让文章过于拖沓,我决定还是把例子搞得再简单一点:我们来实现一个提示框组件吧。
什么是提示框呢?简单来说,就是:
X
按钮,用户点击之后可以让提示框从页面上消失那么很显然的,我们来从头捋一下刚才学到的知识:
x
按钮,提示框就消失,这个很容易实现,有两种实现思路,
display: none
或visibility:hidden
即可。BuildRenderTree
方法返回空白内容。换个角度来说,就是组件渲染时需要判断按钮是否被按过一次:如果是,那么就不渲染任何内容,否则渲染提示信息。下面,我们一步步的来实现它
这次我们不手搓项目了,直接使用dotnet new blazorwasm-empty --hosted -o HelloComponents
从官方模板创建一个Hosted 的Blazor WASM项目。
项目创建结束后,我们需要在HelloComponents.Client
项目下面新建一个名为Components
的目录。我们即将在这个目录下放置我们的提示框组件,整个解决方案的结构如下图所示:
官方模板与我们之前手搓的解决方案的区别在于:
Client
与Server
项目均有默认的Properties/launchSettings.json
文件Client
项目中,在wwwroot
目录下,除了必要的index.html
,还有一个简单的css文件,里面写了一些样式,主要用来修饰在异常发生的情况下,App.razor
无法正确渲染时,在页面上展示一个稍微漂亮点的错误提示Client
项目中有一个_Imports.razor
文件,里面写满了@using
指令,其中就有我们之前多次强调过的@using Microsoft.AspNetCore.Components.Web
,用以引入诸如@onclick
之类的directive attribute,来让Razor引擎能正确的识别事件回调声明Client
项目中有一个MainLayout.razor
文件,这个是一个布局组件,有关这部分知识我们后续会介绍,现在请暂时忽视它Client
项目的App.razor
的内容也比我们手搓的版本要丰富一些,里面的内容我们后续会介绍,现在也请暂时忽视它这些区别对于目前我们来说,都可以暂时忽视,让我们先把注意力集中到如何实现一个组件身上。知识点来了:按约定俗成的规则,
@page
上的路由路径,把它们组织在Pages
目录下。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.razor
与Components.MessageBox.razor
,它们转译后的C#文件,其所属的名称空间还要追加上路径信息,比如前者编译后会是一个名为HelloComponents.Client.Pages.Index
的类,后者会是一个HelloComponents.Client.Components.MessageBox
的类。也就是说,在Razor模板文件中没有@namespace
指令存在的情况下,该模板文件对应的类,所属的名称空间,就是项目默认名称空间
+ 目录结构
所以说,良好的项目布局,不光是在文件层级结构上看起来让人心情舒畅,更重要的是目录路径信息,也是Razor类的名称空间。
首先,它得是个框,所以用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
,再通过top
和left
属性指定叉的位置
<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>
效果如下:
丑是丑了点,但有那么点意思了。这就是我们Components/MessageBox.razor
文件的雏形
我们上面已经通过纯HTML+CSS写出了Components.MessageBox.razor
的雏形,第一个问题:如何调用它?
我们可以在Pages/Index.razor
中,以使用HTML元素类似的方式,去调用这个组件,如下所示:
@page "/"
<HelloComponents.Client.Components.MessageBox />
所以,第一个知识点:
当然如果你嫌类的全名太冗长,可以如下改善:
@page "/"
@using HelloComponents.Client.Components
<MessageBox />
现在如此操作,启动项目,你已经能在首页看到我们写的这个提示框了,但问题来了:提示信息现在是写死的"This is the message!"
,如何把它写成参数呢?
谨记两板斧:
[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!"/>
所以总结起来看,所谓的组件本质上就是函数调用,无非是:
我们上面说了,实现提示框的关闭有两种思路
MessageBox
的BuildRenderTree
选择是否渲染内容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;
}
}
这里就要捡起一个数据渲染方面的一个知识点了:数据渲染不光可以将变量/属性渲染成可视的元素内容,还可以用来渲染属性值。想通这一点,我们就可以写出如下的代码:
<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树中,已经没有相关的结点了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行为 |
---|---|---|
条件渲染 | ||
display:none |
||
visibility:hidden |
那到底哪种实现方式好呢?其实没有绝对的好坏标准,但我个人更倾向于使用条件渲染的试,理由如下:
上面我们已经实现了一个最简单的组件,并且通过介绍如何实现它,介绍了很多概念性的知识。你会意识到,组件的本质其实就是函数,和其它程序没什么不同。
比如上面我们在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>
终于圆回来了。所以现在的问题是:如何声明一个“函数指针参数”呢?
答案是:
[Parameter]
修饰RenderFragment
也就非常合理了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
,改造成“自定义内部内容”的状态
div
的display
样式改为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 />
效果如下:
OK,看到这里相信你也了解了什么是ChildContent
,以及它的“函数指针”本质,接下来是几个无关紧要的额外知识点:
ChildContent
Razor引擎将嵌套的UI片段传递给ChildContent
的行为可以看作是一个特殊的语法糖,事实上,组件内声明的任何参数都可以以attribute的形式传值。
这里有就两个问题:如果我们要向ChildContent
以attribute的语法来传值
RenderFragment
类型到底是什么东西?我们要向一个类型为RenderFragment
类型的参数传值,那么至少应当知道以下二者之一:
@(xxx)
的语法,是将变量/属性/字段进行“渲染”,我们也说过,所谓的“渲染”就是对变量/字段/属性进行ToString()
求值,然后把求出来的字符串值放置在原地。
<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模板文件中:
@(xxx)
是临时转为C#状态对表达式求值并渲染(上面我们也补充了,在一些特殊情况下并不会ToString()
字符串化)@{}
和@code{}
都是切换状态,进入C#状态,前者是在BuildRenderTree
方法的上下文中,为方法补充语句,后者是在类的上下文中,为类补充成员@{}
状态中,即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>;
}
上面的例子中,我们把p1
和p2
写在了类上下文中,即写成了类的成员,以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>");
}
// ...
}
}
我们也可以把p1
和p2
写在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
)));
// ...
}
}
}
RenderFragment
的组件参数传值的几种方式RenderFragment
是相当特殊的一种组件参数:从概念角度来看,它代表的是UI片段,从本质角度来看,它其实是一个函数指针。从使用角度来看,它与其它普通组件参数不一样:它的花活很多。
我们上面已经接触了两种传递RenderFragment
组件参数的方式,这个小节做个归纳,并额外再介绍两种
上面我们说了,当一个组件参数类型是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();
也就是说,实际上在调用方,我们在组件内部写的东西,会被转译成两个东西:
ChildContent
参数的赋值RenderFragment
类型这也是上面已经提到的,通过attribute的方式,像普通组件参数一样传递值。这种方式里,比较痛苦的是如何在调用方构造一个RenderFragment
的值,有两种选择:
RenderFragment p1 =@<p>MessageBox about paragraph</p>;
这种写法既可以写在BuildRenderTree
上下文中,即@{}
代码块中,也可以写在类上下文中,即@code{}
代码块中
@code{}
代码块中,按RenderFragment
的本质,写成一个方法,如下: void p1(RenderTreeBuilder builder)
{
builder.AddMarkupContent(1, "<p>MessageBox about paragraph</p>");
}
这种传值方式从“调用子组件”的角度来说,和第二种方式一样,也是通过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。
我们前面说的第一种传值方式,写出来长下面这样:
<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>
这里,我们来看一个有意思的例子,其实在讲四种传值方式的时候,我们已经提了一嘴了。
下面这份代码是能通过编译,也能正常执行的:
@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 />
编译器给的错误信息如下:
这背后的故事非常智障。不过在讲最终答案之前,我们先梳理一下能编译通过的那份代码的逻辑:
与我们之前写的例子不同,这次,p1
和p2
虽然是以方法定义的,但在方法内部,又使用了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>");
}
}
}
这个事情蠢就蠢在,如果我们在代码中,把p1
和p2
的参数名改为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>");
}
}
}
总结:
BuildRenderTree
上下文中使用各种“从C#转到标记语言,再从标记语言转到C#”之类的花活,如果非要用,确保你切实理解引擎转译的行为BuildRenderTree
上下文中,但事实上,并没有,或者说,至少在dotnet 7.0版本中,没有__builder.AddMarkupContent()
方法的第一个参数是某种"sequence",而在我们“恰好能正常运行”的版本中,这个“sequence”的值即使在p1
和p2
中也在自增。这显然是一个错误逻辑,有风险会制造出一些奇怪的Bug