MVVM(Knockout.js)的新尝试:多个Page,一个ViewModel
对于面向数据的Web应用来说,MVVM模式是一项不错的选择,它借助JS框架提供的“绑定”机制是我们无需过多关注UI(HTML)的细节,只需要操作绑定的数据源。MVVM最早被微软应用于WPF/SL的开发,所以针对Web的MVVM框架来说,Knockout.js(以下简称KO)无疑是“根正苗红”。在进行基于KO的Web应用开发时,我们一般会为具体的Web页面定义针对性的ViewModel,但是在很多情况下很多页面具有相同的UI结构和操作行为,考虑到重用和封装,我们是否为它们创建一个共享的ViewModel呢。最近在一个小项目中,我们对这种方式进行了尝试,觉得是可行的,但同时也发现的一些问题。这篇文章通过一个简化的实例来讨论这种开发方式。[源代码从这里下载]
目录
一、MVVM模式
二、类似的UI结构和操作行为
三、共享的ViewModel
四、Controller的定义
五、View的定义
六、_Layout.cshtml定义
一、MVVM模式
MVVM可以看成是MVC模式的一个变体,Controller被ViewModel取代,但两者具有不同的职能,三元素之间的交互也相同。以通过KO实现的MVVM为例,其核心是“绑定”,我个人又将其分为两类,即“数据的绑定”和“行为的绑定”。所谓数据的绑定,就是将ViewModel定义的数据绑定到View中的UI元素(HTML元素)上,双向/单向绑定同时被支持,而我们通常使用的是双向绑定。而行为绑定体现为事件注册,即View中UI元素的事件(比如某个<button>的click事件)与ViewModel定义的方法(function)进行绑定。
如右图所示,用户行为(比如某个用户点击了页面上的某个Button)触发View的某个事件,与之绑定的定义在ViewModel中的EventHandler(ViewModel的某个方法成员)被自动执行。它可以执行Model,并修改自身维护的数据,由于View和ViewModel的数据绑定是双向的,用户在界面上输入的数据可以被ViewModel捕获,而ViewModel对数据的更新可以自动反映在View上。这样的好出显而易见——我们在通过JS定义UI处理逻辑的时候,无需关注View的细节(View上的HTML),只需要对自身的数据进行操作即可。
二、类似的UI结构和操作行为
通过上面针对MVVM的介绍我们知道ViewModel是三者核心,ViewModel不但定义了绑定在View上的数据,同时也定义了响应View事件的操作。在实际Web应用开发中(尤其是我从事的企业应用开发),往往存在着很多类似的页面。它们不但具有相同的UI结构,对应的操作行为也大同小异,这意味着ViewModel的数据成员和方法成员(实际上KO中用于双向绑定的数据也是方法)也基本上类似,那么出用重用的目的,我们可以考虑为这些相似的页面定义相应的ViewModel。
企业应用很多情况下是在进行数据的维护,即对数据进行基本的CRUD操作。举个实际的例子,假设一个Web应用都采用左图所示的页面和操作行为进行针对不同数据的维护:用户输入查询条件点击“Search”按钮筛选需要操作的数据,获取的数据以表格的形式显示出来;考虑到数据量可能比较大,分页获取往往是必须的;表格的Titile为可点击的链接,用于根据当前列进行排序。
用户可以点击数据行右侧的链接(Update和Delete)修改或者删除当前记录,也可以点击上边的Add按钮添加一条新的数据。数据添加和修改的数据均通过弹出的对话框(如右图所示)的形式进行编辑。
三、共享的ViewModel
那么现在我们希望定义一个公用的“类型”来作为这种页面的ViewModel,并且将相应的数据和行为操作定义其中。虽然这个页面结构比较简单,但是包含的功能还是挺多的,不仅仅具有基本的CRUD操作,还具有排序和分页的功能,所以为这样的页面定义一个公共的ViewMode还是要定义不少的成员。如下所示的就是这个ViewModel的定义,由于我为每个成员加上了注释,所以每个成员的作用和实现逻辑还是比较清晰的,在这里我就不一一解释了。补充一点的是,演示实例的样式和对话框功能是通过Bootstrap实现的。
1: function ViewModel(options) {
2: var self = this;
3:
4: //标题、数据集、弹出对话框和内容(HTML)
5: self.title = ko.observable(options.title);
6: self.recordSet = ko.observableArray();
7: self.dialogContent = ko.observable();
8: self.dialog = options.dialogId ? $("#" + options.dialogId) : $("#dialog");
9:
10: //排序
11: //orderBy,defaultOrderBy & isAsc: 当前排序字段名,默认排序字段名和方向(升序/降序)
12: //totalPages, pageNumbers & pageIndex:总页数,页码列表和当前页
13: self.orderBy = ko.observable();
14: self.isAsc = ko.observable();
15: self.defaultOrderBy = options.defaultOrderBy;
16:
17: //分页
18: //totalPages, pageNumbers & pageIndex:总页数,页码列表和当前页
19: self.totalPages = ko.observable();
20: self.pageNumbers = ko.observableArray();
21: self.pageIndex = ko.observable();
22:
23: //查询条件:标签和输入值
24: self.searchCriteria = ko.observableArray(options.searchCriteria);
25:
26: //作为显示数据的表格的头部:显示文字和对应的字段名(辅助排序)
27: self.headers = ko.observableArray(options.headers);
28:
29: //CRUD均通过Ajax调用实现,这里提供用于获取Ajax请求地址的方法
30: self.dataQueryUrlAccessor = options.dataQueryUrlAccessor;
31: self.dataAddUrlAccessor = options.dataAddUrlAccessor;
32: self.dataUpdateAccessor = options.dataUpdateAccessor;
33: self.dataDeleteAccessor = options.dataDeleteAccessor;
34:
35: //removeData:删除操作完成后将数据从recordSet中移除
36: //replaceData:修改操作后更新recordSet中相应记录
37: self.removeData = options.removeData;
38: self.replaceData = options.replaceData;
39:
40: //Search按钮
41: self.search = function () {
42: self.orderBy(self.defaultOrderBy);
43: self.isAsc(true);
44: self.pageIndex(1);
45: $.ajax(
46: {
47: url: self.dataQueryUrlAccessor(self),
48: type: "GET",
49: success: function (result) {
50: self.recordSet(result.Data);
51: self.totalPages(result.TotalPages);
52: self.resetPageNumbders();
53: }
54: });
55: };
56:
57: //Reset按钮
58: self.reset = function () {
59: for (var i = 0; i < self.searchCriteria().length; i++) {
60: self.searchCriteria()[i].value("");
61: }
62: };
63:
64: //获取数据之后根据记录数重置页码
65: self.resetPageNumbders = function () {
66: self.pageNumbers.removeAll();
67: for (var i = 1; i <= self.totalPages(); i++) {
68: self.pageNumbers.push(i);
69: }
70: };
71:
72: //点击表格头部进行排序
73: self.sort = function (header) {
74: if (self.orderBy() == header.value) {
75: self.isAsc(!self.isAsc());
76: }
77: self.orderBy(header.value);
78: self.pageIndex(1);
79: $.ajax(
80: {
81: url: self.dataQueryUrlAccessor(self),
82: type: "GET",
83: success: function (result) {
84: self.recordSet(result.Data);
85: }
86: });
87: };
88:
89: //点击页码获取当前页数据
90: self.turnPage = function (pageIndex) {
91: self.pageIndex(pageIndex);
92: $.ajax(
93: {
94: url: self.dataQueryUrlAccessor(self),
95: type: "GET",
96: success: function (result) {
97: self.recordSet(result.Data);
98: }
99: });
100: };
101:
102: //点击Add按钮弹出“添加数据”对话框
103: self.onDataAdding = function () {
104: $.ajax(
105: {
106: url: self.dataAddUrlAccessor(self),
107: type: "GET",
108: success: function (result) {
109: self.dialogContent(result);
110: self.dialog.modal("show");
111: }
112: });
113: };
114:
115: //点击“添加数据”对话框的Save按钮关闭对话框,并将添加的记录插入recordSet
116: self.onDataAdded = function (data) {
117: self.dialog.modal("hide");
118: self.recordSet.unshift(data);
119: };
120:
121: //点击Update按钮弹出“修改数据”对话框
122: self.onDataUpdating = function (data) {
123: $.ajax(
124: {
125: url: self.dataUpdateAccessor(data, self),
126: type: "GET",
127: success: function (result) {
128: self.dialogContent(result);
129: self.dialog.modal("show");
130: }
131: });
132: };
133:
134: //点击“修改数据”对话框的Save按钮关闭对话框,并修改recordSet中的数据
135: self.onDataUpdated = function (data) {
136: self.dialog.modal("hide");
137: self.replaceData(data, self);
138: };
139:
140: //点击Delete按钮删除当前记录
141: self.onDataDeleting = function (data) {
142: $.ajax(
143: {
144: url: self.dataDeleteAccessor(data,self),
145: type: "GET",
146: success: function (result) {
147: self.removeData(result, self);
148: }
149: });
150: };
151: }
四、Controller的定义
目前我们公共的View已经定义好了,我们来看看在具体的页面中的绑定如何定义,以及ViewModel如何初始化。我们同样采用一个ASP.NET MVC应用作为例子,模式的场景就是上图中演示的“联系人管理”,如下所示的是表示联系人的Contact类型的定义:
1: public class Contact
2: {
3: [Required]
4: public string Id { get; set; }
5:
6: [Required]
7: public string FirstName { get; set; }
8:
9: [Required]
10: public string LastName { get; set; }
11:
12: [Required]
13: [DataType(DataType.EmailAddress)]
14: public string EmailAddress { get; set; }
15:
16: [Required]
17: [DataType(DataType.PhoneNumber)]
18: public string PhoneNo { get; set; }
19: }
如下所示的是Controller的定义,联系人管理页面通过默认的Action方法Index呈现出来,在View中实现CRUD操作的Ajax请求的目标Action方法也定义其中。用于获取数据的GetContacts方法不仅仅在用户点击“Search”按钮时被调用,实际上用户点击页码获取当前页数据,以及点击表格标头针对某个字段进行排序的时候调用的也是这个方法。该方法返回一个JSON对象,其Data属性返回具体的数据(针对指定的页码),而用于客户端重置页码的TotalPages属性表示总页数,在这里每页记录数设置为2。
1: public class HomeController : Controller
2: {
3: public const int PageSize = 2;
4:
5: private static List<Contact> contacts = new List<Contact>
6: {
7: new Contact{Id = "001", FirstName = "San", LastName = "Zhang", EmailAddress = "zhangsan@gmail.com", PhoneNo="123"},
8: new Contact{Id = "002", FirstName = "Si", LastName = "Li", EmailAddress = "zhangsan@gmail.com", PhoneNo="456"},
9: new Contact{Id = "003", FirstName = "Wu", LastName = "Wang", EmailAddress = "wangwu@gmail.com", PhoneNo="789"}
10: };
11:
12: public ActionResult Index()
13: {
14: return View();
15: }
16:
17: public ActionResult GetContacts(string firstName, string lastName, string orderBy, int pageIndex=1, bool isAsc = true)
18: {
19: IEnumerable<Contact> result = from contact in contacts
20: where (string.IsNullOrEmpty(firstName) || contact.FirstName.ToLower().Contains(firstName.ToLower()))
21: && (string.IsNullOrEmpty(lastName) || contact.LastName.ToLower().Contains(lastName.ToLower()))
22: select contact;
23: int count = result.Count();
24: int totalPages = count / PageSize + (count % PageSize > 0 ? 1 : 0);
25: result = result.Sort(orderBy, isAsc).Skip((pageIndex - 1) * PageSize).Take(PageSize);
26: return Json(new { Data = result.ToArray(), TotalPages = totalPages }, JsonRequestBehavior.AllowGet);
27: }
28:
29: public ActionResult Add()
30: {
31: ViewBag.Action = "Add";
32: ViewBag.OnSuccess = "viewModel.onDataAdded";
33: return View("ContactPartial", new Contact { Id = Guid.NewGuid().ToString() });
34: }
35:
36: [HttpPost]
37: public ActionResult Add(Contact contact)
38: {
39: contacts.Add(contact);
40: return Json(contact);
41: }
42:
43: public ActionResult Update(string id)
44: {
45: ViewBag.Action = "Update";
46: ViewBag.OnSuccess = "viewModel.onDataUpdated";
47: return View("ContactPartial", contacts.First(c=>c.Id == id));
48: }
49:
50: [HttpPost]
51: public ActionResult Update(Contact contact)
52: {
53: Contact existing = contacts.First(c=>c.Id == contact.Id);
54: existing.FirstName = contact.FirstName;
55: existing.LastName = contact.LastName;
56: existing.PhoneNo = contact.PhoneNo;
57: existing.EmailAddress = contact.EmailAddress;
58: return Json(contact);
59: }
60:
61: public ActionResult Delete(string id)
62: {
63: Contact existing = contacts.First(c=>c.Id == id);
64: contacts.Remove(existing);
65: return Json(existing,JsonRequestBehavior.AllowGet);
66: }
67: }
针对HTTP-GET请求的Add和Update方法返回的是一个ViewResult,换句话说客户端通过Ajax请求最终得到的结果是相应的HTML。客户端最终将HTML作为对话框的内容显示出来,就是我们看到的“联系人编辑”对话框。两个方法呈现的都是一个名为ContactPartial的分部View,从如下定义可以看出这是一个Model类型为Contact的强类型View,Contact对象以编辑模式呈现在一个以Ajax方式提交的表单中。由于数据添加和数据更新操作针对不同的目标Action,而且提交之后回调的JavaScript函数也不一样,两者以ViewBag的形式(ViewBag.Action和ViewBag.OnSuccess)来动态设置。
1: @model Contact
2: @{
3: Layout = null;
4: }
5: @using (Ajax.BeginForm((string)ViewBag.Action , "Home", null,
new AjaxOptions { HttpMethod = "Post", OnSuccess = (string)ViewBag.OnSuccess },
new { @class = "form-horizontal" }))
6: {
7: <div class="modal-header">
8: <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
9: <h3>Detail</h3>
10: </div>
11: <div class="modal-body">
12: <div class="control-group">
13: @Html.HiddenFor(model=>model.Id)
14: @Html.LabelFor(model=>model.FirstName,new{@class="control-label"})
15: <div class="controls">
16: @Html.EditorFor(model => model.FirstName)
17: @Html.ValidationMessageFor(model => model.FirstName)
18: </div>
19: </div>
20:
21: <div class="control-group">
22: @Html.LabelFor(model=>model.LastName,new{@class="control-label"})
23: <div class="controls">
24: @Html.EditorFor(model => model.LastName)
25: @Html.ValidationMessageFor(model => model.LastName)
26: </div>
27: </div>
28:
29: <div class="control-group">
30: @Html.LabelFor(model=>model.EmailAddress,new{@class="control-label"})
31: <div class="controls">
32: @Html.EditorFor(model => model.EmailAddress)
33: @Html.ValidationMessageFor(model => model.EmailAddress)
34: </div>
35: </div>
36:
37: <div class="control-group">
38: @Html.LabelFor(model=>model.PhoneNo,new{@class="control-label"})
39: <div class="controls">
40: @Html.EditorFor(model => model.PhoneNo)
41: @Html.ValidationMessageFor(model => model.PhoneNo)
42: </div>
43: </div>
44: </div>
45:
46: <div class="modal-footer">
47: <a href="#" class="btn" data-dismiss="modal">Close</a>
48: <input type="submit" class="btn btn-primary" value="Save" />
49: </div>
50: }
五、View的定义
我们最终来看看作为“联系人管理”页面的Index.cshtml的定义,由于大部分内容都可以与ViewModel的成员进行绑定,所以我们可以将它们通通定义在Layout之中,所以Index.cshtml的定义是非常少的。如下面的代码片断所示,HTML部分只包含针对Contact对象4个属性的绑定而已,因为ViewModel不包括具体数据类型相关的属性定义。对于JS部分,我们指定相应的options创建了一个具体的ViewModel对象并调用ko的applyBindings方法应用到当前页中。options指定的内容包括具体的title、searchCriteria、headers、defaultOrderBy和四个用于获取CRUD操作地址的函数。
1: <td data-bind="text: FirstName"></td>
2: <td data-bind="text: LastName"></td>
3: <td data-bind="text: EmailAddress"></td>
4: <td data-bind="text: PhoneNo"></td>
5:
6: @section Script
7: {
8: <script type="text/javascript">
9: var options = {
10: title: "Maintain Contacts",
11: searchCriteria: [
12: { displayText: "First Name", value: ko.observable() },
13: { displayText: "Last Name", value: ko.observable() }
14: ],
15: headers: [
16: { displayText: "First Name", value: "FirstName", width: "auto" },
17: { displayText: "Last Name", value: "LastName", width: "auto" },
18: { displayText: "Email Address", value: "EmailAddress", width: "auto" },
19: { displayText: "Phone No.", value: "PhoneNo", width: "auto" },
20: { displayText: "", value: "", width: "auto" }
21: ],
22: defaultOrderBy: "FirstName",
23:
24: dataQueryUrlAccessor: function (viewModel) {
25: return appendQueryString('@Url.Action("GetContacts")', {
26: firstName : viewModel.searchCriteria()[0].value(),
27: lastName : viewModel.searchCriteria()[1].value(),
28: pageIndex : viewModel.pageIndex(),
29: orderBy : viewModel.orderBy(),
30: isAsc : viewModel.isAsc()
31: });
32: },
33:
34: dataAddUrlAccessor: function () { return '@Url.Action("Add")'; },
35: dataUpdateAccessor: function (data) { return appendQueryString('@Url.Action("Update")', { id: data.Id }); },
36: dataDeleteAccessor: function (data) { return appendQueryString('@Url.Action("Delete")', { id: data.Id }); },
37:
38: replaceData: function (data, viewModel) {
39: for (var i = 0; i < viewModel.recordSet().length; i++) {
40: var existing = viewModel.recordSet()[i];
41: if (existing.Id == data.Id) {
42: viewModel.recordSet.replace(existing, data);
43: break;
44: }
45: }
46: },
47:
48: removeData: function (data, viewModel) {
49: viewModel.recordSet.remove(function (c) {
50: return c.Id == data.Id;
51: });
52: }
53: };
54:
55: var viewModel = new ViewModel(options);
56: ko.applyBindings(viewModel);
57: </script>
58: }
六、_Layout.cshtml定义
所有能够共享的内容都被定义在如下所示的布局文件中,我们简单地分析一下每个部分具体和ViewModel的哪些成员绑定:
- 作为查询条件的标签和文本框(简单起见,这里只考虑了这一种输入元素类型)与ViewModel的searchCriteria进行绑定,集合元素包含标签(displayText)和对应的值(value)。
- Search、Reset和Add按钮的Click事件则和ViewModel的search、reset和onDataAdding方法进行绑定。
- 与表格头部链接绑定的是ViewModel的headers,headers集合的元素包含显示文字(displayText)、对应的排序字段名(value)和宽度(width)。
- 对于表格头部的每一列,我们还通过KO的visible绑定设置了表示当前排序列和排序方向的图标(<i class="icon-circle-arrow-up" >和<i class="icon-circle-arrow-down" >)。
- 表示获取数据的表格主体部分与ViewModel的recordSet绑定。
- 每个记录后的Update和Delete链接的Click事件与ViewModel的onDataUpdating和onDataDeleting方法绑定。
- 页码列表和ViewModel的pageNumbers绑定,当前页的CSS(.selected)利用ViewModel的pageIndex来设置。
- 表示弹出对话框<div>的内容和ViewModel的dialogContent绑定。
1: <!DOCTYPE html>
2: <html>
3: ...
4: <body>
5: <div class="form-search">
6: <fieldset>
7: <legend data-bind="text: title"></legend>
8: <span class="pull-left">
9: <!--ko foreach: searchCriteria-->
10: <label class="control-label" data-bind="text: displayText"></label>
11: <input class="search-query input-medium" data-bind="value: value" />
12: <!--/ko-->
13: <a href="#" data-bind = "click: search" class="btn btn-primary">Search</a>
14: <a href="#" data-bind = "click: reset" class="btn">reset</a>
15: </span>
16: <span class="pull-right">
17: <a href="#" data-bind = "click: onDataAdding" class="btn btn-primary">Add</a>
18: </span>
19: </fieldset>
20: </div>
21:
22: <table class="table table-striped table-bordered table-condensed">
23: <thead>
24: <tr data-bind="foreach: headers">
25: <th data-bind="style: {width: width}" >
26: <a href="#" data-bind="text: displayText, click: $root.sort"></a>
27: <i class="icon-circle-arrow-up" data-bind="visible: value == $root.orderBy() && $root.isAsc()"> </i>
28: <i class="icon-circle-arrow-down" data-bind="visible: value == $root.orderBy() && !$root.isAsc()" ></i>
29: </th>
30: </tr>
31: </thead>
32: <tbody data-bind="foreach: recordSet">
33: <tr>
34: @RenderBody()
35: <td>
36: <a href="#" data-bind="click: $root.onDataUpdating">Update</a>
37: <a href="#" data-bind="click: $root.onDataDeleting">Delete</a>
38: </td>
39: </tr>
40: </tbody>
41: </table>
42:
43: <div class="pagination pagination-centered">
44: <ul data-bind="foreach: pageNumbers">
45: <li data-bind="css: {selected: $index() == $root.pageIndex() - 1}">
46: <a href="#" data-bind="text: $data, click: $root.turnPage" ></a>
47: </li>
48: </ul>
49: </div>
50: <div class="modal fade hide" id="dialog" data-backdrop ="static" data-bind="html:dialogContent"></div>
51: @RenderSection("Script")
52: </body>
53: </html>