不知不覺來百度已有半年之久,這半年是996的半年,是孤軍奮戰的半年,是跌跌撞撞的半年,一個字:真的是累死人啦!
我所進入的團隊相當於公司內部創業團隊,人員基本全部是新招的,最初開發時連資料庫都沒設計,當時評審需求的時候居然有一個產品經理拿了一份他設計的資料庫,當時我作為一個前端就驚呆了……
最初的前端只有我1人,這事實上與我想來學習學習的願望是背道而馳的,但既然來都來了也只能獨挑大樑,馬上投入開發,當時涉及的專案有:
① H5 站點
② PC 站點
③ Mis 後臺管理系統
④ 各種百度渠道接入
第一階段的重點為H5站點與APP,我們便需要在20天內從無到有的完成第一版的產品,而最初的Native人力嚴重不足,很多頁面依賴於H5這邊,所以前端除了本身業務之外還得約定與Native的互動細節。
這個情況下根本無暇思考其它框架,熟悉的就是最好的!便將自己git上的開源框架直接拿來用了起來:[置頂]【blade利刃出鞘】一起進入移動端webapp開發吧
因為之前的經驗積累,工程化、Hybrid 互動、各種相容、體驗問題已經處理了很多了,所以基礎架構一層比較完備,又有完善的 UI 元件可以使用,這個是最初的設計構想:
構想總是美好的,而在巨大的業務壓力面前任何技術願景都是蒼白的,最初我在哪裡很傻很天真的用 CSS3 畫圖示,然後產品經理天天像一個蒼蠅一樣在我面前嗡嗡嗡,他們事實上是不關注頁面效能是何物的,我也馬上意識的到工期不足,於是便直接用圖示了!
依賴於完善的框架,20天不到的時間,第一版的專案便結束了,業務程式碼有點不堪入目,頁面級的程式碼也沒有太遵循 MVC 規則,這導致了後續的迭代,全部在那裡操作 dom。
其實初期這樣做問題不大,如果專案比較小(比如什麼一次性的活動頁面)問題也不大,但是核心專案便最好不要這樣玩了,因為新需求、新場景,會讓你在原基礎上不斷的改程式碼,如果頁面沒有一個很好的規範,那麼他將不再穩定,也不再容易維護,如何編寫一個可穩定、擴充套件性高、可維護性高的專案,是我們今天討論的重點。
認真閱讀此文可能會在以下方面對你有所幫助:
1 2 3 4 5 |
① 網站初期需要統計什麼資料?產品需要的業務資料,你該如何設計你的網站才能收集到這些資料,提供給他 ② 完整的請求究竟應該如何發出,H5應該如何在前端做快取,伺服器給出的資料應該在哪裡做校驗,前端錯誤日誌應該關注js錯誤還是資料錯誤? ③ 你在寫業務程式碼時犯了什麼錯誤,如何編寫高效可維護的業務程式碼(頁面級別),MVC 到底是個什麼東西? ④ 網站規模大瞭如何複用一些模組? ⑤ 站在業務角度應該如何做效能優化(這個可能不是本文的重點) |
文中是我半年以來的一些業務開發經驗,希望對各位有用,也希望各位多多支援討論,指出文中不足以及提出您的一些建議。
統計需求
通用統計需求
對於伺服器端來說,後期最重要的莫過於監控日誌,對於前端來說,統計無疑是初期最重要的,通用的統計需求包括:
① PV/UV 統計
② 機型/瀏覽器/系統統計
③ 各頁面載入速度統計
④ 某些按鈕的點選統計
⑤ ……
這類統計直接通過百度統計之類的工具即可,算是最基礎的統計需求。百度產品的文件、支援團隊爛估計是公認的事情了,我便只能挖掘很少一部分用法。但是這類資料也是非常重要了,對於產品甚至是老闆判斷整個產品的發展有莫大的幫助與引導作用,如果產品死了,任何技術都是沒有意義的,所以站點沒有這類統計的速度加上吧!
http://tongji.baidu.com/web/welcome/login
渠道統計
所謂渠道統計便是這次訂單來源是哪裡,就我們產品的渠道有:
① 手機百度 APP 入口(由分為生活+入口、首頁 banner 入口、廣告入口……)
② 百度移動站點入口
③ 百度地圖入口(包括 H5 站點)
④ wise 卡片入口(包括:唯一答案、白卡片、極速版、點到點卡片……)
⑤ 各種大禮包、活動入口
⑥ SEM 入口
⑦ ……
你永遠不能預料到你究竟有多少入口,但是這種渠道的統計的重要性直接關乎了產品的存亡,產品需要知道自己的每次的活動,每次的引流是有意義的,比如一次活動便需要得到這次活動每天產生的訂單量,如果你告訴產品,爺做不到,那麼產品會真叫你爺爺。
當然,渠道的統計前端單方面是完成不了的,需要和伺服器端配合,一般而言可以這樣做,前端與伺服器端約定,每次請求皆會帶特定的引數,我一般會與伺服器約定以下引數:
1 2 3 4 5 6 |
var param = { head: { us: '渠道', version: '1.0.0' } }; |
這個head引數是每次 ajax 請求都會帶上的,而us引數一般由url而來,他要求每次由其它渠道落地到我們的站點一定要帶有us引數,us引數拿到後便是我們自己的事情了,有幾種操作方法:
① 直接種到 cookie,這個需要伺服器端特殊處理
② 存入 localstorage,每次請求拿出來,組裝請求引數
③ 因為我們 H5 站點的每一次跳轉都會經過框架中轉,所以我直接將us資料放到了 url 上,每次跳轉都會帶上,一直到跳出網站。
SEM 需求
SEM 其實屬於渠道需求的一類,這裡會獨立出來是因為,他需要統計的資料更多,還會包含一個投放詞之類的資料,SEM 投放人員需要確切的知道某個投放詞每天的訂單量,這個時候上面的引數可能就要變化了:
1 2 3 4 5 6 7 |
var param = { head: { us: '渠道', version: '1.0.0', extra: '擴充套件欄位' } }; |
這個時候可能便需要一個 extra 的擴充套件欄位記錄投放詞是什麼,當然 SEM 落地到我們網站的特殊引數也需要一直傳下去,這個需要做框架層的處理,這裡順便說下我的處理方案吧
統一跳轉
首先我們 H5 站點基本不關注 SEO,對於 SEO 我們有特殊的處理方案,所以在我們的 H5 站點上基本不會出現a標籤,我們站點的每次跳轉皆是由 js 控制,我會在框架封裝幾個方法處理跳轉:
1 2 3 4 5 6 7 8 9 10 |
forward: function (view) { //處理頻道內跳轉 } back: function (view) { } jump: function (project, view) { //處理跨頻道跳轉 } |
這樣做的好處是:
① 統一封裝跳轉會讓前端控制力增加,比如 forward 可以是 location 變化,也可以是 pushState/hash 的方式做單頁跳轉,甚至可以做 Hybrid 中多 Webview 的跳轉
② 誠如上述,forward 時可以由 url 獲取渠道引數帶到下一個頁面
③ 統一跳轉也可以統一為站點做一些打點的操作,比如單頁應用時候的統一加統計程式碼
最簡單的理解就是:封裝一個全域性方法做跳轉控制,所有的跳轉由他發出。
請求模組
ajax是前端到伺服器端的基石,但是前端和伺服器端的互動:
1 2 3 4 |
每個介面必須要寫文件! 每個介面必須要寫文件! 每個介面必須要寫文件! 重要的事情說三遍!!! |
如果不寫文件的話,你就等著吧,因為端上是入口,一旦出問題,老闆會直觀認為是前端的問題,如果發現是伺服器的欄位不統一導致,而伺服器端打死不承認,你就等著吧!
無論什麼時候,前端請求模組的設計是非常關鍵的,因為前端只是資料的搬運工,負責展現資料而已:)
封裝請求模組
與封裝統一跳轉一致,所有的請求必須收口,最爛的做法也是封裝一個全域性的方法處理全站請求,這樣做的好處是:
① 處理公共引數
比如每次請求必須帶上上面所述 head 業務引數,便必須在此做處理
② 處理統一錯誤碼
伺服器與前端一般會有一個格式約定,一般而言是這樣的:
1 2 3 4 5 |
{ data: {}, errno: 0, msg: "success" } |
比如錯誤碼為1的情況就代表需要登入,系統會引導使用者進入登入頁,比如非0的情況下,需要彈出一個提示框告訴使用者出了什麼問題,你不可能在每個地方都做這種錯誤碼處理吧
③ 統一快取處理
有些請求資料不會經常改變,比如城市列表,比如常用聯絡人,這個時候便需要將之存到 localstorage 中做快取
④ 資料處理、日誌處理
1 2 |
這裡插一句監控的問題,因為前端程式碼壓縮後,js錯誤監控變得不太靠譜,而前端的錯誤有很大可能是搬運資料過程中出了問題,所以在請求model層做對應的資料校驗是十分有意義的 如果發現資料不對便發錯誤日誌,好過被使用者抓住投訴,而這裡做資料校驗也為模板中使用資料做了基礎檢查 |
伺服器端給前端的資料可能是鬆散的,前端真實使用時候會對資料做處理,同一請求模組如果在不同地方使用,就需要多次處理,這個是不需要的,比如:
1 2 3 |
//這個判斷應該放在資料模組中 if(data.a) ... if(data.a.b) ... |
這裡我說下 blade 框架中請求模組的處理:
blade 的請求模組
我們現在站點主要還是源於blade框架,實際使用時候做了點改變,後續會迴歸到 blade 框架,專案目錄結構為:
其中 store 依賴於 storage 模組,是處理 localstorage 快取的,他與 model是獨立的,以下為核心程式碼:
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 |
define([], function () { var Model = _.inherit({ //預設屬性 propertys: function () { this.protocol = 'http'; this.domain = ''; this.path = ''; this.url = null; this.param = {}; this.validates = []; // this.contentType = 'application/json'; this.ajaxOnly = true; this.contentType = 'application/x-www-form-urlencoded'; this.type = 'GET'; this.dataType = 'json'; }, setOption: function (options) { _.extend(this, options); }, assert: function () { if (this.url === null) { throw 'not override url property'; } }, initialize: function (opts) { this.propertys(); this.setOption(opts); this.assert(); }, pushValidates: function (handler) { if (typeof handler === 'function') { this.validates.push($.proxy(handler, this)); } }, setParam: function (key, val) { if (typeof key === 'object') { _.extend(this.param, key); } else { this.param[key] = val; } }, removeParam: function (key) { delete this.param[key]; }, getParam: function () { return this.param; }, //構建url請求方式,子類可複寫,我們的model如果localstorage設定了值便直接讀取,但是得是非正式環境 buildurl: function () { // var baseurl = AbstractModel.baseurl(this.protocol); // return this.protocol + '://' + baseurl.domain + '/' + baseurl.path + (typeof this.url === 'function' ? this.url() : this.url); throw "[ERROR]abstract method:buildurl, must be override"; }, onDataSuccess: function () { }, /** * 取model資料 * @param {Function} onComplete 取完的回撥函 * 傳入的第一個引數為model的數第二個資料為後設資料,後設資料為ajax下發時的ServerCode,Message等數 * @param {Function} onError 發生錯誤時的回撥 * @param {Boolean} ajaxOnly 可選,預設為false當為true時只使用ajax調取資料 * @param {Boolean} scope 可選,設定回撥函式this指向的物件 * @param {Function} onAbort 可選,但取消時會呼叫的函式 */ execute: function (onComplete, onError, ajaxOnly, scope) { var __onComplete = $.proxy(function (data) { var _data = data; if (typeof data == 'string') _data = JSON.parse(data); // @description 開發者可以傳入一組驗證方法進行驗證 for (var i = 0, len = this.validates.length; i < len; i++) { if (!this.validates[i](data)) { // @description 如果一個驗證不通過就返回 if (typeof onError === 'function') { return onError.call(scope || this, _data, data); } else { return false; } } } // @description 對獲取的資料做欄位對映 var datamodel = typeof this.dataformat === 'function' ? this.dataformat(_data) : _data; if (this.onDataSuccess) this.onDataSuccess.call(this, datamodel, data); if (typeof onComplete === 'function') { onComplete.call(scope || this, datamodel, data); } }, this); var __onError = $.proxy(function (e) { if (typeof onError === 'function') { onError.call(scope || this, e); } }, this); this.sendRequest(__onComplete, __onError); }, sendRequest: function (success, error) { var url = this.buildurl(); var params = _.clone(this.getParam() || {}); var crossDomain = { 'json': true, 'jsonp': true }; // if (this.type == 'json') // if (this.type == 'POST') { // this.dataType = 'json'; // } else { // this.dataType = 'jsonp'; // } if (this.type == 'POST') { this.dataType = 'json'; } //jsonp與post互斥 $.ajax({ url: url, type: this.type, data: params, dataType: this.dataType, contentType: this.contentType, crossDomain: crossDomain[this.dataType], timeout: 50000, xhrFields: { withCredentials: true }, success: function (res) { success && success(res); }, error: function (err) { error && error(err); } }); } }); Model.getInstance = function () { if (this.instance) { return this.instance; } else { return this.instance = new this(); } }; return Model; }); model |
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 |
define(['AbstractStorage'], function (AbstractStorage) { var Store = _.inherit({ //預設屬性 propertys: function () { //每個物件一定要具有儲存鍵,並且不能重複 this.key = null; //預設一條資料的生命週期,S為秒,M為分,D為天 this.lifeTime = '30M'; //預設返回資料 // this.defaultData = null; //代理物件,localstorage物件 this.sProxy = new AbstractStorage(); }, setOption: function (options) { _.extend(this, options); }, assert: function () { if (this.key === null) { throw 'not override key property'; } if (this.sProxy === null) { throw 'not override sProxy property'; } }, initialize: function (opts) { this.propertys(); this.setOption(opts); this.assert(); }, _getLifeTime: function () { var timeout = 0; var str = this.lifeTime; var unit = str.charAt(str.length - 1); var num = str.substring(0, str.length - 1); var Map = { D: 86400, H: 3600, M: 60, S: 1 }; if (typeof unit == 'string') { unit = unit.toUpperCase(); } timeout = num; if (unit) timeout = Map[unit]; //單位為毫秒 return num * timeout * 1000 ; }, //快取資料 set: function (value, sign) { //獲取過期時間 var timeout = new Date(); timeout.setTime(timeout.getTime() + this._getLifeTime()); this.sProxy.set(this.key, value, timeout.getTime(), sign); }, //設定單個屬性 setAttr: function (name, value, sign) { var key, obj; if (_.isObject(name)) { for (key in name) { if (name.hasOwnProperty(key)) this.setAttr(k, name[k], value); } return; } if (!sign) sign = this.getSign(); //獲取當前物件 obj = this.get(sign) || {}; if (!obj) return; obj[name] = value; this.set(obj, sign); }, getSign: function () { return this.sProxy.getSign(this.key); }, remove: function () { this.sProxy.remove(this.key); }, removeAttr: function (attrName) { var obj = this.get() || {}; if (obj[attrName]) { delete obj[attrName]; } this.set(obj); }, get: function (sign) { var result = [], isEmpty = true, a; var obj = this.sProxy.get(this.key, sign); var type = typeof obj; var o = { 'string': true, 'number': true, 'boolean': true }; if (o[type]) return obj; if (_.isArray(obj)) { for (var i = 0, len = obj.length; i < len; i++) { result[i] = obj[i]; } } else if (_.isObject(obj)) { result = obj; } for (a in result) { isEmpty = false; break; } return !isEmpty ? result : null; }, getAttr: function (attrName, tag) { var obj = this.get(tag); var attrVal = null; if (obj) { attrVal = obj[attrName]; } return attrVal; } }); Store.getInstance = function () { if (this.instance) { return this.instance; } else { return this.instance = new this(); } }; return Store; }); store |
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 |
define([], function () { var Storage = _.inherit({ //預設屬性 propertys: function () { //代理物件,預設為localstorage this.sProxy = window.localStorage; //60 * 60 * 24 * 30 * 1000 ms ==30天 this.defaultLifeTime = 2592000000; //本地快取用以存放所有localstorage鍵值與過期日期的對映 this.keyCache = 'SYSTEM_KEY_TIMEOUT_MAP'; //當快取容量已滿,每次刪除的快取數 this.removeNum = 5; }, assert: function () { if (this.sProxy === null) { throw 'not override sProxy property'; } }, initialize: function (opts) { this.propertys(); this.assert(); }, /* 新增localstorage 資料格式包括唯一鍵值,json字串,過期日期,存入日期 sign 為格式化後的請求引數,用於同一請求不同引數時候返回新資料,比如列表為北京的城市,後切換為上海,會判斷tag不同而更新快取資料,tag相當於簽名 每一鍵值只會快取一條資訊 */ set: function (key, value, timeout, sign) { var _d = new Date(); //存入日期 var indate = _d.getTime(); //最終儲存的資料 var entity = null; if (!timeout) { _d.setTime(_d.getTime() + this.defaultLifeTime); timeout = _d.getTime(); } // this.setKeyCache(key, timeout); entity = this.buildStorageObj(value, indate, timeout, sign); try { this.sProxy.setItem(key, JSON.stringify(entity)); return true; } catch (e) { //localstorage寫滿時,全清掉 if (e.name == 'QuotaExceededError') { // this.sProxy.clear(); //localstorage寫滿時,選擇離過期時間最近的資料刪除,這樣也會有些影響,但是感覺比全清除好些,如果快取過多,此過程比較耗時,100ms以內 if (!this.removeLastCache()) throw '本次資料儲存量過大'; this.set(key, value, timeout, sign); } console && console.log(e); } return false; }, //刪除過期快取 removeOverdueCache: function () { var tmpObj = null, i, len; var now = new Date().getTime(); //取出鍵值對 var cacheStr = this.sProxy.getItem(this.keyCache); var cacheMap = []; var newMap = []; if (!cacheStr) { return; } cacheMap = JSON.parse(cacheStr); for (i = 0, len = cacheMap.length; i < len; i++) { tmpObj = cacheMap[i]; if (tmpObj.timeout < now) { this.sProxy.removeItem(tmpObj.key); } else { newMap.push(tmpObj); } } this.sProxy.setItem(this.keyCache, JSON.stringify(newMap)); }, removeLastCache: function () { var i, len; var num = this.removeNum || 5; //取出鍵值對 var cacheStr = this.sProxy.getItem(this.keyCache); var cacheMap = []; var delMap = []; //說明本次儲存過大 if (!cacheStr) return false; cacheMap.sort(function (a, b) { return a.timeout - b.timeout; }); //刪除了哪些資料 delMap = cacheMap.splice(0, num); for (i = 0, len = delMap.length; i < len; i++) { this.sProxy.removeItem(delMap[i].key); } this.sProxy.setItem(this.keyCache, JSON.stringify(cacheMap)); return true; }, setKeyCache: function (key, timeout) { if (!key || !timeout || timeout < new Date().getTime()) return; var i, len, tmpObj; //獲取當前已經快取的鍵值字串 var oldstr = this.sProxy.getItem(this.keyCache); var oldMap = []; //當前key是否已經存在 var flag = false; var obj = {}; obj.key = key; obj.timeout = timeout; if (oldstr) { oldMap = JSON.parse(oldstr); if (!_.isArray(oldMap)) oldMap = []; } for (i = 0, len = oldMap.length; i < len; i++) { tmpObj = oldMap[i]; if (tmpObj.key == key) { oldMap[i] = obj; flag = true; break; } } if (!flag) oldMap.push(obj); //最後將新陣列放到快取中 this.sProxy.setItem(this.keyCache, JSON.stringify(oldMap)); }, buildStorageObj: function (value, indate, timeout, sign) { var obj = { value: value, timeout: timeout, sign: sign, indate: indate }; return obj; }, get: function (key, sign) { var result, now = new Date().getTime(); try { result = this.sProxy.getItem(key); if (!result) return null; result = JSON.parse(result); //資料過期 if (result.timeout < now) return null; //需要驗證簽名 if (sign) { if (sign === result.sign) return result.value; return null; } else { return result.value; } } catch (e) { console && console.log(e); } return null; }, //獲取簽名 getSign: function (key) { var result, sign = null; try { result = this.sProxy.getItem(key); if (result) { result = JSON.parse(result); sign = result && result.sign } } catch (e) { console && console.log(e); } return sign; }, remove: function (key) { return this.sProxy.removeItem(key); }, clear: function () { this.sProxy.clear(); } }); Storage.getInstance = function () { if (this.instance) { return this.instance; } else { return this.instance = new this(); } }; return Storage; }); storage |
真實的使用場景業務model首先得做一層業務封裝,然後才是真正的使用:
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 |
define(['AbstractModel', 'AbstractStore', 'cUser'], function (AbstractModel, AbstractStore, cUser) { var ERROR_CODE = { 'NOT_LOGIN': '00001' }; //獲取產品來源 var getUs = function () { var us = 'webapp'; //其它操作...... //如果url具有us標誌,則首先讀取 if (_.getUrlParam().us) { us = _.getUrlParam().us; } return us; }; var BaseModel = _.inherit(AbstractModel, { initDomain: function () { var host = window.location.host; this.domain = host; //開發環境 if (host.indexOf('yexiaochai.baidu.com') != -1) { this.domain = 'xxx'; } //qa環境 if (host.indexOf('baidu.com') == -1) { this.domain = 'xxx'; } //正式環境 if (host.indexOf('xxx.baidu.com') != -1 || host.indexOf('xxx.baidu.com') != -1) { this.domain = 'api.xxx.baidu.com'; } }, propertys: function ($super) { $super(); this.initDomain(); this.path = ''; this.cacheData = null; this.param = { head: { us: getUs(), version: '1.0.0' } }; this.dataType = 'jsonp'; this.errorCallback = function () { }; //統一處理分返回驗證 this.pushValidates(function (data) { return this.baseDataValidate(data); }); }, //首輪處理返回資料,檢查錯誤碼做統一驗證處理 baseDataValidate: function (data) { if (!data) { window.APP.showToast('伺服器出錯,請稍候再試', function () { window.location.href = 'xxx'; }); return; } if (_.isString(data)) data = JSON.parse(data); if (data.errno === 0) return true; //處理統一登入邏輯 if (data.errno == ERROR_CODE['NOT_LOGIN']) { cUser.login(); } //其它通用錯誤碼的處理邏輯 if (data.errno == xxxx) { this.errorCallback(); return false; } //如果出問題則列印錯誤 if (window.APP && data && data.msg) window.APP.showToast(data.msg, this.errorCallback); return false; }, dataformat: function (data) { if (_.isString(data)) data = JSON.parse(data); if (data.data) return data.data; return data; }, buildurl: function () { return this.protocol + '://' + this.domain + this.path + (typeof this.url === 'function' ? this.url() : this.url); }, getSign: function () { var param = this.getParam() || {}; return JSON.stringify(param); }, onDataSuccess: function (fdata, data) { if (this.cacheData && this.cacheData.set) this.cacheData.set(fdata, this.getSign()); }, //重寫父類getParam方法,加入方法簽名 getParam: function () { var param = _.clone(this.param || {}); //此處對引數進行特殊處理 //...... return this.param; }, execute: function ($super, onComplete, onError, ajaxOnly, scope) { var data = null; if (!ajaxOnly && !this.ajaxOnly && this.cacheData && this.cacheData.get) { data = this.cacheData.get(this.getSign()); if (data) { onComplete(data); return; } } //記錄請求發出 $super(onComplete, onError, ajaxOnly, scope); } }); //localstorage儲存類 var Store = { RequestStore: _.inherit(AbstractStore, { //預設屬性 propertys: function ($super) { $super(); this.key = 'BUS_RequestStore'; this.lifeTime = '1D'; //快取時間 } }) }; //返回真實的業務類 return { //真實的業務請求 requestModel: _.inherit(BaseModel, { //預設屬性 propertys: function ($super) { $super(); this.url = '/url'; this.ajaxOnly = false; this.cacheData = Store.RequestStore.getInstance(); } }) }; }); 業務封裝 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
define(['BusinessModel'], function (Model) { var model = Model.requestModel.getInstance(); //設定請求引數 model.setParam(); model.execute(function (data) { //這裡的data,如果model設定的完善,則前端使用可完全信任其可用性不用做判斷了 //這個是不需要的 if (data.person && data.person.name) { //... } //根據資料渲染頁面 //...... }); }) |
複雜的前端頁面
我覺得三端的開發中,前端的業務是最複雜的,因為IOS與Andriod的落地頁往往都是首頁,而前端的落地頁可能是任何頁面(產品列表頁,訂單填寫頁,訂單詳情頁等),因為使用者完全可能把這個url告訴朋友,讓朋友直接進入這個產品填寫頁。
而隨著業務發展、需求迭代,前端的頁面可能更加複雜,最初穩定的頁面承受了來自多方的挑戰。這個情況在我們團隊大概是這樣的:
在第一輪產品做完後,產品馬上安排了第二輪迭代,這次迭代的重點是訂單填寫頁,對訂單填寫有以下需求:
① 新增優惠券功能
② 優惠券在H5站點下預設不使用,在IOS、andriod下預設使用(剛好這個時候IOS還在用H5的頁面囧囧囧)
③ 預設自動填入使用者上一次的資訊(站點常用功能)
這裡1、3是正常功能迭代,但是需求2可以說是IOS APP 暫時使用H5站點的頁面,因為當時IOS已經招到了足夠的人,也正在進行訂單填寫的開發,事實上一個月以後他們APP便換掉了H5的訂單填寫,那麼這個時候將對應IOS的邏輯寫到自己的主邏輯中是非常愚蠢的,而且後續的發展更是超出了所料,因為H5站點的容器變成了:
① IOS APP裝載部分H5頁面
② Andriod APP裝載部分H5頁面
PS:這裡之所以把andriod和ios分開,因為andriod都開發了20多天了,ios才招到一個人,他們對H5頁面的需求完全是兩回事囧!
③ 手機百度裝載H5頁面(基本與H5站點邏輯一致,有一些特殊需求,比如登入、支付需要使用clouda呼叫apk)
④ 百度地圖webview容器
於是整個人就一下傻逼了,因為主邏輯基本相似,總有容器會希望一點特殊需求,從重構角度來說,我們不會希望我們的業務中出現上述程式碼太多的if else;
從效能優化角度來說,就普通瀏覽器根本不需要理睬Hybrid互動相關,這個時候我們完善的框架便派上了用場,抽離公共部分了:
H5仍然只關注主邏輯,並且將內部的每部操作儘可能的細化,比如初始化操作,對某一個按鈕的點選行為等都應該儘可能的分解到一個個獨立的方法中,真實專案大概是這個樣子的:
依賴框架自帶的繼承抽象,以及控制器路由層的按環境載入的機制,可以有效解決此類問題,也有效降低了頁面的複雜度,但是他改變不了頁面越來越複雜的事實,並且這個時候迎來了第三輪迭代:
① 加入保險功能
② H5站點在某些渠道下預設開啟使用優惠券功能(囧囧囧!!!)
③ 限制優惠券必須達到某些條件才能使用
④ 訂單填寫頁作為某一合作方的落地頁,請求引數和url有所變化,但是返回的欄位一致,互動一致……
因為最初20天的慌亂處理,加之隨後兩輪的迭代,我已經在訂單填寫頁中買下了太多坑,而且網頁中隨處可見的dom操作讓程式碼可維護程度大大降低,而點選某一按鈕而導致的連鎖變化經常發生,比如,使用者增減購買商品數量時:
① 會改變本身商品數量的展示
② 會根據當前條件去重新整理優惠卷使用資料
③ 改變支付條上的最終總額
④ ……
於是這次迭代後,你會發現訂單填寫頁尼瑪經常出BUG,每次改了又會有地方出BUG,一段時間不在,同事幫助修復了一個BUG,又引起了其它三個BUG,這個時候迎來了第四輪迭代,而這種種跡象表明:
1 |
如果一個頁面開始頻繁的出BUG,如果一個頁面邏輯越來越複雜,如果一個頁面的程式碼你覺得不好維護了,那麼意味著,他應該得到應有的重構了! |
前端的MVC
不太MVC的做法
如果在你的頁面(會長久維護的專案)中有以下情況的話,也許你應該重構你的頁面或者換掉你框架了:
① 在js中大規模的拼接HTML,比如這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
for (i = 0; i < len; i++) { for (key in data[i]) { item = data[i][key]; len2 = item.length; if (len2 === 0) continue; str += '<h2 class="wa-xxx-groupname">' + key + '</h2>'; str += '<ul class=" wa-xxx-city-list-item ">'; for (j = 0; j < len2; j++) { str += '<li data-type="' + item[j].type + '" data-city="' + item[j].regionid + '">' + item[j].cnname + '</li>'; } str += '</ul>'; break; } if (str !== '') html.push('<div class="wa-xxx-city-list">' + str + '</div>'); str = ''; } |
對於這個情況,你應該使用前端模板引擎
② 在js中出現大規模的獲取非文字框元素的值
③ 在html頁面中看到了大規模的資料鉤子,比如這個樣子:
④ 你在js中發現,一個資料由js變數可獲取,也可以由dom獲取,並你對從哪獲取資料猶豫不決
⑤ 在你的頁面中,click事件分散到一個頁面的各個地方
⑥ 當你的js檔案超過1000行,並且你覺得沒法拆分
以上種種跡象表明,喲!這個頁面好像要被玩壞了,好像可以用MVC的思想重構一下啦!
什麼是MVC
其實MVC這個東西有點懸,一般人壓根都不知道他是幹嘛的,就知道一個model-view-controller;
知道一點的又說不清楚;
真正懂的人要麼喜歡東扯西扯,要麼不願意寫部落格或者部落格一來便很難,曲高和寡。
所以前端MVC這個東西一直是一個玄之又玄的東西,很多開發了很久的朋友都不能瞭解什麼是MVC。
今天我作為一個自認為懂得一點的人,便來說一說我對MVC在前端的認識,希望對大家有幫助。
前端給大家的認識便是頁面,頁面由HTML+CSS實現,如果有互動便需要JS的介入,其中:
1 2 3 |
對於真實的業務來說,HTML&CSS是零件,JS是搬運工,資料是設計圖與指令。 JS要根據資料指令將零件組裝為玩具,使用者操作了玩具導致了資料變化,於是JS又根據資料指令重新組裝玩具 我們事實上不寫程式碼,我們只是資料的搬運工 |
上述例子可能不一定準確,但他可以表達一些中心思想,那就是:
1 |
對於頁面來說,要展示的只是資料 |
所以,資料才是我們應該關注的核心,這裡回到我們MVC的基本概念:
MVC即Model-View-Controller三個詞的縮寫
Model
是資料模型,是客觀事物的一種抽象,比如機票訂單填寫的常用聯絡人模組便可以抽象為一個Model類,他會有一次航班最多可選擇多少聯絡人這種被當前業務限制的屬性,並且會有增減聯絡人、獲取聯絡人、獲取最大可設定聯絡人等業務資料。
Model應該是一個比較穩定的模組,不會經常變化並且可被重用的模組;當然最重要的是,每一次資料變化便會有一個通知機制,通知所有的controller對資料變化做出響應
View
View就是檢視,在前端中甚至可簡單理解為html模板,Controller會根據資料組裝為最終的html字串,然後展示給我們,至於怎麼展示是CSS的事情,我們這裡不太關注。
PS:一般來說,過於複雜的if else流程判斷,不應該出現在view中,那是controller該做的事情
當然並不是每次model變化controller都需要完整的渲染頁面,也有可能一次model改變,其響應的controller只是操作了一次dom,只要model的controller足夠細分,每個controller就算是在操作dom也是無所謂的
Controller
控制器其實就是負責與View以及Model打交道的,因為View與Model應該沒有任何互動,model中不會出現html標籤,html標籤也不應該出現完整的model對應資料,更不會有model資料的增刪
PS:html標籤當然需要一些關鍵model值用於controller獲取model相關標誌了
這裡拷貝一個圖示來幫助我們解析:
這個圖基本可以表達清楚MVC是幹嘛的,但是卻不能幫助新手很好的瞭解什麼是MVC,因為真實的場景可能是這樣的:
一個model例項化完畢,通知controller1去更新了view
view發生了click互動通過controller2改變了model的值
model馬上通知了controller3、controller4、controller5響應資料變化
所以這裡controller影響的model可能不止一個,而model通知的controller也不止一個,會引起的介面連鎖反應,上圖可能會誤導初學者只有一個controller在做這些事情。
這裡舉一個簡單的例子說明情況:
① 大家看到新浪微博首頁,你發了一條微博,這個時候你關注的好友轉發了該微博
② 伺服器響應這次微博,並且將這次新增微博推送給了你(也有可能是頁面有一個js不斷輪詢去拉取資料),總之最後資料變了,你的微博Model馬上將這次資料變化通知了至少以下響應程式:
1)訊息通知控制器,他引起了右上角訊息變化,使用者看見了有人轉發我的weib
2)微博主頁面顯示多了一條微博,讓我們點選檢視
3)……
這是一條微博新增產生的變化,如果頁面想再多一個模組響應變化,只需要在微博Model的控制器集合中新增一個控制器即可
MVC的實現
千言不如一碼,我這裡臨時設計一個例子並書寫程式碼來說明自己對MVC的認識,,考慮到簡單,便不使用模組化了,我們設計了一個部落格頁面,大概是這個樣子的:
無論什麼功能,都需要第三方庫,我們這裡選擇了:
① zepto
② underscore
這裡依舊用到了我們的繼承機制,如果對這個不熟悉的朋友煩請看看我之前的部落格:【一次面試】再談javascript中的繼承
Model的實現
我們只是資料的搬運工,所以要以資料為先,這裡先設計了Model的基類:
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 |
var AbstractModel = _.inherit({ initialize: function (opts) { this.propertys(); this.setOption(opts); }, propertys: function () { //只取頁面展示需要資料 this.data = {}; //區域性資料改變對應的響應程式,暫定為一個方法 //可以是一個類的例項,如果是例項必須有render方法 this.controllers = {}; //全域性初始化資料時候呼叫的控制器 this.initController = null; this.scope = null; }, addController: function (k, v) { if (!k || !v) return; this.controllers[k] = v; }, removeController: function (k) { if (!k) return; delete this.controllers[k]; }, 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(); if (this.initController && this.get()) { this.initController.call(this.scope, 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); }, _update: function (key, data) { if (typeof this.controllers[key] === 'function') this.controllers[key].call(this.scope, data); else if (typeof this.controllers[key].render === 'function') this.controllers[key].render.call(this.scope, data); }, //資料跟新後需要做的動作,執行對應的controller改變dom //@override update: function (key) { var data = this.get(); var k; if (!data) return; if (this.controllers[key]) { this._update(key, data); return; } for (k in this.controllers) { this._update(k, data); } } }); |
然後我們開始設計真正的部落格相關model:
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 |
//部落格的model模組應該是完全獨立與頁面的主流層的,並且可複用 var Model = _.inherit(AbstractModel, { propertys: function () { this.data = { blogs: [] }; }, //新增部落格 add: function (title, type, label) { //做資料校驗,具體要多嚴格由業務決定 if (!title || !type) return null; var blog = {}; blog.id = 'blog_' + _.uniqueId(); blog.title = title; blog.type = type; if (label) blog.label = label.split(','); else blog.label = []; this.data.blogs.push(blog); //通知各個控制器變化 this.update(); return blog; }, //刪除某一部落格 remove: function (id) { if (!id) return null; var i, len, data; for (i = 0, len = this.data.blogs.length; i < len; i++) { if (this.data.blogs[i].id === id) { data = this.data.blogs.splice(i, 1) this.update(); return data; } } return null; }, //獲取所有型別對映表 getTypeInfo: function () { var obj = {}; var i, len, type; for (i = 0, len = this.data.blogs.length; i < len; i++) { type = this.data.blogs[i].type; if (!obj[type]) obj[type] = 1; else obj[type] = obj[type] + 1; } return obj; }, //獲取標籤對映表 getLabelInfo: function () { var obj = {}, label; var i, len, j, len1, blog, label; for (i = 0, len = this.data.blogs.length; i < len; i++) { blog = this.data.blogs[i]; for (j = 0, len1 = blog.label.length; j < len1; j++) { label = blog.label[j]; if (!obj[label]) obj[label] = 1; else obj[label] = obj[label] + 1; } } return obj; }, //獲取總數 getNum: function () { return this.data.blogs.length; } }); |
這個時候再附上業務程式碼:
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 |
var AbstractView = _.inherit({ propertys: function () { this.$el = $('#main'); //事件機制 this.events = {}; }, initialize: function (opts) { //這種預設屬性 this.propertys(); }, $: function (selector) { return this.$el.find(selector); }, show: function () { this.$el.show(); 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; } }); 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 |
//頁面主流程 var View = _.inherit(AbstractView, { propertys: function ($super) { $super(); this.$el = $('#main'); //統合頁面所有點選事件 this.events = { 'click .js_add': 'blogAddAction', 'click .js_blog_del': 'blogDeleteAction' }; //例項化model並且註冊需要通知的控制器 //控制器務必做到職責單一 this.model = new Model({ scope: this, controllers: { numController: this.numController, typeController: this.typeController, labelController: this.labelController, blogsController: this.blogsController } }); }, //總部落格數 numController: function () { this.$('.js_num').html(this.model.getNum()); }, //分類數 typeController: function () { var html = ''; var tpl = document.getElementById('js_tpl_kv').innerHTML; var data = this.model.getTypeInfo(); html = _.template(tpl)({ objs: data }); this.$('.js_type_wrapper').html(html); }, //label分類 labelController: function () { //這裡的邏輯與type基本一致,但是真實情況不會這樣 var html = ''; var tpl = document.getElementById('js_tpl_kv').innerHTML; var data = this.model.getLabelInfo(); html = _.template(tpl)({ objs: data }); this.$('.js_label_wrapper').html(html); }, //列表變化 blogsController: function () { console.log(this.model.get()); var html = ''; var tpl = document.getElementById('js_tpl_blogs').innerHTML; var data = this.model.get(); html = _.template(tpl)(data); this.$('.js_blogs_wrapper').html(html); }, //新增部落格點選事件 blogAddAction: function () { //此處未做基本資料校驗,因為校驗的工作應該model做,比如字數限制,標籤過濾什麼的 //這裡只是往model中增加一條資料,事實上這裡還應該寫if預計判斷是否新增成功,略去 this.model.add( this.$('.js_title').val(), this.$('.js_type').val(), this.$('.js_label').val() ); }, blogDeleteAction: function (e) { var el = $(e.currentTarget); this.model.remove(el.attr('data-id')); } }); var view = new View(); view.show(); |
完整程式碼&示例
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 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 |
<!doctype html> <html> <head> <meta charset="UTF-8"> <title>前端MVC</title> <script src="zepto.js" type="text/javascript"></script> <script src="underscore.js" type="text/javascript"></script> <style> li { list-style: none; margin: 5px 0; } fieldset { margin: 5px 0; } </style> </head> <body> <div id="main"> <fieldset> <legend>文章總數</legend> <div class="js_num"> 0 </div> </fieldset> <fieldset> <legend>分類</legend> <div class="js_type_wrapper"> </div> </fieldset> <fieldset> <legend>標籤</legend> <div class="js_label_wrapper"> </div> </fieldset> <fieldset> <legend>部落格列表</legend> <div class="js_blogs_wrapper"> </div> </fieldset> <fieldset> <legend>新增部落格</legend> <ul> <li>標題 </li> <li> <input type="text" class="js_title" /> </li> <li>型別 </li> <li> <input type="text" class="js_type" /> </li> <li>標籤(逗號隔開) </li> <li> <input type="text" class="js_label" /> </li> <li> <input type="button" class="js_add" value="新增部落格" /> </li> </ul> </fieldset> </div> <script type="text/template" id="js_tpl_kv"> <ul> <%for(var k in objs){ %> <li><%=k %>(<%=objs[k] %>)</li> <%} %> </ul> </script> <script type="text/template" id="js_tpl_blogs"> <ul> <%for(var i = 0, len = blogs.length; i < len; i++ ){ %> <li><%=blogs[i].title %> - <span class="js_blog_del" data-id="<%=blogs[i].id %>">刪除</span></li> <%} %> </ul> </script> <script type="text/javascript"> //繼承相關邏輯 (function () { // 全域性可能用到的變數 var arr = []; var slice = arr.slice; /** * inherit方法,js的繼承,預設為兩個引數 * * @param {function} origin 可選,要繼承的類 * @param {object} methods 被建立類的成員,擴充套件的方法和屬性 * @return {function} 繼承之後的子類 */ _.inherit = function (origin, methods) { // 引數檢測,該繼承方法,只支援一個引數建立類,或者兩個引數繼承類 if (arguments.length === 0 || arguments.length > 2) throw '引數錯誤'; var parent = null; // 將引數轉換為陣列 var properties = slice.call(arguments); // 如果第一個引數為類(function),那麼就將之取出 if (typeof properties[0] === 'function') parent = properties.shift(); properties = properties[0]; // 建立新類用於返回 function klass() { if (_.isFunction(this.initialize)) this.initialize.apply(this, arguments); } klass.superclass = parent; // 父類的方法不做保留,直接賦給子類 // parent.subclasses = []; if (parent) { // 中間過渡類,防止parent的建構函式被執行 var subclass = function () { }; subclass.prototype = parent.prototype; klass.prototype = new subclass(); // 父類的方法不做保留,直接賦給子類 // parent.subclasses.push(klass); } var ancestor = klass.superclass && klass.superclass.prototype; for (var k in properties) { var value = properties[k]; //滿足條件就重寫 if (ancestor && typeof value == 'function') { var argslist = /^\s*function\s*\(([^\(\)]*?)\)\s*?\{/i.exec(value.toString())[1].replace(/\s/g, '').split(','); //只有在第一個引數為$super情況下才需要處理(是否具有重複方法需要使用者自己決定) if (argslist[0] === '$super' && ancestor[k]) { value = (function (methodName, fn) { return function () { var scope = this; var args = [ function () { return ancestor[methodName].apply(scope, arguments); } ]; return fn.apply(this, args.concat(slice.call(arguments))); }; })(k, value); } } //此處對物件進行擴充套件,當前原型鏈已經存在該物件,便進行擴充套件 if (_.isObject(klass.prototype[k]) && _.isObject(value) && (typeof klass.prototype[k] != 'function' && typeof value != 'fuction')) { //原型鏈是共享的,這裡處理邏輯要改 var temp = {}; _.extend(temp, klass.prototype[k]); _.extend(temp, value); klass.prototype[k] = temp; } else { klass.prototype[k] = value; } } //靜態屬性繼承 //相容程式碼,非原型屬性也需要進行繼承 for (key in parent) { if (parent.hasOwnProperty(key) && key !== 'prototype' && key !== 'superclass') klass[key] = parent[key]; } if (!klass.prototype.initialize) klass.prototype.initialize = function () { }; klass.prototype.constructor = klass; return klass; }; })(); </script> <script type="text/javascript"> //基類view設計 var AbstractView = _.inherit({ propertys: function () { this.$el = $('#main'); //事件機制 this.events = {}; }, initialize: function (opts) { //這種預設屬性 this.propertys(); }, $: function (selector) { return this.$el.find(selector); }, show: function () { this.$el.show(); 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; } }); //基類Model設計 var AbstractModel = _.inherit({ initialize: function (opts) { this.propertys(); this.setOption(opts); }, propertys: function () { //只取頁面展示需要資料 this.data = {}; //區域性資料改變對應的響應程式,暫定為一個方法 //可以是一個類的例項,如果是例項必須有render方法 this.controllers = {}; //全域性初始化資料時候呼叫的控制器 this.initController = null; this.scope = null; }, addController: function (k, v) { if (!k || !v) return; this.controllers[k] = v; }, removeController: function (k) { if (!k) return; delete this.controllers[k]; }, 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(); if (this.initController && this.get()) { this.initController.call(this.scope, 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); }, _update: function (key, data) { if (typeof this.controllers[key] === 'function') this.controllers[key].call(this.scope, data); else if (typeof this.controllers[key].render === 'function') this.controllers[key].render.call(this.scope, data); }, //資料跟新後需要做的動作,執行對應的controller改變dom //@override update: function (key) { var data = this.get(); var k; if (!data) return; if (this.controllers[key]) { this._update(key, data); return; } for (k in this.controllers) { this._update(k, data); } } }); </script> <script type="text/javascript"> //部落格的model模組應該是完全獨立與頁面的主流層的,並且可複用 var Model = _.inherit(AbstractModel, { propertys: function () { this.data = { blogs: [] }; }, //新增部落格 add: function (title, type, label) { //做資料校驗,具體要多嚴格由業務決定 if (!title || !type) return null; var blog = {}; blog.id = 'blog_' + _.uniqueId(); blog.title = title; blog.type = type; if (label) blog.label = label.split(','); else blog.label = []; this.data.blogs.push(blog); //通知各個控制器變化 this.update(); return blog; }, //刪除某一部落格 remove: function (id) { if (!id) return null; var i, len, data; for (i = 0, len = this.data.blogs.length; i < len; i++) { if (this.data.blogs[i].id === id) { data = this.data.blogs.splice(i, 1) this.update(); return data; } } return null; }, //獲取所有型別對映表 getTypeInfo: function () { var obj = {}; var i, len, type; for (i = 0, len = this.data.blogs.length; i < len; i++) { type = this.data.blogs[i].type; if (!obj[type]) obj[type] = 1; else obj[type] = obj[type] + 1; } return obj; }, //獲取標籤對映表 getLabelInfo: function () { var obj = {}, label; var i, len, j, len1, blog, label; for (i = 0, len = this.data.blogs.length; i < len; i++) { blog = this.data.blogs[i]; for (j = 0, len1 = blog.label.length; j < len1; j++) { label = blog.label[j]; if (!obj[label]) obj[label] = 1; else obj[label] = obj[label] + 1; } } return obj; }, //獲取總數 getNum: function () { return this.data.blogs.length; } }); //頁面主流程 var View = _.inherit(AbstractView, { propertys: function ($super) { $super(); this.$el = $('#main'); //統合頁面所有點選事件 this.events = { 'click .js_add': 'blogAddAction', 'click .js_blog_del': 'blogDeleteAction' }; //例項化model並且註冊需要通知的控制器 //控制器務必做到職責單一 this.model = new Model({ scope: this, controllers: { numController: this.numController, typeController: this.typeController, labelController: this.labelController, blogsController: this.blogsController } }); }, //總部落格數 numController: function () { this.$('.js_num').html(this.model.getNum()); }, //分類數 typeController: function () { var html = ''; var tpl = document.getElementById('js_tpl_kv').innerHTML; var data = this.model.getTypeInfo(); html = _.template(tpl)({ objs: data }); this.$('.js_type_wrapper').html(html); }, //label分類 labelController: function () { //這裡的邏輯與type基本一致,但是真實情況不會這樣 var html = ''; var tpl = document.getElementById('js_tpl_kv').innerHTML; var data = this.model.getLabelInfo(); html = _.template(tpl)({ objs: data }); this.$('.js_label_wrapper').html(html); }, //列表變化 blogsController: function () { console.log(this.model.get()); var html = ''; var tpl = document.getElementById('js_tpl_blogs').innerHTML; var data = this.model.get(); html = _.template(tpl)(data); this.$('.js_blogs_wrapper').html(html); }, //新增部落格點選事件 blogAddAction: function () { //此處未做基本資料校驗,因為校驗的工作應該model做,比如字數限制,標籤過濾什麼的 //這裡只是往model中增加一條資料,事實上這裡還應該寫if預計判斷是否新增成功,略去 this.model.add( this.$('.js_title').val(), this.$('.js_type').val(), this.$('.js_label').val() ); }, blogDeleteAction: function (e) { var el = $(e.currentTarget); this.model.remove(el.attr('data-id')); } }); var view = new View(); view.show(); </script> </body> </html> |
http://sandbox.runjs.cn/show/bvux03nx
分析
這裡註釋寫的很詳細,例子也很簡單很完整,其實並不需要太多的分析,對MVC還不太理解的朋友可以換自己方式實現以上程式碼,然後再加入評論模組,或者其它模組後,體會下開發難度,然後再用這種方式開發試試,體會不同才能體會真理,道不證不明嘛,這裡的程式碼組成為:
① 公共的繼承方法
② 公共的View抽象類,主要來說完成了view的事件繫結功能,可以將所有click事件全部寫在events中
PS:這個view是我閹割便於各位理解的view,真實情況會比較複雜
③ 公共的Model抽象類,主要完成model的骨架相關,其中比較關鍵的是update後的通知機制
④ 業務model,這個是關於部落格model的功能體現,單純的資料操作
⑤ 業務View,這個為類例項化後執行了show方法,便繫結了各個事件
這裡以一次部落格新增為例說明一下程式流程:
① 使用者填好資料後,點選增加部落格,會觸發相應js函式
② js獲取文字框資料,為model新增資料
③ model資料變化後,分發事件通知各個控制器響應變化
④ 各個controller執行,並根據model產生view的變化
好了,這個例子就到此為止,希望對幫助各位瞭解MVC有所幫助
優勢與不足
對於移動端的頁面來說,一個頁面對應著一個View.js,即上面的業務View,其中model可以完全的分離出來,如果以AMD模組化的做法的話,View.js的體積會非常小,而主要邏輯又基本拆分到了Model業務中,controller做的工作由於前端模板的介入反而變得簡單
不足之處,便是所有的controller全部繫結到了view上,互動的觸發點也全部在view身上,而更好的做法,可能是元件化,但是這類模組包含太多業務資料,做成元件化似乎重用性不高,於是就有了業務元件的誕生。
業務元件&公共頻道
所謂業務元件或者公共頻道都是網站上了一定規模會實際遇到的問題,我這裡舉一個例子:
最初我們是做機票專案於是目錄結構為:
blade 框架目錄
flight 機票業務頻道
static 公共樣式檔案
然後逐漸我們多了酒店專案以及用車專案目錄結構變成了:
blade 框架目錄
car 用車頻道
hotel 酒店頻道
flight 機票業務頻道
static 公共樣式檔案
於是一個比較實際的問題出現了,最初機票頻道的城市列表模組以及登入模組與常用聯絡人模組好像其他兩個頻道也能用,但是問題也出現了:
① 將他們抽離為UI元件,但他們又帶有業務資料
② 其它兩個頻道並不想引入機票頻道的模組配置,而且也不信任機票頻道
這個時候便會出現一個叫公共頻道的東西,他完成的工作與框架類似,但是他會涉及到業務資料,並且除了該公司,也許便不能重用:
blade 框架目錄
common 公共頻道
car 用車頻道
hotel 酒店頻道
flight 機票業務頻道
static 公共樣式檔案
各個業務頻道引入公共頻道的產品便可解決重用問題,但這樣也同時發生了耦合,如果公共頻道的頁面做的不夠靈活可配置,業務團隊使用起來會是一個噩夢!
於是更好的方案似乎是頁面模組化,儘可能的將頁面分為一個個可重用的小模組,有興趣的朋友請到這裡看看:
【shadow dom入UI】web components思想如何應用於實際專案
網站慢了
關於系統優化的建議我之前寫了很多文章,有興趣的朋友可以移駕至這裡看看:
我這裡補充一點業務優化點:
① ajax請求剝離無意義的請求,命名使用短拼
這條比較適用於新團隊,伺服器端的同事並不會關注網路請求的耗時,所以請求往往又臭又長,一個真實的例子就是,上週我推動伺服器端同事將城市列表的無意義欄位刪除後容量由90k降到了50k,並且還有優化空間!!!
② 工程化打包時候最好採用MD5的方式,這樣可做到比較舒服的application cache效果,十分推崇!
③ ……
結語&核心點
半年了,專案由最初的無趣到現在可以在上面玩MVC、玩ABTesting等高階東西了,而看著產品訂單破一,破百,破千,破萬,雖然很累,但是這個時候還是覺得是值得的。
只可惜我廠的一些制度有點過於噁心,跨團隊交流跟吃屎一樣,工作量過大,工資又低,這些點滴還是讓人感到失望的。
好了,抱怨結束,文章淺談了一些自己對移動端從0到1做業務開發的一些經驗及建議,沒有什麼高深的知識,也許還有很多錯誤的地方,請各位不吝賜教,多多指點,接下來時間學習的重點應該還是IOS,偶爾會穿插MVVM框架(angularJS等)的相關學習,有興趣的朋友可以一起關注,也希望自己儘快打通端到端吧,突破自身瓶頸。