65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET MVC 局部视图与局部模型

2016 年 2 月 19 日

CPOL

12分钟阅读

viewsIcon

151521

downloadIcon

2327

在多个页面上使用具有自己模型的局部视图, 这不是很好吗?

引言

本文解决了在局部视图中包含表单元素时,将局部视图数据回发的问题。

代码重用是一个非常有用的省时功能,任何优秀的工程师在工作中都会积累大量有用的函数。Web 应用程序重复使用相同的代码是常识。然而在这种情况下,我们还有 HTML 标记代码。ASP.NET MVC 提供了局部视图 (Partial Views)、子操作 (Child Actions) 和编辑器/显示模板 (Editor/Display templates) 来解决这个问题。局部视图可以使用页面模型的数据,而子操作则使用来自控制器的独立数据。编辑器/显示模板将模型中的项传递给系统,但可以被用户局部视图覆盖。本文以公司地址为例,其中一部分内容是邮政地址,探讨了从局部视图回发数据时遇到的问题。当邮政地址作为局部视图实现时,可以在其他页面(例如销售地址)中重复使用。一旦这个问题解决,大型页面就可以通过分解成几个部分来更好地管理。一个很好的例子就是选项卡下的内容。

背景

在我们深入代码之前,我想提供一个简短的背景。我是一名在一家小公司工作的专业软件工程师。自 Visual Basic 6 以来,我一直在使用 Microsoft 的技术。当我解决了一个我认为对未来的项目有用并能造福同事的问题时,我就会感到兴奋。这种情况不常发生,但一旦发生,我就会将其添加到类库中。我已将其中一部分包含在示例项目中。我还使用 One-Note 快速记录这些想法,并链接到其他工程师的发现。

遇到问题时,首先要做的就是看看是否有人遇到过类似的问题。所有工程师都需要具备良好的搜索能力。有很多论坛,包括 Code Project,在自己动手之前可以获得很多想法。

在使用 Microsoft MVC 进行 Web 设计时,大多数功能都已包含在内,但偶尔您需要其他功能。表单领域就是其中之一。Microsoft 使用 HTML 助手生成表单元素。这些助手功能强大,但也有很多遗漏。幸运的是,它们很容易扩展,并且可以在互联网上找到许多示例。

本文将提出一个问题,并通过首先查看其他人所做的工作,然后生成一个新的 HTML 助手来解决问题。

问题

首先让我们看看用于表单的标准 MVC 控制器模式

    [HttpGet]
    public ActionResult Index()
    {
        // load the test data
        TestViewModel model = new TestViewModel();
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(TestViewModel model)
    {
        if (ModelState.IsValid)
        {
            // save the test data
        }
        return View(model);
    }

HttpGet 动作将数据加载到模型中并调用视图来显示数据。视图在表单上显示数据,用户可以编辑。单击提交按钮将表单回发到控制器的 HttpPost 动作,将表单上的数据绑定到模型。控制器验证模型,如果一切正常,则保存数据。然后控制器调用相同的视图来显示编辑后的数据。请注意,在两个动作中都使用了相同的模型,并且坚持这种模式可以保证数据绑定有效,即使是包含其他类和/或列表的复杂模型。事实上,如果我们坚持这种模式,这是 MVC 为我们做的非常聪明的事情之一。

这是一个非常简单的模型

    public class TestViewModel
    {
        public TestModel Test { get; set;}
    }

    public class TestModel
    {
        [Display(Name = "Name:")]
        [Required(ErrorMessage = "Please provide a name")]
        public string Name { get; set; }
        public TestPartialModel Partial { get; set; }
    }

    public class TestPartialModel
    {
        [Display(Name="Partial Name:")]
        [Required(ErrorMessage="Please provide a name")]
        public string Name { get; set; }
    }

这是视图

    @model CSE.Partial.WebApp.Models.TestViewModel
    @{
      ViewBag.Title = "Test";
    }
    <h2>@ViewBag.Title</h2>
    @using (Html.BeginForm())
    {
      @Html.AntiForgeryToken()
  
      <div>
        <hr />
        <dl class="dl-horizontal">
          <dt>@Html.LabelFor(m => m.Test.Name)</dt>
          <dd>
            @Html.EditorFor(m => m.Test.Name)
            @Html.ValidationMessageFor(m => m.Test.Name)
          </dd>

          <dt>@Html.LabelFor(m => m.Test.Partial.Name)</dt>
          <dd>
            @Html.EditorFor(m => m.Test.Partial.Name)
            @Html.ValidationMessageFor(m => m.Test.Partial.Name)
          </dd>

          <dt></dt>
          <dd><input class="btn btn-primary" type="submit" value="Save" /></dd>
        </dl>
      </div>
    }

这按预期工作,当按下保存按钮时,任何输入的值都会回发到控制器,并且基于这些值返回新的视图。因此,测试只是值的持久化。

下一步是将局部代码移到局部视图中。

_TestPartial 可以位于相同的视图文件夹中或位于 Shared 文件夹中。

     @model CSE.Partial.WebApp.Models.TestViewModel
    <dt>@Html.LabelFor(m => m.Test.Partial.Name)</dt>
    <dd>
      @Html.EditorFor(m => m.Test.Partial.Name)
      @Html.ValidationMessageFor(m => m.Test.Partial.Name)
    </dd>

现在视图是

    @model CSE.Partial.WebApp.Models.TestViewModel
    @{
      ViewBag.Title = "Test";
    }
    <h2>@ViewBag.Title</h2>
    @using (Html.BeginForm())
    {
      @Html.AntiForgeryToken()
       <div>
         <hr />
         <dl class="dl-horizontal">
           <dt>@Html.LabelFor(m => m.Test.Name)</dt>
           <dd>
              @Html.EditorFor(m => m.Test.Name)
              @Html.ValidationMessageFor(m => m.Test.Name)
            </dd>

            @Html.Partial("_TestPartial")

           <dt></dt>
           <dd><input class="btn btn-primary" type="submit" value="Save" /></dd>
        </dl>
      </div>
    }

这按预期工作正常,因为整个模型默认传递给局部视图。但我希望局部视图是可重用的,在上面的例子中,它与页面的模型绑定在一起。

所以让我们尝试将局部模型传递给局部视图。修改局部视图以使用其自己的模型

    @model CSE.Partial.Service.Models.TestPartialModel
    <dt>@Html.LabelFor(m => m.Name)</dt>
    <dd>
      @Html.EditorFor(m => m.Name)
      @Html.ValidationMessageFor(m => m.Name)
    </dd>

从页面视图中传递正确的模型

    @Html.Partial("_TestPartial", Model.Test.Partial)

现在回发时会有一个异常。检查回发数据时,局部模型丢失了。这就是问题所在!

解决问题

那么哪里出了问题?使用 F12 工具或查看源代码时,查看生成的 HTML 会非常明显。在工作测试中存在的元素命名在最终测试中不同。

这有效

    <input name="Test.Partial.Name" id="Test_Partial_Name" type="text" value="">

这失败了

    <input name="Name" id="Name" type="text" value="">

局部 HTML 中缺少名称前缀。这很明显;它怎么可能知道它是更大模型的一部分!

搜索论坛发现其他人也在尝试做同样的事情,我们发现 MVC 中内置了一个名为 TemplateInfo 的类。它有一个名为 HtmlFieldPrefix 的属性。如果我们f在调用局部视图之前设置它,我们可以强制所有元素名称都带有一个前缀。

    @{ Html.ViewData.TemplateInfo.HtmlFieldPrefix = "Test.Partial"; }
    @Html.Partial("_TestPartial", Model.Test.Partial)

这在这种情况下有效,但局部视图之后的任何元素也会获得前缀。

然后是 @Html.Partial( , , ViewDataDictionary) 中的第三个参数。我们可以将一个新的 ViewData 传递给我们的局部视图,并按照上面的方式设置 HtmlFieldPrefix。

    @Html.Partial("_TestPartial", Model.Test.Partial, new ViewDataDictionary
    {
      TemplateInfo = new System.Web.Mvc.TemplateInfo
      {
        HtmlFieldPrefix = "Test.Partial"
      }
    })

这将前缀隔离到局部视图。但是现在 ModelState 和 ViewData 丢失了!错误消息现在无法显示。在第三个参数中丢失了很多默认传递的东西。如果我们在复制视图数据后修改 TemplateInfo,这应该可以解决问题。ViewDataDictionary 构造函数将为我们复制 Html.ViewData

    @Html.Partial("_TestPartial",
    Model.Test.Partial,
    new ViewDataDictionary(Html.ViewData)
    {
      TemplateInfo = new System.Web.Mvc.TemplateInfo
      {
        HtmlFieldPrefix = "Test.Partial"
      }
    })

还有一个最后一个问题。如果我们将此技术用于嵌套局部视图,前缀将不得不附加而不是设置。我在 MVC 中找到了一个方法来做到这一点。所以这是一个可行的解决方案。

    @{
      string name = Html.NameFor(m => m.Test.Partial).ToString();
      string prefix = Html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);
      ViewDataDictionary viewData = new ViewDataDictionary(Html.ViewData)
      {
          TemplateInfo = new TemplateInfo { HtmlFieldPrefix = prefix }
      };
    }

    @Html.Partial("_TestPartial", Model.Test.Partial, viewData)

这确实有效,但这有点混乱,而且在每个 Html.Partial 前面都写这么多代码有点多。所以现在它正在工作,让我们把它变成一个 HTML 助手。这就是我们想要的

    @Html.PartialFor(m => m.Test.Partial)

这是扩展方法。请注意 lambda 表达式。这是一种非常类型安全的方式,用于将局部模型的名称和值传递给助手。局部视图名称可以作为参数传递,或者如果存在,它将设置为 TProperty 的类名或 UIHint("模板名称")

    /// <summary>
    /// Return Partial View.
    /// The element naming convention is maintained in the partial view by setting the prefix name from the expression.
    /// The name of the view (by default) is the class name of the Property or a UIHint("partial name").
    /// @Html.PartialFor(m => m.Address)  - partial view name is the class name of the Address property.
    /// </summary>
    /// <param name="expression">Model expression for the prefix name (m => m.Address)</param>
    /// <returns>Partial View as Mvc string</returns>
    public static MvcHtmlString PartialFor<tmodel, tproperty>(this HtmlHelper<tmodel> html,
        Expression<func<TModel, TProperty>> expression)
    {
        return html.PartialFor(expression, null);
    }

    /// <summary>
    /// Return Partial View.
    /// The element naming convention is maintained in the partial view by setting the prefix name from the expression.
    /// </summary>
    /// <param name="partialName">Partial View Name</param>
    /// <param name="expression">Model expression for the prefix name (m => m.Group[2])</param>
    /// <returns>Partial View as Mvc string</returns>
    public static MvcHtmlString PartialFor<TModel, TProperty>(this HtmlHelper<TModel> html,
        Expression<Func<TModel, TProperty>> expression,
        string partialName
        )
    {
        string name = ExpressionHelper.GetExpressionText(expression);
        string modelName = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(name);
        ModelMetadata metaData = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
        object model = metaData.Model;


        if (partialName == null)
        {
            partialName = metaData.TemplateHint == null
                ? typeof(TProperty).Name    // Class name
                : metaData.TemplateHint;    // UIHint("template name")
        }

        // Use a ViewData copy with a new TemplateInfo with the prefix set
        ViewDataDictionary viewData = new ViewDataDictionary(html.ViewData)
        {
            TemplateInfo = new TemplateInfo { HtmlFieldPrefix = modelName }
        };

        // Call standard MVC Partial
        return html.Partial(partialName, model, viewData);
    }

这里额外的部分是来自 MVC 的两个方法

    ExpressionHelper.GetExpressionText gets the name of the expression m => m.Test.Partial would return "Test.Partial".
    ModelMetadata.FromLambdaExpression gets the value of the expression. e.g. the model Partial.

我并非声称自己是这项扩展的完全发明者,而更像是一个融合了多人想法,形成了一个可行方法的人。

编辑器/显示模板

解决了上述问题后,我想研究一下编辑器模板。根据 Microsoft MSDN 的说法

EditorFor 方法用于根据传入表达式的数据类型生成 MVCHtmlString 标记。

网上有很多这样的例子,你会觉得它是一个单一的方法,可以根据属性的类型调整其渲染输出,并使用属性的元数据(属性特性)来添加额外细节。例如,它会为字符串显示一个文本框,为布尔值显示一个复选框。它会将输入类型设置为相应的 Html5 属性。当你使用一个对象时,它会为每个属性吐出带有标签和输入元素的

。这正是有趣的地方,你可以通过在视图文件夹或共享文件夹中添加一个名为 EditorTemplates 和/或 DisplayTemplates 的文件夹来覆盖所有系统模板的默认行为。在这个文件夹中,你放置一个与你要覆盖的类型同名的局部视图。这适用于简单类型和复杂类型。我用上面的项目尝试了一下,就像魔术一样,它与 PartialFor 扩展方法完全相同。它还将前缀通过层级向下传递。

在项目(可通过zip链接获取)中,我将所有局部视图都命名为其模型类名,并将其放在 ViewName/DisplayTemplate 或 Shared/DisplayTemplate 文件夹中。然后你可以这样使用它们

    @Html.EditorFor(m => m.Partial.First)

Html.Partial 期望局部视图位于当前视图文件夹或共享文件夹中,而不是子文件夹中。为了测试,我通过使用局部名称参数来覆盖此设置,以使用与 EditorFor 相同的局部视图。

    @Html.PartialFor(m => m.Partial.First, "EditorTemplates/FirstPartialModel")

PartialFor 与 EditorFor

那么有什么区别呢?据我所知,差别不大。主要区别在于局部视图的位置。

  • PartialFor – 与 Partial 相同;在当前视图文件夹或 Shared 文件夹中
  • EditorFor – 在视图或共享文件夹的 EditorTemplates 子文件夹中

我运行调试器并跟踪到 MVC EditorFor 代码(参见附录如何操作)。它找到了位置,然后测试了类型以确定它是一个复杂的类,复制了 ViewData,在 TemplateInfo 中设置了附加的前缀,并调用了与 @Html.Partial 相同的局部视图代码。如果你在互联网上搜索“html.editorfor vs partial view”,你会发现很多讨论。我真希望很久以前就发现这个了。PartialFor 的一个优点是当你想控制局部视图的绝对位置时。PartialFor 也适用于继承、接口和集合。

  • @Html.PartialFor(expression, partialName); partialName 可以是名称、完整路径或相对路径。
  • @HtmlEditorFor(expression, templateName); templateName 可以是名称或相对路径。“EditorTemplates”在源代码中是硬编码的。

接口与继承

掌握了这些新知识,我开始使用模板来定义用于页面表单的数据对象的视图。一切进展顺利,直到我尝试将类的继承部分拆分为自己的局部视图。我认为这是一个非常好的代码重用候选。EditorFor 渲染结果为空,而 PartrialFor 按预期工作。在调试器中跟踪 MVC 代码发现 EditorFor 代码正在检查先前访问过的对象并拒绝了对象的继承部分。它基本上试图保护我们免受递归,但在我们的例子中,它不会递归,它只是将对象的派生部分和继承部分渲染为单独的局部视图。所以我很高兴我没有删除我的 PartialFor 版本。

局部视图 vs 子操作 vs EditorFor

  中心性 描述 用途
局部视图 以视图为中心 适用于没有模型或页面模型的场景。如果子模型是只读的,也可以使用。 适用于复杂页面的标记拆分,例如选项卡。在布局页面上使用时,实现动态视图选择。
子操作 以视图为中心 将局部视图创建为控制器方法 [ChildActionOnly]。使用 @Html.Action("action") 调用。在渲染视图之前可以使用控制器逻辑。不使用页面模型。 适用于在多个页面上重用独立视图。
EditorFor 以模型为中心 适用于简单类型和复杂模型,并保持元素命名约定以实现正确的模型回发绑定。元素命名遵循模型层次结构。 非常适合在表单中使用的可重用数据模型视图。
DisplayFor 以模型为中心 按照惯例,是 EditorFor 的只读版本。  
PartialFor 以模型为中心 类似于 EditorFor,用于复杂对象。不进行递归对象测试。 适用于对象的继承或接口部分。适用于集合。

选哪个?由您决定,以鼓励最大限度的代码重用,避免重复。

使用代码

您将需要 Microsoft Visual Studio 2013 才能运行此解决方案。测试项目可以下载为 zip 文件。将文件解压到一个测试文件夹中,然后双击解决方案文件(CSE.Partial.WebApp.sln)。在尝试运行项目之前,您需要恢复 NuGet 包。这可能会根据您的 Visual Studio 设置自动工作。如果不行,请尝试:

  • 右键单击解决方案,然后单击“启用 Nuget 包恢复”。
  • 确保“工具/选项/NuGet 包管理器/通用包恢复”已选中“允许 NuGet 下载缺失的包”。

菜单项中包含一个名为“Partial”的下拉菜单。点击此菜单将显示 3 个测试视图。

  • “邮政地址”显示一个包含公司详细信息和邮政地址的表单。邮政地址在局部视图中实现。这是一个简单的演示,但展示了局部视图的分离。未来,邮政地址可以扩展为使用邮政编码查找服务,从而提供一个非常好的可重用“组件”。
  • “嵌套局部视图”是一个测试局部视图中嵌套局部视图的页面,并展示了多层数据绑定工作。它还证明 ModelState 错误处理仍然有效,并且 ViewData 正在传递到局部视图中。我从论坛上找到的几种替代解决方案在这方面都失败了。
  • “测试”是上述“问题”和“解决问题”的来源。

关注点

我相信上面使用的控制器模式很棒,而且数据绑定神奇地起作用。我发现跟踪 MVC 代码的能力非常有帮助。事实上,如果没有它,我将无法完成上述工作。CSE 命名空间使用我公司 Cambridge Software Engineering 的首字母缩写。

附录

深入 .NET 框架代码

MSDN 中关于符号/源服务器的说明。

要配置 Visual Studio 以使用符号/服务器,请按照以下说明操作:

  1. 转到“工具”->“选项”->“调试器”->“常规”。
  2. 取消选中“仅启用我的代码(仅限托管代码)”。
  3. 取消选中“启用 .NET Framework 源步进”。是的,这具有误导性,但如果不取消选中,Visual Studio 将忽略您的自定义服务器顺序(参见下文)。
  4. 勾选“启用源服务器支持”。
  5. 取消勾选“要求源文件与原始版本完全匹配”。
  6. 转到“工具”->“选项”->“调试器”->“符号”。
  7. 为本地符号/源缓存选择一个文件夹。
  8. http://srv.symbolsource.org/pdb/Public 添加 MVC
  9. 按如下设置

历史

版本 1 – 2016 年 2 月 10 日

© . All rights reserved.