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

另一套ASP.NET MVC (4) 技巧

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (79投票s)

2013年8月11日

CPOL

35分钟阅读

viewsIcon

253098

downloadIcon

2628

这是我年度技巧列表的第二部分,这次包括了JavaScript、控件、工具和定制化,这些都来自于最近的ASP.NET MVC 4编程实践。

ASP.NET MVC 3

目录

引言

一年前,我发表了文章“实用的ASP.NET MVC 3技巧”,这篇文章对许多人帮助很大。由于这篇文章对我自己来说也是一个很好的参考,所以我想发布另一篇文章,包含一些新技巧,希望能再次带来价值。

这次的改变是,我们将重点放在ASP.NET MVC 4上。大部分技巧仍然适用于旧版本(和/或未来版本)的ASP.NET MVC。其中一些技巧将涉及JavaScript与ASP.NET MVC的交互。大部分技巧将围绕用户交互和构建自定义控件——遵循现代Web和MVC原则的控件。

有些技巧会比其他技巧长,有些会比其他技巧更琐碎。我希望每个人都能找到至少一两个有用的技巧。我个人喜欢将重要的事情作为一种参考。碎片化总是难以应对的,这促使我更倾向于统一。

和上次一样,请注意以下免责声明:本文不会试图教你MVC、HTML、JavaScript或CSS。在本文中,我将提供一系列(大多不相关的)技巧,这些技巧在处理ASP.NET MVC时可能很有用。其中一些技巧可能会随着时间的推移而过时,但每个技巧都包含一个教训(对我来说,当我遇到问题时,它包含了一个教训!)。

背景

从我第一次看到ASP.NET MVC起,我就知道这是创建可扩展、健壮且优雅的动态Web应用程序的最佳解决方案。关注点分离使得即使在大型Web应用程序中也能轻松地跟踪一切。许多聪明人出色地设计了ASP.NET MVC框架,它(核心上)轻量且灵活。这种灵活性使其易于扩展或根据我们的需求进行调整。

然而,主要问题是很少有人知道如何实现某些事情。我个人总是查看MVC的源代码,以了解事物是如何实现的或在那里是如何完成的。在本文中,我们将了解ASP.NET MVC的一些内部工作原理,希望这有助于我们理解为什么有些代码有效而有些则无效。

作为一名顾问,我最近在Web方面的工作比以往任何时候都多。Web发展迅速,似乎每个人都想要一个出色的Web应用程序。这对一些公司来说是一个挑战,这些公司最终会(以艰难的方式)意识到它们的架构过于僵化,因为它只适用于客户端(桌面)应用程序。诸如无状态请求或多用户等问题在它们当前的架构上很难实现。然而,最终它们总是会开发出一种不仅能满足其先前需求,也能满足所有未来需求的架构。

那么,这篇文章的真正目的是什么?这些技巧将朝着以下几个方向发展:

  • 遵循Web原则
  • 遵守设计的架构
  • 减少开销
  • 根据我们的需求扩展MVC

如果您还没有尝试过ASP.NET MVC,但您了解C#或.NET框架(甚至ASP.NET),那么您应该立即尝试!如果您已经尝试过,并且现在对正在发生的事情有所了解,并且希望在万一的情况下了解更多,那么本文是您的正确选择。我也可以推荐我以前关于ASP.NET MVC的文章:“实用的ASP.NET MVC 3技巧”

技巧 1:帮助MVC绑定模型

ASP.NET MVC的模型构建器有一些限制。尽管构建器在从参数字符串(以各种形式接收,例如URL本身、查询参数或请求内容)实例化真实对象方面做得几乎完美(当然令人难以置信),但它无法从日常字符串(如简单的日期)实例化某些非常特殊的我对象。

要编写自己的模型绑定器,我们只需做两件事:

  1. 编写一个实现IModelBinder接口的类
  2. ModelBinders类中注册您的绑定器

让我们为DateTime模型绑定器做一个示例实现。我们希望默认日期格式是dd of MM (yyyy)这种奇怪的格式。这可以通过编写以下类来实现:

public class CustomDateBinder : IModelBinder
{
  static readonly Regex check = new Regex(@"^([0-9]{1,2})\s?of(\s[0-9]{1,2})\s?\(([0-9]{4})\)$");

  public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
  {
    var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
    var result = DateTime.Now;

    if (check.IsMatch(value.AttemptedValue))
    {
      var matches = check.Matches(value.AttemptedValue);

      if (matches.Count == 1 && matches[0].Groups.Count == 4)
      {
        try
        {
          int year = Int32.Parse(matches[0].Groups[3].Value);
          int month = Int32.Parse(matches[0].Groups[2].Value);
          int day = Int32.Parse(matches[0].Groups[1].Value);
          return new DateTime(year, month, day);
        }
        catch { }
      }
    }
    else if (DateTime.TryParse(value.AttemptedValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
      return result;

    bindingContext.ModelState.AddModelError(bindingContext.ModelName, "The value does not represent a valid date.");
    return null;
  }
}

我们使用正则表达式,因为DateTime的解析机制(通常通过选择带有格式字符串的ParseExact方法)不适合我们的需求。我们不希望因为插入无效数字而措手不及,因此我们将DateTime实例的实例化包装在try-catch块中。旧格式仍应起作用,因此我们只使用普通的TryParse

Global.asax.cs文件中的Application_Start方法中应完成注册绑定器的操作。通常,我们会创建一个执行所有注册的方法。就我们而言,我们只需要注册两个额外的绑定器:

public static void RegisterBinders()
{
  ModelBinders.Binders.Add(typeof(DateTime), new CustomDateBinder());
  ModelBinders.Binders.Add(typeof(DateTime?), new CustomDateBinder());
}

这里我们只注册了两个,因为Nullable<T>转换将自动执行。

注意:当然,您可以获取之前注册的DateTime绑定器,并在新创建的实例中使用它。大多数时候,这比尝试在我们自己的实现的其余部分中进行常规绑定更有意义。

现在,应该注意几个陷阱:

  • 我们期望的格式在服务器上有效,但客户端验证(开箱即用)将不起作用。
  • 在这个简单的例子中,一切都有效,包括以前支持的格式,但并非总是如此。

最关键的点其实是客户端验证。通常我们希望启用它,以提供更好的用户体验。但如果这个小脚本会阻止用户提交一个有效的表单呢?所以我们必须用自己的代码扩展客户端验证(jQuery.validate)。由于我们不想直接更改原始脚本(否则一旦收到更新,我们的更改就会被覆盖),最好的解决方案实际上是编写另一个脚本文件。

我们将此脚本文件命名为jquery.validate.custom.js,并添加以下代码:

(function ($, undefined) {
  var oldDate = $.validator.methods['date'];
  $.validator.addMethod(
    "date",
    function (value, element) {
      if (/^([0-9]{1,2})\s?of(\s[0-9]{1,2})\s?\(([0-9]{4})\)$/.test(value)) {
        alert('Hi from our own client-side validation !');
        return true;
      }

      return oldDate(value, element);
    }, "The given string is not a valid date ...");
})(jQuery);

代码看起来比实际要复杂。基本上,我们只是获取(当前)日期验证函数,添加(即替换)新的验证函数,并设置验证消息。如果字符串看起来合法,我们还会显示一个警报(这只是作为证明,不应在任何生产环境中使用)。

技巧 2:布局中的布局

有时我们只是为我们的主页构建一个框架。在这个框架中,我们将为区域或网页留下许多可更改的内容。要实现这种灵活性,一个好方法是使用布局中的布局。

要在布局中使用布局,我们只需要在视图中指定新的布局。如果在区域中这样做,我们可能只在区域的_Viewstart.cshtml文件中指定新的布局。现在,这个新布局中有趣的事情发生了:

@{
  Layout = "~/Views/Shared/_Layout.cshtml";
}

<div class="row">
  <div class="span2"> 
    <!-- specify new stuff here -->
  </div>

  <div class="span6">
    @RenderBody()
  </div>
</div>

像往常一样,对于布局,我们调用RenderBody方法。然而,新的地方在于我们再次指定了另一个Layout。此规范至关重要,它将当前布局包含在指定的布局中。

另一点需要注意的是,可以通过简单地应用Layout = null来轻松关闭父布局(或任何父布局),例如:

@{
  Layout = null;
}
@* Start something completely new here! *@

技巧 3:关于jQuery和打包...

jQuery是一个非常出色的JavaScript库。它提供了许多有趣的功能,并且拥有非常好的架构。jQuery的整个设计是其成功的基石,它也开启了Web开发工作的新浪潮。尽管我一直认为最好使用CDN上的jQuery,或者(如果不行)通过NuGet提要获取并更新它,但我现在强烈反对这样做。

有几个理由**不**(至少不自动)更新jQuery。通常,我们处理大量的jQuery插件(它们是我们自己的或第三方的),我们以及其他人都在假设jQuery API的状态。然而,有时API的状态比人们想象的要脆弱得多。这会导致当前状态的删除或修改。

如果我们现在盲目地更新jQuery(如果它在NuGet上,很容易发生),或者立即更新(通过CDN),我们可能会遇到一个或多个插件的问题。这已经发生在我身上好几次了,由于我没有心情调试这些第三方插件(有时我会,但通常我没有时间),所以我现在强烈反对这种(自动)更新。这些更新应首先进行评估和测试。

从NuGet中删除jQuery非常简单直接。我们只需固定版本,现在一切都处于确定状态,我们也应该考虑确定打包。当然,只包含整个目录非常有效,但有时顺序很重要。我一直推荐一个基本结构,例如:

Scripts/
Scripts/abilities
Scripts/plugins
Scripts/...

在scripts中,我们放置主文件,例如jquery.js或page.js(如果您想这样命名页面主JavaScript文件)。plugins文件夹只包含jQuery插件,这使得它们的顺序是任意的。这里不应有任何依赖关系(除了放在根目录中的文件)。

让我们来看一个RegisterBundles方法的示例配置:

public class BundleConfig
{
  public static void RegisterBundles(BundleCollection bundles)
  {
    //Determine the perfect ordering ourselves
    bundles.FileSetOrderList.Clear();

    //jQuery and its plugins!
    bundles.Add(GetJquery());

    //Separate the page JS from jQuery
    bundles.Add(GetPage());
  }

  static ScriptBundle GetJquery()
  {
    var jquery = new ScriptBundle("~/bundles/jquery");
    jquery.Include("~/Scripts/jquery.core.js");
    jquery.Include("~/Scripts/jquery.validate.js");
    jquery.Include("~/Scripts/jquery.validate.unobtrusive.js");
    jquery.IncludeDirectory("~/Scripts/plugins/", "*.js");
    //More to come - or even plugins of plugins (subdirectories of the plugins folder)
    return jquery;
  }

  static Bundle GetPage()
  {
    var page = new ScriptBundle("~/bundles/page");
    page.Include("~/Scripts/page.js");
    //and maybe others
    return page;
  }
}

为什么我们需要通过FileSetOrderList重置顺序?嗯,如果我们不清除默认值,那么将jquery.js重命名为jquery.core.js(例如)将导致jQuery在jquery.validate.js之后加载,因为该文件的名称在优先级列表中,而jquery.core.js不在。

技巧 4:构建自己的基础

如果我们直接从Controller派生我们的控制器,将来可能会遇到麻烦。使用一个(从代码角度来看)掌握在我们手中的抽象会更好。

我通常将自己的基控制器称为BaseController,但有时其他名称更合适。这样的控制器将包含所有其他控制器使用的方**法。原则上,这样的控制器也可以包含动作,尽管通常不是这样。

一个非常有用的起点可能是拥有以下结构:

public abstract class BaseController : Controller
{
  protected static String MyName([CallerMemberName] String name = null)
  {
    return name;
  }
}

此方法可用于任何操作(或其他方法)来确定操作的名称。结果少了一个字符串,如果我们被要求在某处传递当前操作的字符串,这可能会出错。

技巧 5:保持非侵入性

ASP.NET MVC背后的团队做得最好的事情之一是他们理解Web。关注点分离不仅对服务器端架构(Model-View-Controller)重要,对客户端(Description-Style-Interactivity,即HTML-CSS-JavaScript)也同样重要。这意味着HTML不应包含任何CSS或JavaScript。行内样式确实不是一个好主意(尽管在某些情况下它是有意义的,尤其是在调试或玩耍时)。同样适用于在HTML中使用JavaScript。这意味着应避免使用包含内容(通常是JavaScript)的<script>标签。

解决此问题的方法当然是非侵入性JavaScript。在这里,我们将选项设置为HTML属性的形式(通常是data-*属性)。JavaScript会根据某个类/或其他属性的设置来获取元素。事实上,这就是(客户端)验证器(jquery.validate.js)获取其信息的方式。还有一个名为jquery.validate.unobtrusive.js的JavaScript文件,它会获取这些元素并将原始验证器脚本馈送给找到的元素。

最好构建我们自己的控件,并提供(可选的)非侵入性模型,或者编写我们自己的包装器。让我们考虑一个(第三方)日期选择器控件在jquery.datepicker.js文件中的示例如。实际上,控件本身不是非侵入性的,因此我们创建一个名为jquery.datepicker.unobtrusive.js的新文件。我们只需要以下内容:

$(function () {
  $('input.pickdate').each(function () {
    var options = {};

    for (var name in this.dataset)
      options[name] = isNaN(this.dataset[name] * 1) ? this.dataset[name] : this.dataset[name] * 1;

    $(this).datepicker(options);
  });
});

这里做了什么?不多,我们获取所有符合特定非侵入性标准的元素(在本例中,所有带有pickdate类的<input>元素),并遍历它们。然后,我们获取所有data-*属性,并将它们放入一个名为options的对象中。最后,我们使用创建的选项调用jQuery插件。

在我们的MVC视图中,我们现在可以编写如下代码:

@Html.TextBox("mydate", DateTime.Now, new { @class = "pickdate", data_week_start = "4", data_format = "dd-mm-yyyy" })

并且无需任何额外的JavaScript代码,即可创建日期选择器控件——正如所期望的那样,是非侵入性的。

这里有两点需要注意:

  1. 我们对给定的值是否为数字进行简单评估。如果是数字,我们将其存储为数字。这里可以更智能或更具体,例如,只对某些名称执行此转换,或者如果匹配某些名称,则包括转换为布尔值等。
  2. 在MVC中,我们只能命名小写属性,但一个小技巧可以帮助我们:如果我们命名为data_week_start,那么结果将在HTML中为data-week-start。此属性名称实际上将被翻译成JavaScript中的dataWeekStart,即dataset属性将为我们提供一个键,形式为weekStart

保持非侵入性为我们提供了更多的灵活性和更轻松的维护。

技巧 6:传递数组的数组

很少有表单可能包含一个数组。更少见的是一个表单包含一个数组,而这个数组又包含一个数组。在我看来,我有一个复杂的JavaScript控件,可以添加、编辑或删除条目。一旦用户决定通过单击按钮保存,所有操作都会被跟踪并发送到服务器。提交是通过AJAX调用(通过jQuery)完成的。

人们期望的结构是IEnumerable<GridStateSaver<RowData>>。在这种情况下,RowData只是一个包含一些数据的模型(如ID、名称等)。泛型类GridStateSaver如下所示:

public class GridStateSaver<T>
{
  public GridSaveState State
  {
    get;
    set;
  }

  public IEnumerable<T> Rows
  {
    get;
    set;
  }
}
public enum GridSaveState
{
  Added,
  Updated,
  Deleted
}

总而言之,我们只是枚举所有可能的更改,其中我们接收了具有相同修改类型(添加、更新、删除)的整批行。使用jQuery来完成这项工作,我们得到了一个相当不错的响应,其中所有内容都使用了数组索引表示法。然而,即使ASP.NET MVC找到了传输的状态数量(例如,2表示只有添加和更新,或1表示只有删除等),它也不会深入到树中来实例化行或设置状态。

MVC控制器中的操作签名如下所示:

public ActionResult ActionName(IEnumerable<GridStateSaver<RowData>> states)
{
  /* ... */
}

现在我们已经讨论了直接方法不起作用,让我们看看一个有效的方法。假设我们将信息存储在一个名为data的数组中。以下代码会将此数组作为字符串化的JSON对象发布:

$.ajax({
  contentType: 'application/json; charset=utf-8',
  url: /* place URL to post here */,
  type: 'POST',
  success: /* place success callback here */,
  error: /* place error callback here */,
  data: JSON.stringify({ states : data })
});

这种方法效果很好,因为MVC会自动检测到传输是通过JSON完成的。这种方式也比常规检测更快,因为JSON有直接的数组表示法。因此,**使用JSON来发布复杂的JavaScript数据**。

技巧 7:架构你的JavaScript

每个Web应用程序中非常重要的一部分是JavaScript,它基本上连接了所有包含的JavaScript文件和网页。通常,一切都从以下块之一开始(使用jQuery):

$(function() {}
	/* Content */
);

$(document).ready(function() {
	/* Content */
});

$(window).load(function() {
	/* Content */
});

虽然这种方法有很多好处,但它也有一个缺点:没有一个对象可以与(可能的)其他脚本通信。或者换句话说:这种方法不是可插拔的。可以通过提供这样的对象来解决这个问题,无论是显式地使用window对象作为宿主,还是创建一个全局对象。全局对象也可以放在另一个JavaScript文件中。

这种方法还有一个优点是,全局对象还可以为调试信息提供访问。让我们设计一个非常简单的全局容器(仅仅是一个对象,尽管有更高级、更好的方法可以做到这一点):

var app = {
  initialized: false,
  path : '/',
	name : 'myapp',
	debug : [],
	goto : function() { /* ... */ }
};

这样的中心对象还有许多其他优点。当然,如果我们要构建类似单页应用程序的东西,在那里某些页面可能需要额外的JavaScript,那么它最有用。在这种情况下,可以这样做:

var app = {
  queue : [],
  load : function(callback) {
    app.queue.push(callback);
  },
  run : function() {
    for (var i = 0, n = app.queue.length; i < n; i++)
      (app.queue[i])();

    app.queue.splice(0, n);
  },
  /* ... */
};

所以,每个(附加的)JavaScript将运行类似的代码:

app.load(function() {
  /* additional code to load */
});

而不是通常的包装代码:

$(function() {
  /* additional code to load */
});

现在,这样的架构可以用于任何一种模块化体验。加载函数可以执行额外的绑定、激活一些漂亮的动画或仅仅设置一些更专业的控件。

技巧 8:构建可插拔的区域

有时客户有特殊要求。他们希望模块化他们的Web项目,但他们不希望在同一个项目中包含区域。当然,这样的处理是可能的,但是实现起来并不直接。有几种可能的解决方案,每种解决方案都有优点和缺点。让我们看看一些可能的解决方案:

  1. 在同一个解决方案中编码区域,并在代码内打开/关闭代码段。
  2. 创建一个位于主应用程序Areas子文件夹中的新项目。
  3. 创建一个新项目,并将资源(视图、脚本等)仅放在主应用程序的Areas子文件夹中。将这些文件作为链接插入新项目中。
  4. 创建一个新项目,并运行一个构建后事件来将资源复制到Areas子文件夹。
  5. 创建一个新项目,并将资源标记为嵌入式。使用虚拟路径提供程序来访问这些资源。

在我看来,最好的解决方案当然是第一种。但这并不是原始问题的解决方案——通过添加/删除单个库(*.dll文件)来使一切都可插拔!因此,选项2-4也被排除,因为这些选项有额外的文件需要传输。然而,应该注意的是,NuGet可以使这个过程非常优雅和简单。

所以,如果一家公司选择第四种方案,它肯定会有一些好处。可插拔的架构将通过NuGet提供——如果找到一个(尚未使用的)NuGet包,它将被自动安装(资源将被复制,并且库将被放置)。否则,NuGet包也可以被删除——这将导致库和资源的干净删除。

尽管如此,在本技巧中,我们将关注第五种方案。由于编写自己的虚拟路径提供程序很繁琐,我们将使用**MvcContrib**库。最终,我们得到一个以中央应用程序为中心的Web应用程序,可插拔模块打包在库中。

MvcContrib pluggable area

MvcContrib库为我们提供的远不止是提供抽象的PortableAreaRegistration类,我们需要从中派生我们的可移植区域。它还提供了消息总线,现在已包含在Mvc架构中。消息总线将两个(否则松散耦合的)模块耦合在一起,即它帮助我们建立从可移植区域到任何Web应用程序的连接,反之亦然。

技巧 9:始终考虑无JavaScript(NoJS)

如今,几乎每个(主要的)网页都提供了大量的交互性和功能。然而,真正的测试不是在用户启用JavaScript时网页是否真正具有交互性和可用性,而是在没有JavaScript时。当然,在一些明显的情况下,这个测试会惨败(尝试制作一个真正的实时游戏(如跑酷游戏)或绘画程序而不使用JavaScript),在这些情况下需要高度的交互性。

然而,在大多数情况下,测试不应失败。如果网页在没有JavaScript的情况下变得不可用,那么就有严重的问题了。想想亚马逊要求在结账过程中使用JavaScript。大多数人不会受到影响,但那些少数人,无论是因为公司政策无法在浏览器中启用JavaScript,还是因为安全原因不想启用它,都无法在网页上消费。因此,亚马逊会赚更少的钱。

如果我们页面上有将被JavaScript修改的部分,那么添加一个NoJS回退很容易。考虑以下:

<div class="loadfeed">
<noscript>This feature requires JavaScript.</noscript>
</div>

就是这么简单!只要禁用的功能被清楚地标记并且对页面运行不是必需的,一切都很好。然而,当我们考虑表单控件或相关的用户交互元素时,真正的NoJS必须进入我们的头脑。显然,我们的应用程序必须独立于此类控件。如果我们使用它们,那么必须不言而喻,这些控件只会增强用户的体验,但不是必需品。

考虑以下示例:我们在网页上包含一个日期选择器控件。

<input type="date" class="datepicker" placeholder="Please enter a date in the format DD-MM-YYYY" /> 

如果启用了JavaScript,我们将获取所有带有datepicker类设置的<input>标签。然后,我们将隐藏原始输入并显示另一个输入(带有日期选择器)。这个解决方案相当健壮。为什么?

  • 如果用户拥有支持HTML5输入元素的浏览器,将显示“原生”日期选择器。
  • 如果用户的浏览器不知道日期输入,但知道placeholder属性,则会显示一个提示。
  • 如果用户拥有相当旧的浏览器,他们仍然有一个文本框。验证仍在服务器端进行,因此插入错误格式不会造成损害。
  • 如果用户启用了JavaScript,那么他们将获得一个更优越的控件。

当然,有时需要更多的努力才能提供如此灵活的访问方式。有时,对于没有JavaScript的用户来说,提供一个合适的客户端解决方案可能是不可能的。然而,在大多数情况下,额外的努力是值得的。

以下是无JavaScript激活时的演示外观:

No JavaScript

至少测试一次网页/Web应用程序在没有JavaScript激活的情况下是值得的。

技巧 10:使用LESS和TypeScript

在我们的代码(C#或JavaScript)中,我们总是尝试遵循一些原则,如DRY或SOLID。我们架构一切并封装数据。为什么我们不对CSS做同样的事情?变量将是一个很好的起点,然后是混合(mixings)和包含的层级选择器。这基本上就是LESS提供的。由于分发LESS样式表的问题(需要一个转译器,因为提供另一个JavaScript听起来完全错误),我确实有所怀疑。不用说,Visual Studio已经包含了一个完美的答案:一个名为**Web Essentials**的插件。

该插件会自动保存LESS样式表,此外还有CSS和最小化的CSS格式。因此,我们可以简单地打包/分发CSS,而不必过多考虑LESS或样式表预处理器。

TypeScript的情况非常相似。TypeScript实际上是JavaScript,它为我们提供了许多出色的内置功能。虽然LESS带有Web Essentials,但TypeScript(此外)需要下载和安装。整个过程并不麻烦,从下载中心开始。

同样,每个TypeScript(.ts)文件都将自动保存为JavaScript(.js)和以.min.js结尾的最小化版本。所以这里没有真正的负担,尽管使用它!

最后一点:如果我们想有效地使用TypeScript,那么我们可能需要添加TypeScript定义文件*.d.ts。甚至有一个很好的在线数据库。此外,应该通过将它们拖入编辑器来包含对其他包含的JavaScript文件的引用。

技巧 11:“真正”的标签控件

很多时候我们想在标签中组织内容。标签需要我们编写3件事:

  • 一些CSS代码,以正确的方式显示标签。
  • 一些JavaScript代码,用于在单击相应的标题时显示正确的标签(以及更多)。
  • 实际的HTML代码,用于组织内容并放置标题。

因此,编写一个小扩展程序来生成这样的标签听起来是个好主意。最终,我们想生成这样的HTML:

<div class="tabs">
  <ul class="tabs-head">
    <-- For every tab we need the following -->
    <li>
      <-- name of the tab -->
    </li>
  </ul>
  <div class="tabs-body">
    <-- For every tab we need the following -->
    <div class="tab">
      <-- content of the tab -->
    </div>
  </div>
</div>

这段HTML可以用以下CSS代码以正确的方式进行样式设计(使其看起来像标签):

ul.tabs-head {
  display: block;
  list-style: none;
  border-bottom: 1px solid #ccc;
  margin: 0;
  padding: 0;
}

ul.tabs-head li {
  display: inline-block;
  margin: 0 10px;
  border: 1px solid #ccc;
  position: relative;
  top: 1px;
  height: 25px;
  padding: 10px 20px 0 20px;
  background: #eee;
  cursor: pointer;
}

ul.tabs-head li:hover {
  background: #fff;
}

ul.tabs-head li.active-tab {
  border-bottom: 1px solid #fdfdfd;
  background: #fff;
  font-weight: bold;
}

div.tabs-body {
  border: 1px solid #ccc;
  border-top: 0;
  padding: 10px;
}

当然,我们还需要一点JavaScript来使其顺利工作。最简单的解决方案(无需记住标签等)可以这样写:

; (function ($, undefined) {
    $.fn.tabs = function () {
        return this.each(function () {
            var links = $('ul.tabs-head > li', this);
            var tabs = $('.tab', this);

            var showTab = function (i) {
                links.removeClass('previous-tab next-tab active-tab')
                    .eq(i).addClass('active-tab');
                if (i > 0) links.eq(i - 1).addClass('previous-tab');
                if (i < links.length - 1) links.eq(i + 1).addClass('next-tab');
                tabs.hide().eq(i).show(i);
            };

            links.each(function(i, v) {
                $(v).click(function() {
                    showTab(i);
                });
            });

            showTab(0);
        });
    };
})(jQuery);

现在我们需要将所有内容连接起来。首先,我们想创建一个用于构建此类标签的扩展方法。问题在于,HTML不是顺序的。我们需要在两个地方输入来自我们标签的数据(一个地方用于所有标题,另一个地方用于所有内容)。当然,可以通过将扩展方法分成两部分来解决,但这并不优雅。

因此,我们选择了一种感觉非常接近BeginForm扩展方法的解决方案。扩展方法非常简单:

public static TabPanel Tabs(this HtmlHelper html)
{
    return new TabPanel(html.ViewContext);
}

这看起来并不复杂!最终,我们像这样使用它:

@using(var tabs = Html.Tabs())
{
    @tabs.NewTab("First tab",
    @<text>
        <strong>Some content (in first tab)...</strong>
    </text>)
    
    @tabs.NewTab("Second tab",
    @<text>
        <strong>More content (in second tab)...</strong>
    </text>)
}

显然,这个TabPanel类中发生了一些魔法。让我们看看实现:

public sealed class TabPanel : IDisposable
{
    Boolean _isdisposed;
    ViewContext _viewContext;
    List<Func<Object, Object>> _tabs;

    internal TabPanel(ViewContext viewContext)
    {
        _viewContext = viewContext;
        _viewContext.Writer.Write("<div class=\"tabs\"><ul class=\"tabs-head\">");
        _tabs = new List<Func<Object, Object>>();
    }

    public MvcHtmlString NewTab(String name, Func<Object, Object> markup)
    {
        var tab = new TagBuilder("li");
        tab.SetInnerText(name);
        _tabs.Add(markup);
        return MvcHtmlString.Create(tab.ToString(TagRenderMode.Normal));
    }

    public void Dispose()
    {
        if (!_isdisposed)
        {
            _isdisposed = true;
            _viewContext.Writer.Write("</ul><div class=\"tabs-body\">");

            for (int i = 0; i < _tabs.Count; i++)
            {
                _viewContext.Writer.Write("<div class=\"tab\">");
                _viewContext.Writer.Write(_tabs[i].DynamicInvoke(_viewContext));
                _viewContext.Writer.Write("</div>");
            }

            _viewContext.Writer.Write("</div></div>");
        }
    }
}

基本原理很简单:我们直接写入ViewContext。为了实现这种非顺序的东西,我们在顺序写出标题的同时,缓冲标签的内容。最后,我们关闭头部,刷新所有内容,并完成容器的HTML。

为了缓冲内容,我们使用了一个小技巧,通过视图生成器自动转换为函数委托。这个技巧有一个缺点:在标签内,其他直接写入ViewContext的辅助函数是无用的(最常见的例子是BeginForm方法)。在这里,我们将需要编写手动HTML或使用另一个直接返回MvcHtmlString的辅助函数。

现在,我们只需要将我们的jQuery标签插件与生成的**内容**连接起来:

$(function() {
  $('div.tabs').tabs();
});

最后,结果可能如**下图**所示。

jQuery Tabs MVC Control

其中一个问题是,它确实不适合较小的屏幕尺寸(例如在移动设备上)。这可以通过简单地添加以下CSS代码来改变:

@media only screen and (max-width: 540px) {
  ul.tabs-head {
    position: relative;
  }

  ul.tabs-head li {
    display: none;
    margin: 0;
    height: auto;
    padding: 0;
    cursor: default;
    overflow: hidden;
  }

  ul.tabs-head li:hover {
    background: none;
  }

  ul.tabs-head li.active-tab {
    display: block;
    background: none;
    font-weight: normal;
    padding: 10px 50px;
    font-size: 24px;
  }

  ul.tabs-head li.previous-tab, ul.tabs-head li.next-tab {
    color: transparent;
    display: block;
    position: absolute;
    width: 32px;
    height: 32px;
    top: 10px;
    border: 0;
    z-index: 100;
    cursor: pointer;
  }

  ul.tabs-head li.previous-tab {
    left: 10px;
    background: url(images/back.png) #ffffff;
  }

  ul.tabs-head li.next-tab {
    right: 10px;
    background: url(images/next.png) #ffffff;
  }
}

540px的值决定了阈值。低于此值,我们将启用响应式设计。这个值可能太低(取决于标签的数量),所以一个更高的值可能更好。它看起来是这样的:

jQuery Tabs Responsive Design

技巧 12:优化数据库查询

网页最大的性能杀手之一是数据库系统。这是应用程序的中央大脑,通过一些内部同步将其分片复制是一个真正的推动。唯一能做的就是最小化数据库负载(因此最小化页面生成时间并最大化每分钟请求数),就是改进我们编写的查询。

如上一组技巧中所述,我们应始终使用DAL与数据库进行通信。一种可能的方法是使用Entity Framework。它是免费的,包含许多很棒的功能,并且总的来说是一个非常健壮的实现。优化分为以下几类:

  • 缓存
  • 压缩
  • 减少
  • 合并 (Merging)
  • 避免

一段时间内(或仅在特定时间间隔内)不更改的Select可以被缓存。一些查询可以写得更少语句和/或导致更好的查询路径。返回的值数量也可能比所需的要多,这又是一个优化的来源。当然,如果我们能完全避免查询或将多个查询合并成一个查询,我们将获得大量的性能提升。

让我们来看一些例子来理解这些类别可以应用于哪里。让我们从以下未缓存的查询开始:

User GetUser(Guid id)
{
  return Db.Users.Where(m => m.Id == id).FirstOrDefault();
}

现在,我们可能会用类似以下代码片断的东西来替换它:

User GetUser(Guid id)
{
  return HttpRuntime.Cache.GetOrStore<User>(
    "User" + id, 
    () => Db.Users.Where(m => m.Id == id).FirstOrDefault()
  );
}

这个GetOrStore方法的非常简单的实现可以如下所示:

public static class CacheExtensions
{
  public static T GetOrStore<T>(this Cache cache, String key, Func<T> generator)
  {
    var result = cache[key];

    if(result == null)
    {
      result = generator();
      cache[key] = result;
    }

    return (T)result;
  }
}

但是,我们应该注意,此缓存算法不包含任何丢弃策略。因此,这是一个内存泄漏。在生产环境中,在启用缓存系统之前,应始终考虑合适的丢弃策略。

那么,压缩是什么意思?有时人们会编写过于复杂的查询。因此,简化查询或使其更轻量级是可能获得的最佳性能提升之一。在这里用LINQ举例比较困难,所以我们只使用纯SQL。

考虑以下SQL:

SELECT  *
FROM    mytable mo
WHERE   EXISTS
        (
          SELECT  *
          FROM    othertable o
          WHERE   o.othercol = mo.col
        )

现在我们用JOIN替换它:

SELECT  mo.*
FROM    mytable mo
INNER JOIN othertable o on o.othercol = mo.col

总的来说,压缩不是关于写更短的查询,而是更有效率。这应该会产生更快的执行计划。

减少不需要任何示例。大多数时候,我们从数据库获取了过多的数据。即使我们对某个用户购买的所有书籍感兴趣,我们真的对所有书籍数据感兴趣吗?还是返回这些书籍的ID和名称就足够了?

第四类,合并,通过一个非常有说服力的例子来解释。

public IEnumerable<Book> GetPurchasedBooksBy(String userName)
{
  var userId = Db.Users.Where(m => m.Login == userName).FirstOrDefault();

  if(userId != null)
  {
    var books = Db.Books.Where(m => m.FKBuyer == userId).ToEnumerable();
    return books;
  }

  return Enumerable.Empty<Book>();
}

为什么我们需要两个查询,而这一切都可以用一个查询完成?代码也会更直接:

public IEnumerable<Book> GetPurchasedBooksBy(String userName)
{
    return Db.Books.Join(
      Db.Users.Where(m => m.Login == userName), 
      m => m.FKBuyer, 
      m => m.Id, 
      (book, user) => book
    ).ToEnumerable();
}

在这里,我们在映射到用户主键的外键上连接了两个表。此外,我们遵守我们的用户名约束,并且我们只对书籍感兴趣。

最后,避免只是跳过实际不需要的查询。此类查询的王者通常由经验不足的用户执行,他们正在使用强大的框架。

public List<User> GetCreatedUsers(Guid id)
{
  return Db.Users.Where(m => m.Creator.Id == id);
}

这只在ORM相当好时才有效。但即便如此,执行此查询也需要一个左外连接。使用(已放置的)外键会**更好**:

public List<User> GetCreatedUsers(Guid id)
{
  return Db.Users.Where(m => m.FKCreator == id);
}

差别不大,但总是一个更好的选择(即使某些ORM可能会优化上述情况)。

技巧 13:生成内容

有时,我们只想展示可能的操作。通常这属于特定控制器**的**范围。在这种情况下,反射非常有用。

如果我们结合反射和属性的使用,我们就能得到自生成代码。我们所需要做的就是编写一个漂亮的、可重用的接口。让我们考虑以下类:

public static class Generator<TController, TAttribute>
  where TController : Controller
  where TAttribute : Attribute
{
  public static IEnumerable<Item> Create()
  {
    var controller = typeof(TController);
    var attribute = typeof(TAttribute);

    return controller.GetMethods()
      .Where(m => m.DeclaringType == controller)
      .Select(m => new
      {
        Method = m,
        Attributes = m.GetCustomAttributes(attribute, false)
      })
      .Where(m => m.Attributes.Length == 1)
      .Select(m => new Item
      {
        Action = m.Method,
        Attribute = (TAttribute)m.Attributes[0]
      });
  }

  public class Item
  {
    public MethodInfo Action
    {
      get;
      set;
    }

    public TAttribute Attribute
    {
      get;
      set;
    }
  }
}

有了控制器和属性类型的信息,我们就会遍历给定特定控制器的所有操作。最后,我们生成一种临时对象(但不是匿名的,因为否则我们会丢失信息),并返回这个枚举。

我们如何使用它?首先,让我们看一个示例控制器:

public MyController : Controller
{
  public ViewResult Index()
  {
    return View();
  }

  [Item("First item", Description = "This is the first item")]
  public ViewResult First()
  {
    /* ... */
  }

  [Item("2nd item", Description = "Another item - actually the second ...")]
  public ViewResult Second()
  {
    /* ... */
  }

  [Item("Third item", Description = "This is most probably the last item")]
  public ViewResult Third()
  {
    /* ... */
  }
}

好的,那么(显示的)除了Index操作之外的所有操作都用ItemAttribute属性进行了装饰。这是有意义的,因为我们(很可能)想在Index视图中获得所有方法的列表。同样,在其他视图中,我们可能只对子操作感兴趣,而不对index操作感兴趣。属性类的实现如下面的代码片段所示。

[AttributeUsage(AttributeTargets.Method)]
public sealed class ItemAttribute : Attribute
{
  public ItemAttribute(String name)
  {
    Name = name;
  }

  public String Name
  {
    get;
    private set;
  }

  public String Description
  {
    get;
    set;
  }
}

那么,我们如何使用我们的小生成器呢?实际上并不难。让我们看一个示例生成:

var list = Generator<MyController, ItemAttribute>.Create()
  .Select(m => new MyModel
  {
    Action = m.Action.Name,
    Description = m.Attribute.Description,
    Name = m.Attribute.Name
  }).ToList();

生成器独立于特定的属性、控制器或模型。因此,它可以在任何地方使用。

技巧 14:Web API的自动文档

ASP.NET MVC 4添加了一些很棒的功能。我最喜欢的功能之一是全新的ApiController,它被称为Web API。这使得创建遵循CRUD(Create/POST, Read/GET, Update/PUT, Delete/DELETE)原则的RESTful服务变得相当容易。在这里,我们可以很好地利用HTTP,并获得出色的自动行为,如OData处理、协议(例如JSON、XML等)检测或常规的模型构建。

然而,如果我们向我们网站服务中的特定功能提供开放API,我们也需要提供一个良好且稳健的API,列出并解释各种API调用。编写文档已经够难了,但Visual Studio在编写方法的内联文档方面给了我们很大的帮助。正如我们已经知道的,内联文档可以转换为XML,然后可以进行传输和读取。

Save documentation XML

如上所示,只需激活XML导出并将输出写入另一个路径即可。App_Data文件夹是一个自然的选择,因为它已经配置为仅供内部使用。因此,从外部访问*应该*被禁止。这是我们想要的,否则人们也可以看到我们代码其余部分的文档,这会给他们关于我们的Web应用程序如何工作的提示。

最后,我们可能需要一个漂亮的帮助页作为起点。以下NuGet命令将完成这项工作:

Install-Package Microsoft.AspNet.WebApi.HelpPage

它安装了一个预配置的帮助页。如果您从MVC 4 Web API应用程序项目类型开始,此帮助页已安装。现在只需要再做一件事:

config.SetDocumentationProvider(new XmlDocumentationProvider(HttpContext.Current.Server.MapPath("~/App_Data/XmlDocument.xml")));

此代码段必须放置在新帮助页的App_Start文件夹中的HelpPageConfig.cs文件中。此外,如果您还没有从global.asax.cs文件中的AreaRegistration类调用RegisterAllAreas方法,还需要一个步骤。

技巧 15:使用强类型视图

MVC确实很美,然而,有时它过于遵循Web精神,过于依赖字符串。在我看来,应该始终可以通过字符串或在编译时可以捕获的内容来指定事物。这在运行时提供了灵活性,但确保了编译时的健壮性。一种以强类型方式调用视图的方法是Omar Gamil Salem编写的小而有用的StronglyTypedViews T4模板。我们可以通过NuGet安装该模板,只需运行以下命令:

Install-Package StronglyTypedViews

现在,我们可以将以下语句替换为:

public ViewResult Product(int id)
{
    return View("Product", id);
}

改为这个版本:

public ViewResult Product(int id)
{
    return MVCStronglyTypedViews.Products.Product(id);
}

现在这看起来比以前长得多,而且相当没用。在所呈现的场景中,我们也可以写成:

public ViewResult Product(int id)
{
    return View(id);
}

现在也不需要字符串来指定视图(因为我们想要与所调用操作对应的视图)。但我们必须记住两件事:

  1. 操作可以直接(不是重定向)从其他操作调用,导致可能显示错误的视图(因为原始操作不同)。所以我们可能想显式指定视图。
  2. 我们传入一个Int32类型的模型,这是因为视图实际上是强类型到Int32模型的(这只是一个说明,不应在实践中这样做——即使我们现在只有(目前…)一个整数)。但由于参数非常通用(只是一个Object),我们也可以传递其他东西。

第二点是这里的真正杀手级论点。假设我们对代码进行了一点更改:

public ViewResult Product(Guid id)
{
    return View(id);
}

我们不会看到错误消息。然而,在我们的网页上,我们会看到一个错误(而且是一个非常糟糕的错误:因为这是在运行时发生的!)。因此,选择强类型视图将在三种情况下有所帮助:

  1. 避免视图名称中的拼写错误
  2. 避免传递错误的模型类型
  3. 确保我们始终与正确的视图对话

安装NuGet包后,我们在解决方案的根目录中会有一个名为StronglyTypedViews.tt的新文件。

StronglyTypedViews T4

右键单击文件,如上图所示,会给**出**运行它的选项。嗯,这就是我们在添加新视图后需要做的所有事情!

技巧 16:自定义错误屏幕

ASP.NET MVC中的内部异常会得到很好的处理。这里的共享文件夹中Error.cshtml文件的约定就足够了。这个约定实际上是由一个过滤器实现的——以HandleErrorAttribute的形式。该过滤器集成在global.asax.cs文件中。

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
  filters.Add(new HandleErrorAttribute());
}

我个人认为自定义错误模式应该**始终**仅限于远程。我们不想向外部暴露我们系统的任何信息。然而,我们真正想要的是在任何情况下都提供自定义错误屏幕。

<-- place this in the system.web node -->
<customErrors mode="RemoteOnly" />

要实现这一点,我们必须安装一个EndRequest处理程序。代码放在global.asax.cs文件中,如下所示。

protected void Application_EndRequest(Object sender, EventArgs e)
{
  ErrorConfig.Handle(Context);
}

现在的问题是ErrorConfig类中的静态Handle方法是如何实现的。这里我们只看给定的状态码。我们不想改变状态码,但我们实际上想显示一个自定义视图。最好的做法是创建另一个控制器并创建它。

public class ErrorConfig
{
  public static void Handle(HttpContext context)
  {
    switch (context.Response.StatusCode)
    {
      //Not authorized
      case 401:
        Show(context, 401);
        break;

      //Not found
      case 404:
        Show(context, 404);
        break;
    }
  }

  static void Show(HttpContext context, Int32 code)
  {
    context.Response.Clear();

    var w = new HttpContextWrapper(context);
    var c = new ErrorController() as IController;
    var rd = new RouteData();

    rd.Values["controller"] = "Error";
    rd.Values["action"] = "Index";
    rd.Values["id"] = code.ToString();

    c.Execute(new RequestContext(w, rd));   
  }
}

控制器本身可能像下面一样简单。

internal class ErrorController : Controller
{
  [HttpGet]
  public ViewResult Index(Int32? id)
  {
    var statusCode = id.HasValue ? id.Value : 500;
    var error = new HandleErrorInfo(new Exception("An exception with error " + statusCode + " occurred!"), "Error", "Index");
    return View("Error", error);
  }
}

这里真正重要的是将ErrorController保持为internal(这里明确写出来是为了清晰)。我们不希望任何用户故意调用此控制器的任何操作。相反,我们只希望在发生真正错误时才可能调用此控制器的操作。

技巧 17:移除ASPX视图引擎

大多数人使用ASP.NET MVC与Razor视图引擎。有相当多的理由选择Razor而不是ASPX,然而,想要坚持ASPX的人也可以这样做。还存在其他视图引擎,对于某些人或在某些情况下可能更好。

默认情况下,MVC附带ASPX和Razor视图引擎。实际选择在这里并不重要,它只会影响标准视图(如果有的话)将以何种语言生成。如果视图丢失,我们实际上会看到,对*.aspx的搜索路径也被搜索了。这种搜索当然有点昂贵。

以下代码足以移除所有视图引擎。

ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new RazorViewEngine());

我们应该将其放置在Application_Start方法中执行的某个位置。此外,禁用写入标准的MVC响应头是明智的。向响应添加特定头实际上是一种安全问题(不是大问题),因为我们告诉其他人我们系统的实现(这里有人可能 spy 到明显使用了ASP.NET MVC)。

MvcHandler.DisableMvcResponseHeader = true;

这样,我们的Web应用程序将拥有优化的头(节省少量输出生成时间)和优化的搜索路径(在搜索*.cshtml文件之前不会搜索*.aspx文件)。

技巧 18:包含HTML函数

Razor允许我们在代码中定义函数。当然,我们不会用它来简单地生成数学函数或LINQ查询,而是用来生成返回HTML的函数——而无需进行字符串连接和管理标签的负担。

为了在Razor中创建一个函数,我们只需要@helper指令。让我们看一个简单的例子。

You have @PluralPick(Model.Count, "octopus", "octopuses") in your collection. 
 
@helper PluralPick(Int32 amount, String singular, String plural)
{
    <span>
        @amount @(amount == 1 ? singular : plural)
    </span>
}

现在,我们可以在同一个视图中的任何地方使用PluralPick函数。更**有**用的是**全局**创建这样的辅助函数,即对任何视图都可以使用。如何做到这一点?嗯,这里App_Code文件夹派上了用场。这是ASP.NET MVC的一个特殊文件夹。这里的任何*.cshtml文件都不会从WebViewPage派生,而是从HelperPage派生。

这样的视图将从@helper指令创建public static方法。因此,在App_Code文件夹中创建诸如Helper.cshtml之类的文件,并将所有(可能)有用的全局辅助函数放在其中是明智的。

让我们以上面的代码为例,并将辅助方法放在名为Helper.cshtml的视图中:

@helper PluralPick(Int32 amount, String singular, String plural)
{
    <span>
        @amount @(amount == 1 ? singular : plural)
    </span>
}

这与上面的代码完全相同!现在,我们原始视图需要改变什么?

You have @Helper.PluralPick(Model.Count, "octopus", "octopuses") in your collection. 

我们没有改变多少,但我们需要指定HelperPage的名称,其中定义了辅助函数。

使用代码

我编译了一个小型示例项目,其中包含几乎所有技巧的动作(或技巧的代码片段)。您可以随意使用代码/调整它或根据需要删除它。

基本上,这是一个MVC 4 Web应用程序,它包含了大部分技巧。其中一些技巧已在某些文件中应用,而另一些则作为示例实现在可用的操作中。

值得关注的点

尽管大多数技巧对每个MVC开发者来说都是已知的,但我希望其中一些技巧是有趣和有用的,或者至少阅读起来很有趣。

如果您有一个或两个技巧可以分享,请随时在评论中发布。与第一篇文章一样,我非常乐意通过您关于ASP.NET MVC的最佳技巧和窍门来扩展这篇文章。

参考文献

历史

  • v1.0.0 | 初始发布 | 2013.08.11
  • v1.1.0 | 添加了两个技巧 | 2013.08.12
  • v1.1.1 | 修复了一些拼写错误 | 2013.08.13
  • v1.1.2 | 更新了演示项目 | 2013.08.13
© . All rights reserved.