背景
支持的业务需要做客户端的页面嵌入,并且此类页面大部分是单页应用。为了能够是组内的人都能快速的入手,并且可以分工开发,制订了这么一个规范,主要做的就是能够快速入手,便于分工协作;另一方面清晰的思路便于查找问题。
为什么要做分离?
我们知道一个网页的展示,离不开一下部分: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对结构的梳理。