都淑贞 发表于 2025-5-29 18:49:11

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元素的事件(比如某个的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实现的。
   3:   public const int PageSize = 2;{function ViewModel(options) {   3:   public const int PageSize = 2;    var self = this;   3:     4:   //标题、数据集、弹出对话框和内容(HTML)   5:   self.title          = ko.observable(options.title);   6:   self.recordSet      = ko.observableArray();   7:   self.dialogContent= ko.observable();19: }self.dialog         = options.dialogId ? $("#" + options.dialogId) : $("#dialog");   9:  122:   self.onDataUpdating = function (data) {//排序 123:         $.ajax(//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);71:  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);   4:   public string Id { get; set; }self.isAsc(true);16:      self.pageIndex(1);45:         $.ajax(   7:   public string FirstName { get; set; }      {48:   }             url: self.dataQueryUrlAccessor(self),   9:         type: "GET",10:   public string LastName { get; set; }          success: function (result) {51:   public ActionResult Update(Contact contact)      self.recordSet(result.Data);51:               self.totalPages(result.TotalPages);54:         existing.FirstName = contact.FirstName;      Contact existing = contacts.First(c=>c.Id == contact.Id);            self.resetPageNumbders();54:         existing.FirstName = contact.FirstName;            }54:         });55:   };56:  57:   //Reset按钮58:   self.reset = function () {59:         for (var i = 0; i < self.searchCriteria().length; i++) {60:             self.searchCriteria().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");针对HTTP-GET请求的Add和Update方法返回的是一个ViewResult,换句话说客户端通过Ajax请求最终得到的结果是相应的HTML。客户端最终将HTML作为对话框的内容显示出来,就是我们看到的“联系人编辑”对话框。两个方法呈现的都是一个名为ContactPartial的分部View,从如下定义可以看出这是一个Model类型为Contact的强类型View,Contact对象以编辑模式呈现在一个以Ajax方式提交的表单中。由于数据添加和数据更新操作针对不同的目标Action,而且提交之后回调的JavaScript函数也不一样,两者以ViewBag的形式(ViewBag.Action和ViewBag.OnSuccess)来动态设置。
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:  71:  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: }   3:   public const int PageSize = 2;{public class Contact   3:   public const int PageSize = 2;{   3:      4:   public string Id { get; set; }16:      6:      7:   public string FirstName { get; set; }   8:     9:   10:   public string LastName { get; set; }11:   
五、View的定义

我们最终来看看作为“联系人管理”页面的Index.cshtml的定义,由于大部分内容都可以与ViewModel的成员进行绑定,所以我们可以将它们通通定义在Layout之中,所以Index.cshtml的定义是非常少的。如下面的代码片断所示,HTML部分只包含针对Contact对象4个属性的绑定而已,因为ViewModel不包括具体数据类型相关的属性定义。对于JS部分,我们指定相应的options创建了一个具体的ViewModel对象并调用ko的applyBindings方法应用到当前页中。options指定的内容包括具体的title、searchCriteria、headers、defaultOrderBy和四个用于获取CRUD操作地址的函数。
12:   13:   14:   public string EmailAddress { get; set; }15:  16:   17:   18:   public string PhoneNo { get; set; }19: }   3:   public const int PageSize = 2;{public class HomeController : Controller 
六、_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来设置。
[*]表示弹出对话框的内容和ViewModel的dialogContent绑定。
   3:   public const int PageSize = 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"}, 119:   };         19: }            10:   }; 122:   self.onDataUpdating = function (data) {                      123:         $.ajax(                      124:         {                              14:         return View();15:   } 127:             success: function (result) {    17:   public ActionResult GetContacts(string firstName, string lastName, string orderBy, int pageIndex=1, bool isAsc = true)18:   { 130:             }    131:         });    21:                         && (string.IsNullOrEmpty(lastName) || contact.LastName.ToLower().Contains(lastName.ToLower()))22:                      select contact;23:      int count = result.Count(); 135:   self.onDataUpdated = function (data) {25:      result = result.Sort(orderBy, isAsc).Skip((pageIndex - 1) * PageSize).Take(PageSize);26:      return Json(new { Data = result.ToArray(), TotalPages = totalPages }, JsonRequestBehavior.AllowGet); 138:   };             139:                29:   public ActionResult Add() 141:   self.onDataDeleting = function (data) {            31:         ViewBag.Action = "Add"; 143:         {33:         return View("ContactPartial", new Contact { Id = Guid.NewGuid().ToString() });34:   } 146:             success: function (result) {      @RenderBody() 147:               self.removeData(result, self);            37:   public ActionResult Add(Contact contact) 149:         });               Delete39:         contacts.Add(contact); 151: }    41:   }43:   public ActionResult Update(string id)85:             }   4:   public string Id { get; set; }16:            
46:         ViewBag.OnSuccess = "viewModel.onDataUpdated";   7:   public string FirstName { get; set; }                  48:   }   9:         
10:   public string LastName { get; set; }      51:   public ActionResult Update(Contact contact)54:         existing.FirstName = contact.FirstName;      Contact existing = contacts.First(c=>c.Id == contact.Id);{54:         existing.FirstName = contact.FirstName;      Contact existing = contacts.First(c=>c.Id == contact.Id);54:         existing.FirstName = contact.FirstName;
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: MVVM(Knockout.js)的新尝试:多个Page,一个ViewModel