[Asp.net core 3.1] 通过一个小组件熟悉Blazor服务端组件开发
通过一个小组件,熟悉 blazor 服务端组件开发。github
一、环境搭建
vs2019 16.4, asp.net core 3.1 新建 blazor 应用,选择 asp.net core 3.1。 根文件夹下新增目录 components,放置代码。
二、组件需求定义
components 目录下新建一个接口文件(interface)当作文档,加个 using using microsoft.aspnetcore.components;
。
先从直观的方面入手。
- 类似 html 标签对的组件,样子类似
<xxx propa="aaa" data-propb="123" ...>其他标签或内容...</xxx>
或<xxx .../>
。接口名:intag. - 需要 id 和名称,方便区分和调试。
string tagid{get;set;} string tagname{get;set;}
. - 需要样式支持。加上
string class{get;set;} string style{get;set;}
。 - 不常用的属性也提供支持,使用字典。
idictionary<string,object> customattributes { get; set; }
- 应该提供 js 支持。加上
using microsoft.jsinterop;
属性ijsruntime jsruntime{get;set;}
。
考虑一下功能方面。
- 既然是标签对,那就有可能会嵌套,就会产生层级关系或父子关系。因为只是可能,所以我们新建一个接口,用来提供层级关系处理,ihierarchycomponent。
- 需要一个 parent ,类型就定为 microsoft.aspnetcore.components.icomponent.
icomponent parent { get; set; }
. - 要能添加子控件,
void addchild(icomponent child);
,有加就有减,void removechild(icomponent child);
。 - 提供一个集合方便遍历,我们已经提供了 add/remove,让它只读就好。
ienumerable<icomponent> children { get;}
。 - 一旦有了 children 集合,我们就需要考虑什么时候从集合里移除组件,让 ihierarchycomponent 实现 idisposable,保证组件被释放时解开父子/层级关系。
- 组件需要处理样式,仅有 class 和 style 可能不够,通常还会需要 skin、theme 处理,增加一个接口记录一下,
public interface itheme{ string getclass<tcomponent>(tcomponent component); }
。intag 增加一个属性itheme theme { get; set; }
intag:
public interface intag { string tagid { get; set; } string tagname { get; } string class { get; set; } string style { get; set; } itheme theme { get; set; } ijsruntime jsruntime { get; set; } idictionary<string,object> customattributes { get; set; } }
ihierarchycomponent:
public interface ihierarchycomponent:idisposable { icomponent parent { get; set; } ienumerable<icomponent> children { get;} void addchild(icomponent child); void removechild(icomponent child); }
itheme
public interface itheme { string getclass<tcomponent>(tcomponent component); }
组件的基本信息 intag 有了,需要的话可以支持层级关系 ihierarchycomponent,可以考虑下一些特定功能的处理及类型部分。
- blazor 组件实现类似
<xxx>....</xxx>
这种可打开的标签对,需要提供一个renderfragment 或 renderfragment<targs>
属性。renderfragment 是一个委托函数,带参的明显更灵活些,但是参数类型不好确定,不好确定的类型用泛型。再加一个接口,intag< targs >:intag
, 一个属性renderfragment<targs> childcontent { get; set; }
. - 组件的主要目的是为了呈现我们的数据,也就是一般说的 xxxmodel,data....,类型不确定,那就加一个泛型。
intag< targs ,tmodel>:intag
. - renderfragment 是一个函数,childcontent 是一个函数属性,不是方法。在方法内,我们可以使用 this 来访问组件自身引用,但是函数内部其实是没有 this 的。为了更好的使用组件自身,这里增加一个泛型用于指代自身,
public interface intag<ttag, targs, tmodel>:intag where ttag: intag<ttag, targs, tmodel>
。
intag[ttag, targs, tmodel ]
public interface intag<ttag, targs, tmodel>:intag where ttag: intag<ttag, targs, tmodel> { /// <summary> /// 标签对之间的内容,<see cref="targs"/> 为参数,childcontent 为blazor约定名。 /// </summary> renderfragment<targs> childcontent { get; set; } }
回顾一下我们的几个接口。
- intag:描述了组件的基本信息,即组件的样子。
- ihierarchycomponent 提供了层级处理能力,属于组件的扩展能力。
- itheme 提供了 theme 接入能力,也属于组件的扩展能力。
- intag<ttag, targs, tmodel> 提供了打开组件的能力,childcontent 像一个动态模板一样,让我们可以在声明组件时自行决定组件的部分内容和结构。
- 所有这些接口最主要的目的其实是为了产生一个合适的 targs, 去调用 childcontent。
- 有描述,有能力还有了主要目的,我们就可以去实现 ntag 组件。
三、组件实现
抽象基类 abstractntag
components 目录下新增 一个 c#类,abstractntag.cs, using microsoft.aspnetcore.components;
借助 blazor 提供的 componentbase,实现接口。
public abstract class abstractntag<ttag, targs, tmodel> : componentbase, ihierarchycomponent, intag<ttag, targs, tmodel> where ttag: abstractntag<ttag, targs, tmodel>{ }
调整一下 vs 生成的代码, ihierarchycomponent 使用字段实现一下。
children:
list<icomponent> _children = new list<icomponent>(); public void addchild(icomponent child) { this._children.add(child); } public void removechild(icomponent child) { this._children.remove(child); }
parent,dispose
icomponent _parent; public icomponent parent { get=>_parent; set=>_parent=onparentchange(_parent,value); } protected virtual icomponent onparentchange(icomponent oldvalue, icomponent newvalue) { if(oldvalue is ihierarchycomponent o) o.removechild(this); if(newvalue is ihierarchycomponent n) n.addchild(this); return newvalue; } public void dispose() { this.parent = null; }
增加对浏览器 console.log 的支持, razor attribute...,完整的 abstractntag.cs
public abstract class abstractntag<ttag, targs, tmodel> : componentbase, ihierarchycomponent, intag<ttag, targs, tmodel> where ttag: abstractntag<ttag, targs, tmodel> { list<icomponent> _children = new list<icomponent>(); icomponent _parent; public string tagname => typeof(ttag).name; [inject]public ijsruntime jsruntime { get; set; } [parameter]public renderfragment<targs> childcontent { get; set; } [parameter] public string tagid { get; set; } [parameter]public string class { get; set; } [parameter]public string style { get; set; } [parameter(captureunmatchedvalues =true)]public idictionary<string, object> customattributes { get; set; } [cascadingparameter] public icomponent parent { get=>_parent; set=>_parent=onparentchange(_parent,value); } [cascadingparameter] public itheme theme { get; set; } public bool trygetattribute(string key, out object value) { value = null; return customattributes?.trygetvalue(key, out value) ?? false; } public ienumerable<icomponent> children { get=>_children;} protected virtual icomponent onparentchange(icomponent oldvalue, icomponent newvalue) { consolelog($"onparentchange: {newvalue}"); if(oldvalue is ihierarchycomponent o) o.removechild(this); if(newvalue is ihierarchycomponent n) n.addchild(this); return newvalue; } protected bool firstrender = false; protected override void onafterrender(bool firstrender) { firstrender = firstrender; base.onafterrender(firstrender); } public override task setparametersasync(parameterview parameters) { return base.setparametersasync(parameters); } int logid = 0; public object consolelog(object msg) { logid++; task.run(async ()=> await this.jsruntime.invokevoidasync("console.log", $"{tagname}[{tagid}_{ logid}:{msg}]")); return null; } public void addchild(icomponent child) { this._children.add(child); } public void removechild(icomponent child) { this._children.remove(child); } public void dispose() { this.parent = null; } }
- inject 用于注入
- parameter 支持组件声明的 razor 语法中直接赋值,<ntag class="ssss" .../>;
-
parameter(captureunmatchedvalues =true)
支持声明时将组件上没定义的属性打包赋值; -
cascadingparameter
配合 blazor 内置组件<cascadingvalue value="xxx" >... <ntag /> ...</cascadingvalue>
,捕获 value。处理过程和级联样式表(css)很类似。
具体类 ntag
泛型其实就是定义在类型上的函数,ttag,targs,tmodel
就是 入参,得到的类型就是返回值。因此处理泛型定义的过程,就很类似函数逐渐消参的过程。比如:
func(a,b,c) 确定a之后,func(b,c)=>func(1,b,c); 确定b之后,func(c)=>func(1,2,c); 最终: func()=>func(1,2,3); 执行 func 可以得到一个明确的结果。
同样的,我们继承 ntag 基类时需要考虑各个泛型参数应该是什么:
- ttag:这个很容易确定,谁继承了基类就是谁。
- tmodel: 这个不到最后使用我们是无法确定的,需要保留。
- targs: 前面说过,组件的主要目的是为了给 childcontent 提供参数.从这一目的出发,ttag 和 tmodel 的用途之一就是给
targs
提供类型支持,或者说 targs 应该包含 ttag 和 tmodel。又因为 childcontent 只有一个参数,因此 targs 应该有一定的扩展性,不妨给他一个属性做扩展。 综合一下,targs 的大概模样就有了,来个 struct。
public struct renderargs<ttag,tmodel> { public ttag tag; public tmodel model; public object arg; public renderargs(ttag tag, tmodel model, object arg ) { this.tag = tag; this.model = model; this.arg = arg; } }
- renderargs 属于常用辅助类型,因此不需要给 targs 指定约束。
components 目录下新增 razor 组件,ntag.razor;aspnetcore3.1 组件支持分部类,新增一个 ntag.razor.cs;
ntag.razor.cs 就是标准的 c#类写法
public partial class ntag< tmodel> :abstractntag<ntag<tmodel>,renderargs<ntag<tmodel>,tmodel>,tmodel> { [parameter]public tmodel model { get; set; } public renderargs<ntag<tmodel>, tmodel> args(object arg=null) { return new renderargs<ntag<tmodel>, tmodel>(this, this.model, arg); } }
重写一下 ntag 的 tostring,方便测试
public override string tostring() { return $"{this.tagname}<{typeof(tmodel).name}>[{this.tagid},{model}]"; }
ntag.razor
@typeparam tmodel @inherits abstractntag<ntag<tmodel>,renderargs<ntag<tmodel>,tmodel>,tmodel>//保持和ntag.razor.cs一致 @if (this.childcontent == null) { <div>@this.tostring()</div>//默认输出,用于测试 } else { @this.childcontent(this.args()); } @code { }
简单测试一下, 数据就用项目模板自带的 data 打开项目根目录,找到_imports.razor
,把 using 加进去
@using xxxx.data @using xxxx.components
新增 razor 组件【test.razor】
未打开的ntag,输出ntag.tostring(): <ntag tmodel="object" /> 打开的ntag: <ntag model="testdata" context="args" > <div>ntag内容 @args.model.summary; </div> </ntag> <ntag model="@(new {name="匿名对象" })" context="args"> <div>匿名model,使用参数输出【name】属性: @args.model.name</div> </ntag> @code{ weatherforecast testdata = new weatherforecast { temperaturec = 222, summary = "aaa" }; }
转到 pages/index.razor, 增加一行<test />
,f5 。
应用级联参数 cascadingvalue/cascadingparameter
我们的组件中 theme 和 parent 被标记为【cascadingparameter】,因此需要通过 cascadingvalue 把值传递过来。
首先,修改一下测试组件,使用嵌套 ntag,描述一个树结构,model 值指定为树的 level。
<ntag model="0" tagid="root" context="root"> <div>root.parent:@root.tag.parent </div> <div>root theme:@root.tag.theme</div> <ntag tagid="t1" model="1" context="t1"> <div>t1.parent:@t1.tag.parent </div> <div>t1 theme:@t1.tag.theme</div> <ntag tagid="t1_1" model="2" context="t1_1"> <div>t1_1.parent:@t1_1.tag.parent </div> <div>t1_1 theme:@t1_1.tag.theme </div> <ntag tagid="t1_1_1" model="3" context="t1_1_1"> <div>t1_1_1.parent:@t1_1_1.tag.parent </div> <div>t1_1_1 theme:@t1_1_1.tag.theme </div> </ntag> <ntag tagid="t1_1_2" model="3" context="t1_1_2"> <div>t1_1_2.parent:@t1_1_2.tag.parent</div> <div>t1_1_2 theme:@t1_1_2.tag.theme </div> </ntag> </ntag> </ntag> </ntag>
1、 theme:theme 的特点是共享,无论组件在什么位置,都应该共享同一个 theme。这类场景,只需要简单的在组件外套一个 cascadingvalue。
<cascadingvalue value="theme.default"> <ntag tagid="root" ...... </cascadingvalue>
f5 跑起来,结果大致如下:
<div>root theme:theme[blue]</div> <div>t1.parent: </div> <div>t1 theme:theme[blue]</div> <div>t1_1.parent: </div> <div>t1_1 theme:theme[blue] </div> <div>t1_1_1.parent: </div> <div>t1_1_1 theme:theme[blue] </div> <div>t1_1_2.parent:</div> <div>t1_1_2 theme:theme[blue] </div>
2、parent:parent 和 theme 不同,我们希望他和我们组件的声明结构保持一致,这就需要我们在每个 ntag 内部增加一个 cascadingvalue,直接写在 test 组件里过于啰嗦了,让我们调整一下 ntag 代码。打开 ntag.razor,修改一下,test.razor 不动。
<cascadingvalue value="this"> @if (this.childcontent == null) { <div>@this.tostring()</div>//默认输出,用于测试 } else { @this.childcontent(this.args()); } </cascadingvalue>
看一下结果
<div>root theme:theme[blue]</div> <div> t1.parent:ntag`1[root,0] </div> <div>t1 theme:theme[blue]</div> <div> t1_1.parent:ntag`1[t1,1] </div> <div> t1_1 theme:theme[blue] </div> <div> t1_1_1.parent:ntag`1[t1_1,2] </div> <div> t1_1_1 theme:theme[blue] </div> <div> t1_1_2.parent:ntag`1[t1_1,2]</div> <div> t1_1_2 theme:theme[blue] </div>
- cascadingvalue/cascadingparameter 除了可以通过类型匹配之外还可以指定 name。
呈现 model
到目前为止,我们的 ntag 主要在处理一些基本功能,比如隐式的父子关系、子内容 childcontent、参数、泛型。。接下来我们考虑如何把一个 model 呈现出来。
对于常见的 model 对象来说,呈现 model 其实就是把 model 上的属性、字段。。。这些成员信息呈现出来,因此我们需要给 ntag 增加一点能力。
- 描述成员最直接的想法就是 lambda,model=>model.xxxx,此时我们只需要 model 就足够了;
- ui 呈现时仅有成员还不够,通常会有格式化需求,比如:{0:xxxx}; 或者带有前后缀: "¥{xxxx}元整",甚至就是一个常量。。。。此类信息通常应记录在组件上,因此我们需要组件自身。
- 呈现时有时还会用到一些环境变量,比如序号/行号这种,因此需要引入一个参数。
- 以上需求可以很容易的推导出一个函数类型:func<ttag, tmodel,object,object> ;考虑 ttag 就是组件自身,这里可以简化一下:func<tmodel,object,object>。 主要目的是从 model 上取值,兼顾格式化及环境变量处理,返回结果会直接用于页面呈现输出。
调整下 ntag 代码,增加一个类型为 func<tmodel,targ,object> 的 getter 属性,打上【parameter】标记。
[parameter]public func<tmodel,object,object> getter { get; set; }
- 此处也可使用表达式(expression<func<tmodel,object,object>>),需要增加一些处理。
- 呈现时通常还需要一些文字信息,比如 lable,text 之类, 支持一下;
[parameter] public string text { get; set; }
- ui 呈现的需求难以确定,通常还会有对状态的处理, 这里提供一些辅助功能就可以。
一个小枚举
public enum nvisibility { default, markup, hidden }
状态属性和 render 方法,ntag.razor.cs
[parameter] public nvisibility textvisibility { get; set; } = nvisibility.default; [parameter] public bool showcontent { get; set; } = true; public renderfragment rendertext() { if (textvisibility == nvisibility.hidden|| string.isnullorempty(this.text)) return null; if (textvisibility == nvisibility.markup) return (b) => b.addcontent(0, (markupstring)text); return (b) => b.addcontent(0, text); } public renderfragment rendercontent(renderargs<ntag<tmodel>, tmodel> args) { return this.childcontent?.invoke(args) ; } public renderfragment rendercontent(object arg=null) { return this.rendercontent(this.args(arg)); }
ntag.razor
<cascadingvalue value="this"> @rendertext() @if (this.showcontent) { var render = rendercontent(); if (render == null) { <div>@this</div>//测试用 } else { @render//render 是个函数,使用@才能输出,如果不考虑测试代码,可以直接 @rendercontent() } } </cascadingvalue>
test.razor 增加测试代码
7、呈现model <br /> value:@@arg.tag.getter(arg.model,null) <br /> <ntag text="日期" model="testdata" getter="(m,arg)=>m.date" context="arg"> <input type="datetime" value="@arg.tag.getter(arg.model,null)" /> </ntag> <br /> text中使用markup:value:@@((datetime)arg.tag.getter(arg.model, null)) <br /> <label> <ntag text="<span style='color:red;'>日期</span>" textvisibility="nvisibility.markup" model="testdata" getter="(m,a)=>m.date" context="arg"> <input type="datetime" value="@((datetime)arg.tag.getter(arg.model,null))" /> </ntag> </label> <br /> 也可以直接使用childcontent:value:@@arg.model.date <div> <ntag model="testdata" getter="(m,a)=>m.date" context="arg"> <label> <span style='color:red;'>日期</span> <input type="datetime" value="@arg.model.date" /></label> </ntag> </div> getter 格式化:@@((m,a)=>m.date.tostring("yyyy-mm-dd")) <div> <ntag model="testdata" getter="@((m,a)=>m.date.tostring("yyyy-mm-dd"))" context="arg"> <label> <span style='color:red;'>日期</span> <input type="datetime" value="@arg.tag.getter(arg.model,null)" /></label> </ntag> </div> 使用customattributes ,借助外部方法推断tmodel类型 <div> <ntag type="datetime" getter="@getgetter(testdata,(m,a)=>m.date)" context="arg"> <label> <span style='color:red;'>日期</span> <input @attributes="arg.tag.customattributes" value="@arg.tag.getter(arg.model,null)" /></label> </ntag> </div> @code { weatherforecast testdata = new weatherforecast { temperaturec = 222, date = datetime.now, summary = "test summary" }; func<t, object, object> getgetter<t>(t model, func<t, object, object> func) { return (m, a) => func(model, a); } }
考察一下测试代码,我们发现 用作取值的 arg.tag.getter(arg.model,null)
明显有些啰嗦了,调整一下 renderargs,让它可以直接取值。
public struct renderargs<ttag,tmodel> { public ttag tag; public tmodel model; public object arg; func<tmodel, object, object> _valuegetter; public object value => _valuegetter?.invoke(model, arg); public renderargs(ttag tag, tmodel model, object arg , func<tmodel, object, object> valuegetter=null) { this.tag = tag; this.model = model; this.arg = arg; _valuegetter = valuegetter; } } //ntag.razor.cs public renderargs<ntag<tmodel>, tmodel> args(object arg = null) { return new renderargs<ntag<tmodel>, tmodel>(this, this.model, arg,this.getter); }
集合,table 行列
集合的简单处理只需要循环一下。test.razor
<ul> @foreach (var o in this.datas) { <ntag model="o" getter="(m,a)=>m.summary" context="arg"> <li @key="o">@arg.value</li> </ntag> } </ul> @code { ienumerable<weatherforecast> datas = enumerable.range(0, 10) .select(i => new weatherforecast { summary = i + "" }); }
复杂一点的时候,比如 table,就需要使用列。
- 列有 header:可以使用 ntag.text;
- 列要有单元格模板:ntag.childcontent;
- 行就是所有列模板的呈现集合,行数据即是集合数据源的一项。
- 具体到 table 上,thead 定义列,tbody 生成行。
新增一个组件用于测试:testtable.razor,试着用 ntag 呈现一个 table。
<ntag tagid="table" tmodel="weatherforecast" context="tbl"> <table> <thead> <tr> <ntag text="<th>#</th>" textvisibility="nvisibility.markup" showcontent="false" tmodel="weatherforecast" getter="(m, a) =>a" context="arg"> <td>@arg.value</td> </ntag> <ntag text="<th>summary</th>" textvisibility="nvisibility.markup" showcontent="false" tmodel="weatherforecast" getter="(m, a) => m.summary" context="arg"> <td>@arg.value</td> </ntag> <ntag text="<th>date</th>" textvisibility="nvisibility.markup" showcontent="false" tmodel="weatherforecast" getter="(m, a) => m.date" context="arg"> <td>@arg.value</td> </ntag> </tr> </thead> <tbody> <cascadingvalue value="default(object)"> @{ var cols = tbl.tag.children; var i = 0; tbl.tag.consolelog(cols.count()); } @foreach (var o in source) { <tr @key="o"> @foreach (var col in cols) { if (col is ntag<weatherforecast> tag) { @tag.rendercontent(tag.args(o,i )) } } </tr> i++; } </cascadingvalue> </tbody> </table> </ntag> @code { ienumerable<weatherforecast> source = enumerable.range(0, 10) .select(i => new weatherforecast { date=datetime.now,summary=$"data_{i}", temperaturec=i }); }
- 服务端模板处理时,代码会先于输出执行,直观的说,就是组件在执行时会有层级顺序。所以我们在 tbody 中增加了一个 cascadingvalue,推迟一下代码的执行时机。否则,
tbl.tag.children
会为空。 - thead 中的 ntag 作为列定义使用,与最外的 ntag(table)正好形成父子关系。
- 观察下 ntag,我们发现有些定义重复了,比如 tmodel,单元格
<td>@arg.value</td>
。下面试着简化一些。
之前测试 model 呈现的代码中我们说到可以 “借助外部方法推断 tmodel 类型”,当时使用了一个 getgetter 方法,让我们试着在 renderarg 中增加一个类似方法。
renderargs.cs:
public func<tmodel, object, object> getgetter(func<tmodel, object, object> func) => func;
- getgetter 极简单,不需要任何逻辑,直接返回参数。原理是 renderargs 可用时,tmodel 必然是确定的。
用法:
<ntag text="<th>#<th>" textvisibility="nvisibility.markup" showcontent="false" getter="(m, a) =>a" context="arg"> <td>@arg.value</td>
作为列的 ntag,每列的 childcontent 其实是一样的,变化的只有 renderargs,因此只需要定义一个就足够了。
ntag.razor.cs 增加一个方法,对于 childcontent 为 null 的组件我们使用一个默认组件来 render。
public renderfragment renderchildren(tmodel model, object arg=null) { return (builder) => { var children = this.children.oftype<ntag<tmodel>>(); ntag<tmodel> defaulttag = null; foreach (var child in children) { if (defaulttag == null && child.childcontent != null) defaulttag = child; var render = (child.childcontent == null ? defaulttag : child); render.rendercontent(child.args(model, arg))(builder); } }; }
testtable.razor
<ntag tagid="table" tmodel="weatherforecast" context="tbl"> <table> <thead> <tr> <ntag text="<th >#</th>" textvisibility="nvisibility.markup" showcontent="false" getter="tbl.getgetter((m,a)=>a)" context="arg"> <td>@arg.value</td> </ntag> <ntag text="<th>summary</th>" textvisibility="nvisibility.markup" showcontent="false" getter="tbl.getgetter((m, a) => m.summary)"/> <ntag text="<th>date</th>" textvisibility="nvisibility.markup" showcontent="false" getter="tbl.getgetter((m, a) => m.date)" /> </tr> </thead> <tbody> <cascadingvalue value="default(object)"> @{ var i = 0; foreach (var o in source) { <tr @key="o"> @tbl.tag.renderchildren(o, i++) </tr> } } </cascadingvalue> </tbody> </table> </ntag>
结束
- 文中通过 ntag 演示一些组件开发常用技术,因此功能略多了些。
- targs 可以视作 js 组件中的 option.