Ajax with the ASP.NET MVC Framework
原文链接:http://www.nikhilk.net/Ajax-MVC.aspx
Hopefully everyone had a good few days off. Before the holiday break, I did some app-building on top of the ASP.NET MVC framework. Actually rather than building some sort of fancy app, instead I was prototyping some features on top of the framework bits slated for an initial release. I've shared out the sample code, sample app and tests - yes, sorry for another tease :-)... but stay tuned... and you'll soon have actual bits to play with as well. Until then, you can download the sample code and browse it locally, and follow along the rest of the post. In particular there are two projects within the solution: TaskList (the web app) and AjaxMVC (a class library with Ajax extensions).
One of the prototypes is around bringing some basic Ajax functionality - basically to get post-back-less partial rendering and some behavior-like extensions to associate with DOM elements - sort of like ASP.NET Ajax but in a manner that fits with the pattern around how controllers and views are written. I should say that eventually Ajax functionality will exist out-of-the-box, so you can think of this as an early experiment, and by no means complete. In the spirit of experimentation, feedback and suggestions are welcome.
A Super-simple Task List Application
I know, the task list application has been beaten to death, but it’s a simple enough that it allows focusing on the Ajax features. Below is the screen-shot of the application that you get when browsing /TaskList on the site.
For a great intro to MVC, to get a sense of what is involved, the application structure etc, check out Scott's intro post if you haven't already done so. I won't repeat that material here, but like Scott's product catalog application, the TaskList application has a controller to handle the incoming requests, a set of classes making up the model representing a collection of task items, and a set of views used to render the user interface.
I'll point out some basic aspects of the application as it exists before I start adding some Ajax functionality.
The Model
Lets start with the model, which is primarily made up of two types:
public class Task { public int ID { get; } public bool IsComplete { get; set; } public string Name { get; set; } } public interface ITaskDB { Task AddTask(string name); void CompleteTask(Task task); Task GetTask(int taskID); ICollection<Task> GetTasks(); void RemoveTask(Task task); }The implementation of this isn't that interesting. For my sample purposes, I actually used an in-memory collection in my concrete implementation of ITaskDB rather than an actual database, since that isn't the focus here. I chose the interface approach as it allows me to plug in a test implementation when writing tests, as we'll see later.
The Controller
I have a single controller, called TaskListController which has a few actions it exposes. Here are two of them:
public class TaskListController : Controller { private ITaskDB _taskDB; public TaskListController() : this(GlobalTaskDB) { } public TaskListController(ITaskDB taskDB) { _taskDB = taskDB; } // Example URL: /TaskList or /TaskList/List [ControllerAction] public void List() { Dictionary<string, object> viewData = new Dictionary<string, object>(); viewData["Tasks"] = _taskDB.GetTasks(); RenderView("List", viewData); } // Example URL: /TaskList/Add [ControllerAction] public void Add(string name) { Task newTask = null; if (String.IsNullOrEmpty(name) == false) { newTask = _taskDB.AddTask(name); } if (newTask != null) { RedirectToAction("List"); } else { Dictionary<string, object> viewData = new Dictionary<string, object>(); viewData["Tasks"] = _taskDB.GetTasks(); viewData["ShowAddTaskError"] = true; RenderView("List", viewData); } } // Other actions: DeleteTask, CompleteTask }
The View
Next, I've got my UI implemented in List.aspx within the /Views/TaskList folder of my application. Here is some of the interesting markup:
<div id="taskList"> <% foreach (Task task in Tasks) { %> <div> <div id="taskItem<%= task.ID %>" class="taskPanel"> <form method="post" action='<% Url.Action("CompleteTask") %>'> <input type="hidden" name="taskID" value="<%= task.ID %>" /> <input type="submit" name="completeTask" value="Done!" /> <input type="submit" name="deleteTask" value="Delete" /> <span><%= Html.Encode(task.Name) %></span> </form> </div> </div> <% } %> </div> <form method="post" action='<%= Url.Action("Add") %>'> <input type="text" name="name" /> <input type="submit" name="addTask" value="Add Task" /> </form>
The Unit Tests
Finally, I've got my set of associated test cases within a separate Test project. Here are a couple of the tests associated with the Add action of my controller.
public void TestAddEmptyName() { TestTaskDB taskDB = new TestTaskDB(); taskDB.AddTask("Test Task 1"); taskDB.AddTask("Test Task 2"); TestTaskListController controller = new TestTaskListController(taskDB); controller.Add(null); Assert.AreEqual("List", controller.RenderedView); Assert.AreEqual(true, controller.GetRenderedViewData("ShowAddTaskError")); Assert.AreEqual(2, taskDB.Count); } [TestMethod] public void TestAddValidName() { TestTaskDB taskDB = new TestTaskDB(); taskDB.AddTask("Test Task 1"); taskDB.AddTask("Test Task 2"); TestTaskListController controller = new TestTaskListController(taskDB); controller.Add("New Task"); Assert.AreEqual("List", controller.RedirectedView); Assert.AreEqual(3, taskDB.Count); }
So that is basically the setup - a simple TaskList application, ready to be ajaxified. Here is a set of things I'd like to do:
- Add items to the list without a full post-back, and add the newly added task item to the end of the list.
- Complete and delete tasks, also without full post-backs.
- Add a watermark to the textbox
I'd like to minimize any differences between the Ajax and non-Ajax versions. Of course, I'll be making the changes in the application while using the prototype I am putting together, which introduces some new classes in the System.Web.Mvc namespace such as Ajax extension methods etc.
Adding Ajax Support in the Controller
The first thing I'll do is derive TaskListController from AjaxController instead of just Controller.
AjaxController is a class I added, and it introduces a new property IsAjaxRequest, which I can use in my action methods to do things like render different views. It also introduces members such as RenderPartial, which can be used to render a portion of the user interface defined within a partial view as opposed to the full page. So here is my updated controller and the updated Add method with the changes and additions in bold.
public class TaskListController : AjaxController { public void Add(string name) { Task newTask = null; if (String.IsNullOrEmpty(name) == false) { newTask = _taskDB.AddTask(name); } if (IsAjaxRequest) { if (newTask != null) { RenderPartial("TaskView", newTask); } } else { if (newTask != null) { RedirectToAction("List"); } else { Dictionary<string, object> viewData = new Dictionary<string, object>(); viewData["Tasks"] = _taskDB.GetTasks(); viewData["ShowAddTaskError"] = true; RenderView("List", viewData); } } } }
My TaskView partial is defined as TaskView.ascx within the same /Views/TaskList folder and is as follows:
<div id="taskItem<%= Task.ID %>" class="taskPanel"> <form method="post" action='<%= Url.Action("CompleteTask") %>'> <input type="hidden" name="taskID" value="<%= Task.ID %>" /> <input type="submit" name="completeTask" value="Done!" /> <input type="submit" name="deleteTask" value="Delete" /> <span><%= Html.Encode(task.Name) %></span> </form> </div>
If this looks familiar, it is because it was essentially re-factored out of the existing List.aspx. The view data for this partial is a single Task instance. Now that I've got this partial defined, I can use it from the main List.aspx view page as well. This is accomplished using the RenderPartial extension method I provide. Once I've done this the task list portion of my List.aspx gets reduced to:
<div id="taskList"> <% foreach (Task task in Tasks) { %> <div> <% this.RenderPartial("TaskView", task); %> </div> <% } %> </div>
Next I need to cause the view to issue XMLHttp requests rather than do a regular form submit. Again, I've provided a few extension methods: RenderBeginForm which renders a regular form tag, RenderBeginAjaxForm which renders an Ajax-enabled form that I will be interested in this scenario, and RenderEndForm. Using these, the form tag representing the UI to add tasks becomes (with changes represented in bold):
<% RenderBeginAjaxForm(Url.Action("Add"), new { Update="taskList, UpdateType="appendBottom", Highlight="True", Starting="startAddTask", Completed="endAddTask" }); %> <input type="text" name="name" /> <input type="submit" name="addTask" value="Add Task" /> <% RenderEndForm(); %>
As you can see the contents of the form did not change. Just the declaration of the form itself. Basically RenderBeginAjaxForm takes in the URL representing the action to invoke when the form is submitted, and then some Ajax-specific parameters as follows:
- Update: The id of the DOM element to update with the results. In this case, it’s the container of the task items.
- UpdateType: This can be "none", "replace", "replaceContent", "insertTop", or "appendBottom" - in our case we choose the latter, which causes the newly rendered task to show up at the bottom of our list.
- Highlight: This is optional, and when set, it causes the newly added item to be highlighted for a short duration with a subtle yellow fade effect.
- Starting, and Completed: These are essentially events. You can handle them and write little bits of Javascript to do things like disable buttons, show progress indicators, add additional bits to the outgoing request, or pre-process the incoming response etc.
Here is the Javascript, written in TaskList.js, which I've placed in the /Views/Scripts folder of my application.
function startAddTask() { $('addTaskGroup').disabled = true; return true; } function endAddTask() { $('addTaskGroup').disabled = false; return true; }
In my startAddTask method, I can also perform validation, and return false, if the form is invalid to prevent the request from being issued. The sample code does show some rudimentary form of validation. However, an actual validation system would be a whole blog post in itself, which I'll get to in the future.
My final step is to actually include the TaskList script as well as the script framework that provides the core bits of functionality. I can go into my master page for the application which is in the /Views/Layouts folder and add some rendering directives e to initialize the Ajax functionality, register scripts, and render out scripts at the bottom of the generated HTML. This is accomplished by calling the extension methods I've added to the Ajax object.
<% Ajax.Initialize(); %> <% Ajax.RegisterScript("~/Views/Scripts/TaskList.js"); %> <!-- UI goes here --> <% Ajax.RenderScripts(); %>
Actually another step I need to do is add a test case, so I'll add a test case that tests the Ajax variant of my Add controller action.
I can do the same thing for handling the completion and deletion of the tasks. Completion of tasks causes the UI for that task to be re-rendered and the new HTML replaces the existing HTML. Deletion of tasks is a bit more interesting - rather than updating HTML, the old rendering is simply removed from the DOM, and the HighlightLeave effect (a red fade effect) can be used to make it visually interesting. Either way, you can check out the mechanics by browsing through the source code (specifically TaskList.js and TaskView.ascx, as well as the associated controller actions).
Adding Some More Ajax
I can do something very similar for the <form> contained within the TaskView.ascx representing each task item to convert it from a regular submit-based form to an Ajax-enabled form, so that item completion and deletion can occur without page refreshes. The sample code demonstrates this, so I won't repeat it all here.
Instead, what I do want to show is adding script behaviors and associating it with the HTML being rendered for creating additional Ajax-based interactivity within your application's UI. Specifically as I indicated above, I want to add a watermark to the textbox, that provides an in-place hint to the user about what is the expected input. The watermark appears as long as there is no user input, and it goes away when the user focuses into the textbox.
For the purposes of this post, I won't go too deep into the actual script itself. Again the script is included in the sample. The watermark behavior is implemented as a Behavior for those of you familiar with the ASP.NET Ajax model of implementing script behaviors. Like all behaviors it is associated with a DOM element, the textbox in our scenario, and it subscribes to events raised by the element to do its job.
In traditional web forms pages, I would then have an Ajax-enabled server control such as a WatermarkExtender I could associate with a TextBox server control. Here instead I have a rendering method implemented as an extension method that allows me to do the equivalent of creating and initializing an instance of the script behavior. Using that, here is my updated view:
<% RenderBeginAjaxForm(Url.Action("Add"), new { Update="taskList, UpdateType="appendBottom", Highlight="True", Starting="startAddTask", Completed="endAddTask" }); %> <input type="text" name="name" id="nameTextBox" /> <% Ajax.Watermark("nameTextBox", new { watermarkText="[What do you need to do?]", watermarkCssClass="watermark"}); %> <input type="submit" name="addTask" value="Add Task" /> <% RenderEndForm(); %>
The extension method implementation is really simple. It pretty much just calls into my Ajax framework. Here is what my WatermarkBehavior class looks like:
public static class WatermarkBehavior { public static void Watermark(this AjaxHelper ajaxHelper, string id, object watermarkOptions) { ajaxHelper.RegisterScript("~/Views/Scripts/Watermark.js"); ajaxHelper.RegisterScriptBehavior(id, "Ajax.Watermark", watermarkOptions); } }
Of course in reality, I might have more to my implementation but what this shows is the core framework providing the functionality of collecting registered scripts, rendering them out into the page, and then instantiating the behavior objects, hooking them up with their associated DOM elements, and passing in the option values used by the developer in their view implementation to customize the specific instances.
Conclusion
This shows in brief a first stab at getting the core aspects of the Ajax functionality that exists in ASP.NET pages - partial rendering, behaviors and extender controls - and bringing those concepts to MVC views. I am sure there is a lot more to be done to round out these two scenarios, as well as address other Ajax features (validation, periodic refreshes, simplify invoking controller methods via script proxies ala web services etc. etc.). Check out the prototype and sample app as they exist right now, and go ahead and share your thoughts and ideas.