如何将 ModelExpression 绑定到 ASP.NET Core 中的 ViewComponent? [英] How to bind a ModelExpression to a ViewComponent in ASP.NET Core?
问题描述
我想将模型表达式(例如属性)绑定到视图组件——就像我使用 HTML 帮助程序(例如,@Html.EditorFor()
)或标签一样帮助器(例如,
)——和在带有嵌套 HTML 和/或标签帮助器的视图中重用此模型.我能够将 ModelExpression
定义为视图组件上的参数,并从中检索许多有用的元数据.除此之外,我开始遇到障碍:
- 我如何中继和绑定到底层源模型,例如
asp-for
标签助手? - 如何确保来自
ViewData.ModelMetadata
的属性元数据(例如验证属性)得到尊重? - 如何为字段
name
属性组装完全限定HtmlFieldPrefix
?
我提供了一个(简化的)场景,其中包含下面的代码和结果——但代码暴露的未知数多于答案.已知大部分代码是不正确的,但我将其包含在内,以便我们可以有一个具体的基线来评估和讨论替代方案.
场景
列表的值需要通过数据存储库填充.假设填充可能的值作为例如的一部分是不切实际或不可取的.原始视图模型(请参阅下面的替代选项").
示例代码
/Components/SelectListViewComponent.cs
使用系统;使用 Microsoft.AspNetCore.Mvc.Rendering;公共类 SelectViewComponent{私有只读 IRepository _repository;公共 SelectViewComponent(IRepository 存储库){_repository = 存储库??throw new ArgumentNullException(nameof(repository));}公共 IViewComponentResult 调用(ModelExpression aspFor){var sourceList = _repository.Get($"{aspFor.Metadata.Name}Model");var 模型 = 新的 SelectViewModel(){Options = new SelectList(sourceList, "Id", "Name")};ViewData.TemplateInfo.HtmlFieldPrefix = ViewData.TemplateInfo.GetFullHtmlFieldName(modelMetadata.Name);返回视图(模型);}}
注意事项
- 使用
ModelExpression
不仅可以让我使用模型表达式调用视图组件,还可以通过反射为我提供许多有用的元数据,例如验证参数. - 参数名称
for
在 C# 中是非法的,因为它是一个保留关键字.因此,我改为使用aspFor
,它将以asp-for
的形式公开给标记帮助程序格式.这有点小技巧,但为开发人员提供了熟悉的界面. - 显然,
_repository
代码和逻辑会随着实现的不同而有很大差异.在我自己的用例中,我实际上从一些自定义属性中提取参数. GetFullHtmlFieldName()
不构造完整 HTML 字段名称;它总是返回我提交给它的任何值,这只是模型表达式名称.在下面的问题"中详细介绍了这一点.
/Models/SelectViewModel.cs
使用 Microsoft.AspNetCore.Mvc.Rendering;公共类 SelectViewModel {公共选择列表选项 { 获取;放;}}
注意事项
- 从技术上讲,在这种情况下,我可以直接将
SelectList
返回到视图,因为它将处理当前值.但是,如果您将模型绑定到的
asp-for
标签助手,那么它会自动启用multiple
,是绑定到集合模型时的默认行为.
/Views/Shared/Select/Default.cshtml
@model SelectViewModel<select asp-for=@Model asp-items="Model.Options"><option value="">选择一个...</option></选择>
注意事项
- 从技术上讲,
@Model
的值将返回SelectViewModel
.如果这是一个,那将是显而易见的.由于
SelectList
识别了正确的值,大概是从ViewData.ModelMetadata
中识别出来的,这个问题被掩盖了. - 我可以将
aspFor.Model
设置为例如SelectViewModel
上的UnderlyingModel
属性.这将导致{HtmlFieldPrefix}.UnderlyingModel
的 HTML 字段名称 - 并且仍然无法从原始属性中检索任何元数据(例如验证属性).
变化
如果我不设置 HtmlFieldPrefix
,并将视图组件放在例如的上下文中
或 @Html.EditorFor()
然后字段名称将是正确的,因为 HtmlFieldPrefix
正在被定义在父上下文中.但是,如果我将其直接放在顶级视图中,则会由于未定义 HtmlFieldPrefix
而出现以下错误:
ArgumentException:HTML 字段的名称不能为 null 或为空.而是使用具有非空 htmlFieldName 参数值的 Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper.Editor 或 Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper``1.EditorFor 方法.(参数'表达式')
问题
HtmlFieldPrefix
未正确填充完全限定的值.例如,如果模型属性名称是Country
,它将始终返回Country
,即使实际模型路径是,例如,ShippingAddress.Country
或地址[2].国家
.- jQuery Validation Unobtrusive 功能未触发.例如,如果绑定到的属性被标记为
[Required]
那么这里就不会被标记.这大概是因为它被绑定到SelectViewModel
,而不是父属性. - 原始模型不会以任何方式中继到视图组件的视图;
SelectList
能够从ViewData
推断出原始值,但是这在视图中丢失了.我可以通过视图模型中继aspFor.Model
,但它无法访问原始元数据(例如验证属性).
替代选项
我考虑过的一些其他选项,但在我的用例中被拒绝.
- 标签助手:这很容易通过标签助手实现.将依赖项(例如存储库)注入标签助手不太优雅,因为没有办法通过组合根实例化标签助手,例如可以这样做
IViewComponentActivator
. - 控制器:在这个简化的例子中,还可以在顶级视图模型上定义源集合,紧邻实际属性(例如,
Country
为值,CountryList
为选项).在更复杂的示例中,这可能不实用或不优雅. - AJAX: 可以通过对 Web 服务的 JavaScript 调用检索值,将 JSON 输出绑定到客户端上的
元素.我在其他应用程序中使用了这种方法,但在这里不受欢迎,因为我不想将所有潜在的查询逻辑暴露给公共接口.
- 显式值:我可以显式地将父模型与
ModelExpression
一起传递,以便在视图组件下重新创建父上下文.这有点麻烦,所以我想先尝试一下ModelExpression
方法.
以前的研究
这个问题以前曾被问过(并回答过):
然而,在这两种情况下,接受的答案(OP 的一个答案)并没有完全探索问题,而是决定标签助手更适合他们的场景.标签助手很棒,有他们的目的;但是,对于视图组件更合适的场景(例如依赖于外部服务),我想充分探讨原始问题.
我是在追兔子吗?或者社区对模型表达式的更深入理解可以解决哪些选项?
否定地回答我自己的问题:我最终得出的结论是,虽然就我们的父级而言,这很可能是直观且理想的功能视图,就我们的视图组件而言,它最终是一个混乱的概念.
即使您通过从 ModelExpression
中提取完全限定的 HtmlFieldPrefix
解决了技术问题,更深层次的问题是概念性的.据推测,视图组件将组装额外的数据,并通过新的视图模型将其传递给视图——例如,问题中提出的 SelectViewModel
.否则,使用视图组件没有真正的好处.然而,在视图组件的视图中,没有将子视图模型的属性映射回父视图模型的逻辑方法.>
例如,假设在您的父视图中,您将视图组件绑定到一个 UserViewModel.Country
属性:
@model UserViewModel<vc:select asp-for="Country"/>
那么,你在子视图中绑定了哪些属性?
@model SelectViewModel<选择 asp-for=@???asp-items="Model.Options"><option value="">选择一个...</option></选择>
在我最初的问题中,我提出了 @Model
,这类似于您在例如通过@Html.EditorFor()
调用的编辑器模板:
这可能会返回正确的 id
和 name
属性,因为它会回退到 ViewData
的 HtmlFieldPrefix
>.但是,它不会访问任何例如数据验证属性,因为它绑定到 SelectViewModel
而不是对原始 UserViewModel.Country
属性的引用,就像在编辑器模板.
同样,您可以通过例如传递ModelExpression.Model
一个 SelectViewModel.Model
属性...
……但这也不能解决问题,因为很明显,传递值并不能传递源属性的属性.
最终,您想要的是将您的 asp-for
绑定到您的 ModelExpression
解析到的原始对象上的原始属性.虽然您可以从 ModelExpression
describing 该属性和对象中获取元数据,但似乎没有一种方法可以将 reference 传递给它以 asp-for
标签助手识别的方式.
显然,可以设想微软在 ModelExpression
和允许中继 ModelExpression 的
对象一直沿线向下.或者,他们可能会建立一个关键字——例如 asp-for
标签助手的核心实现中构建低级工具@ParentModel
——它允许从 父视图 引用模型.然而,如果没有这一点,这似乎是不可行的.
我不会将此标记为答案,希望有人在某个时候找到我遗漏的东西.但是,我想把这些笔记留在这里,以防其他人试图完成这项工作,并记录我自己的结论.
I would like to bind a model expression (such as a property) to a view component—much like I would with an HTML helper (e.g., @Html.EditorFor()
) or a tag helper (e.g., <partial for />
)—and reuse this model in the view with nested HTML and/or tag helpers. I am able to define a ModelExpression
as a parameter on a view component, and retrieve a lot of useful metadata from it. Beyond this, I start running into roadblocks:
- How do I relay and bind to the underlying source model to e.g. an
asp-for
tag helper? - How do I ensure property metadata (e.g. validation attributes) from
ViewData.ModelMetadata
are honored? - How do I assemble a fully qualified
HtmlFieldPrefix
for the fieldname
attribute?
I've provided a (simplified) scenario with code and outcomes below—but the code exposes more unknowns than answers. Much of the code is known to be incorrect, but I'm including it so we can have a concrete baseline to evaluate and discuss alternatives to.
Scenario
The values of a <select>
list need to be populated via a data repository. Assume it is impractical or undesirable to populate the possible values as part of e.g. the original view model (see "Alternate Options" below).
Sample Code
/Components/SelectListViewComponent.cs
using system;
using Microsoft.AspNetCore.Mvc.Rendering;
public class SelectViewComponent
{
private readonly IRepository _repository;
public SelectViewComponent(IRepository repository)
{
_repository = repository?? throw new ArgumentNullException(nameof(repository));
}
public IViewComponentResult Invoke(ModelExpression aspFor)
{
var sourceList = _repository.Get($"{aspFor.Metadata.Name}Model");
var model = new SelectViewModel()
{
Options = new SelectList(sourceList, "Id", "Name")
};
ViewData.TemplateInfo.HtmlFieldPrefix = ViewData.TemplateInfo.GetFullHtmlFieldName(modelMetadata.Name);
return View(model);
}
}
Notes
- Using
ModelExpression
not only allows me to call the view component with a model expression, but also gives me a lot of useful metadata via reflection such as validation parameters. - The parameter name
for
is illegal in C#, since it's a reserved keyword. As such, I'm instead usingaspFor
, which will be exposed to the tag helper format asasp-for
. This is a bit of a hack, but yields a familiar interface for developers. - Obviously, the
_repository
code and logic will vary considerably with implementation. In my own use case, I actually pull the arguments from some custom attributes. - The
GetFullHtmlFieldName()
doesn't construct a full HTML field name; it always returns whatever value I submit to it, which is just the model expression name. More on this under "Issues" below.
/Models/SelectViewModel.cs
using Microsoft.AspNetCore.Mvc.Rendering;
public class SelectViewModel {
public SelectList Options { get; set; }
}
Notes
- Technically, in this case, I could just return the
SelectList
directly to the view, since it will handle the current value. However, if you bind your model to your<select>
'sasp-for
tag helper, then it will automatically enablemultiple
, which is the default behavior when binding to a collection model.
/Views/Shared/Select/Default.cshtml
@model SelectViewModel
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
Notes
- Technically, the value for
@Model
will returnSelectViewModel
. If this were an<input />
that would be obvious. This issue is obscured due to theSelectList
identifying the correct value, presumably from theViewData.ModelMetadata
. - I could instead set the
aspFor.Model
to e.g. anUnderlyingModel
property on theSelectViewModel
. That would result in an HTML field name of{HtmlFieldPrefix}.UnderlyingModel
—and would still fail to retrieve any of the metadata (such as validation attributes) from the original property.
Variations
If I don't set the HtmlFieldPrefix
, and place the view component within the context of e.g. a <partial for />
or @Html.EditorFor()
then the field names will be correct, as the HtmlFieldPrefix
is getting defined in a parent context. If I place it directly in a top-level view, however, I will get the following error due to the HtmlFieldPrefix
not being defined:
ArgumentException: The name of an HTML field cannot be null or empty. Instead use methods Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper.Editor or Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper``1.EditorFor with a non-empty htmlFieldName argument value. (Parameter 'expression')
Issues
- The
HtmlFieldPrefix
doesn't get properly populated with a fully qualified value. E.g., if the model property name isCountry
it will always returnCountry
, even if the actual model path is, say,ShippingAddress.Country
orAddresses[2].Country
. - The jQuery Validation Unobtrusive functionality isn't firing. For instance, if the property this is bound to is marked as
[Required]
then that's not getting flagged here. That's presumably because it's being bound to theSelectViewModel
, not the parent property. - The original model isn't being relayed in any way to the view component's view; the
SelectList
is able to infer the original value fromViewData
, but that is lost to the view. I could relay theaspFor.Model
via the view model, but it won't have access to the original metadata (such as validation attributes).
Alternate Options
Some other options I've considered, and rejected for my use cases.
- Tag Helpers: This is easy to achieve via tag helpers. Injecting dependencies, such as a repository, into a tag helper is less elegant since there isn't a way to instantiate a tag helper via the composition root, as one can do with e.g.
IViewComponentActivator
. - Controllers: In this simplified example, it is also possible to define the source collection on the top-level view model, next to the actual property (e.g.,
Country
for the value,CountryList
for the options). That may not be practical or elegant in more sophisticated examples. - AJAX: The values could be retrieved via a JavaScript call to a web service, binding the JSON output to the
<select>
element on the client. I use this approach in other applications, but it's undesirable here since I don't want to expose the full range of potential query logic to a public interface. - Explicit Values: I could explicitly relay the parent model along with the
ModelExpression
in order to recreate the parent context under the view component. That's a bit of a kludge, so I'd like to game out theModelExpression
approach first.
Previous Research
This question has been asked (and answered) before:
In both cases, however, the accepted answer (one by the OP) doesn't fully explore the question, and instead decides that a tag helper is more suitable for their scenarios. Tag helpers are great, and have their purpose; I'd like to fully explore the original questions, however, for the scenarios where view components are more appropriate (such as depending on an external service).
Am I chasing a rabbit down a hole? Or are there options that the community's deeper understanding of model expressions can resolve?
To answer my own question in the negative: I ultimately came to the conclusion that while this may well be intuitive and desirable functionality in terms of our parent views, it's ultimately a confused concept in terms of our view components.
Even if you resolve the technical issue with extracting the fully-qualified HtmlFieldPrefix
from ModelExpression
, the deeper issue is conceptual. Presumably, the view component will assemble additional data, and relay it down to the view via a new view model—e.g., the SelectViewModel
proposed in the question. Otherwise, there's no real benefit to using a view component. In the view component's view, however, there's no logical way to map properties of the child view model back to the parent view model.
So, for example, let us say that in your parent view you bind the view component to a UserViewModel.Country
property:
@model UserViewModel
<vc:select asp-for="Country" />
Then, what properties do you bind to in the child view?
@model SelectViewModel
<select asp-for=@??? asp-items="Model.Options">
<option value="">Select one…</option>
</select>
In my original question, I proposed @Model
, which is similar to what you would do in e.g. an editor template called via @Html.EditorFor()
:
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
That might return the correct id
and name
attributes, since it's falling back to the HtmlFieldPrefix
of the ViewData
. But, it's not going to have access to any e.g. data validation attributes, since it's binding to a SelectViewModel
and not a reference to the original UserViewModel.Country
property, as it would in an editor template.
Similarly, you could relay the ModelExpression.Model
down via e.g. a SelectViewModel.Model
property…
<select asp-for=@Model asp-items="Model.Options">
<option value="">Select one…</option>
</select>
…but that doesn't solve the problem either since, obviously, relaying a value doesn't relay the attributes of the source property.
Ultimately, what you want is to bind your asp-for
to the original property on the original object that your ModelExpression
is resolving to. And while you can get metadata from ModelExpression
describing that property and object, there doesn't seem to be a way to relay a reference to it in a way that the asp-for
tag helpers recognize.
Obviously, one could conceive of Microsoft building in lower-level tooling into ModelExpression
and the core implementations of the asp-for
tag helpers which allow relaying ModelExpression
objects all the way down the line. Alternatively, they might establish a keyword—such as @ParentModel
—which allows a reference to the model from the parent view. In absence of that, however, this doesn't seem feasible.
I'm not going to mark this as the answer in hopes that someone, at some point, finds something I'm missing. I wanted to leave these notes here, however, in case anyone else is trying to make this work, and to document my own conclusions.
这篇关于如何将 ModelExpression 绑定到 ASP.NET Core 中的 ViewComponent?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!