背景
支持的業務需要做客戶端的頁面嵌入,並且此類頁面大部分是單頁應用。為了能夠是組內的人都能快速的入手,並且可以分工開發,制訂了這么一個規范,主要做的就是能夠快速入手,便於分工協作;另一方面清晰的思路便於查找問題。
為什么要做分離?
我們知道一個網頁的展示,離不開一下部分:UI(包括結構和樣式)、UI事件(DOM上綁定的鍵盤或者是鼠標事件)、邏輯處理(各個事件有各自相應的處理邏輯,發送相關請求或DOM操作)、數據(包括UI自身的數據傳遞、接口數據、客戶端數據)。如果將這幾個部分進行分離,一方面可以清晰結構,另一方面出現問題也可以很方便的查找,之間通過一種通信方式進行通信,各部分不用關注各自的具體實現,只需要在自己想要使用的時候進行調用即可。如果沒做做分離,那么邏輯層是溝通UI和數據的橋梁,這樣的話,所有的內容都會混在一起,就是普通的一種開發模式。
確定通信方式
作為前端,我們最熟悉的就是DOM的事件了,jquery在實現事件的時候,有這么一套東西,可以做到事件綁定($.fn.on)、解綁($.fn.off)、觸發($.fn.trigger或$.fn.triggerHandler)。那么我們能不能自己封裝一下這個事件,讓它作為我們通信的一種方式呢?答案當然是可以的。既然已經確定了通信方式,那我們就可以實現上述分離開的各部分進行通信了,當然最主要的就是邏輯層和UI層的通信。
期望呈現的是形式
我們期望達到的一種狀態是:我們所有使用的數據(客戶端數據和接口返回數據)都寫在一個文件里,這樣如果是數據層面的調整,如:接口調整,只需要更換接口地址即可,不需要在每次調用的地方找接口地址進行替換,做到一改全改的效果;所有DOM綁定的事件需要做的事情,都通過邏輯層按需發送相應請求,當請求返回后,我們肯定是需要拿到數據然后對DOM進行操作(重繪、重排),在邏輯層里,我們並不關注DOM到底應該怎么操作,我們關注的是達到一定的階段需要通知UI做相應的操作,至於UI是否響應對於邏輯層來說並不關注。此時,我們可以使用這個通信事件做很多事情,如:打點統計,暴漏鈎子。。。方便業務在使用的時候,監聽某個步驟發生了什么,在單元測試或者是代碼使用率測試上都有作用。
基礎結構具體實現(配合代碼分析)
下面就以業務中實現的一個例子(QClient,客戶端開發工具)來分析一下前端分離規范的實現過程吧。
1. 核心模塊(core.js)
做的就是創建可能會使用到的命名空間,便於其他模塊使用。
/**
* @Overview 核心模塊
* 1.創建命名空間
* 2.初始化公共事件
*/
(function(window) {
'use strict';
var QClient = window.QClient = {
//框架類型
$ : jQuery,
//工具類
utils: {},
//UI集合
ui: {},
//數據集合
sync: {},
//事件
events: {},
//調試模式
DEBUG: false
};
})(window);
2. 數據模塊(data.js)
將常見的數據獲取方式放在這里處理,常見的數據方式有:接口請求返回數據和客戶端返回數據(這個在客戶端內嵌頁面會比較常用)。可以處理get、post請求以及客戶端請求,同時處理同域和跨域問題。這樣在調用的時候就不用關注這個請求是什么形式了,暴漏相應的API方便調用即可。這里使用promise方式封裝,使用起來更加方便。這里使用的接口主要是get的跨域請求和客戶端數據,如果想實現其他請求可以參考之前的一篇文章,
跨域請求解決方案。
/**
* @Overview 數據接口模塊
* 數據交互,如果接口很少可以放到logic中
*/
(function(Q){
var $ = Q.$;
/**
* 交互類
* @param {object} param 要提交的數據
* @param {Object} [ajaxOpt] ajax配置
* @constructor
*/
var Sync = function(param, ajaxOpt) {
if(!param) {
return;
}
var protocol = this.protocol = 'http';
var ajaxOptDefault = {
url: protocol + '://'+location.host,
type: 'GET',
dataType: 'jsonp',
timeout: 20000
};
this.protocol = protocol;
this.param = $.extend({}, param);
this.ajaxOpt = $.extend({data: this.param}, ajaxOptDefault, ajaxOpt);
this.HOST = protocol + '://'+location.host;
};
/* 示例:window.external.getSID(arg0)需要改為 external_call("getSID",arg0) 的形式進行調用 */
function external_call(extName,arg0,arg1,arg2){
var args = arguments, fun = args.callee, argsLen = args.length, funLen = fun.length;
if(argsLen>funLen){
throw new Error("window.external_call僅接受"+funLen+"個形參,但當前(extName:"+extName+")傳入了"+argsLen+"個實參,請適當調整external_call,以保證參數正常傳遞,避免丟失!");
}
if(window.external_call_test){
return window.external_call_test.apply(null,[].slice.apply(args));
}
/* 這里的參數需要根據external_call的形參進行調整,以保證正常傳遞
* IE系列external方法不支持apply、call...
* 甚至部分客戶端對參數長度也要求必須嚴格按約定傳入
* 所以保證兼容性就必須人肉維護下面這么一坨..
*/
if(argsLen==1)return window.external[extName]();
if(argsLen==2)return window.external[extName](arg0);
if(argsLen==3)return window.external[extName](arg0,arg1);
if(argsLen==4)return window.external[extName](arg0,arg1,arg2);
}
$.extend(Sync.prototype, {
/**
* 通過get方式(jsonp)提交
* @param {String} [url] 請求鏈接
* @return {Object} promise對象
*/
get: function(url) {
var self = this;
var send = $.ajax(url, this.ajaxOpt);
return send.then(this.done, function(statues) {
return self.fail(statues);
});
},
/**
* 通知客戶端
*/
informClient: function() {
var self = this;
var deferred = $.Deferred();
var args = [].slice.apply(arguments);
try {
var data = external_call.apply(null, args);
deferred.resolve(data);
}catch (e) {
deferred.reject({
errno: 10000,
errmsg: '通知客戶端異常'
});
}
return deferred.promise()
.then(self.done, self.fail);
},
/**
* 收到響應時默認回調
* @param {Object} data 數據
* @return {Object}
*/
done: function (data) {
var deferred = $.Deferred();
deferred.resolve(data);
return deferred.promise();
},
/**
* 未收到響應時默認回調
* @param {Object} error 錯誤信息
* @return {Object}
*/
fail: function(error) {
var deferred = $.Deferred();
deferred.reject({
errno: 999999,
errmsg: '網絡超時,請稍后重試'
});
return deferred.promise();
}
});
QClient.Sync = Sync;
})(QClient);
3. 邏輯模塊(logic_factory.js)
主要關聯UI和邏輯層,這里主要做了這么一些事情:第一,作為整個應用的入口,傳入相關參數后,初始化UI;第二:處理整個邏輯內的數據傳遞;第三:根據實際情況暴漏相關接口給外部調用;第四:建立基礎的通信方式,實現邏輯層與UI層的事件通信。具體實現方式和解釋,如下:
/**
* @Overview 邏輯模塊工廠
* 1.定義各功能公用的功能
* 2.單功能間數據緩存
*/
(function(Q) {
//'use strict';
var $ = Q.$;
var $events = $(Q.events);
var Logic = function(props) {
this.name = 'func_' + Q.utils.getGuid();
this.extend(props);
this._initFlag = false;
this._data = {};
};
$.extend(Logic.prototype, {
/**
* 初始化函數
*/
init : function() {
var self = this;
if (!self._initFlag) {
self._initFlag = true;
Q.ui[self.name].init(self);
self.initJsBind();
}
return self;
},
/**
* 獲取是否已經初始化的標記
* @returns {boolean}
*/
isInit: function() {
return this._initFlag;
},
/**
* 獲取數據
* @param {String} key
* @param {*} defaultValue
* @returns {*}
*/
get : function(key, defaultValue) {
var value = this._data[key];
return value !== undefined ? value : defaultValue;
},
/**
* 設置數據
* @param {String|Object} key
* @param {*} value
*/
set : function(key, value) {
if ($.isPlainObject(key)) {
$.extend(this._data, key);
} else {
this._data[key] = value;
}
return this;
},
/**
* 清理數據
*/
clear : function() {
this._data = {};
return this;
},
/**
* 客戶端調用頁面JS
*/
initJsBind: function () {
var self = this;
window.jsBind = function(funcName) {
var args = [].slice.apply(arguments, [1]);
return self[funcName].apply(self, args);
};
},
/**
* 擴展實例方法
* @param {...object} - 待mixin的對象
*/
extend : function() {
var args = [].slice.apply(arguments);
args.unshift(this);
$.extend.apply(null, args);
}
});
//創建事件通信方式
$.each(['on', 'off', 'one', 'trigger'], function(i, type) {
Logic.prototype[type] = function() {
$.fn[type].apply($events, arguments);
return this;
};
});
Q.getLogic = function(props) {
return new Logic(props);
};
})(QClient);
4. 工具模塊(utils.js)
這個模塊兒沒什么特殊的含義,只是存放一些工具方法。可以在基礎包,也可以自己在使用的時候定義。
如何使用?
1. 引入上面所講到的基礎文件
2.分別創建上面對應的功能文件,如:data.js、ui.js、logic.js、utils.js,當然如果項目並不是很大,也可以放在一個文件里實現,這里分開是為了結構更加的清晰
2.1 創建data.js,存放業務將要使用到的所有數據接口
(function(Q) {
'use strict';
var Sync = Q.Sync;
Q.sync = {
//獲取class
getClassify: function(catgoryConf) {
var sync = new Sync();
return sync.informClient('onGetClassify', catgoryConf);
},
//獲取當前皮膚
getCurrentSkin: function() {
var sync = new Sync();
return sync.informClient('GetCurrentSkinName');
}
};
}(QClient));
2.2 創建logic.js,建議每個功能創建一個,可以更好的組裝和分離
/**
* @Overview 邏輯交互
* 接收UI狀態,通知UI新操作
*/
(function(Q){
'use strict';
var utils = Q.utils;
var logic = Q.getLogic({
name: 'changeSkin',
run: function(opts){
var _this = this;
var catgoryConf = opts.catgoryConf;
_this.init();
Q.sync.getClassify(utils.stringify(catgoryConf));
Q.sync.getCurrentSkin()
.done(function(data) {
var currKey = data.extra_info;
_this.setCurrentSkin( currKey );
});
},
//通過事件進行通信
setCurrentSkin: function (key) {
this.trigger('setCurrentSkin', key);
},
setDownLoadStart: function(key) {
this.trigger('setDownLoadStart', key);
},
setDownLoadSuccess: function(key) {
this.trigger('setDownLoadSuccess', key);
},
setDownLoadFailed: function(key) {
this.trigger('setDownLoadFailed', key);
}
//也可以通過promise對結果進行處理,方便UI直接調用邏輯操作,同時在這里可以保證UI使用到的數據是完全可信的狀態,UI不用判斷數據是否為空等異常情況
});
Q.changeSkin = function(opts) {
logic.run(opts);
};
})(QClient);
2.3 創建ui.js,和邏輯層配合使用
/**
* @Overview UI模塊
* 頁面交互,通知狀態
*/
(function(Q){
'use strict';
var $ = Q.$;
var $skinlist = $('.skin-list');
var ui = {
init : function(model) {
this.model = model;
this.initEvent();
this.initModelEvent();
},
initModelEvent: function() {
var _this = this;
//監聽邏輯層觸發的事件
this.model
.on('setDownLoadFailed', function( e, key ){
var $item = _this.getCurrentItem( key );
_this.stopLoading( $item );
$item.addClass('err');
})
.on('setDownLoadSuccess', function( e, key ){
var $item = _this.getCurrentItem( key );
_this.stopLoading( $item );
})
.on('setDownLoadStart', function( e, key ){
var $item = _this.getCurrentItem( key );
var $loading = $item.find('i span');
var i = 0;
$item.addClass('loading').removeClass('err hover');
$item[0].timer = setInterval(function(){
i = i >= 12 ? 0 : i;
var x = -i*32 ;
$loading.css('left' , x );
i++;
},100);
})
.on('setCurrentSkin', function( e, key ){
var $item = _this.getCurrentItem( key );
_this.stopLoading( $item );
$item.addClass('selected').siblings().removeClass('selected');
});
},
initEvent: function() {
var _this = this;
//Q.utils.disabledKey();
$skinlist.on('click','a',function(){
var $item = $(this).parent();
if( $item.hasClass('loading') || $item.hasClass('selected')){
return false;
}
});
//hover狀態
$skinlist.on('mouseover','a',function(){
var $parent = $(this).parent();
(!$parent.hasClass('loading') && !$parent.hasClass('selected')) && $parent.addClass('hover');
}).on('mouseout','a',function(){
$(this).parent().removeClass('hover');
});
//圖片延遲加載
var $img = $skinlist.find('img');
$img.lazyload({
container: $skinlist
});
//初始化滾動條
_this.scrollBar = new CusScrollBar({
scrollDir:"y",
contSelector: $skinlist ,
scrollBarSelector:".scroll",
sliderSelector:".slider",
wheelBindSelector:".wrapper",
wheelStepSize:151
});
_this.scrollBar._sliderW.hover(function(){
$(this).addClass('cus-slider-hover');
}, function(){
$(this).removeClass('cus-slider-hover');
});
_this.scrollBar.on("resizeSlider",function(){
$(".slider-bd").css("height",this.getSliderSize()-10);
}).resizeSlider();
},
reload: function () {
var _this = this;
var $cur = [];
$(".item").each(function(){
if( $(this).css('display') == 'block' ){
$cur.push($(this));
}
});
$.each( $cur , function( index ){
if( index <= 9 ){
var $img = $(this).find('img');
$img.attr('src',$img.attr('data-original'));
}
});
if( $cur.length <=6 ){
$(_this.scrollBar.options.scrollBarSelector).hide();
}
else{
$(_this.scrollBar.options.scrollBarSelector).show();
}
$(_this.scrollBar.options.sliderSelector).css('top',0);
_this.scrollBar.resizeSlider().scrollToAnim(0);
},
LoadCatgory : function( type ){
if( type && type!="all" ){
var $items = $skinlist.find('.item[data-type="'+ type +'"]');
$skinlist.find('.item').hide();
$items.fadeIn(100);
}
else{
$skinlist.find('.item').fadeIn(100);
}
this.reload();
},
setErrByLevel : function(){
console&&console.log('等級不符,快去升級吧!');
},
getCurrentItem: function( key ){
return $skinlist.find('.item[data-key="'+ key +'"]');
},
stopLoading : function( $item ){
if( $item.hasClass('loading') ){
clearInterval($item[0].timer);
$item[0].timer = null;
$item.removeClass('loading');
$item.find('i span').css('left','0');
}
}
};
Q.ui.changeSkin = {
init : function() {
ui.init.apply(ui, arguments);
}
};
})(QClient);
2.4 創建utils.js,除了基礎包中存在的工具,自己業務可能使用到的可以放在這里(可有可無,非必須),以下是舉例這個項目使用到的工具方法
/**
* @Overview 工具方法
* 各種子功能方法:cookie、滾動條、屏蔽按鍵等等
*/
(function(Q){
'use strict';
var utils = Q.utils;
var guid = parseInt(new Date().getTime().toString().substr(4), 10);
/**
* 獲取唯一ID
* @returns {number}
*/
utils.getGuid = function() {
return guid++;
};
/**
* 通用回調解析函數
* @param {String|Function|Boolean} callback 回調函數 或 跳轉url 或 true刷新頁面
* @returns {Function} 解析后的函數
*/
utils.parseCallback = function(callback) {
if ($.type(callback) == 'function') {
return callback;
} else if (callback === true) {
return function() {
location.reload();
};
} else if ($.type(callback) == 'string' && callback.indexOf('http') === 0) {
return function() {
location.href = callback;
};
} else {
return function() {};
}
};
/**
* 阻止各種按鍵
*/
utils.disabledKey = function() {
document.onkeydown = function(e){
//屏蔽刷新 F5 Ctrl + F5 Ctrl + R Ctrl + N
var event = e || window.event;
var k = event.keyCode;
if((event.ctrlKey === true && k == 82) || (event.ctrlKey === true && k == 78) || (k == 116) || (event.ctrlKey === true && k == 116))
{
event.keyCode = 0;
event.returnValue = false;
event.cancelBubble = true;
return false;
}
};
document.onclick = function( e ){
//屏蔽 Shift + click Ctrl + click
var event = e || window.event;
var tagName = '';
try{
tagName = (event.target || event.srcElement).tagName.toLowerCase();
}catch(error){}
if( (event.shiftKey || event.ctrlKey) && tagName == 'a' ){
event.keyCode = 0;
event.returnValue = false;
event.cancelBubble = true;
return false;
}
};
document.oncontextmenu = function(){
//屏右鍵菜單
return false;
};
document.ondragstart = function(){
//屏蔽拖拽
return false;
};
document.onselectstart = function( e ){
//屏蔽選擇,textarea 和 input 除外
var event = e || window.event;
var tagName = '';
try{
tagName = (event.target || event.srcElement).tagName.toLowerCase();
}catch(error){}
if( tagName != 'textarea' && tagName != 'input'){
return false;
}
};
};
/**
* 對象轉字符串
* @param {Object} obj
*/
utils.stringify = function(obj) {
if ("JSON" in window) {
return JSON.stringify(obj);
}
var t = typeof (obj);
if (t != "object" || obj === null) {
// simple data type
if (t == "string") obj = '"' + obj + '"';
return String(obj);
} else {
// recurse array or object
var n, v, json = [], arr = (obj && obj.constructor == Array);
for (n in obj) {
v = obj[n];
t = typeof(v);
if (obj.hasOwnProperty(n)) {
if (t == "string") {
v = '"' + v + '"';
} else if (t == "object" && v !== null){
v = Safe.stringify(v);
}
json.push((arr ? "" : '"' + n + '":') + String(v));
}
}
return (arr ? "[" : "{") + String(json) + (arr ? "]" : "}");
}
};
})(QClient);
3. 通過入口方法進行調用
QClient.changeSkin({
catgoryConf: CATGORY_CONF //需要從頁面初始化的參數
});
優勢
- 實現了前端各功能結構上的分離,使得結構上更加清晰
- UI和邏輯分離使用事件通信,可以更好的進行分工開發
- 便於擴展,方便代碼的重構和升級
劣勢
最明顯的是一個功能可能產出多個文件,不過可以按照上面提到的方式(各模塊都放在一個文件里)解決;也或者搞一個工具來做文件的合並處理,這個現在也不是什么難事。這里針對我們的業務實現了一個專門處理這個的工具,僅供參考(
QClient開發工具,該工具集成了上述前端分離規范)【由於第一次開發這種工具,代碼組織的還不是很好,大概就是說明有這么一種形式】
感謝
感謝摩天營的同學提出的改進建議,特別感謝
@jedmeng對結構的梳理。
