ASP.NET Core – View Component
前言
以前写过 Asp.net core 学习笔记 ( ViewComponent 组件 ), 这篇作为翻新版.
参考
Docs – View components in ASP.NET Core
Don't replace your View Components with Razor Components (Razor Component 无法替代 View Component)
介绍
View Component 是 Partial View 的升级版本. 区别就是它多了一个 .cs 可以写逻辑.
Overview
View Component 是由一个 class (.cs) 和一个 View (.cshtml) 组成的.
Component 有名字, View 也有名字, 在某个 Page/View 中想使用 Component 就通过名字召唤它
然后 Component 又会去找寻它的 View 做渲染.
ASP.NET Core 有一套命名机制去声明是否一个 class 属于 View Component, 也有一套机制去寻找 Component 的 View.
我呢是很讨厌这些机制的, 因为它们设计的不直观. 经常莫名其妙的找不到, 需要死背它那不直观的设计才能用好. 但幸好我们可以通过一些 best practice 去规定使用的方式, 这样就不会老是搞错了.
Create View Component Class
两个点需要注意,
第一点是如何告诉 ASP.NET Core 这个 class 是一个 ViewComponent
第二点是这个 Component 的名字是什么.
第一种: 继承 ViewComponent class
public class CallToAction : ViewComponent
class name CallToAction 自动成为 component name, 如果 class name ends with "ViewComponent" 则会被忽略
比如: CallToActionViewComponent 最终的 component name 依然是 CallToAction.
第二种: class name ends with "ViewComponent"
public class CallToActionViewComponent
component name = CallToAction
例外
由于第二种方式是通过命名, 这就导致会出现一些例外. 比如
[NonViewComponent] public class ReviewComponent
这时需要通过 NonViewComponentAttribute 声明这不是一个 ViewComponent
第三种: 通过 ViewComponentAttribute
[ViewComponent(Name = "CallToAction")] public class CallToAction
这也是唯一一个自定义 component name 的方式, 如果没有定义, 那么它依然是通过 class name auto become component name.
如果 Name = "CallToActionViewComponent" 那么最终就是 "CallToActionViewComponent" ViewComponent 只有在 class name auto become component name 时会被忽视.
Best Practice
上面这些方式是可以混用的哦, 但是搞那么多方除了乱以外没有任何好处.
我的建议:
1. 通过继承 ViewComponent 来让 ASP.NET Core 知道这个 class 是 View Component, 继承的好处是可以用 build-in 的功能, 比如 return View()
2. class name 命名规范最好是 CallToActionViewComponent, 最终 component name 是 CallToAction
Implement Invoke Method
ViewComponent class 必须实现 Invoke 或 InvokeAsync 方法, 不然会报错
很奇葩的地方是它并不是 interface of ViewComponent class 哦, 所以不是 override method, 它就是一个你要自己知道的方法. 也只有在 runtime 会报错. 设计的真烂.
public class CallToActionViewComponent : ViewComponent { public IViewComponentResult Invoke() { return View(); } }
View (.cshtml) 查询
下面 3 个是默认查询的地方.
Component name 上面提到了, view name 默认是 "Default" (不要问我为什么不是 Index)
所以上面 Component 对应的 View Localtion 是
Views/Shared/Components/CallToAction/Default.cshtml
可以通过 options 添加更多匹配的路径.
{0} 就是最终的 view name
查看 ViewViewComponentResult.cs 源码, 可以看到它 hardcore 了 format "Components/{0}/{1}", 0 = component name, 1 = view name.
然后通过 viewEngine 去找. 而 Options 的 ViewLocationFormats 是添加给 viewEngine 用的. 但是无论如何你都避不开它 hardcode 的 "Components/{0}/{1}"
viewEngine 的所有 format 可以通过 RazorViewEngineOptions 查看
var razorViewEngineOptions = app.Services.GetRequiredService<IOptions<RazorViewEngineOptions>>().Value;
下面是 MVC, RazorPages 的所有默认 format
它的玩法是定义一些 format, 然后 viewEngine.FindView 的时候会把 parameter 丢进去成为最终的路径.
ViewLocationExpanders 是一个动态 generate format 的方式. 几年前有写过一篇, 大概长这样:
context 可以看到什么 Page 调用了这个 Component, 然后 viewName 就是 hardcode 的 component format.
viewLocations 是默认的 3 个 formats.
虽然 ViewLocationExpanders 比 ViewLocationFormats dynamic 多一些, 但是很多时候还是不够用的, 因为它也拿不到 component 的 physical file path.
比如, 我的 folder 结构是 MyViewComponent > MyViewComponent.cs + Index.cshtml
同一个 folder 里面有 component class 和 view
由于我拿不到 component 的 physical 路径, 也就没有办法设定成 "{MyViewComponent.cs 路径}Index.cshtml"
MVC 其实也是同样的局限. Router 找到 Controller 以后, Controller 也只能通过 ControllerName 找到 View 没办法依据 physical file path.
Best Practice
我的建议是直接写绝对路径, 或者固定好 component 的 folder 位置. 然后写一个 ViewLocationExpanders 去扩展它.
public class PageTitleSectionComponent : ViewComponent { public IViewComponentResult Invoke(PageTitleSectionComponentViewModel viewModel) { return View( $"~/Web/Shared/Component/PageTitleSection/Index.cshtml", viewModel ); } }
调用
有 3 种调用方式
@await Component.InvokeAsync("CallToAction", new { myName = "test1" }) @await Component.InvokeAsync(typeof(CallToActionViewComponent)) <!-- 推荐 --> <vc:call-to-action my-name="test2"></vc:call-to-action>
一个通过 component name (string), 一个用 type, 一个用 tag helper (记得要 @addTagHelper 哦)
也是一样, 搞一堆来乱而已. 我个人的建议是使用 Type 的方式. 这样可以避开 PascalCase convert kebab-case 的问题.
想传入 parameter 就通过匿名对象, 它这个方式也是很烂, 类型在 compile 时无法检测.
public IViewComponentResult Invoke(string myName = "abc")
另外还有一些奇葩
如果是 string 要传变量的话, 记得加上 @. 其它类似就不需要 (统一加上会比较好)
Passing HTML Template
想 passing HTML template 也是可以, 类似 Angular 的 ng-template
@{ Func<string, object> content = @<div> <p>@item</p> </div> ; } @await Component.InvokeAsync(typeof(CallToActionViewComponent), new { content = content })
Func<string, object> string 是 @item 的类型.
把委托当普通参数传进去 View Component.
public class CallToActionViewComponent : ViewComponent { public IViewComponentResult Invoke(Func<string, object> content) { return View(content); } }
在通过 ViewModel 传给 View.
最后在 View 执行就可以了
@model Func<string, object> @Model("item1") <h1>Hello World</h1>
提醒:
1. Templated Razor delegates 只能一个 tag, 如果想传入多个 tag 可以用 List<Func<string, object>> 来装, 然后传进去, 虽然很麻烦但是勉强能用, 或者 wrap 一层 div 也是可以破, 只是对 HTML 结构有点乱而已, 有个相关的 Issue 但没人理了。
还有一个方法就是做一个 root-element,然后利用 tag helper 在最终把它 remove 掉。
@{ var title = "Derrick"; Func<dynamic?, IHtmlContent> template = @<stg-root> <h1>Hello World @title</h1> @await Component.InvokeAsync(typeof(ItemComponent)) </stg-root>; } @template(null)
stg-root 就是一个特别的 element,最后会被 remove 掉。
[HtmlTargetElement(tag: "stg-root")] public class StgRootTagHelper : TagHelper { public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { output.TagName = null; var childContent = await output.GetChildContentAsync(); output.Content.AppendHtml(childContent.GetContent()); } }
注:当涉及到与其它 tag helper 一起操作时,要记得它一开始是存在的,到了 tag helper 的时候才被 remove 掉哦,忘记容易出 bug。
2. 委托最多传 1 个参数 , 像这样 Func<string, int, object> 2个参数是不 ok 的, 会报错 error CS1593: Delegate 'Func<string, int, object>' does not take 1 arguments
因为 @item 只有一个丫. 所以要嘛传入一个对象, 或者 Tuple 也可以.
public Func<(string Value, int Number), object> BodyTemplate // view model BodyTemplate = @<div>@item.Value - @item.Number</div> // template @Model.BodyTemplate(("string value", 11)) // generate template
另外最少也要传一个, 可以像下面这样传 null
@{ Func<dynamic?, object> value = @<h1>Hello World</h1> ; } <div class="text-center"> @value(null) </div>
3. dynamic 的对象不可以不一致
会出现这样的 error
Passing HTML Content
参考 :
Stack Overflow – How to pass csHtml to ViewComponent in .Net Core MVC?
Github Issue – Feature Request: View Component Slots (很遗憾, 目前没有支持)
类似 Angular 的 ng-content。content 和 template 不同哦,content 是 build 好了 HTML 丢进去,里面只是显示而已。
template 是丢进去后才 build HTML。用函数比喻的话,一个是传 string 进去。一个是传 () => string 函数进去。
1. 通过 Html.Raw 传入 IHtmlContent
2. 直接渲染对象就可以了
Build HTML content from template
直接写 Raw HTML 开发体验很差,我们可以尝试搭配 template 手法来实现。
比如我有个组件,需要 content 一个 item list 进去。
首先做一个 HTML builder
@functions { public static IHtmlContent Repeat( List<dynamic> items, Func<dynamic, IHtmlContent> template ) { var html = new HtmlContentBuilder(); foreach (var item in items) { html.AppendHtml(template(item)); } return html; } }
使用
@await Component.InvokeAsync( typeof(TestComponent), new { itemList = Repeat(new List<dynamic> { "a", "b", "c" }, @<h1>Item @item</h1>)} )
因为 template 执行后就是返回 HtmlContent,所以配上一个 HtmlContentBuilder 就做到了。虽然手法感觉有点取巧,但这个是官方教的方式哦。
再多一个 build from 组件的 sample:
有 TestComponent 和 ItemComponent
TestComponent 内部需要一个 list
我们 HtmlContentBuilder for loop build 出 ItemComponent HTML 最后 passing 进去给 TestComponent。
@{ var items = new List<string> { "value1", "value2", "value3" }; var itemList = new HtmlContentBuilder(); foreach (var item in items) { itemList.AppendHtml(await Component.InvokeAsync(typeof(ItemComponent), new { value = item })); } } @await Component.InvokeAsync( typeof(TestComponent), new { itemList = itemList} )
不支持泛型 Generic
ViewComponent class 和参数是不可以放泛型的哦.
参考
Github – Invoke MVC ViewComponent with generic type parameter
stackoverflow – Using a generic model in ASP.NET MVC Razor
Github – Supporting open generic models in Views
不支持 @section Inside View Component
需求是这样的, 有一个 View Component 封装了一个 modal 功能. 这个 modal element 需要放到 body 这样 CSS 才容易定位.
如果在 Page, 我们可以通过 @section 把 modal pass to layout 然后放到 body 的最尾部. 但如果不在 Page 而是在 View Component @section 就不可用了.
参考:
Stack Overflow – Where should I include a script for a view component?
Github Issus – Use Section From Layout Inside View Component
目前最好是避开这样的场景. 如果真的避不开可以考虑利用 JS 把 modal element append 到 body.