閱讀400 返回首頁    go 阿裏雲 go 技術社區[雲棲]


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">&times;</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{@})
  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{@})
  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{@})
  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{@})
  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>

作者:蔣金楠
微信公眾賬號:大內老A
微博:www.weibo.com/artech
如果你想及時得到個人撰寫文章以及著作的消息推送,或者想看看個人推薦的技術資料,可以掃描左邊二維碼(或者長按識別二維碼)關注個人公眾號(原來公眾帳號蔣金楠的自媒體將會停用)。
本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁麵明顯位置給出原文連接,否則保留追究法律責任的權利。
原文鏈接

最後更新:2017-10-25 16:04:06

  上一篇:go  Ajax請求過程中顯示“進度”的簡單實現
  下一篇:go  一個簡易版的T4代碼生成"框架"