整理轻量级的前端分离规范

    背景    

 
    支持的业务需要做客户端的页面嵌入,并且此类页面大部分是单页应用。为了能够是组内的人都能快速的入手,并且可以分工开发,制订了这么一个规范,主要做的就是能够快速入手,便于分工协作;另一方面清晰的思路便于查找问题。

    为什么要做分离?
    
    我们知道一个网页的展示,离不开一下部分: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对结构的梳理。
posted @ 2015-01-09 14:58  黑MAO  阅读(2653)  评论(3编辑  收藏  举报