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