先来发一通牢骚。
其实这是一篇迟发布近2个月的文章。事实上在ASP.NET MVC Preview 2发布之前我就已经将这篇文章的所有内容准备完毕了。当时想,就等Preview 2发布吧,而真一旦Preview 2发布之后却又懒得进行移植——移植了之后却又懒得写文章。这一拖就是近2个月,毫无长进。可能工作等其他事情的确多了些,但是扪心自问,也并没有忙到不可开交。时间往往都是在点点滴滴间浪费的。唉,可能是自视太高,越来越不愿意写一些普普通通的介绍性文章,导致可写的东西大大减少。不过话说回来,其实打算写的,甚至多次说过要写得东西也并不少,为什么就就是没有动笔呢?其实还是一个“懒”字——当年的勤奋劲儿到哪里去了呢?
言归正传。先解释一下标题,什么是“UpdatePanel for ASP.NET MVC”呢?ASP.NET AJAX中的UpdatePanel相信大家都有所了解。可惜的是,ASP.NET MVC框架的诞生“毁灭”了大量基于PostBack的控件,首当其冲地可能就是UpdatePanel了。如果没有PostBack,UpdatePanel就失去了全部作用,甚至不如一些绑定控件,至少它们还能够用于展示。为UpdatePanel长吁短叹之后,我们不禁又开始怀念UpdatePanel的优势:“透明”。在UpdatePanel的帮助下,实现AJAX操作对于开发人员几乎完全透明。我们要做的仅仅是将需要AJAX更新的内容用UpdatePanel包装起来,一切都是那么优雅。
我们能否在ASP.NET MVC中拯救UpdatePanel呢?也许是可以的吧,但这更像是一个“不可能完成的任务”。我不是传说中的阿汤哥,因此重新为ASP.NET MVC量身定制一个AJAX解决方案似乎更为可行。虽然我们不会苛求一个新生事物从诞生开始就趋向完美,但即使只是一个原型,它也必须严格遵守的一些原则:
- 不得破坏MVC中的协议(协作,职责等等)
- 对开发人员尽可能地透明
Nikhil Kothari曾经提出了他在ASP.NET MVC框架下的AJAX解决方案。如果您还不了解他的做法,那么我先在这里进行一点概括。Nikhil扩展了Controller使之支持一种Ajax操作,于是我们在代码中就可以写如下代码:
public class TaskListController : AjaxController {
...
public void CompleteTask(int taskID) {
if (String.IsNullOrEmpty(Request.Form["deleteTask"]) == false) {
InvokeAction("DeleteTask");
return;
}
Task task = _taskDB.GetTask(taskID);
if (task != null) {
_taskDB.CompleteTask(task);
}
if (IsAjaxRequest) {
if (task != null) {
RenderPartial("TaskView", task);
}
}
else {
RedirectToAction("List");
}
}
...
}
与AjaxController类似,Nikhil也为ViewPage和ViewControl提供了一些扩展方法,因此目前在View(List.aspx)中我们就能看到如下的代码:
<div id="taskList">
<% foreach (Task task in Tasks) { %>
<div>
<% this.RenderPartial("TaskView", task); %>
div>
<% } %>
div>
在View和Controller中都存在对于RenderPartiel方法的调用,它们的作用就是向客户端输出一个“Partial Template”生成的HTML代码。而在ASP.NET MVC的默认配置中,Partial Template即为User Control。而在TaskView这个Partial Template中可以看到一些辅助方法:
<div id="taskItem<%= Task.ID %>" class="taskPanel">
<% Ajax.Initialize(); %>
<% this.RenderBeginAjaxForm(
Url.Action("CompleteTask"),
new {
Update = "taskItem" + Task.ID,
UpdateType = "replace",
Completed = "endUpdateTask"}); %>
<input type="hidden" name="taskID" value="<%= Task.ID %>" />
<input type="submit" class="completeButton" name="completeTask" value="Done!" />
<input type="submit" class="deleteButton" name="deleteTask" value="Delete" />
<span><%= Html.Encode(Task.Name) %>span>
<% this.RenderEndForm(); %>
<% Ajax.RenderScripts(); %>
div>
这些辅助方法的作用是生成一些触发AJAX更新的标签及脚本,当用户点击RenderBeginAjaxForm与RenderEndForm方法生成的tag之间的提交按钮时,网页将会向服务器端发出一个AJAX请求,而服务器端的Action并最终会通过RenderPartial方法输出一个Partial Template生成的HTML。服务器端最终输出的HTML将会被替换或添加到页面的某个元素内。这就形成了一个AJAX效果。这个解决方案从某些方面看上去很酷,尤其是生成的代码可以添加到某个元素中,而不单单是如同UpdatePanel的替换,例如Nikhil在他的例子中就使用了这个特性实现了一个添加功能。不过如果使用之前提出的原则来衡量的话,似乎这个解决方案并不十分理想。
原因很简单,因为不够透明。
也有评论认为,Controller中的逻辑不该根据一个请求AJAX与否而进行不同处理(Nikhil的解决方案使用RenderPartial来替代RenderView为AJAX操作进行输出),因此这个解决方案破坏了MVC的职责。我不这么认为,但是我希望能做到这一点,因为做到这一点即意味着绝对的透明。绝对透明则意味着Controller将一个应用程序是否AJAX的决定权完全交给了客户端,这点非常理想,因为AJAX完全是一个表现层的概念。ASP.NET AJAX中的UpdatePanel在这方面的表现可圈可点(虽然还远不够完美),因此我最后决定也为ASP.NET MVC开发一款类似UpdatePanel的组件。值得庆幸的是,ASP.NET MVC默认使用WebForm页面作为视图模板,在这个强大的模型之下,构建出这样一个AJAX解决方案(的原形)似乎并不十分困难。
我将这个控件命名为MvcAjaxPanel。MvcAjaxPanel与UpdatePanel最大的区别在于后者接收的是PostBack,而前者接收的只是普通的HTTP请求。Post“Back”意味着Post过后回到了原来的Page,而ASP.NET MVC的请求往往会被引导至不同的页面。因此如何跨页面进行内容更新是MvcAjaxPanel首要解决的问题。最终我选择了为每个MvcAjaxPanel指定一个UpdateAreaID的做法。
<mvc:MvcAjaxPanel runat="server" ID="mvcAjaxPanel" UpdateAreaID="Header">
...
mvc:MvcAjaxPanel>
当页面向服务器端发出一个AJAX请求时将会附带页面中的UpdateAreaID信息,而服务器端的Action并不会意识到这一点,因此依旧按照寻常逻辑指定一个视图模版并输出HTML。不过,如果视图模板中的MvcAjaxPanel发现这个请求实际上是一个符合约定的AJAX请求(请注意,只有View组件意识到这是个请求的性质),则会使用新的方法来替换标准的输出。这时候模板就会根据客户端传递过来的UpdateAreaID,寻找页面上具有同样属性值的MvcAjaxPanel,有选择性地输出内容。在客户端就会有对应的JavaScript代码接收服务器端的数据,并且更新页面中的相应区域。
很明显,MvcAjaxPanel的工作原理与UpdatePanel有颇多相似之处,也做到了一定程度上的透明。而且与Nikhil的解决方案相比,一个非常重要的优势就是可以一次更新页面中的多个区域——其实这也就是UpdatePanel的特性之一。而且这种对Controller透明的做法又有一个天然的特点,那就是能够轻松地在不支持AJAX的浏览器中使用传统的方式切换页面。
服务器端的实现原理并不复杂,不过作为解决方案的另一个关键部分,如何在客户端触发一个AJAX提交也是一个值得思考的话题。UpdatePanel的方式可谓“全自动”:页面加载时将会把服务器端的Trigger信息输出至客户端,然后在客户端截获form的提交事件,并通过UniqueID或DOM结构等方式来判断这次提交是否该转化为AJAX方式。不过在一个ASP.NET MVC页面中几乎不会出现产生PostBack的元素,相反会有大量的普通链接,它们才是AJAX更新的主要截获目标。
为此我提供了一些JavaScript代码,截获一个链接原本的目标地址并将其转化为一个AJAX请求。我在这里通过示例中的代码来展示这种使用方式(这个示例源于Brad Abrams提供的ASP.NET MVC示例,不过我舍弃了Northwind数据库与Entity Framework,取而代之的是XML数据以及自定义的简单Model。此外,我也将其移植到ASP.NET MVC框架的0416 Build中):
<% foreach (var category in this.ProductCategories)
{ %>
<li>
<%= Html.ActionLink<ProductsController>(
c => c.List(category, 1),
category,
new { onclick = "mvcAjax.get(this, event)" })%>
li><%
} %>
这段代码来自分类列表页。与AJAX改进之前的代码相比,唯一的区别就是额外指定了元素的onclick事件(加粗部分)。在onclick事件执行中,这个链接默认的跳转行为将被取消,取而代之的是一个AJAX请求,请求的目标便是ProductsController中名为List的Action。
我们可以使用上面的方式应对普通链接,那么又该如何将一个客户端from的提交行为也变成AJAX操作呢?以下依旧是示例中的代码:
<form method="post"
action="<%= Url.Action("Update", new { id = this.Product.ProductID }) %>"
onsubmit="mvcAjax.submit(this, event);">
<table>
<tr>
<td>Name:td>
<td><%= Html.TextBox("Name", this.Product.Name) %>td>
tr>
...
table>
<input type="submit" value="Save" />
form>
在截获了form的submit事件之后,客户端将会收集该form中的所有input、select等值,组成一个请求的body,并且以HTTP POST的方式发出一个AJAX请求。余下的事情和之前就没有什么区别了。
与UpdatePanel相比,MvcAjaxPanel的客户端截获方式可谓“纯手工”,但是我并不认为这会造成什么问题。ASP.NET MVC强调的就是职责分离,而这种分离并不仅仅体现在代码上,也体现在开发人员的职责上。在开发ASP.NET MVC应用程序时,负责View的是前端开发工程师,对他们来说JavaScript与AJAX可谓是再熟悉不过的技术。在合时的地方手动编写一些JavaScript调用反而会让他们得到无比的自由性。例如在之前的代码示例中,调用mvcAjax.get或mvcAjax.submit方法时完全可以在前后自由地加入额外操作或者条件判断。这就不会像使用UpdatePanel时,如果需要使用JavaScript提交一个AJAX更新,还需要借助不登大雅之堂的trick。
也正因为如此,Nikhil提出的解决方案非常不错,它能够和前台开发人员的自定义逻辑进行灵活地结合。此外,通过阅读ASP.NET MVC框架0416 Build的代码,我发现在新版本的ASP.NET MVC中似乎将会内置这种AJAX解决方案了——不过这也的确符合微软的一贯做法,不是吗?:)
这个AJAX解决方案原型的使用方式和工作原理已经描述完了,如果您对其具体实现感兴趣,或者想亲自尝试一下,可以下载文章末尾的附件。附件中的解决方案包含三个项目,MvcAjax为提供MvcAjaxPanel的项目,而MvcWebApp是一个普通的ASP.NET MVC示例程序,而MvcAjaxWebApp自然就是添加AJAX效果之后的结果了。在示例中,我还在Master Page中定义的菜单(即页面左侧的菜单)里显示了一块当前时间,这是为了体现MvcAjaxPanel的“一次提交,多处更新”的特点。
不过需要强调的是,这仅仅是个原型。或者说这只是一种实现上尝试,在很多细节方面并没有作太多追求。如果要成为一个完善的AJAX解决方案,还需要作大量的改进。例如:
- 提供一些客户端的hook供前台开发人员使用(如提交前、接受后、或者处理一个提交还没有返回,客户端就发起另一个请求的情况等等)。
- 更强大的功能,更好的开发体验(如客户端触发机制)
- 异常处理
- 支持脚本
- 支持跳转(Redirection)
- ...
此外,作为面向ASP.NET MVC特有的AJAX解决方案,也有一些额外的问题需要考虑。最典型的问题之一就是在使用ASP.NET MVC时很少使用模板控件,而更多的使用页面中的循环,那么如何让MvcAjaxPanel在循环内容生效?我也产生过一些想法,但是如果要真正确定下来最终的实现方式,很多东西还需要进一步思考。如果您对于这个AJAX解决方案有什么建议或其他任何想法,也请尽快告诉我。
最后再说一件有趣的事情:在我实现了这个原型之后的某一天,忽然意识到这个控件似乎不光可以为ASP.NET MVC使用,也能够用于普通的WebForms应用程序。这真是一个令人意外的发现。
附件:源代码及使用示例