加入收藏 | 设为首页 | 会员中心 | 我要投稿 李大同 (https://www.lidatong.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 编程开发 > asp.Net > 正文

MVVM(Knockout.js)的新尝试:多个Page,一个ViewModel

发布时间:2020-12-16 09:10:04 所属栏目:asp.Net 来源:网络整理
导读:对于面向数据的Web应用来说,MVVM模式是一项不错的选择,它借助JS框架提供的“绑定”机制是我们无需过多关注UI(HTML)的细节,只需要操作绑定的数据源。MVVM最早被微软应用于WPF/SL的开发,所以针对Web的MVVM框架来说,Knockout.js(以下简称KO)无疑是“根

对于面向数据的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:         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的哪些成员绑定:

  • 作为查询条件的标签和文本框(简单起见,这里只考虑了这一种输入元素类型)与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 "icon-circle-arrow-down" >)。
  • 表示获取数据的表格主体部分与ViewModel的recordSet绑定。
  • 每个记录后的Update和Delete链接的Click事件与ViewModel的onDataUpdating和onDataDeleting方法绑定。
  • 页码列表和ViewModel的pageNumbers绑定,当前页的CSS(.selected)利用ViewModel的pageIndex来设置。
  • 表示弹出对话框<div>的内容和ViewModel的dialogContent绑定。