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






4.97/5 (79投票s)
这是我年度技巧列表的第二部分,这次包括了JavaScript、控件、工具和定制化,这些都来自于最近的ASP.NET MVC 4编程实践。
目录
- 引言
- 背景
- 技巧 1:分离实体和模型
- 技巧 2:布局中的布局
- 技巧 3:关于jQuery和打包...
- 技巧 4:构建自己的基础
- 技巧 5:保持非侵入性
- 技巧 6:传递数组的数组
- 技巧 7:架构你的JavaScript
- 技巧 8:构建可插拔的区域
- 技巧 9:始终考虑无JavaScript(NoJS)
- 技巧 10:使用LESS和TypeScript
- 技巧 11:“真正”的标签控件
- 技巧 12:优化数据库查询
- 技巧 13:生成内容
- 技巧 14:Web API的自动文档
- 技巧 15:使用强类型视图
- 技巧 16:自定义错误屏幕
- 技巧 17:移除ASPX视图引擎
- 技巧 18:包含HTML函数
- 使用代码
- 关注点
引言
一年前,我发表了文章“实用的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本身、查询参数或请求内容)实例化真实对象方面做得几乎完美(当然令人难以置信),但它无法从日常字符串(如简单的日期)实例化某些非常特殊的我对象。
要编写自己的模型绑定器,我们只需做两件事:
- 编写一个实现
IModelBinder
接口的类 - 在
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代码,即可创建日期选择器控件——正如所期望的那样,是非侵入性的。
这里有两点需要注意:
- 我们对给定的值是否为数字进行简单评估。如果是数字,我们将其存储为数字。这里可以更智能或更具体,例如,只对某些名称执行此转换,或者如果匹配某些名称,则包括转换为布尔值等。
- 在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项目,但他们不希望在同一个项目中包含区域。当然,这样的处理是可能的,但是实现起来并不直接。有几种可能的解决方案,每种解决方案都有优点和缺点。让我们看看一些可能的解决方案:
- 在同一个解决方案中编码区域,并在代码内打开/关闭代码段。
- 创建一个位于主应用程序Areas子文件夹中的新项目。
- 创建一个新项目,并将资源(视图、脚本等)仅放在主应用程序的Areas子文件夹中。将这些文件作为链接插入新项目中。
- 创建一个新项目,并运行一个构建后事件来将资源复制到Areas子文件夹。
- 创建一个新项目,并将资源标记为嵌入式。使用虚拟路径提供程序来访问这些资源。
在我看来,最好的解决方案当然是第一种。但这并不是原始问题的解决方案——通过添加/删除单个库(*.dll文件)来使一切都可插拔!因此,选项2-4也被排除,因为这些选项有额外的文件需要传输。然而,应该注意的是,NuGet可以使这个过程非常优雅和简单。
所以,如果一家公司选择第四种方案,它肯定会有一些好处。可插拔的架构将通过NuGet提供——如果找到一个(尚未使用的)NuGet包,它将被自动安装(资源将被复制,并且库将被放置)。否则,NuGet包也可以被删除——这将导致库和资源的干净删除。
尽管如此,在本技巧中,我们将关注第五种方案。由于编写自己的虚拟路径提供程序很繁琐,我们将使用**MvcContrib**库。最终,我们得到一个以中央应用程序为中心的Web应用程序,可插拔模块打包在库中。
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激活时的演示外观:
至少测试一次网页/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();
});
最后,结果可能如**下图**所示。
其中一个问题是,它确实不适合较小的屏幕尺寸(例如在移动设备上)。这可以通过简单地添加以下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的值决定了阈值。低于此值,我们将启用响应式设计。这个值可能太低(取决于标签的数量),所以一个更高的值可能更好。它看起来是这样的:
技巧 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,然后可以进行传输和读取。
如上所示,只需激活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);
}
现在也不需要字符串来指定视图(因为我们想要与所调用操作对应的视图)。但我们必须记住两件事:
- 操作可以直接(不是重定向)从其他操作调用,导致可能显示错误的视图(因为原始操作不同)。所以我们可能想显式指定视图。
- 我们传入一个
Int32
类型的模型,这是因为视图实际上是强类型到Int32
模型的(这只是一个说明,不应在实践中这样做——即使我们现在只有(目前…)一个整数)。但由于参数非常通用(只是一个Object
),我们也可以传递其他东西。
第二点是这里的真正杀手级论点。假设我们对代码进行了一点更改:
public ViewResult Product(Guid id)
{
return View(id);
}
我们不会看到错误消息。然而,在我们的网页上,我们会看到一个错误(而且是一个非常糟糕的错误:因为这是在运行时发生的!)。因此,选择强类型视图将在三种情况下有所帮助:
- 避免视图名称中的拼写错误
- 避免传递错误的模型类型
- 确保我们始终与正确的视图对话
安装NuGet包后,我们在解决方案的根目录中会有一个名为StronglyTypedViews.tt的新文件。
右键单击文件,如上图所示,会给**出**运行它的选项。嗯,这就是我们在添加新视图后需要做的所有事情!
技巧 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的最佳技巧和窍门来扩展这篇文章。
参考文献
- 实用的ASP.NET MVC (3) 技巧
- 使用ASP.NET MVC中的IModelBinder进行自定义模型绑定——两个陷阱
- ASP.NET MVC4:打包和最小化
- jQuery日期选择器插件
- Web Essentials 2012插件
- Web Essentials 2012插件
- Web Essentials 2012插件
- TypeScript定义文件数据库
- 一个非常好的LESS教程
- 官方TypeScript教程
- 关于Mvc可移植区域的教程
- MVC可移植区域:3年之后
- StackOverflow关于简化SQL查询的讨论
- 为ASP.NET Web API创建帮助页
- StronglyTypedViews库的GitHub仓库
历史
- v1.0.0 | 初始发布 | 2013.08.11
- v1.1.0 | 添加了两个技巧 | 2013.08.12
- v1.1.1 | 修复了一些拼写错误 | 2013.08.13
- v1.1.2 | 更新了演示项目 | 2013.08.13