在 MVC 上动态添加控件到分层结构
如何在 MVC 中动态地向分层结构添加控件。
引言
在 MVC 中构建分层 UI,当你想编辑它并向服务器提交数据时,通常非常困难。根据其复杂程度,将数据发布到服务器可能会非常困难。我将要介绍的解决方案将展示一个如何编辑分层结构的示例,例如
在 UI 中拥有这种结构,要动态添加控件并 POST 数据到服务器是困难的。通常在这种情况下,你需要手动迭代所有 UI 控件并创建你想要 POST 到服务器的对象。此解决方案的目标是自动创建对象(视图的模型),并将其 POST 到服务器,这样我们就不需要进行任何解析。基本上,我们将能够添加项目(例如,类别项目、子类别项目或产品项目),当表单发布到服务器时,模型会自动为我们创建。服务器将接收到如下对象:
解决方案
此示例使用的模型类是:
public class OrderViewModel
{
public OrderViewModel()
{
Categories = new List<CategoryViewModel>();
}
public string Name { get; set; }
public List<CategoryViewModel> Categories { get; set; }
}
public class CategoryViewModel
{
public CategoryViewModel()
{
Subcategories = new List<SubCategoryViewModel>();
}
public string Name { get; set; }
public List<SubCategoryViewModel> Subcategories { get; set; }
}
public class SubCategoryViewModel
{
public SubCategoryViewModel()
{
Products = new List<ProductViewModel>();
}
public string Name { get; set; }
public List<ProductViewModel> Products { get; set; }
public string PreviousFieldId { get; set; }
}
public class ProductViewModel
{
public string Name { get; set; }
public double Price { get; set; }
public string PreviousFieldId { get; set; }
}
对于 UI,我们需要为每个模型类创建部分视图。到目前为止,一切都很简单直接,但问题将开始出现,当你处理这些部分视图时。例如:对于类别视图,你会这样做:
<div>
<div class="editor-label">
<div>
Categories
</div>
</div>
<fieldset>
<div>
Name:@Html.TextBoxFor(t => t.Name)
</div>
@Html.Partial("SubcategoriesPartial", Model.Subcategories)
</fieldset>
</div>
对于 SubcategoriesPartial 部分视图,你会这样做:
<div>
<fieldset>
<div>
<div class="editor-label">
<div>
Subcategories
</div>
</div>
<div class="editor-label">
<div>
@Html.ActionLink("Add another subcategory...",
"AddSubCategory", null, new { @class = "addSubcategoryItem" })
@foreach (var subcategory in Model)
{
@Html.Partial("SubcategoryPartial", subcategory)
}
</div>
</div>
</div>
</fieldset>
</div>
而 SubcategoryPartial 部分视图是
<fieldset>
<div>
<div class="editor-label" style="float: left; width: 70px;">
Name:
</div>
<div style="float: left">
@Html.TextBoxFor(m => m.Name)
</div>
</div>
<div style="clear: both">
<div class="editor-label" style="float: left; width: 70px">
<div>
Products:
</div>
</div>
<div style="float: left">
<fieldset>
<div>
@Html.ActionLink("Add product...", "AddProduct", null, new { @class = "addProductItem" })
@foreach (var prod in @Model.Products)
{
@Html.Partial("ProductPartial", prod)
}
</div>
</fieldset>
</div>
</div>
</fieldset>
以此类推,你将完成 UI。不幸的是,这只适用于你想显示数据。当你希望允许用户编辑这些数据(添加更多/编辑类别、子类别或产品)时,这些视图就做得不好。如果你有这些视图,那么当你将表单提交到服务器时,MVC 框架将无法重新创建 OrderViewModel,因为每个控件的 ID 生成不正确。
在这种情况下,为类别和子类别名称生成的输入标签看起来像:
For Category Name:
<input id="Name" type="text" value="category 0" name="Name">
For SubCategory Name:
<input id="Name" type="text" value="SubCategory 0" name="Name">
正如你所见,无法区分类别名称和子类别名称,因为生成的输入控件名称和 ID 都相同。为了区分它们,每个输入控件都必须有一个合适的 ID,看起来像:
For Category Name:
<input id="Categories_0__Name" type="text" value="category 0" name="Categories[0].Name">
For SubCategory Name:
<input id="Categories_0__Subcategories_0__Name" type="text" value="SubCategory 0" name="Categories[0].Subcategories[0].Name">
正如你所见,在这种情况下,你可以区分这些输入控件,并且在提交表单到服务器时可以生成模型。这种方法只在你允许编辑现有项目时才好(而不是从这些模型集合中添加或删除项目)。如果你想添加或删除项目,你需要为每个控件生成正确的索引,这很难跟踪每个集合使用的所有索引。
另一种方法是将索引替换为 GUID,并将生成的 GUID 用作集合的索引。使用此最新方法,输入控件看起来像:
For Category:
<input type="hidden" value="8b9309b2-ad7b-45c0-9725-efd1cc56a514" autocomplete="off" name="Categories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Name" type="text"
value="category 0" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Name">
For SubCategory:
<input type="hidden" value="ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc"
autocomplete="off" name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories.index">
<input id="Categories_8b9309b2-ad7b-45c0-9725-efd1cc56a514__Subcategories_ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc__Name"
type="text" value="SubCategory 0"
name="Categories[8b9309b2-ad7b-45c0-9725-efd1cc56a514].Subcategories[ecafcc7c-f1e1-4a2c-af3f-fd1b1ff376cc].Name">
基本上,你需要为每个生成的 GUID 创建一个隐藏字段,以指定此 ID 的用途,并使用该 ID 作为索引。通过这种方法,你可以添加/编辑/删除项目,并且在将数据提交到服务器时,模型会正确生成。
现在我们需要创建一个 HTML 助手(HTML 扩展方法),它能够生成这些 GUID 并将这些 ID 放在正确的元素上。为此,我们需要修改 `TemplateInfo.HtmlFieldPrefix`,仅针对代码块内的元素(每个集合项内)。
这些助手方法的代码是:
public static class HtmlModelBindingHelperExtensions
{
private const string IdsToReuseKey = "__HtmlModelBindingHelperExtensions_IdsToReuse_";
public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
{
var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();
var previousPrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
// autocomplete="off" is needed to work around a very
// annoying Chrome behaviour whereby it reuses old values after
// the user clicks "Back", which causes the xyz.index
// and xyz[...] values to get out of sync.
html.ViewContext.Writer.WriteLine(string.Format("<input " +
"type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\"
value=\"{1}\" />", !string.IsNullOrEmpty(previousPrefix) ?
string.Concat(previousPrefix.Trim('.'), ".", collectionName) :
collectionName, html.Encode(itemIndex)));
return BeginHtmlFieldPrefix(html, string.Format("{0}[{1}]", collectionName, itemIndex));
}
public static IDisposable BeginHtmlFieldPrefix(this HtmlHelper html, string htmlFieldPrefix)
{
return new HtmlFieldPrefix(html.ViewData.TemplateInfo, htmlFieldPrefix);
}
private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
{
// We need to use the same sequence of IDs following a server-side validation failure,
// otherwise the framework won't render the validation error messages next to each item.
string key = IdsToReuseKey + collectionName;
var queue = (Queue<string>)httpContext.Items[key];
if (queue == null)
{
httpContext.Items[key] = queue = new Queue<string>();
var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
if (!string.IsNullOrEmpty(previouslyUsedIds))
foreach (string previouslyUsedId in previouslyUsedIds.Split(','))
queue.Enqueue(previouslyUsedId);
}
return queue;
}
private class HtmlFieldPrefix : IDisposable
{
private readonly TemplateInfo _templateInfo;
private readonly string _previousHtmlFieldPrefix;
public HtmlFieldPrefix(TemplateInfo templateInfo, string htmlFieldPrefix)
{
this._templateInfo = templateInfo;
_previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
if (!string.IsNullOrEmpty(htmlFieldPrefix))
{
templateInfo.HtmlFieldPrefix = string.Format("{0}.{1}", templateInfo.HtmlFieldPrefix,
htmlFieldPrefix);
}
}
public void Dispose()
{
_templateInfo.HtmlFieldPrefix = _previousHtmlFieldPrefix;
}
}
}
你所需要做的就是在构建部分视图时使用这些助手方法。请注意,`HtmlFieldPrefix` 实现 `IDisposable`,每个助手方法都返回一个 `IDisposable` 对象,因为我们只需要为在我们的 `using` 块内执行的代码更改 `HtmlFieldPrefix`。
如何使用这些助手方法的示例是:
For Category items:
@using (@Html.BeginCollectionItem("Categories"))
{
<div>
<div>
<div>
Categories
</div>
</div>
<fieldset>
<div>
Name:@Html.TextBoxFor(t => t.Name)
</div>
@Html.Partial("SubcategoriesPartial", Model.Subcategories)
</fieldset>
</div>
}
对于 SubCategories 项目
<fieldset>
@using (@Html.BeginHtmlFieldPrefix(Model.PreviousFieldId))
{
using (@Html.BeginCollectionItem("Subcategories"))
{
<div>
<div style="float: left; width: 70px;">
Name:
</div>
<div style="float: left">
@Html.TextBoxFor(m => m.Name)
</div>
</div>
<div style="clear: both">
<div style="float: left; width: 70px">
<div>
Products:
</div>
</div>
<div style="float: left">
<fieldset>
<div id="@Html.ViewData.TemplateInfo.HtmlFieldPrefix.Trim('.')">
@Html.ActionLink("Add product...",
"AddProduct", null, new { @class = "addProductItem" })
@foreach (var prod in @Model.Products)
{
@Html.Partial("ProductPartial", prod)
}
</div>
</fieldset>
</div>
</div>
}
}
</fieldset>
现在我们只需要让 Add 链接正常工作。这非常直接。正如你所见,链接具有 ID 或类属性,因此我们可以附加一个 JavaScript 方法,该方法将调用服务器并将响应追加到正确的 div。执行此操作的代码,用于 Add Category 链接是:
$("#addCategoryItem").click(function () {
$.ajax({
url: this.href,
cache: false,
success: function (html) {
$("#div_Categories").append(html);
},
error: function (html) {
alert(html);
}
});
return false;
});
对于另外两个链接(Add Sub Category 和 Add Product),我们需要发送父 div 的 ID,因为在控制器中,我们需要将父 ID 设置到模型,以便为添加的项目获得正确的 ID 序列。所以,执行此操作的代码是:
$(".addSubcategoryItem").click(handleNewItemClick);
$(".addProductItem").click(handleNewItemClick);
function handleNewItemClick() {
var parent = this.parentElement;
var formData = {
id: parent.id
};
$.ajax({
type: "POST",
url: this.href,
data: JSON.stringify(formData),
contentType: "application/json; charset=utf-8",
success: function (data) {
$(parent).append(data);
},
error: function (data) {
alert(data);
}
});
return false;
}
就是这样。试试这个解决方案,让我知道你是否有任何问题。
使用代码
源 zip 文件包含 MVC 项目。你只需要解压并用 Visual Studio 打开解决方案。要看到它的效果,你需要在 `OrderManagerController` 中的 `public ActionResult Edit(OrderViewModel viewModel)` 方法([HttpPost])上设置一个断点,以查看你在 UI 上所做的所有更改是否已自动反映在 viewModel 对象中。
参考文献
最初的方法可以在这里找到,或者在这里找到。我增强了他的方法,使其能够处理分层数据。
历史
- 2012 年 8 月 5 日:首次发布到 CodeProject