我還在攜程的作業務的時候,每一個看似簡單的移動頁面背後每每會隱藏5個以上的數據請求,其中最過複雜的當屬機票與酒店的訂單填寫業務代碼javascript
這裏先看看比較「簡單」的機票代碼:css
而後看看稍微複雜的酒店業務邏輯:html
機票一個頁面的代碼量達到了5000行代碼,而酒店的代碼居然超過了8000行,這裏還不包括模板(html)文件!!!前端
而後初略看了機票的代碼,就該頁面可能發生的接口請求有19個之多!!!而酒店的的交互DOM事件基本多到了使人髮指的地步:java
固然,機票團隊的交互DOM事件已經多到了我筆記本不能截圖了:ios
1 events: { 2 'click .js_check_invoice_type': 'checkInvoiceType', //切換髮票類型 3 'click .flight-hxtipshd': 'huiXuanDesc', //惠選說明 4 'click .js_ListReload': 'hideNetError', 5 'click #js_return': 'backAction', //返回列表頁 6 'click div[data-rbtType]': 'showRebate', //插爛返現說明 7 'click #paybtn .j_btn': 'beforePayAction', //提交訂單 //flightDetailsStore, passengerQueryStore, mdStore, postAddressStorage, userStore, flightDeliveryStore 8 'click .flight-loginbtn2': 'bookLogin', //登陸 9 'input #linkTel': 'setContact', //保存用戶輸入的聯繫人 10 'click #addPassenger .flight-labq': 'readmeAction',//姓名幫助 11 'click .jsDelivery': 'selDelivery', //選擇配送方式 12 'click #jsViewCoupons': 'viewCoupons', //查看消費券使用說明 //flightDetailsStore 13 // 'click .j_refundPolicy': 'fanBoxAction', //查看返現信息 14 //'click .flight-bkinfo-tgq .f-r': 'tgBoxAction', //查看退改簽 15 'click .js_del_tab': 'showDelListUI', //配送方式 16 // 'click .js_del_cost .flight-psf i': 'selectPaymentType', // 選擇快遞費用方式 17 'click #js_addrList': 'AddrListAction', //選擇地址 18 'click #date-picker': 'calendarAction', //取票日期 //airportDeliveryStore 19 'click #done-address': 'zqinairselect', //取票櫃檯 20 'click #selectCity': 'selectCityAction', //選擇城市 21 'click #date-zqtime': 'showZqTimeUI', //取票時間 //airportDeliveryStore 22 'click #jsinsure': 'viewInsure', //保險說明 23 'click #js_invoice_title': 'inTitleChangeWrp', //發票擡頭更改 // userStore, flightOrderInfoInviceStore, flightOrderStore //don't move outside 24 'click #js_invoice_title_div': 'inTitleChangeWrp', 25 'click .flight-icon-arrrht': 'showinTitleList', //‘+’號,跳轉發票擡頭列表 //userStore, invoiceURLStore 26 'focusin #linkTel': 'telInput', 27 'focusout #linkTel': 'telInputFinish', 28 'touchstart input': 'touchStartAction', // 處理Android手機上點擊不靈敏問題 29 'click #package .flight-arrrht': 'packageSelect', 30 'focusin input': 'hideErrorTips', 31 'click #dist_text_div': 'hideErrorTips', 32 'click .j_PackageNotice': 'toggletips', 33 'click .j_AnnouncementNotice': 'toggleNotice', 34 'click #travalPackageDesc': 'forwardToTravalPackage', //don't move into child modules 35 'click #airInsureDesc': 'showAirInsureDesc', 36 'click #paybtn': 'orderDetailAction',//價格明細 37 'click .J_retriveVerifyCodeBtn': 'getVerifyCode', 38 'click .J_toPay': 'toPayAction', 39 'click .J_closeVerifyCode': 'closeVerifyCodePopup', 40 'keyup .J_verifyCodePopup input': 'setToPayBtnStatus', 41 'click .js_flight_seat': 'selectRecommendCabin', // 選擇推薦倉位 42 'click .j_changeFlight': 'changeFlightAction', // 推薦航班彈層中更改航班 43 'focusin input:not([type=tel])': 'adjustInputPosition', // iphone5/5s ios8搜狗輸入法遮住input 44 'click .js_addr,#js_addr_div': 'editDeliverAddress',//報銷憑證,詳細地址編輯 45 'click .js_showUserInfo': 'showUserInfo', // add by hkhu v2.5.9 46 'click #logout': 'logout', // add by hkhu v2.5.9 47 'click #gotoMyOrder': 'gotoMyOrder', // add by hkhu v2.5.9 48 'touchstart #logout': function (e) { $(e.currentTarget).addClass('current'); }, 49 'touchstart #gotoMyOrder': function (e) { $(e.currentTarget).addClass('current'); }, 50 'click .js_buddypayConfirm': 'buddypayConfirmed', 51 'click .js_pickupTicket': 'viewPickUp', //261接送機券說明 52 'click .flt-bking-logintips': 'closelogintips'//關閉接送機券提示 53 },
就這種體量的頁面,若是須要迭代需求、打BUG補丁的話,我敢確定的說,一個BUG的修復很容易引發其它BUG,而上面還僅僅是其中一個業務頁面,後面還有強大而複雜的前端框架呢!如此複雜的前端代碼維護工做可不是開玩笑的!git
PS:說道此處,不得不爲攜程的前端水平點個贊,業內少有的單頁應用,一套代碼H5&Hybrid同時運行不說,還解決了SEO問題,嗯,很贊。github
如何維護這種頁面,如何設計這種頁面是咱們今天討論的重點,而上述是攜程合併後的代碼,他們兩個團隊的設計思路不便在此處展開。web
今天,我這裏提供一個思路,認真閱讀此文可能在如下方面對你有所幫助:面試
1 ① 如何將一個複雜的頁面拆分爲一個個獨立的頁面組件模塊 2 ② 如何將分拆後的業務組件模塊從新合爲一個完整的頁面 3 ③ 從重構角度看組件化開發帶來的好處
4 ④ 從前端優化的角度看待組件化開發
文中是我我的的一些框架&業務開發經驗,但願對各位有用,也但願各位多多支持討論,指出文中不足以及提出您的一些建議。
因爲該項目涉及到了項目拆分與合併,基本屬於一個完整的前端工程化案例了,因此將之放到了github上:https://github.com/yexiaochai/mvc
其中工程化一塊的代碼,後續會由另外一位小夥伴持續更新,若是該文對各位有所幫助的話請各位給項目點個贊、加顆星:)
我相信若是是中級水平的前端,認真閱讀此文必定會對你有一點幫助滴。
http://yexiaochai.github.io/mvc/webapp/bus/list.html
代碼倉促,可能會有BUG哦:)
代碼地址:https://github.com/yexiaochai/mvc/
由於訂單填寫頁通常有密度,我這裏挑選相對複雜而又沒有密度的產品列表頁來作說明,其中框架以及業務代碼已經作過抽離,不會包含敏感信息,一些優化後續會同步到開源blade框架中去。
咱們這裏列表頁的首屏頁面以下:
簡單來講組成以下:
① 框架級別UI組件UIHeader,頭部組件
② 點擊日期會出框架級別UI,日曆組件UICalendar
③ 點擊出發時段、出發汽車站、到達汽車站,皆會出框架級別UI
④ header下面的日期工具欄須要做爲獨立的業務模塊
⑤ 列表區域能夠做爲獨立的業務模塊,可是與主業務靠太近,不太適合
⑥ 出發時段、出發汽車站、到達汽車站皆是獨立的業務模塊
一個頁面被咱們拆分紅了若干個小模塊,咱們只須要關注模塊內部的交互實現,而包括業務模塊的通訊,業務模塊的樣式,業務模塊的重用,暫時有如下約定:
① 單個頁面的樣式所有寫在一個文件中,好比list裏面全部模塊對應的是list.css
② 模塊之間採用觀察者模式觀察數據實體變化,以數據爲媒介通訊
③ 通常來講業務模塊不可重用,若是有重用的模塊,須要分離到common目錄中,由於咱們今天不考慮common重用,這塊暫時不予理睬
這裏有些朋友可能認爲單個模塊的CSS以及image也應該參與獨立,我這裏不太贊成,業務頁面樣式粒度太細的話會給設計帶來不小的麻煩,這裏再以通俗的話來講:尼瑪,我CSS功底通常,拆分的太細,對我來講難度過高......
很差的這個事情實際上是相對的,由於很差的作法通常是比較簡單的作法,對於一次性項目或者業務比較簡單的頁面來講反而是好的作法,好比這裏的業務邏輯能夠這樣寫:
1 define(['AbstractView', 'list.layout.html', 'list.html', 'BusModel', 'BusStore', 'UICalendarBox', 'UILayerList', 'cUser', 'UIToast'], 2 function (AbstractView, layoutHtml, listTpl, BusModel, BusStore, UICalendarBox, UILayerList, cUser, UIToast) { 3 return _.inherit(AbstractView, { 4 propertys: function ($super) { 5 $super(); 6 //一堆基礎屬性定義 7 //...... 8 //交互業務邏輯 9 this.events = { 10 'click .js_pre_day': 'preAction', //點擊前一天觸發 11 'click .js_next_day': 'nextAction', //點擊後一天觸發 12 'click .js_bus_list li': 'toBooking', //點擊列表項目觸發 13 'click .js_show_calendar': 'showCalendar', //點擊日期項出日曆組件 14 'click .js_show_setoutdate': 'showSetoutDate', //篩選出發時段 15 'click .js_show_setstation': 'showStation', //篩選出發站 16 'click .js_show_arrivalstation': 'showArrivalStation', //篩選到達站 17 //迭代需求,增長其它頻道入口 18 'click .js-list-tip': function () {} 19 }; 20 }, 21 //初始化頭部標題欄 22 initHeader: function (t) { }, 23 //首次dom渲染後,初始化後續會用到的全部dom元素,以避免重複獲取 24 initElement: function () {}, 25 showSetoutDate: function () {}, 26 showStation: function () {}, 27 showArrivalStation: function () {}, 28 showCalendar: function () {}, 29 preAction: function (e) {}, 30 nextAction: function () {}, 31 toBooking: function (e) {}, 32 listInit: function () {}, 33 bindScrollEvent: function () {}, 34 unbindScrollEvent: function () { }, 35 addEvent: function () { 36 this.on('onShow', function () { 37 //當頁面渲染結束,須要作的初始化操做,好比渲染頁面 38 this.listInit(); 39 //...... 40 }); 41 this.on('onHide', function () { 42 this.unbindScrollEvent(); 43 }); 44 } 45 }); 46 });
根據以前的經驗,若是僅僅包含這些業務邏輯,這樣寫代碼問題不是很是大,代碼量預計在800行左右,可是爲了完成完整的業務邏輯,咱們這裏立刻產生了新的需求。
由於我這裏的班次列表,最初是沒有URL參數,因此根本沒法產出班次列表,頁面上全部組件模塊都是擺設,因而這裏新增一個需求:
當url沒有出發-到達相關參數信息時,默認彈出出發城市到達城市選擇框
因而,咱們這裏會新增一個簡單的彈出層:
這個看似簡單的彈出層,背後卻隱藏了一個巨大的陷阱,由於點擊出發或者到達時會出城市列表,而城市列表自己就是一個比較複雜的業務:
因而頁面的組成發生了改變:
① 自己業務邏輯約800行代碼
② 新增出發到達篩選彈出層
③ 出發城市頁面,預計300行代碼
而彈出層的新增對業務自己形成了深遠的影響,原本url是不帶有業務參數的,可是點擊了彈出層的肯定按鈕,須要改變URL參數,而且刷新自己頁面的數據,因而簡單的一個彈出層新增直接將頁面的複雜程度提高了一倍。
因而該頁面代碼輕輕鬆鬆破千了,後續需求迭代js代碼量破2000僅僅是時間問題,到時候維護便複雜了,頁面複雜無規律的DOM操做將會令你焦頭爛額,這個時候組件化開發的優點便得以體現了,因而下面進入組件化開發的設計。
此次的代碼依賴於blade骨架,包括:
① MVC模塊,完成經過url獲取正確的page控制器,從而經過view.js完成渲染頁面的功能
② 數據請求模塊,完成接口請求
全站依賴於javascript的繼承功能,詳情見:【一次面試】再談javascript中的繼承,若是不太瞭解面向對象編程,文中代碼可能會有點吃力,也請各位多多瞭解。
整體業務架構如圖:
框架架構圖:
下面分別介紹下各個模塊,幫助各位在下文中能更好的瞭解代碼,首先是基本MVC的介紹,這裏請參考我這篇文章:簡單的MVC介紹
其實控制器可謂是變化萬千的一個對象,對於服務器端來講,控制器完成的功能是將本次請求分發到具體的代碼模塊,由代碼模塊處理後返回字符串給前端;
對於請求已經來到瀏覽器的前端來講,根據此次請求URL(或者其它判斷條件),判斷該次請求應該由哪一個前端js控制器執行,這是前端控制器乾的事情;
當真的此次處理邏輯進入一個具體的page後,這個page事實上也能夠做爲一個控制器存在......
咱們這裏的控制器,主要完成根據當前請求實例化View的功能,而且會提供一些view級別但願單例使用的接口:
1 define([ 2 'UIHeader', 3 'UIToast', 4 'UILoading', 5 'UIPageView', 6 'UIAlert' 7 ], function (UIHeader, UIToast, UILoading, UIPageView, UIAlert) { 8 9 return _.inherit({ 10 propertys: function () { 11 //view搜索目錄 12 this.viewRootPath = 'views/'; 13 14 //默認view 15 this.defaultView = 'index'; 16 17 //當前視圖路徑 18 this.viewId; 19 this.viewUrl; 20 21 //視圖集 22 this.views = {}; 23 24 //是否開啓單頁應用 25 // this.isOpenWebapp = _.getHybridInfo().platform == 'baidubox' ? true : false; 26 this.isOpenWebapp = false; 27 28 this.viewMapping = {}; 29 30 //UIHeader須要釋放出來 31 this.UIHeader = UIHeader; 32 33 this.interface = [ 34 'forward', 35 'back', 36 'jump', 37 'showPageView', 38 'hidePageView', 39 'showLoading', 40 'hideLoading', 41 'showToast', 42 'hideToast', 43 'showMessage', 44 'hideMessage', 45 'showConfirm', 46 'hideConfirm', 47 'openWebapp', 48 'closeWebapp' 49 ]; 50 51 }, 52 53 initialize: function (options) { 54 this.propertys(); 55 this.setOption(options); 56 this.initViewPort(); 57 this.initAppMapping(); 58 59 //開啓fastclick 60 $.bindFastClick && $.bindFastClick(); 61 62 }, 63 64 setOption: function (options) { 65 _.extend(this, options); 66 }, 67 68 //建立dom結構 69 initViewPort: function () { 70 71 this.d_header = $('#headerview'); 72 this.d_state = $('#js_page_state'); 73 this.d_viewport = $('#main'); 74 75 //實例化全局使用的header,這裏好像有點不對 76 this.header = new this.UIHeader({ 77 wrapper: this.d_header 78 }); 79 80 //非共享資源,這裏應該引入app概念了 81 this.pageviews = {}; 82 this.toast = new UIToast(); 83 this.loading = new UILoading(); 84 this.alert = new UIAlert(); 85 this.confirm = new UIAlert(); 86 }, 87 88 openWebapp: function () { 89 this.isOpenWebapp = true; 90 }, 91 92 closeWebapp: function () { 93 this.isOpenWebapp = false; 94 }, 95 96 showPageView: function (name, _viewdata_, id) { 97 var view = null, k, scope = this.curViewIns || this; 98 if (!id) id = name; 99 if (!_.isString(name)) return; 100 // for (k in _viewdata_) { 101 // if (_.isFunction(_viewdata_[k])) _viewdata_[k] = $.proxy(_viewdata_[k], scope); 102 // } 103 view = this.pageviews[id]; 104 var arr = name.split('/'); 105 var getViewPath = window.getViewPath || window.GetViewPath; 106 if (!view) { 107 view = new UIPageView({ 108 // bug fixed by zzx 109 viewId: arr[arr.length - 1] || name, 110 viewPath: getViewPath ? getViewPath(name) : name, 111 _viewdata_: _viewdata_, 112 onHide: function () { 113 scope.initHeader(); 114 } 115 }); 116 this.pageviews[id] = view; 117 } else { 118 view.setViewData(_viewdata_); 119 } 120 view.show(); 121 122 }, 123 124 hidePageView: function (name) { 125 if (name) { 126 if (this.pageviews[name]) this.pageviews[name].hide(); 127 } else { 128 for (var k in this.pageviews) this.pageviews[k].hide(); 129 } 130 }, 131 132 showLoading: function () { 133 this.loading.show(); 134 }, 135 136 hideLoading: function () { 137 this.loading.hide(); 138 }, 139 140 showToast: function (msg, callback) { 141 this.toast.resetDefaultProperty(); 142 this.toast.content = msg; 143 if (callback) this.toast.hideAction = callback; 144 this.toast.refresh(); 145 this.toast.show(); 146 }, 147 148 hideToast: function () { 149 this.toast.hide(); 150 }, 151 152 showMessage: function (param) { 153 if (_.isString(param)) { 154 param = { content: param }; 155 } 156 157 this.alert.resetDefaultProperty(); 158 this.alert.setOption(param); 159 this.alert.refresh(); 160 this.alert.show(); 161 }, 162 163 hideMessage: function () { 164 this.alert.hide(); 165 }, 166 167 showConfirm: function (params) { 168 if (!params) params = {}; 169 if (typeof params == 'string') { 170 params = { 171 content: params 172 }; 173 } 174 175 this.confirm.resetDefaultProperty(); 176 177 //與showMessage不同的地方 178 this.confirm.btns = [ 179 { name: '取消', className: 'cm-btns-cancel js_cancel' }, 180 { name: '肯定', className: 'cm-btns-ok js_ok' } 181 ]; 182 this.confirm.setOption(params); 183 this.confirm.refresh(); 184 this.confirm.show(); 185 }, 186 187 hideConfirm: function () { 188 this.confirm.hide(); 189 }, 190 191 //初始化app 192 initApp: function () { 193 194 //首次加載不須要走路由控制 195 this.loadViewByUrl(); 196 197 //後面的加載所有要通過路由處理 198 if (this.isOpenWebapp === true) 199 $(window).on('popstate.app', $.proxy(this.loadViewByUrl, this)); 200 201 }, 202 203 loadViewByUrl: function (e) { 204 this.hidePageView(); 205 206 var url = decodeURIComponent(location.href).toLowerCase(); 207 var viewId = this.getViewIdRule(url); 208 209 viewId = viewId || this.defaultView; 210 this.viewId = viewId; 211 this.viewUrl = url; 212 this.switchView(this.viewId); 213 214 }, 215 216 //@override 217 getViewIdRule: function (url) { 218 var viewId = '', hash = ''; 219 var reg = /webapp\/.+\/(.+)\.html/; 220 221 var match = url.match(reg); 222 if (match && match[1]) viewId = match[1]; 223 224 return viewId; 225 }, 226 227 //@override 228 setUrlRule: function (viewId, param, replace, project) { 229 var reg = /(webapp\/.+\/)(.+)\.html/; 230 var url = window.location.href; 231 var match = url.match(reg); 232 var proj = project ? 'webapp/' + project : match[1]; 233 var preUrl = '', str = '', i = 0, _k, _v; 234 //這裏這樣作有點過於業務了 *bug* 235 var keepParam = [ 236 'us' 237 ], p; 238 if (!viewId) return; 239 if (!match || !match[1]) { 240 preUrl = url + '/webapp/bus/' + viewId + '.html'; 241 } else { 242 preUrl = url.substr(0, url.indexOf(match[1])) + proj + viewId + '.html'; ; 243 } 244 245 //特定的參數將會一直帶上去,渠道、來源等標誌 246 for (i = 0; i < keepParam.length; i++) { 247 p = keepParam[i]; 248 if (_.getUrlParam()[p]) { 249 if (!param) param = {}; 250 param[p] = _.getUrlParam()[p]; 251 } 252 } 253 254 i = 0; 255 256 for (k in param) { 257 _k = encodeURIComponent(_.removeAllSpace(k)); 258 _v = encodeURIComponent(_.removeAllSpace(param[k])); 259 if (i === 0) { 260 str += '?' + _k + '=' + _v; 261 i++; 262 } else { 263 str += '&' + _k + '=' + _v; 264 } 265 } 266 267 url = preUrl + str; 268 269 if (this.isOpenWebapp === false) { 270 window.location = url; 271 return; 272 } 273 274 if (replace) { 275 history.replaceState('', {}, url); 276 } else { 277 history.pushState('', {}, url); 278 } 279 280 }, 281 282 switchView: function (id) { 283 284 var curView = this.views[id]; 285 286 //切換前的當前view,立刻會隱藏 287 var tmpView = this.curView; 288 289 if (tmpView && tmpView != curView) { 290 this.lastView = tmpView; 291 } 292 293 //加載view樣式,權宜之計 294 // this.loadViewStyle(id); 295 296 //若是當前view存在,則執行請onload事件 297 if (curView) { 298 299 //若是當前要跳轉的view就是當前view的話便不予處理 300 //這裏具體處理邏輯要改************************************* 301 if (curView == this.curView) { 302 return; 303 } 304 305 this.curView = curView; 306 this.curView.show(); 307 this.lastView && this.lastView.hide(); 308 } else { 309 310 // this.showLoading(); 311 this.loadView(id, function (View) { 312 //每次加載結束將狀態欄隱藏,這個代碼要改 313 // this.hideLoading(); 314 315 this.curView = new View({ 316 viewId: id, 317 refer: this.lastView ? this.lastView.viewId : null, 318 APP: this, 319 wrapper: this.d_viewport 320 }); 321 322 //設置網頁上的view標誌 323 this.curView.$el.attr('page-url', id); 324 325 //保存至隊列 326 this.views[id] = this.curView; 327 328 this.curView.show(); 329 this.lastView && this.lastView.hide(); 330 331 }); 332 } 333 }, 334 335 //加載view 336 loadView: function (path, callback) { 337 var self = this; 338 requirejs([this.buildUrl(path)], function (View) { 339 callback && callback.call(self, View); 340 }); 341 }, 342 343 //override 344 //配置可能會有的路徑擴展,爲Hybrid與各個渠道作適配 345 initAppMapping: function () { 346 // console.log('該方法必須被重寫'); 347 }, 348 349 //@override 350 buildUrl: function (path) { 351 var mappingPath = this.viewMapping[path]; 352 return mappingPath ? mappingPath : this.viewRootPath + '/' + path + '/' + path; 353 }, 354 355 //此處須要一個更新邏輯,好比在index view再點擊到index view不會有反應,下次改************************** 356 forward: function (viewId, param, replace) { 357 if (!viewId) return; 358 viewId = viewId.toLowerCase(); 359 360 this.setUrlRule(viewId, param, replace); 361 this.loadViewByUrl(); 362 }, 363 jump: function (path, param, replace) { 364 var viewId; 365 var project; 366 if (!path) { 367 return; 368 } 369 path = path.toLowerCase().split('/'); 370 if (path.length <= 0) { 371 return; 372 } 373 viewId = path.pop(); 374 project = path.length === 1 ? path.join('') + '/' : path.join(''); 375 this.setUrlRule(viewId, param, replace, project); 376 this.loadViewByUrl(); 377 }, 378 back: function (viewId, param, replace) { 379 if (viewId) { 380 this.forward(viewId, param, replace) 381 } else { 382 if (window.history.length == 1) { 383 this.forward(this.defaultView, param, replace) 384 } else { 385 history.back(); 386 } 387 } 388 } 389 390 }); 391 392 });
這裏屬於框架控制器層面的代碼,與今天的主題不是很是相關,有興趣的朋友能夠詳細讀讀。
這裏的核心是頁面級別的處理,這裏會作比較多的介紹,首先咱們爲全部的業務級View提供了一個繼承的View:
1 define([], function () { 2 'use strict'; 3 4 return _.inherit({ 5 6 _propertys: function () { 7 this.APP = this.APP || window.APP; 8 var i = 0, len = 0, k; 9 if (this.APP && this.APP.interface) { 10 for (i = 0, len = this.APP.interface.length; i < len; i++) { 11 k = this.APP.interface[i]; 12 if (k == 'showPageView') continue; 13 14 if (_.isFunction(this.APP[k])) { 15 this[k] = $.proxy(this.APP[k], this.APP); 16 } 17 else this[k] = this.APP[k]; 18 } 19 } 20 21 this.header = this.APP.header; 22 }, 23 24 showPageView: function (name, _viewdata, id) { 25 this.APP.curViewIns = this; 26 this.APP.showPageView(name, _viewdata, id) 27 }, 28 propertys: function () { 29 //這裏設置UI的根節點所處包裹層 30 this.wrapper = $('#main'); 31 this.id = _.uniqueId('page-view-'); 32 this.classname = ''; 33 34 this.viewId = null; 35 this.refer = null; 36 37 //模板字符串,各個組件不一樣,如今加入預編譯機制 38 this.template = ''; 39 //事件機制 40 this.events = {}; 41 42 //自定義事件 43 //此處須要注意mask 綁定事件先後問題,考慮scroll.radio插件類型的mask應用,考慮組件通訊 44 this.eventArr = {}; 45 46 //初始狀態爲實例化 47 this.status = 'init'; 48 49 this._propertys(); 50 }, 51 52 getViewModel: function () { 53 //假若有datamodel的話,便直接返回,否則便重寫,這裏基本爲了兼容 54 if (_.isObject(this.datamodel)) return this.datamodel; 55 return {}; 56 }, 57 58 //子類事件綁定若想保留父級的,應該使用該方法 59 addEvents: function (events) { 60 if (_.isObject(events)) _.extend(this.events, events); 61 }, 62 63 on: function (type, fn, insert) { 64 if (!this.eventArr[type]) this.eventArr[type] = []; 65 66 //頭部插入 67 if (insert) { 68 this.eventArr[type].splice(0, 0, fn); 69 } else { 70 this.eventArr[type].push(fn); 71 } 72 }, 73 74 off: function (type, fn) { 75 if (!this.eventArr[type]) return; 76 if (fn) { 77 this.eventArr[type] = _.without(this.eventArr[type], fn); 78 } else { 79 this.eventArr[type] = []; 80 } 81 }, 82 83 trigger: function (type) { 84 var _slice = Array.prototype.slice; 85 var args = _slice.call(arguments, 1); 86 var events = this.eventArr; 87 var results = [], i, l; 88 89 if (events[type]) { 90 for (i = 0, l = events[type].length; i < l; i++) { 91 results[results.length] = events[type][i].apply(this, args); 92 } 93 } 94 return results; 95 }, 96 97 createRoot: function (html) { 98 99 //若是存在style節點,而且style節點不存在的時候須要處理 100 if (this.style && !$('#page_' + this.viewId)[0]) { 101 $('head').append($('<style id="page_' + this.viewId + '" class="page-style">' + this.style + '</style>')) 102 } 103 104 //若是具備fake節點,須要移除 105 $('#fake-page').remove(); 106 107 //UI的根節點 108 this.$el = $('<div class="cm-view page-' + this.viewId + ' ' + this.classname + '" style="display: none; " id="' + this.id + '">' + html + '</div>'); 109 if (this.wrapper.find('.cm-view')[0]) { 110 this.wrapper.append(this.$el); 111 } else { 112 this.wrapper.html('').append(this.$el); 113 } 114 115 }, 116 117 _isAddEvent: function (key) { 118 if (key == 'onCreate' || key == 'onPreShow' || key == 'onShow' || key == 'onRefresh' || key == 'onHide') 119 return true; 120 return false; 121 }, 122 123 setOption: function (options) { 124 //這裏能夠寫成switch,開始沒有想到有這麼多分支 125 for (var k in options) { 126 if (k == 'events') { 127 _.extend(this[k], options[k]); 128 continue; 129 } else if (this._isAddEvent(k)) { 130 this.on(k, options[k]) 131 continue; 132 } 133 this[k] = options[k]; 134 } 135 // _.extend(this, options); 136 }, 137 138 initialize: function (opts) { 139 //這種默認屬性 140 this.propertys(); 141 //根據參數重置屬性 142 this.setOption(opts); 143 //檢測不合理屬性,修正爲正確數據 144 this.resetPropery(); 145 146 this.addEvent(); 147 this.create(); 148 149 this.initElement(); 150 151 window.sss = this; 152 153 }, 154 155 $: function (selector) { 156 return this.$el.find(selector); 157 }, 158 159 //提供屬性重置功能,對屬性作檢查 160 resetPropery: function () { }, 161 162 //各事件註冊點,用於被繼承override 163 addEvent: function () { 164 }, 165 166 create: function () { 167 this.trigger('onPreCreate'); 168 //若是沒有傳入模板,說明html結構已經存在 169 this.createRoot(this.render()); 170 171 this.status = 'create'; 172 this.trigger('onCreate'); 173 }, 174 175 //實例化須要用到到dom元素 176 initElement: function () { }, 177 178 render: function (callback) { 179 var data = this.getViewModel() || {}; 180 var html = this.template; 181 if (!this.template) return ''; 182 //引入預編譯機制 183 if (_.isFunction(this.template)) { 184 html = this.template(data); 185 } else { 186 html = _.template(this.template)(data); 187 } 188 typeof callback == 'function' && callback.call(this); 189 return html; 190 }, 191 192 refresh: function (needRecreate) { 193 this.resetPropery(); 194 if (needRecreate) { 195 this.create(); 196 } else { 197 this.$el.html(this.render()); 198 } 199 this.initElement(); 200 if (this.status != 'hide') this.show(); 201 this.trigger('onRefresh'); 202 }, 203 204 /** 205 * @description 組件顯示方法,首次顯示會將ui對象實際由內存插入包裹層 206 * @method initialize 207 * @param {Object} opts 208 */ 209 show: function () { 210 this.trigger('onPreShow'); 211 // //若是包含就不要亂搞了 212 // if (!$.contains(this.wrapper[0], this.$el[0])) { 213 // //若是須要清空容器的話便清空 214 // if (this.needEmptyWrapper) this.wrapper.html(''); 215 // this.wrapper.append(this.$el); 216 // } 217 218 this.$el.show(); 219 this.status = 'show'; 220 221 this.bindEvents(); 222 223 this.initHeader(); 224 this.trigger('onShow'); 225 }, 226 227 initHeader: function () { }, 228 229 hide: function () { 230 if (!this.$el || this.status !== 'show') return; 231 232 this.trigger('onPreHide'); 233 this.$el.hide(); 234 235 this.status = 'hide'; 236 this.unBindEvents(); 237 this.trigger('onHide'); 238 }, 239 240 destroy: function () { 241 this.status = 'destroy'; 242 this.unBindEvents(); 243 this.$root.remove(); 244 this.trigger('onDestroy'); 245 delete this; 246 }, 247 248 bindEvents: function () { 249 var events = this.events; 250 251 if (!(events || (events = _.result(this, 'events')))) return this; 252 this.unBindEvents(); 253 254 // 解析event參數的正則 255 var delegateEventSplitter = /^(\S+)\s*(.*)$/; 256 var key, method, match, eventName, selector; 257 258 // 作簡單的字符串數據解析 259 for (key in events) { 260 method = events[key]; 261 if (!_.isFunction(method)) method = this[events[key]]; 262 if (!method) continue; 263 264 match = key.match(delegateEventSplitter); 265 eventName = match[1], selector = match[2]; 266 method = _.bind(method, this); 267 eventName += '.delegateUIEvents' + this.id; 268 269 if (selector === '') { 270 this.$el.on(eventName, method); 271 } else { 272 this.$el.on(eventName, selector, method); 273 } 274 } 275 276 return this; 277 }, 278 279 unBindEvents: function () { 280 this.$el.off('.delegateUIEvents' + this.id); 281 return this; 282 }, 283 284 getParam: function (key) { 285 return _.getUrlParam(window.location.href, key) 286 }, 287 288 renderTpl: function (tpl, data) { 289 if (!_.isFunction(tpl)) tpl = _.template(tpl); 290 return tpl(data); 291 } 292 293 294 }); 295 296 });
一個Page級別的View會有如下幾個關鍵屬性&方法:
① template,html字符串,不包含請求的基礎模塊,會構成頁面的html骨架層
② events,全部的DOM事件定義處,以事件代理的方式定義,因此沒必要擔憂執行順序
③ addEvent,用於頁面級別各個階段的監控事件註冊點,通常來講用戶只須要關注不多幾個事件,好比:
1 //寫法 2 addEvent: function () { 3 //頁面渲染結束,並顯示時候觸發的事件 4 this.on('onShow', function () { 5 }); 6 //離開頁面,頁面隱藏時候觸發的事件 7 this.on('onHide', function () { 8 }); 9 }
一個頁面的基本寫法:
1 define(['AbstractView'], function (AbstractView) { 2 return _.inherit(AbstractView, { 3 propertys: function ($super) { 4 $super(); 5 //一堆基礎屬性定義 6 //...... 7 //交互業務邏輯 8 this.events = { 9 'click .js_pre_day': 'preAction' 10 }; 11 }, 12 preAction: function (e) { }, 13 addEvent: function () { 14 this.on('onShow', function () { 15 //當頁面渲染結束,須要作的初始化操做,好比渲染頁面 16 //...... 17 }); 18 this.on('onHide', function () { 19 }); 20 } 21 }); 22 });
只要按照這種規則寫,便能展現頁面,而且具有DOM交互事件。
所謂頁面模塊類,即是用於拆分一個頁面爲單個組件模塊所用類,這裏有這些約定:
① 一個模塊類實例必定會依賴一個Page的基類實例
② 模塊類實例經過this.view能夠訪問到依賴類的一切資源
③ 模塊類實例與模塊之間經過數據entity作通訊
這裏代碼能夠再優化,但不是咱們這裏關注的重點:
1 define([], function () { 2 'use strict'; 3 4 return _.inherit({ 5 6 propertys: function () { 7 //這裏設置UI的根節點所處包裹層,必須設置 8 this.$el = null; 9 10 //用於定位dom的選擇器 11 this.selector = ''; 12 13 //每一個moduleView必須有一個父view,頁面級容器 14 this.view = null; 15 16 //模板字符串,各個組件不一樣,如今加入預編譯機制 17 this.template = ''; 18 19 //事件機制 20 this.events = {}; 21 22 //實體model,跨模塊通訊的橋樑 23 this.entity = null; 24 }, 25 26 setOption: function (options) { 27 //這裏能夠寫成switch,開始沒有想到有這麼多分支 28 for (var k in options) { 29 if (k == 'events') { 30 _.extend(this[k], options[k]); 31 continue; 32 } 33 this[k] = options[k]; 34 } 35 // _.extend(this, options); 36 }, 37 38 //@override 39 initData: function () { 40 }, 41 42 //若是傳入了dom便 43 initWrapper: function (el) { 44 if (el && el[0]) { 45 this.$el = el; 46 return; 47 } 48 this.$el = this.view.$(this.selector); 49 }, 50 51 initialize: function (opts) { 52 53 //這種默認屬性 54 this.propertys(); 55 //根據參數重置屬性 56 this.setOption(opts); 57 this.initData(); 58 59 this.initWithoutRender(); 60 61 }, 62 63 //處理dom已經存在,不須要渲染的狀況 64 initWithoutRender: function () { 65 if (this.template) return; 66 var scope = this; 67 this.view.on('onShow', function () { 68 scope.initWrapper(); 69 if (!scope.$el[0]) return; 70 //若是沒有父view則不能繼續 71 if (!scope.view) return; 72 scope.initElement(); 73 scope.bindEvents(); 74 }); 75 }, 76 77 $: function (selector) { 78 return this.$el.find(selector); 79 }, 80 81 //實例化須要用到到dom元素 82 initElement: function () { }, 83 84 //@override 85 //收集來自各方的實體組成view渲染須要的數據,須要重寫 86 getViewModel: function () { 87 throw '必須重寫'; 88 }, 89 90 _render: function (callback) { 91 var data = this.getViewModel() || {}; 92 var html = this.template; 93 if (!this.template) return ''; 94 //引入預編譯機制 95 if (_.isFunction(this.template)) { 96 html = this.template(data); 97 } else { 98 html = _.template(this.template)(data); 99 } 100 typeof callback == 'function' && callback.call(this); 101 return html; 102 }, 103 104 //渲染時必須傳入dom映射 105 render: function () { 106 this.initWrapper(); 107 if (!this.$el[0]) return; 108 109 //若是沒有父view則不能繼續 110 if (!this.view) return; 111 112 var html = this._render(); 113 this.$el.html(html); 114 this.initElement(); 115 this.bindEvents(); 116 117 }, 118 119 bindEvents: function () { 120 var events = this.events; 121 122 if (!(events || (events = _.result(this, 'events')))) return this; 123 this.unBindEvents(); 124 125 // 解析event參數的正則 126 var delegateEventSplitter = /^(\S+)\s*(.*)$/; 127 var key, method, match, eventName, selector; 128 129 // 作簡單的字符串數據解析 130 for (key in events) { 131 method = events[key]; 132 if (!_.isFunction(method)) method = this[events[key]]; 133 if (!method) continue; 134 135 match = key.match(delegateEventSplitter); 136 eventName = match[1], selector = match[2]; 137 method = _.bind(method, this); 138 eventName += '.delegateUIEvents' + this.id; 139 140 if (selector === '') { 141 this.$el.on(eventName, method); 142 } else { 143 this.$el.on(eventName, selector, method); 144 } 145 } 146 147 return this; 148 }, 149 150 unBindEvents: function () { 151 this.$el.off('.delegateUIEvents' + this.id); 152 return this; 153 } 154 }); 155 156 });
這裏的數據實體對應着,MVC中的Model,由於以前已經使用model用做了數據請求相關的命名,這裏便使用Entity作該工做:
1 define([], function () { 2 /* 3 一些原則: 4 init方法時,不可引發其它字段update 5 */ 6 var Entity = _.inherit({ 7 initialize: function (opts) { 8 this.propertys(); 9 this.setOption(opts); 10 }, 11 12 propertys: function () { 13 //只取頁面展現須要數據 14 this.data = {}; 15 16 //局部數據改變對應的響應程序,暫定爲一個方法 17 //能夠是一個類的實例,若是是實例必須有render方法 18 this.controllers = {}; 19 20 this.scope = null; 21 22 }, 23 24 subscribe: function (namespace, callback, scope) { 25 if (typeof namespace === 'function') { 26 scope = callback; 27 callback = namespace; 28 namespace = 'update'; 29 } 30 if (!namespace || !callback) return; 31 if (scope) callback = $.proxy(callback, scope); 32 if (!this.controllers[namespace]) this.controllers[namespace] = []; 33 this.controllers[namespace].push(callback); 34 }, 35 36 unsubscribe: function (namespace) { 37 if (!namespace) this.controllers = {}; 38 if (this.controllers[namespace]) this.controllers[namespace] = []; 39 }, 40 41 publish: function (namespace, data) { 42 if (!namespace) return; 43 if (!this.controllers[namespace]) return; 44 var arr = this.controllers[namespace]; 45 var i, len = arr.length; 46 for (i = 0; i < len; i++) { 47 arr[i](data); 48 } 49 }, 50 51 setOption: function (opts) { 52 for (var k in opts) { 53 this[k] = opts[k]; 54 } 55 }, 56 57 //首次初始化時,須要矯正數據,好比作服務器適配 58 //@override 59 handleData: function () { }, 60 61 //通常用於首次根據服務器數據源填充數據 62 initData: function (data) { 63 var k; 64 if (!data) return; 65 66 //若是默認數據沒有被覆蓋可能有誤 67 for (k in this.data) { 68 if (data[k]) this.data[k] = data[k]; 69 } 70 71 this.handleData(); 72 this.publish('init', this.get()); 73 }, 74 75 //驗證data的有效性,若是無效的話,不該該進行如下邏輯,而且應該報警 76 //@override 77 validateData: function () { 78 return true; 79 }, 80 81 //獲取數據前,能夠進行格式化 82 //@override 83 formatData: function (data) { 84 return data; 85 }, 86 87 //獲取數據 88 get: function () { 89 if (!this.validateData()) { 90 //須要log 91 return {}; 92 } 93 return this.formatData(this.data); 94 }, 95 96 //數據跟新後須要作的動做,執行對應的controller改變dom 97 //@override 98 update: function (key) { 99 key = key || 'update'; 100 var data = this.get(); 101 this.publish(key, data); 102 } 103 104 }); 105 106 return Entity; 107 });
這裏的數據實體會以實例的方式注入給模塊類實例,他的工做是起一箇中樞左右,完成模塊之間的通訊,反正很是重要就是了
數據請求統一使用abstract.model,數據前端緩存使用abstract.store,這裏由於目標是作頁面拆分,請求模塊不是關鍵,各位能夠把這段代碼看層一個簡單的ajax便可:
1 this.model.setParam({}); 2 this.model.execute(function (data) { 3 });
最後簡單說下業務入口文件:
1 (function () { 2 var project = './'; 3 var viewRoot = 'pages'; 4 require.config({ 5 paths: { 6 //BUS相關模板根目錄 7 IndexPath: project + 'pages/index', 8 ListPath: project + 'pages/list', 9 10 BusStore: project + 'model/bus.store', 11 BusModel: project + 'model/bus.model' 12 } 13 }); 14 require(['AbstractApp', 'UIHeader'], function (APP, UIHeader) { 15 window.APP = new APP({ 16 UIHeader: UIHeader, 17 viewRootPath: viewRoot 18 }); 19 window.APP.initApp(); 20 }); 21 })();
很簡單的代碼,指定了下require的path配置,最後咱們看看入口頁面的調用:
1 <!doctype html> 2 <html> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, minimal-ui" /> 6 <meta content="yes" name="apple-mobile-web-app-capable" /> 7 <meta content="black" name="apple-mobile-web-app-status-bar-style" /> 8 <meta name="format-detection" content="telephone=no" /> 9 <link href="../static/css/global.css" rel="stylesheet" type="text/css" /> 10 <title>班次列表</title> 11 </head> 12 <body> 13 <div id="headerview"> 14 <div class="cm-header"> 15 <h1 class="cm-page-title js_title"> 16 正在加載... 17 </h1> 18 </div> 19 </div> 20 <div class="cm-page-wrap"> 21 <div class="cm-state" id="js_page_state"> 22 </div> 23 <article class="cm-page" id="main"> 24 </article> 25 </div> 26 <script type="text/javascript" src="../blade/libs/zepto.js"></script> 27 <script src="../blade/libs/fastclick.js" type="text/javascript"></script> 28 <script type="text/javascript" src="../blade/libs/underscore.js"></script> 29 <script src="../blade/libs/underscore.extend.js" type="text/javascript"></script> 30 <script type="text/javascript" src="../blade/libs/require.js"></script> 31 <script type="text/javascript" src="../blade/common.js"></script> 32 <script type="text/javascript" src="main.js"></script> 33 </body> 34 </html> 35 36 list.html
webapp ├─blade //框架目錄 │ ├─data │ ├─libs │ ├─mvc │ └─ui ├─bus │ ├─model //數據請求模塊,徹底可使用zepto ajax替換 │ └─pages │ ├─booking │ ├─index │ └─list //demo代碼模塊 └─static
接下來,讓咱們真實的開始拆分頁面吧。
首先,咱們進行最簡單的骨架設計,這裏依次是其js代碼與模板代碼:
1 define(['AbstractView', 'text!ListPath/list.css', 'text!ListPath/tpl.layout.html'], function (AbstractView, style, layoutHtml) { 2 return _.inherit(AbstractView, { 3 propertys: function ($super) { 4 $super(); 5 this.style = style; 6 this.template = layoutHtml; 7 }, 8 9 initHeader: function (name) { 10 var title = '班次列表'; 11 this.header.set({ 12 view: this, 13 title: title 14 }); 15 }, 16 17 addEvent: function () { 18 this.on('onShow', function () { 19 console.log('頁面渲染結束'); 20 }); 21 } 22 }); 23 });
1 <div class="calendar-bar-wrapper js_calendar_wrapper"> 2 日曆工具條模塊 3 </div> 4 <div class="none-data js_none_data" style="display: none;"> 5 當前暫無班次可預訂</div> 6 <div class="js_list_wrapper"> 7 列表模塊 8 </div> 9 <div class="js_list_loading" style="display: none; text-align: center; padding: 10px 0;"> 10 正在加載...</div> 11 <ul class="bus-tabs list-filter"> 12 <li class="tabs-item js_show_setoutdate"> 13 <div class="line"> 14 <i class="icon-time"></i>出發時段<i class="icon-sec"></i></div> 15 <div class="line js_day_sec"> 16 全天</div> 17 </li> 18 <li class="tabs-item js_show_setstation"> 19 <div class="line"> 20 <i class="icon-circle icon-setout "></i>出發汽車站<i class="icon-sec"></i></div> 21 <div class="line js_start_sec"> 22 所有車站</div> 23 </li> 24 <li class="tabs-item js_show_arrivalstation"> 25 <div class="line"> 26 <i class="icon-circle icon-arrival "></i>到達汽車站<i class="icon-sec"></i></div> 27 <div class="line js_arrival_sec"> 28 所有車站</div> 29 </li> 30 </ul>
頁面展現如圖:
這裏要作的第一步是將日曆工具欄模塊實現,以數據爲先的思考,咱們先實現了一個與日曆業務有關的數據實體:
1 define(['AbstractEntity'], function (AbstractEntity) { 2 3 var Entity = _.inherit(AbstractEntity, { 4 propertys: function ($super) { 5 $super(); 6 var n = new Date(); 7 var curTime = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(); 8 this.data = { 9 date: curTime, 10 title: '當前日期' 11 }; 12 }, 13 14 set: function (date) { 15 if (!date) return; 16 if (_.isDate(date)) date = date.getTime(); 17 if (typeof date === 'string') date = parseInt(date); 18 this.data.date = date; 19 this.update(); 20 }, 21 22 getDateStr: function () { 23 var date = new Date(); 24 date.setTime(this.data.date); 25 var dateDetail = _.dateUtil.getDetail(date); 26 var name = dateDetail.year + '-' + dateDetail.month + '-' + dateDetail.day + ' ' + dateDetail.weekday + (dateDetail.day1 ? '(' + dateDetail.day1 + ')' : ''); 27 return name; 28 }, 29 30 nextDay: function () { 31 this.set(this.getDate() + 86400000); 32 return true; 33 }, 34 35 getDate: function () { 36 return parseInt(this.data.date); 37 }, 38 39 //是否可以再往前一天 40 canPreDay: function () { 41 var n = new Date(); 42 var curTime = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime(); 43 44 //若是當前日期已是第一天,則不可預訂 45 if (curTime <= this.getDate() - 86400000) { 46 return true; 47 } 48 return false; 49 }, 50 51 preDay: function () { 52 if (!this.canPreDay()) return false; 53 this.set(this.getDate() - 86400000); 54 return true; 55 } 56 57 }); 58 59 return Entity; 60 });
裏面完成日期工具欄全部相關數據操做,而且不包含實際的業務邏輯。
而後這裏開始設計日期工具欄的模塊View:
1 define(['ModuleView', 'UICalendarBox', 'text!ListPath/tpl.calendar.bar.html'], function (ModuleView, UICalendarBox, tpl) { 2 return _.inherit(ModuleView, { 3 4 //此處如果要使用model,處實例化時候必定要保證entity的存在,若是不存在即是業務BUG 5 initData: function () { 6 7 this.template = tpl; 8 this.events = { 9 'click .js_pre_day': 'preAction', 10 'click .js_next_day': 'nextAction', 11 'click .js_show_calendar': 'showCalendar' 12 }; 13 14 //初始化時候須要執行的回調 15 this.dateEntity.subscribe('init', this.render, this); 16 this.dateEntity.subscribe(this.render, this); 17 18 }, 19 20 initDate: function () { 21 var t = new Date().getTime(); 22 //默認狀況下獲取當前日期,也有過了18.00就設置爲次日日期 23 //當時一旦url上有startdatetime參數的話,便須要使用之 24 if (_.getUrlParam().startdatetime) t = _.getUrlParam().startdatetime; 25 this.dateEntity.initData({ 26 date: t 27 }); 28 }, 29 30 getViewModel: function () { 31 var data = this.dateEntity.get(); 32 data.formatStr = this.dateEntity.getDateStr(); 33 data.canPreDay = this.dateEntity.canPreDay(); 34 return data; 35 }, 36 37 preAction: function () { 38 if (this.dateEntity.preDay()) return; 39 this.view.showToast('前一天不可預訂'); 40 }, 41 42 nextAction: function () { 43 this.dateEntity.nextDay(); 44 }, 45 46 showCalendar: function () { 47 var scope = this, endDate = new Date(); 48 var secDate = new Date(); 49 secDate.setTime(this.dateEntity.getDate()); 50 51 endDate.setTime(new Date().getTime() + 2592000000); 52 53 if (!this.calendar) { 54 this.calendar = new UICalendarBox({ 55 endTime: endDate, 56 selectDate: secDate, 57 onItemClick: function (date, el, e) { 58 scope.dateEntity.set(date); 59 this.hide(); 60 } 61 }); 62 } else { 63 this.calendar.calendar.selectDate = secDate; 64 this.calendar.calendar.refresh(); 65 } 66 this.calendar.show(); 67 } 68 69 }); 70 71 });
這個組件模塊幹了幾個事情:
① 首先,dateEntity實體須要由list.js這個主view注入
② 這裏爲dateEntity註冊了兩個數據響應事件:
1 this.dateEntity.subscribe('init', this.render, this); 2 this.dateEntity.subscribe(this.render, this);
render方法繼承至基類,使用template與數據生成html,其中數據產生必須重寫父類一個方法:
1 getViewModel: function () { 2 var data = this.dateEntity.get(); 3 data.formatStr = this.dateEntity.getDateStr(); 4 data.canPreDay = this.dateEntity.canPreDay(); 5 return data; 6 },
由於這裏的日曆數據,默認取當前時間,可是url參數可能傳遞日期參數,因此定義了一個數據初始化方法:
1 initDate: function () { 2 var t = new Date().getTime(); 3 //默認狀況下獲取當前日期,也有過了18.00就設置爲次日日期 4 //當時一旦url上有startdatetime參數的話,便須要使用之 5 if (_.getUrlParam().startdatetime) t = _.getUrlParam().startdatetime; 6 this.dateEntity.initData({ 7 date: t 8 }); 9 },
該方法在主頁面渲染結束後會第一時間調用,這個時候日曆工具欄便渲染出來,其中日曆組件的使用便不予理睬了,主控制器的代碼改變以下:
1 define([ 2 'AbstractView', 3 'text!ListPath/list.css', 4 5 'ListPath/en.date', 6 7 8 'ListPath/mod.date', 9 10 'text!ListPath/tpl.layout.html' 11 ], function ( 12 AbstractView, 13 style, 14 15 DateEntity, 16 17 DateModule, 18 19 layoutHtml 20 ) { 21 return _.inherit(AbstractView, { 22 23 _initEntity: function () { 24 this.dateEntity = new DateEntity(); 25 }, 26 27 _initModule: function () { 28 this.dateModule = new DateModule({ 29 view: this, 30 selector: '.js_calendar_wrapper', 31 dateEntity: this.dateEntity 32 }); 33 }, 34 35 propertys: function ($super) { 36 $super(); 37 38 this._initEntity(); 39 this._initModule(); 40 41 this.style = style; 42 this.template = layoutHtml; 43 }, 44 45 initHeader: function (name) { 46 var title = '班次列表'; 47 this.header.set({ 48 view: this, 49 title: title 50 }); 51 }, 52 53 addEvent: function () { 54 this.on('onShow', function () { 55 56 //初始化date數據 57 this.dateModule.initDate(); 58 59 60 }); 61 } 62 }); 63 64 });
1 _initEntity: function () { 2 this.dateEntity = new DateEntity(); 3 }, 4 5 _initModule: function () { 6 this.dateModule = new DateModule({ 7 view: this, 8 selector: '.js_calendar_wrapper', 9 dateEntity: this.dateEntity 10 }); 11 },
1 addEvent: function () { 2 this.on('onShow', function () { 3 //初始化date數據 4 this.dateModule.initDate(); 5 6 }); 7 }
因而,整個界面變成了這個樣子:
這裏是對應的日曆工具模板文件tpl.calendar.html:
1 <ul class="bus-tabs calendar-bar"> 2 <li class="tabs-item js_pre_day <%=!canPreDay ? 'disabled' : '' %>">前一天</li> 3 <li class="tabs-item js_show_calendar" style="-webkit-flex: 2; flex: 2;"><%=formatStr %></li> 4 <li class="tabs-item js_next_day">後一天</li> 5 </ul>
咱們如今的頁面,就算不傳任何URL參數,已經能渲染出部分頁面了,可是下面出發站汽車等業務數據必須等待班次列表數據請求結束才能替換數據,可是這些數據若是沒有出發城市和到達城市是不能發起請求的,因此這裏先實現搜索工具欄功能:
在出發城市或者到達城市不存在的話便彈出搜索工具欄,引導用戶選擇城市,這裏新增彈出層須要在主頁面控制器(檢測主控制器)中使用一個UI組件:
1 define([ 2 'AbstractView', 3 'text!ListPath/list.css', 4 5 'ListPath/en.date', 6 7 8 'ListPath/mod.date', 9 10 'text!ListPath/tpl.layout.html', 11 'text!ListPath/tpl.search.box.html', 12 'UIScrollLayer' 13 ], function ( 14 AbstractView, 15 style, 16 17 DateEntity, 18 19 DateModule, 20 21 layoutHtml, 22 searchBoxHtml, 23 UIScrollLayer 24 ) { 25 return _.inherit(AbstractView, { 26 27 _initEntity: function () { 28 this.dateEntity = new DateEntity(); 29 }, 30 31 _initModule: function () { 32 this.dateModule = new DateModule({ 33 view: this, 34 selector: '.js_calendar_wrapper', 35 dateEntity: this.dateEntity 36 }); 37 }, 38 39 propertys: function ($super) { 40 $super(); 41 42 this._initEntity(); 43 this._initModule(); 44 45 this.style = style; 46 this.template = layoutHtml; 47 }, 48 49 initHeader: function (name) { 50 var title = '班次列表'; 51 this.header.set({ 52 view: this, 53 title: title, 54 back: function () { 55 console.log('回退'); 56 }, 57 right: [ 58 { 59 tagname: 'search-bar', 60 value: '搜索', 61 callback: function () { 62 console.log('彈出搜索框'); 63 this.showSearchBox(); 64 } 65 } 66 ] 67 }); 68 }, 69 70 71 72 //搜索工具彈出層 73 showSearchBox: function () { 74 var scope = this; 75 if (!this.searchBox) { 76 this.searchBox = new UIScrollLayer({ 77 title: '請選擇搜索條件', 78 html: searchBoxHtml, 79 events: { 80 'click .js-start': function () { 81 82 }, 83 'click .js-arrive': function () { 84 85 }, 86 'click .js_search_list': function () { 87 88 console.log('查詢列表'); 89 } 90 } 91 }); 92 } 93 this.searchBox.show(); 94 }, 95 96 addEvent: function () { 97 this.on('onShow', function () { 98 //初始化date數據 99 this.dateModule.initDate(); 100 101 //這裏判斷是否須要彈出搜索彈出層 102 if (!_.getUrlParam().startcityid || !_.getUrlParam().arrivalcityid) { 103 this.showSearchBox(); 104 return; 105 } 106 107 108 }); 109 } 110 }); 111 112 });
對應搜索彈出層html模板:
1 <div class="c-row search-line" data-flag="start"> 2 <div class="c-span3"> 3 出發</div> 4 <div class="c-span9 js-start search-line-txt"> 5 請選擇出發地</div> 6 </div> 7 <div class="c-row search-line" data-flag="arrive"> 8 <div class="c-span3"> 9 到達</div> 10 <div class="c-span9 js-arrive search-line-txt"> 11 請選擇到達地</div> 12 </div> 13 <div class="c-row " data-flag="arrive"> 14 <span class="btn-primary full-width js_search_list">查詢</span> 15 </div>
這裏核心代碼是:
1 //搜索工具彈出層 2 showSearchBox: function () { 3 var scope = this; 4 if (!this.searchBox) { 5 this.searchBox = new UIScrollLayer({ 6 title: '請選擇搜索條件', 7 html: searchBoxHtml, 8 events: { 9 'click .js-start': function () { 10 11 }, 12 'click .js-arrive': function () { 13 14 }, 15 'click .js_search_list': function () { 16 17 console.log('查詢列表'); 18 } 19 } 20 }); 21 } 22 this.searchBox.show(); 23 },
因而當URL什麼參數都沒有的時候,就會彈出這個搜索框
這裏也迎來了一個難點,由於城市列表事實上應該是一個獨立的可訪問的頁面,可是這裏是想用彈出層的方式調用他,因此我在APP層實現了一個方法能夠用彈出層的方式調起一個獨立的頁面。
注意:
這裏city城市列表未徹底採用組件化的方式開發,有興趣的朋友能夠本身嘗試着開發
這裏有一個不一樣的地方是,由於咱們點擊查詢的時候纔會作實體數據更新,這裏是單純的作DOM操做了,這裏不設置數據實體一個緣由就是:
這個搜索彈出層是一個頁面級DOM以外的部分,數據實體變化通常只應該影響Page級別的DOM,除非真的有兩個頁面級View會公用一個數據實體。
1 define([ 2 'AbstractView', 3 'text!ListPath/list.css', 4 5 'ListPath/en.date', 6 7 8 'ListPath/mod.date', 9 10 'text!ListPath/tpl.layout.html', 11 'text!ListPath/tpl.search.box.html', 12 'UIScrollLayer' 13 ], function ( 14 AbstractView, 15 style, 16 17 DateEntity, 18 19 DateModule, 20 21 layoutHtml, 22 searchBoxHtml, 23 UIScrollLayer 24 ) { 25 return _.inherit(AbstractView, { 26 27 _initEntity: function () { 28 this.dateEntity = new DateEntity(); 29 30 31 }, 32 33 _initModule: function () { 34 this.dateModule = new DateModule({ 35 view: this, 36 selector: '.js_calendar_wrapper', 37 dateEntity: this.dateEntity 38 }); 39 40 }, 41 42 propertys: function ($super) { 43 $super(); 44 45 this._initEntity(); 46 this._initModule(); 47 48 this.style = style; 49 this.template = layoutHtml; 50 51 //主控制器業務屬性 52 this.urlData = { 53 start: {}, 54 end: {} 55 }; 56 57 58 }, 59 60 initHeader: function (name) { 61 var title = '班次列表'; 62 this.header.set({ 63 view: this, 64 title: title, 65 back: function () { 66 console.log('回退'); 67 }, 68 right: [ 69 { 70 tagname: 'search-bar', 71 value: '搜索', 72 callback: function () { 73 console.log('彈出搜索框'); 74 this.showSearchBox(); 75 } 76 } 77 ] 78 }); 79 }, 80 81 //搜索工具彈出層 82 showSearchBox: function () { 83 var scope = this; 84 if (!this.searchBox) { 85 this.searchBox = new UIScrollLayer({ 86 title: '請選擇搜索條件', 87 html: searchBoxHtml, 88 events: { 89 'click .js-start': function (e) { 90 scope._showCityView('start', $(e.currentTarget)); 91 }, 92 'click .js-arrive': function (e) { 93 scope._showCityView('end', $(e.currentTarget)); 94 }, 95 'click .js_search_list': function () { 96 var param = {}; 97 98 if (!scope.urlData.start.id) { 99 scope.showToast('請先選擇出發城市'); 100 return; 101 } 102 103 if (!scope.urlData.end.id) { 104 scope.showToast('請先選擇到達城市'); 105 return; 106 } 107 108 //這裏必定會有出發城市與到達城市等數據 109 param.startcityid = scope.urlData.start.id; 110 param.arrivalcityid = scope.urlData.end.id; 111 param.startdatetime = scope.dateEntity.getDate(); 112 param.startname = scope.urlData.start.name; 113 param.arrivename = scope.urlData.end.name; 114 115 if (scope.urlData.start.station) { 116 param.startstationid = scope.urlData.start.station 117 } 118 119 if (scope.urlData.end.station) { 120 param.arrivalstationid = end_station 121 } 122 123 scope.forward('list', param); 124 this.hide(); 125 } 126 } 127 }); 128 } 129 this.searchBox.show(); 130 }, 131 132 _showCityView: function (key, el) { 133 var scope = this; 134 135 if (key == 'end') { 136 //由於到達車站會依賴出發車站的數據,因此這裏得先作判斷 137 if (!this.urlData.start.id) { 138 this.showToast('請先選擇出發城市'); 139 return; 140 } 141 } 142 143 this.showPageView('city', { 144 flag: key, 145 startId: this.urlData.start.id, 146 type: this.urlData.start.type, 147 onCityItemClick: function (id, name, station, type) { 148 scope.urlData[key] = {}; 149 scope.urlData[key]['id'] = id; 150 scope.urlData[key]['type'] = type; 151 scope.urlData[key]['name'] = name; 152 if (station) scope.urlData[key]['name'] = station; 153 el.text(name); 154 scope.hidePageView(); 155 }, 156 onBackAction: function () { 157 scope.hidePageView(); 158 } 159 }); 160 }, 161 162 addEvent: function () { 163 this.on('onShow', function () { 164 //初始化date數據 165 this.dateModule.initDate(); 166 167 //這裏判斷是否須要彈出搜索彈出層 168 if (!_.getUrlParam().startcityid || !_.getUrlParam().arrivalcityid) { 169 this.showSearchBox(); 170 return; 171 } 172 173 }); 174 } 175 }); 176 177 });
搜索功能完成後,咱們這裏即可以進入真正的數據請求功能渲染列表了。
在實現數據請求以前,我按照日期模塊的方式將下面三個模塊的功能也一併完成了,這裏惟一不一樣的是,這些模塊的DOM已經存在,咱們不須要渲染了,完成後的代碼大概是這樣的:
1 define([ 2 'AbstractView', 3 'text!ListPath/list.css', 4 5 'ListPath/en.station', 6 'ListPath/en.date', 7 'ListPath/en.time', 8 9 'ListPath/mod.date', 10 'ListPath/mod.time', 11 'ListPath/mod.setout', 12 'ListPath/mod.arrive', 13 14 'text!ListPath/tpl.layout.html', 15 'text!ListPath/tpl.search.box.html', 16 'UIScrollLayer' 17 ], function ( 18 AbstractView, 19 style, 20 21 StationEntity, 22 DateEntity, 23 TimeEntity, 24 25 DateModule, 26 TimeModule, 27 SetoutModule, 28 ArriveModule, 29 30 layoutHtml, 31 searchBoxHtml, 32 UIScrollLayer 33 ) { 34 return _.inherit(AbstractView, { 35 36 _initEntity: function () { 37 this.dateEntity = new DateEntity(); 38 39 this.timeEntity = new TimeEntity(); 40 this.timeEntity.subscribe('init', this.renderTime, this); 41 this.timeEntity.subscribe(this.renderTime, this); 42 43 this.setoutEntity = new StationEntity(); 44 this.setoutEntity.subscribe('init', this.renderSetout, this); 45 this.setoutEntity.subscribe(this.renderSetout, this); 46 47 this.arriveEntity = new StationEntity(); 48 this.arriveEntity.subscribe('init', this.renderArrive, this); 49 this.arriveEntity.subscribe(this.renderArrive, this); 50 51 }, 52 53 _initModule: function () { 54 this.dateModule = new DateModule({ 55 view: this, 56 selector: '.js_calendar_wrapper', 57 dateEntity: this.dateEntity 58 }); 59 60 this.timeModule = new TimeModule({ 61 view: this, 62 selector: '.js_show_setoutdate', 63 timeEntity: this.timeEntity 64 }); 65 66 this.setOutModule = new SetoutModule({ 67 view: this, 68 selector: '.js_show_setstation', 69 setoutEntity: this.setoutEntity 70 }); 71 72 this.arriveModule = new ArriveModule({ 73 view: this, 74 selector: '.js_show_arrivalstation', 75 arriveEntity: this.arriveEntity 76 }); 77 78 }, 79 80 propertys: function ($super) { 81 $super(); 82 83 this._initEntity(); 84 this._initModule(); 85 86 this.style = style; 87 this.template = layoutHtml; 88 89 //主控制器業務屬性 90 this.urlData = { 91 start: {}, 92 end: {} 93 }; 94 95 96 }, 97 98 initHeader: function (name) { 99 var title = '班次列表'; 100 this.header.set({ 101 view: this, 102 title: title, 103 back: function () { 104 console.log('回退'); 105 }, 106 right: [ 107 { 108 tagname: 'search-bar', 109 value: '搜索', 110 callback: function () { 111 console.log('彈出搜索框'); 112 this.showSearchBox(); 113 } 114 } 115 ] 116 }); 117 }, 118 119 initElement: function () { 120 this.d_list_wrapper = this.$('.js_list_wrapper'); 121 this.d_none_data = this.$('.js_none_data'); 122 123 this.d_js_show_setoutdate = this.$('.js_show_setoutdate'); 124 this.d_js_show_setstation = this.$('.js_show_setstation'); 125 this.d_js_show_arrivalstation = this.$('.js_show_arrivalstation'); 126 this.d_js_list_loading = this.$('.js_list_loading'); 127 this.d_js_tabs = this.$('.js_tabs'); 128 129 this.d_js_day_sec = this.$('.js_day_sec'); 130 this.d_js_start_sec = this.$('.js_start_sec'); 131 this.d_js_arrival_sec = this.$('.js_arrival_sec'); 132 }, 133 134 //搜索工具彈出層 135 showSearchBox: function () { 136 var scope = this; 137 if (!this.searchBox) { 138 this.searchBox = new UIScrollLayer({ 139 title: '請選擇搜索條件', 140 html: searchBoxHtml, 141 events: { 142 'click .js-start': function (e) { 143 scope._showCityView('start', $(e.currentTarget)); 144 }, 145 'click .js-arrive': function (e) { 146 scope._showCityView('end', $(e.currentTarget)); 147 }, 148 'click .js_search_list': function () { 149 var param = {}; 150 151 if (!scope.urlData.start.id) { 152 scope.showToast('請先選擇出發城市'); 153 return; 154 } 155 156 if (!scope.urlData.end.id) { 157 scope.showToast('請先選擇到達城市'); 158 return; 159 } 160 161 //這裏必定會有出發城市與到達城市等數據 162 param.startcityid = scope.urlData.start.id; 163 param.arrivalcityid = scope.urlData.end.id; 164 param.startdatetime = scope.dateEntity.getDate(); 165 param.startname = scope.urlData.start.name; 166 param.arrivename = scope.urlData.end.name; 167 168 if (scope.urlData.start.station) { 169 param.startstationid = scope.urlData.start.station 170 } 171 172 if (scope.urlData.end.station) { 173 param.arrivalstationid = end_station 174 } 175 176 scope.forward('list', param); 177 this.hide(); 178 } 179 } 180 }); 181 } 182 this.searchBox.show(); 183 }, 184 185 _showCityView: function (key, el) { 186 var scope = this; 187 188 if (key == 'end') { 189 //由於到達車站會依賴出發車站的數據,因此這裏得先作判斷 190 if (!this.urlData.start.id) { 191 this.showToast('請先選擇出發城市'); 192 return; 193 } 194 } 195 196 this.showPageView('city', { 197 flag: key, 198 startId: this.urlData.start.id, 199 type: this.urlData.start.type, 200 onCityItemClick: function (id, name, station, type) { 201 scope.urlData[key] = {}; 202 scope.urlData[key]['id'] = id; 203 scope.urlData[key]['type'] = type; 204 scope.urlData[key]['name'] = name; 205 if (station) scope.urlData[key]['name'] = station; 206 el.text(name); 207 scope.hidePageView(); 208 }, 209 onBackAction: function () { 210 scope.hidePageView(); 211 } 212 }); 213 }, 214 215 //初始化出發車站,該數據會隨着數據加載結束而變化 216 //若是url具備出發站名稱以及id,須要特殊處理 217 initSetoutEntity: function () { 218 var data = {}; 219 if (_.getUrlParam().startstationid) { 220 //出發車站可能並無傳,兼容老代碼 221 data.name = _.getUrlParam().startname || '所有車站'; 222 data.id = _.getUrlParam().startstationid; 223 } 224 225 this.setoutEntity.initData(data, data.id); 226 }, 227 228 //初始化到達站 229 initArriveEntity: function () { 230 231 var data = {}; 232 if (_.getUrlParam().arrivalstationid) { 233 //出發車站可能並無傳,兼容老代碼 234 data.name = _.getUrlParam().arrivename || '所有車站'; 235 data.id = _.getUrlParam().arrivalstationid; 236 } 237 238 this.arriveEntity.initData(data, data.id); 239 }, 240 241 //時段只有變化時候才具備顯示狀態 242 renderTime: function () { 243 var name = this.timeEntity.getName(); 244 this.d_js_day_sec.html(name); 245 }, 246 247 renderSetout: function () { 248 var name = this.setoutEntity.getName(); 249 this.d_js_start_sec.html(name); 250 }, 251 252 renderArrive: function () { 253 var name = this.arriveEntity.getName(); 254 this.d_js_arrival_sec.html(name); 255 }, 256 257 addEvent: function () { 258 this.on('onShow', function () { 259 //初始化date數據 260 this.dateModule.initDate(); 261 262 //這裏判斷是否須要彈出搜索彈出層 263 if (!_.getUrlParam().startcityid || !_.getUrlParam().arrivalcityid) { 264 this.showSearchBox(); 265 return; 266 } 267 268 //初始化時段選擇 269 this.timeEntity.initData(); 270 this.initSetoutEntity(); 271 this.initArriveEntity(); 272 273 }); 274 } 275 }); 276 277 });
這個時候整個邏輯結構大概出來了:
注意:
由於該文耗時過長,致使我如今體力有點虛脫,因此這裏的代碼不必定最優
最後功能:
到此,demo結束了,最後造成的目錄:
一個js即可以拆分紅這麼多的小組件模塊,若是是更加複雜的頁面,這裏的文件會不少,好比訂單填寫頁的組件模塊是這裏的三倍。
組件化帶來的幾個優勢十分明顯:
① 組件化拆分,使得主控制業務邏輯清晰簡單
② 各個業務組件模塊功能相對獨立,可維護性可測試性大大提高
③ 組件之間能夠任意組合,有必定可重用性
④ 增刪模塊不會怕打斷骨頭連着筋
⑤ 一個業務模塊所需代碼所有在一個目錄,比較好操做(有點湊數嫌疑)
事實上,組件化不會帶來什麼不足,對於不瞭解的朋友可能會認爲代碼複雜度有所增長,其實不這樣作代碼才真正叫一個難呢!
真正的美中不足的要挑一個毛病的話,這種分拆可能會比單個文件代碼量稍大
不管什麼前端優化,最後的瓶頸必定是在請求量上作文章:壓縮、緩存、僅僅作首屏渲染、將jQuery緩存zepto......
說都會說,可是不少場景由不得你那樣作,項目足夠複雜,而UI又提供給了不一樣團隊使用的話,有一天前端作了一次UI優化,而如何將此次UI優化反應到線上纔是考驗架構設計的時候,若是是很差的設計的話,想將此次優化推上線,會發生兩個事情:
① 業務團隊大改代碼
② 框架資源(js&css)膨脹
這種頭疼的問題是通常人作優化考慮不到的,而業務團隊不會由於你的更新而去修改代碼,因此通常會以代碼膨脹爲代價將此次優化強推上線,那每每會讓狀況更加複雜:
新老代碼融合,半年後你根本不知道哪些代碼能夠刪,哪些代碼能夠留,很大時候這個問題會體如今具備公共特性的CSS中
若是你的CSS同時服務於多個團隊,而各個團隊的框架版本不一致,那麼UI升級對你來講多是一個噩夢!
若是你想作第三輪的UI升級,那仍是算了吧......
事實上,我評價一個前端是否足夠厲害,每每就會從這裏考慮:
當一個項目足夠複雜後,你私下作好了優化,可是你的優化代碼不能無縫的讓業務團隊使用,而須要業務團隊作不少改變,你如何解決這種問題
不少前端作一個優化,即是從新作了一個東西,剛開始確定比線上的好,但半年後,那個代碼質量還未必有之前的好呢,因此咱們這裏應該解決的是:
如何設計一個機制,讓業務團隊以最小的修改,而能夠用上新的UI(樣式、特性),而不會增長CSS(JS)體積
這個多是組件化真正要解決的事情!
理想狀況下,一個H5的資源組成狀況是這樣的:
① 公共核心CSS文件(200行左右)
② 框架核心文件(包含框架核心和第三方庫)
③ UI組件(有不少獨立的UI組件組成,每一個UI組件又包含完整的HTML&CSS)
④ 公共業務模塊(提供業務級別公共服務,好比登陸、城市列表等業務相關功能)
⑤ 業務頻道一個頁面,也就是咱們這裏的list頁的代碼
由於框架核心通常來講是不常常改變的,就算改變也是對錶現層透明的,UI採用增量與預加載機制,這樣作會對後續樣式升級,UI升級有莫大的好處,而業務組件化後自己要作什麼滾動加載也是垂手可得
好的前端架構設計應該知足不停的UI升級需求,而不增長業務團隊下載量
本文就如何分解複雜的前端頁面提出了一些本身的想法,而且給予了實現,但願對各位有所幫助。
前端代碼有分拆就有合併,由於最終一個完整的頁面須要全部資源才能運行,但考慮到此文已經很長了,關於合併一塊的工做留待下文分析吧
爲了方便各位理解組件化開發的思想,我這裏寫了一個完整的demo幫助各位分析,因爲精力有限,代碼不免會有BUG,各位多多包涵:
https://github.com/yexiaochai/mvc
可能會瀏覽的代碼:
https://github.com/yexiaochai/blade
最後,個人微博粉絲及其少,若是您以爲這篇博客對您哪怕有一絲絲的幫助,微博求粉博客求贊!!!