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模式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: 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: 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: 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: 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: 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,
145: type: 146: success: function (result) { 147: self.removeData(result,self);
148: }
149: });
150: };
151: }
? 四、Controller的定义目前我们公共的View已经定义好了,我们来看看在具体的页面中的绑定如何定义,以及ViewModel如何初始化。我们同样采用一个ASP.NET MVC应用作为例子,模式的场景就是上图中演示的“联系人管理”,如下所示的是表示联系人的Contact类型的定义: class HomeController : Controller
3: 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: "002",1)">"Si",1)">"Li",1)">"456"}, 9: "003",1)">"Wu",1)">"Wang",1)">"wangwu@gmail.com",1)">"789"} 10: };
public ActionResult Index()
13: {
14: return View(); 15: }
public ActionResult GetContacts(string firstName,string lastName,1)">string orderBy,1)">int pageIndex=1,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: }
public ActionResult Add()
30: {
31: ViewBag.Action = "Add"; 32: ViewBag.OnSuccess = "viewModel.onDataAdded"; 33: return View("ContactPartial",1)">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: 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: <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> 6: @section Script 7: {
8: <script type="text/javascript"> 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: "FirstName",width: "auto" }, 17: { displayText: "LastName", 18: { displayText: "Email Address",1)">"EmailAddress", 19: { displayText: "Phone No.",1)">"PhoneNo", 20: { displayText: "",1)">"auto" } 21: ],
22: defaultOrderBy: 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) { '@Url.Action("Update")',{ id: data.Id }); }, 36: dataDeleteAccessor: '@Url.Action("Delete")', 37:?
38: replaceData: function (data,viewModel) { 39: 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: 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的哪些成员绑定:
相关内容
推荐文章
站长推荐
热点阅读
|