【移动前端开发实践】从无到有(统计、请求、MVC、模块化)H5开发须知

前言

不知不觉来百度已有半年之久,这半年是996的半年,是孤军奋战的半年,是跌跌撞撞的半年,一个字:真的是累死人啦!

我所进入的团队相当于公司内部创业团队,人员基本全部是新招的,最初开发时连数据库都没设计,当时评审需求的时候居然有一个产品经理拿了一份他设计的数据库,当时我作为一个前端就惊呆了......

最初的前端只有我1人,这事实上与我想来学习学习的愿望是背道而驰的,但既然来都来了也只能独挑大梁,马上投入开发,当时涉及的项目有:

① H5站点

② PC站点

③ Mis后台管理系统

④ 各种百度渠道接入

第一阶段的重点为H5站点与APP,我们便需要在20天内从无到有的完成第一版的产品,而最初的Native人力严重不足,很多页面依赖于H5这边,所以前端除了本身业务之外还得约定与Native的交互细节。

这个情况下根本无暇思考其它框架,熟悉的就是最好的!便将自己git上的开源框架直接拿来用了起来:[置顶]【blade利刃出鞘】一起进入移动端webapp开发吧

因为之前的经验积累,工程化、Hybrid交互、各种兼容、体验问题已经处理了很多了,所以基础架构一层比较完备,又有完善的UI组件可以使用,这个是最初的设计构想:

构想总是美好的,而在巨大的业务压力面前任何技术愿景都是苍白的,最初我在哪里很傻很天真的用CSS3画图标,然后产品经理天天像一个苍蝇一样在我面前嗡嗡嗡,他们事实上是不关注页面性能是何物的,我也马上意识的到工期不足,于是便直接用图标了!

依赖于完善的框架,20天不到的时间,第一版的项目便结束了,业务代码有点不堪入目,页面级的代码也没有太遵循MVC规则,这导致了后续的迭代,全部在那里操作dom。

其实初期这样做问题不大,如果项目比较小(比如什么一次性的活动页面)问题也不大,但是核心项目便最好不要这样玩了,因为新需求、新场景,会让你在原基础上不断的改代码,如果页面没有一个很好的规范,那么他将不再稳定,也不再容易维护,如何编写一个可稳定、扩展性高、可维护性高的项目,是我们今天讨论的重点。

认真阅读此文可能会在以下方面对你有所帮助:

① 网站初期需要统计什么数据?产品需要的业务数据,你该如何设计你的网站才能收集到这些数据,提供给他
② 完整的请求究竟应该如何发出,H5应该如何在前端做缓存,服务器给出的数据应该在哪里做校验,前端错误日志应该关注js错误还是数据错误?
③ 你在写业务代码时犯了什么错误,如何编写高效可维护的业务代码(页面级别),MVC到底是个什么东西?
④ 网站规模大了如何复用一些模块?
⑤ 站在业务角度应该如何做性能优化(这个可能不是本文的重点)

文中是我半年以来的一些业务开发经验,希望对各位有用,也希望各位多多支持讨论,指出文中不足以及提出您的一些建议

统计需求

通用统计需求

对于服务器端来说,后期最重要的莫过于监控日志,对于前端来说,统计无疑是初期最重要的,通用的统计需求包括:

① PV/UV统计

② 机型/浏览器/系统统计

③ 各页面载入速度统计

④ 某些按钮的点击统计

⑤ ......

这类统计直接通过百度统计之类的工具即可,算是最基础的统计需求。百度产品的文档、支持团队烂估计是公认的事情了,我便只能挖掘很少一部分用法。但是这类数据也是非常重要了,对于产品甚至是老板判断整个产品的发展有莫大的帮助与引导作用,如果产品死了,任何技术都是没有意义的,所以站点没有这类统计的速度加上吧!

http://tongji.baidu.com/web/welcome/login

渠道统计

所谓渠道统计便是这次订单来源是哪里,就我们产品的渠道有:

① 手机百度APP入口(由分为生活+入口、首页banner入口、广告入口......)

② 百度移动站点入口

③ 百度地图入口(包括H5站点)

④ wise卡片入口(包括:唯一答案、白卡片、极速版、点到点卡片......)

⑤ 各种大礼包、活动入口

⑥ SEM入口

⑦ ......

你永远不能预料到你究竟有多少入口,但是这种渠道的统计的重要性直接关乎了产品的存亡,产品需要知道自己的每次的活动,每次的引流是有意义的,比如一次活动便需要得到这次活动每天产生的订单量,如果你告诉产品,爷做不到,那么产品会真叫你爷爷。

当然,渠道的统计前端单方面是完成不了的,需要和服务器端配合,一般而言可以这样做,前端与服务器端约定,每次请求皆会带特定的参数,我一般会与服务器约定以下参数:

var param = {
    head: {
        us: '渠道',
        version: '1.0.0'
    }
};

这个head参数是每次ajax请求都会带上的,而us参数一般由url而来,他要求每次由其它渠道落地到我们的站点一定要带有us参数,us参数拿到后便是我们自己的事情了,有几种操作方法:

① 直接种到cookie,这个需要服务器端特殊处理

② 存入localstorage,每次请求拿出来,组装请求参数

③ 因为我们H5站点的每一次跳转都会经过框架中转,所以我直接将us数据放到了url上,每次跳转都会带上,一直到跳出网站。

SEM需求

SEM其实属于渠道需求的一类,这里会独立出来是因为,他需要统计的数据更多,还会包含一个投放词之类的数据,SEM投放人员需要确切的知道某个投放词每天的订单量,这个时候上面的参数可能就要变化了:

var param = {
    head: {
        us: '渠道',
        version: '1.0.0',
        extra: '扩展字段'
    }
};

这个时候可能便需要一个extra的扩展字段记录投放词是什么,当然SEM落地到我们网站的特殊参数也需要一直传下去,这个需要做框架层的处理,这里顺便说下我的处理方案吧

统一跳转

首先我们H5站点基本不关注SEO,对于SEO我们有特殊的处理方案,所以在我们的H5站点上基本不会出现a标签,我们站点的每次跳转皆是由js控制,我会在框架封装几个方法处理跳转:

forward: function (view) {
     //处理频道内跳转
}

back: function (view) {
}

jump: function (project, view) {
     //处理跨频道跳转
}

这样做的好处是:

① 统一封装跳转会让前端控制力增加,比如forward可以是location变化,也可以是pushState/hash的方式做单页跳转,甚至可以做Hybrid中多Webview的跳转

② 诚如上述,forward时可以由url获取渠道参数带到下一个页面

③ 统一跳转也可以统一为站点做一些打点的操作,比如单页应用时候的统一加统计代码

最简单的理解就是:封装一个全局方法做跳转控制,所有的跳转由他发出。

请求模块

ajax是前端到服务器端的基石,但是前端和服务器端的交互:

每个接口必须要写文档!
每个接口必须要写文档!
每个接口必须要写文档!
重要的事情说三遍!!!

如果不写文档的话,你就等着吧,因为端上是入口,一旦出问题,老板会直观认为是前端的问题,如果发现是服务器的字段不统一导致,而服务器端打死不承认,你就等着吧!

无论什么时候,前端请求模块的设计是非常关键的,因为前端只是数据的搬运工,负责展现数据而已:)

封装请求模块

与封装统一跳转一致,所有的请求必须收口,最烂的做法也是封装一个全局的方法处理全站请求,这样做的好处是:

① 处理公共参数

比如每次请求必须带上上面所述head业务参数,便必须在此做处理

② 处理统一错误码

服务器与前端一般会有一个格式约定,一般而言是这样的:

{
  data: {},
  errno: 0,
  msg: "success"
}

比如错误码为1的情况就代表需要登录,系统会引导用户进入登录页,比如非0的情况下,需要弹出一个提示框告诉用户出了什么问题,你不可能在每个地方都做这种错误码处理吧

③ 统一缓存处理

有些请求数据不会经常改变,比如城市列表,比如常用联系人,这个时候便需要将之存到localstorage中做缓存

④ 数据处理、日志处理

这里插一句监控的问题,因为前端代码压缩后,js错误监控变得不太靠谱,而前端的错误有很大可能是搬运数据过程中出了问题,所以在请求model层做对应的数据校验是十分有意义的
如果发现数据不对便发错误日志,好过被用户抓住投诉,而这里做数据校验也为模板中使用数据做了基础检查

服务器端给前端的数据可能是松散的,前端真实使用时候会对数据做处理,同一请求模块如果在不同地方使用,就需要多次处理,这个是不需要的,比如:

//这个判断应该放在数据模块中
if(data.a) ...
if(data.a.b) ...

这里我说下blade框架中请求模块的处理:

blade的请求模块

我们现在站点主要还是源于blade框架,实际使用时候做了点改变,后续会回归到blade框架,项目目录结构为:

其中store依赖于storage模块,是处理localstorage缓存的,他与model是独立的,以下为核心代码:

  1 define([], function () {
  2 
  3   var Model = _.inherit({
  4     //默认属性
  5     propertys: function () {
  6       this.protocol = 'http';
  7       this.domain = '';
  8       this.path = '';
  9       this.url = null;
 10       this.param = {};
 11       this.validates = [];
 12       //      this.contentType = 'application/json';
 13 
 14       this.ajaxOnly = true;
 15 
 16       this.contentType = 'application/x-www-form-urlencoded';
 17       this.type = 'GET';
 18       this.dataType = 'json';
 19     },
 20 
 21     setOption: function (options) {
 22       _.extend(this, options);
 23     },
 24 
 25     assert: function () {
 26       if (this.url === null) {
 27         throw 'not override url property';
 28       }
 29     },
 30 
 31     initialize: function (opts) {
 32       this.propertys();
 33       this.setOption(opts);
 34       this.assert();
 35 
 36     },
 37 
 38     pushValidates: function (handler) {
 39       if (typeof handler === 'function') {
 40         this.validates.push($.proxy(handler, this));
 41       }
 42     },
 43 
 44     setParam: function (key, val) {
 45       if (typeof key === 'object') {
 46         _.extend(this.param, key);
 47       } else {
 48         this.param[key] = val;
 49       }
 50     },
 51 
 52     removeParam: function (key) {
 53       delete this.param[key];
 54     },
 55 
 56     getParam: function () {
 57       return this.param;
 58     },
 59 
 60     //构建url请求方式,子类可复写,我们的model如果localstorage设置了值便直接读取,但是得是非正式环境
 61     buildurl: function () {
 62       //      var baseurl = AbstractModel.baseurl(this.protocol);
 63       //      return this.protocol + '://' + baseurl.domain + '/' + baseurl.path + (typeof this.url === 'function' ? this.url() : this.url);
 64       throw "[ERROR]abstract method:buildurl, must be override";
 65 
 66     },
 67 
 68     onDataSuccess: function () {
 69     },
 70 
 71     /**
 72     *    取model数据
 73     *    @param {Function} onComplete 取完的回调函
 74     *    传入的第一个参数为model的数第二个数据为元数据,元数据为ajax下发时的ServerCode,Message等数
 75     *    @param {Function} onError 发生错误时的回调
 76     *    @param {Boolean} ajaxOnly 可选,默认为false当为true时只使用ajax调取数据
 77     * @param {Boolean} scope 可选,设定回调函数this指向的对象
 78     * @param {Function} onAbort 可选,但取消时会调用的函数
 79     */
 80     execute: function (onComplete, onError, ajaxOnly, scope) {
 81       var __onComplete = $.proxy(function (data) {
 82         var _data = data;
 83         if (typeof data == 'string') _data = JSON.parse(data);
 84 
 85         // @description 开发者可以传入一组验证方法进行验证
 86         for (var i = 0, len = this.validates.length; i < len; i++) {
 87           if (!this.validates[i](data)) {
 88             // @description 如果一个验证不通过就返回
 89             if (typeof onError === 'function') {
 90               return onError.call(scope || this, _data, data);
 91             } else {
 92               return false;
 93             }
 94           }
 95         }
 96 
 97         // @description 对获取的数据做字段映射
 98         var datamodel = typeof this.dataformat === 'function' ? this.dataformat(_data) : _data;
 99 
100         if (this.onDataSuccess) this.onDataSuccess.call(this, datamodel, data);
101         if (typeof onComplete === 'function') {
102           onComplete.call(scope || this, datamodel, data);
103         }
104 
105       }, this);
106 
107       var __onError = $.proxy(function (e) {
108         if (typeof onError === 'function') {
109           onError.call(scope || this, e);
110         }
111       }, this);
112 
113       this.sendRequest(__onComplete, __onError);
114 
115     },
116 
117     sendRequest: function (success, error) {
118       var url = this.buildurl();
119       var params = _.clone(this.getParam() || {});
120       var crossDomain = {
121         'json': true,
122         'jsonp': true
123       };
124 
125       //      if (this.type == 'json')
126       //      if (this.type == 'POST') {
127       //        this.dataType = 'json';
128       //      } else {
129       //        this.dataType = 'jsonp';
130       //      }
131 
132       if (this.type == 'POST') {
133         this.dataType = 'json';
134       }
135 
136       //jsonp与post互斥
137       $.ajax({
138         url: url,
139         type: this.type,
140         data: params,
141         dataType: this.dataType,
142         contentType: this.contentType,
143         crossDomain: crossDomain[this.dataType],
144         timeout: 50000,
145         xhrFields: {
146           withCredentials: true
147         },
148         success: function (res) {
149           success && success(res);
150         },
151         error: function (err) {
152           error && error(err);
153         }
154       });
155 
156     }
157 
158   });
159 
160   Model.getInstance = function () {
161     if (this.instance) {
162       return this.instance;
163     } else {
164       return this.instance = new this();
165     }
166   };
167 
168   return Model;
169 });
model
  1 define(['AbstractStorage'], function (AbstractStorage) {
  2 
  3   var Store = _.inherit({
  4     //默认属性
  5     propertys: function () {
  6 
  7       //每个对象一定要具有存储键,并且不能重复
  8       this.key = null;
  9 
 10       //默认一条数据的生命周期,S为秒,M为分,D为天
 11       this.lifeTime = '30M';
 12 
 13       //默认返回数据
 14       //      this.defaultData = null;
 15 
 16       //代理对象,localstorage对象
 17       this.sProxy = new AbstractStorage();
 18 
 19     },
 20 
 21     setOption: function (options) {
 22       _.extend(this, options);
 23     },
 24 
 25     assert: function () {
 26       if (this.key === null) {
 27         throw 'not override key property';
 28       }
 29       if (this.sProxy === null) {
 30         throw 'not override sProxy property';
 31       }
 32     },
 33 
 34     initialize: function (opts) {
 35       this.propertys();
 36       this.setOption(opts);
 37       this.assert();
 38     },
 39 
 40     _getLifeTime: function () {
 41       var timeout = 0;
 42       var str = this.lifeTime;
 43       var unit = str.charAt(str.length - 1);
 44       var num = str.substring(0, str.length - 1);
 45       var Map = {
 46         D: 86400,
 47         H: 3600,
 48         M: 60,
 49         S: 1
 50       };
 51       if (typeof unit == 'string') {
 52         unit = unit.toUpperCase();
 53       }
 54       timeout = num;
 55       if (unit) timeout = Map[unit];
 56 
 57       //单位为毫秒
 58       return num * timeout * 1000 ;
 59     },
 60 
 61     //缓存数据
 62     set: function (value, sign) {
 63       //获取过期时间
 64       var timeout = new Date();
 65       timeout.setTime(timeout.getTime() + this._getLifeTime());
 66       this.sProxy.set(this.key, value, timeout.getTime(), sign);
 67     },
 68 
 69     //设置单个属性
 70     setAttr: function (name, value, sign) {
 71       var key, obj;
 72       if (_.isObject(name)) {
 73         for (key in name) {
 74           if (name.hasOwnProperty(key)) this.setAttr(k, name[k], value);
 75         }
 76         return;
 77       }
 78 
 79       if (!sign) sign = this.getSign();
 80 
 81       //获取当前对象
 82       obj = this.get(sign) || {};
 83       if (!obj) return;
 84       obj[name] = value;
 85       this.set(obj, sign);
 86 
 87     },
 88 
 89     getSign: function () {
 90       return this.sProxy.getSign(this.key);
 91     },
 92 
 93     remove: function () {
 94       this.sProxy.remove(this.key);
 95     },
 96 
 97     removeAttr: function (attrName) {
 98       var obj = this.get() || {};
 99       if (obj[attrName]) {
100         delete obj[attrName];
101       }
102       this.set(obj);
103     },
104 
105     get: function (sign) {
106       var result = [], isEmpty = true, a;
107       var obj = this.sProxy.get(this.key, sign);
108       var type = typeof obj;
109       var o = { 'string': true, 'number': true, 'boolean': true };
110       if (o[type]) return obj;
111 
112       if (_.isArray(obj)) {
113         for (var i = 0, len = obj.length; i < len; i++) {
114           result[i] = obj[i];
115         }
116       } else if (_.isObject(obj)) {
117         result = obj;
118       }
119 
120       for (a in result) {
121         isEmpty = false;
122         break;
123       }
124       return !isEmpty ? result : null;
125     },
126 
127     getAttr: function (attrName, tag) {
128       var obj = this.get(tag);
129       var attrVal = null;
130       if (obj) {
131         attrVal = obj[attrName];
132       }
133       return attrVal;
134     }
135 
136   });
137 
138   Store.getInstance = function () {
139     if (this.instance) {
140       return this.instance;
141     } else {
142       return this.instance = new this();
143     }
144   };
145 
146   return Store;
147 });
store
  1 define([], function () {
  2 
  3   var Storage = _.inherit({
  4     //默认属性
  5     propertys: function () {
  6 
  7       //代理对象,默认为localstorage
  8       this.sProxy = window.localStorage;
  9 
 10       //60 * 60 * 24 * 30 * 1000 ms ==30天
 11       this.defaultLifeTime = 2592000000;
 12 
 13       //本地缓存用以存放所有localstorage键值与过期日期的映射
 14       this.keyCache = 'SYSTEM_KEY_TIMEOUT_MAP';
 15 
 16       //当缓存容量已满,每次删除的缓存数
 17       this.removeNum = 5;
 18 
 19     },
 20 
 21     assert: function () {
 22       if (this.sProxy === null) {
 23         throw 'not override sProxy property';
 24       }
 25     },
 26 
 27     initialize: function (opts) {
 28       this.propertys();
 29       this.assert();
 30     },
 31 
 32     /*
 33     新增localstorage
 34     数据格式包括唯一键值,json字符串,过期日期,存入日期
 35     sign 为格式化后的请求参数,用于同一请求不同参数时候返回新数据,比如列表为北京的城市,后切换为上海,会判断tag不同而更新缓存数据,tag相当于签名
 36     每一键值只会缓存一条信息
 37     */
 38     set: function (key, value, timeout, sign) {
 39       var _d = new Date();
 40       //存入日期
 41       var indate = _d.getTime();
 42 
 43       //最终保存的数据
 44       var entity = null;
 45 
 46       if (!timeout) {
 47         _d.setTime(_d.getTime() + this.defaultLifeTime);
 48         timeout = _d.getTime();
 49       }
 50 
 51       //
 52       this.setKeyCache(key, timeout);
 53       entity = this.buildStorageObj(value, indate, timeout, sign);
 54 
 55       try {
 56         this.sProxy.setItem(key, JSON.stringify(entity));
 57         return true;
 58       } catch (e) {
 59         //localstorage写满时,全清掉
 60         if (e.name == 'QuotaExceededError') {
 61           //            this.sProxy.clear();
 62           //localstorage写满时,选择离过期时间最近的数据删除,这样也会有些影响,但是感觉比全清除好些,如果缓存过多,此过程比较耗时,100ms以内
 63           if (!this.removeLastCache()) throw '本次数据存储量过大';
 64           this.set(key, value, timeout, sign);
 65         }
 66         console && console.log(e);
 67       }
 68       return false;
 69     },
 70 
 71     //删除过期缓存
 72     removeOverdueCache: function () {
 73       var tmpObj = null, i, len;
 74 
 75       var now = new Date().getTime();
 76       //取出键值对
 77       var cacheStr = this.sProxy.getItem(this.keyCache);
 78       var cacheMap = [];
 79       var newMap = [];
 80       if (!cacheStr) {
 81         return;
 82       }
 83 
 84       cacheMap = JSON.parse(cacheStr);
 85 
 86       for (i = 0, len = cacheMap.length; i < len; i++) {
 87         tmpObj = cacheMap[i];
 88         if (tmpObj.timeout < now) {
 89           this.sProxy.removeItem(tmpObj.key);
 90         } else {
 91           newMap.push(tmpObj);
 92         }
 93       }
 94       this.sProxy.setItem(this.keyCache, JSON.stringify(newMap));
 95 
 96     },
 97 
 98     removeLastCache: function () {
 99       var i, len;
100       var num = this.removeNum || 5;
101 
102       //取出键值对
103       var cacheStr = this.sProxy.getItem(this.keyCache);
104       var cacheMap = [];
105       var delMap = [];
106 
107       //说明本次存储过大
108       if (!cacheStr) return false;
109 
110       cacheMap.sort(function (a, b) {
111         return a.timeout - b.timeout;
112       });
113 
114       //删除了哪些数据
115       delMap = cacheMap.splice(0, num);
116       for (i = 0, len = delMap.length; i < len; i++) {
117         this.sProxy.removeItem(delMap[i].key);
118       }
119 
120       this.sProxy.setItem(this.keyCache, JSON.stringify(cacheMap));
121       return true;
122     },
123 
124     setKeyCache: function (key, timeout) {
125       if (!key || !timeout || timeout < new Date().getTime()) return;
126       var i, len, tmpObj;
127 
128       //获取当前已经缓存的键值字符串
129       var oldstr = this.sProxy.getItem(this.keyCache);
130       var oldMap = [];
131       //当前key是否已经存在
132       var flag = false;
133       var obj = {};
134       obj.key = key;
135       obj.timeout = timeout;
136 
137       if (oldstr) {
138         oldMap = JSON.parse(oldstr);
139         if (!_.isArray(oldMap)) oldMap = [];
140       }
141 
142       for (i = 0, len = oldMap.length; i < len; i++) {
143         tmpObj = oldMap[i];
144         if (tmpObj.key == key) {
145           oldMap[i] = obj;
146           flag = true;
147           break;
148         }
149       }
150       if (!flag) oldMap.push(obj);
151       //最后将新数组放到缓存中
152       this.sProxy.setItem(this.keyCache, JSON.stringify(oldMap));
153 
154     },
155 
156     buildStorageObj: function (value, indate, timeout, sign) {
157       var obj = {
158         value: value,
159         timeout: timeout,
160         sign: sign,
161         indate: indate
162       };
163       return obj;
164     },
165 
166     get: function (key, sign) {
167       var result, now = new Date().getTime();
168       try {
169         result = this.sProxy.getItem(key);
170         if (!result) return null;
171         result = JSON.parse(result);
172 
173         //数据过期
174         if (result.timeout < now) return null;
175 
176         //需要验证签名
177         if (sign) {
178           if (sign === result.sign)
179             return result.value;
180           return null;
181         } else {
182           return result.value;
183         }
184 
185       } catch (e) {
186         console && console.log(e);
187       }
188       return null;
189     },
190 
191     //获取签名
192     getSign: function (key) {
193       var result, sign = null;
194       try {
195         result = this.sProxy.getItem(key);
196         if (result) {
197           result = JSON.parse(result);
198           sign = result && result.sign
199         }
200       } catch (e) {
201         console && console.log(e);
202       }
203       return sign;
204     },
205 
206     remove: function (key) {
207       return this.sProxy.removeItem(key);
208     },
209 
210     clear: function () {
211       this.sProxy.clear();
212     }
213   });
214 
215   Storage.getInstance = function () {
216     if (this.instance) {
217       return this.instance;
218     } else {
219       return this.instance = new this();
220     }
221   };
222 
223   return Storage;
224 
225 });
storage

真实的使用场景业务model首先得做一层业务封装,然后才是真正的使用:

  1 define(['AbstractModel', 'AbstractStore', 'cUser'], function (AbstractModel, AbstractStore, cUser) {
  2 
  3     var ERROR_CODE = {
  4         'NOT_LOGIN': '00001'
  5     };
  6 
  7     //获取产品来源
  8     var getUs = function () {
  9         var us = 'webapp';
 10         //其它操作......
 11 
 12         //如果url具有us标志,则首先读取
 13         if (_.getUrlParam().us) {
 14             us = _.getUrlParam().us;
 15         }
 16         return us;
 17     };
 18 
 19     var BaseModel = _.inherit(AbstractModel, {
 20 
 21         initDomain: function () {
 22             var host = window.location.host;
 23 
 24             this.domain = host;
 25 
 26             //开发环境
 27             if (host.indexOf('yexiaochai.baidu.com') != -1) {
 28                 this.domain = 'xxx';
 29             }
 30 
 31             //qa环境
 32             if (host.indexOf('baidu.com') == -1) {
 33                 this.domain = 'xxx';
 34             }
 35 
 36             //正式环境
 37             if (host.indexOf('xxx.baidu.com') != -1 || host.indexOf('xxx.baidu.com') != -1) {
 38                 this.domain = 'api.xxx.baidu.com';
 39             }
 40 
 41         },
 42 
 43         propertys: function ($super) {
 44             $super();
 45 
 46             this.initDomain();
 47 
 48             this.path = '';
 49 
 50             this.cacheData = null;
 51             this.param = {
 52                 head: {
 53                     us: getUs(),
 54                     version: '1.0.0'
 55                 }
 56             };
 57             this.dataType = 'jsonp';
 58 
 59             this.errorCallback = function () { };
 60 
 61             //统一处理分返回验证
 62             this.pushValidates(function (data) {
 63                 return this.baseDataValidate(data);
 64             });
 65 
 66         },
 67 
 68         //首轮处理返回数据,检查错误码做统一验证处理
 69         baseDataValidate: function (data) {
 70             if (!data) {
 71                 window.APP.showToast('服务器出错,请稍候再试', function () {
 72                     window.location.href = 'xxx';
 73                 });
 74                 return;
 75             }
 76 
 77             if (_.isString(data)) data = JSON.parse(data);
 78             if (data.errno === 0) return true;
 79 
 80             //处理统一登录逻辑
 81             if (data.errno == ERROR_CODE['NOT_LOGIN']) {
 82                 cUser.login();
 83             }
 84 
 85             //其它通用错误码的处理逻辑
 86             if (data.errno == xxxx) {
 87                 this.errorCallback();
 88                 return false;
 89             }
 90 
 91             //如果出问题则打印错误
 92             if (window.APP && data && data.msg) window.APP.showToast(data.msg, this.errorCallback);
 93 
 94             return false;
 95         },
 96 
 97         dataformat: function (data) {
 98             if (_.isString(data)) data = JSON.parse(data);
 99             if (data.data) return data.data;
100             return data;
101         },
102 
103         buildurl: function () {
104             return this.protocol + '://' + this.domain + this.path + (typeof this.url === 'function' ? this.url() : this.url);
105         },
106 
107         getSign: function () {
108             var param = this.getParam() || {};
109             return JSON.stringify(param);
110         },
111 
112         onDataSuccess: function (fdata, data) {
113             if (this.cacheData && this.cacheData.set)
114                 this.cacheData.set(fdata, this.getSign());
115         },
116 
117         //重写父类getParam方法,加入方法签名
118         getParam: function () {
119             var param = _.clone(this.param || {});
120 
121             //此处对参数进行特殊处理
122             //......
123 
124             return this.param;
125         },
126 
127         execute: function ($super, onComplete, onError, ajaxOnly, scope) {
128             var data = null;
129             if (!ajaxOnly && !this.ajaxOnly && this.cacheData && this.cacheData.get) {
130                 data = this.cacheData.get(this.getSign());
131                 if (data) {
132                     onComplete(data);
133                     return;
134                 }
135             }
136 
137             //记录请求发出
138             $super(onComplete, onError, ajaxOnly, scope);
139         }
140 
141     });
142 
143     //localstorage存储类
144     var Store = {
145         RequestStore: _.inherit(AbstractStore, {
146             //默认属性
147             propertys: function ($super) {
148                 $super();
149                 this.key = 'BUS_RequestStore';
150                 this.lifeTime = '1D'; //缓存时间
151             }
152         })
153     };
154 
155     //返回真实的业务类
156     return {
157         //真实的业务请求
158         requestModel: _.inherit(BaseModel, {
159             //默认属性
160             propertys: function ($super) {
161                 $super();
162                 this.url = '/url';
163                 this.ajaxOnly = false;
164                 this.cacheData = Store.RequestStore.getInstance();
165             }
166         })
167     };
168 });
业务封装
 1 define(['BusinessModel'], function (Model) {
 2     var model = Model.requestModel.getInstance();
 3 
 4     //设置请求参数
 5     model.setParam();
 6     model.execute(function (data) {
 7         //这里的data,如果model设置的完善,则前端使用可完全信任其可用性不用做判断了
 8 
 9         //这个是不需要的
10         if (data.person && data.person.name) {
11             //...
12         }
13 
14         //根据数据渲染页面
15         //......
16     });
17 })

复杂的前端页面

我觉得三端的开发中,前端的业务是最复杂的,因为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,这个时候迎来了第四轮迭代,而这种种迹象表明:

如果一个页面开始频繁的出BUG,如果一个页面逻辑越来越复杂,如果一个页面的代码你觉得不好维护了,那么意味着,他应该得到应有的重构了!

前端的MVC

不太MVC的做法

如果在你的页面(会长久维护的项目)中有以下情况的话,也许你应该重构你的页面或者换掉你框架了:

① 在js中大规模的拼接HTML,比如这样:

 1 for (i = 0; i < len; i++) {
 2     for (key in data[i]) {
 3         item = data[i][key];
 4         len2 = item.length;
 5         if (len2 === 0) continue;
 6         str += '<h2 class="wa-xxx-groupname">' + key + '</h2>';
 7         str += '<ul class=" wa-xxx-city-list-item ">';
 8         for (j = 0; j < len2; j++) {
 9             str += '<li data-type="' + item[j].type + '" data-city="' + item[j].regionid + '">' + item[j].cnname + '</li>';
10         }
11         str += '</ul>';
12         break;
13     }
14     if (str !== '')
15         html.push('<div class="wa-xxx-city-list">' + str + '</div>');
16     str = '';
17 }

对于这个情况,你应该使用前端模板引擎

② 在js中出现大规模的获取非文本框元素的值

③ 在html页面中看到了大规模的数据钩子,比如这个样子:

④ 你在js中发现,一个数据由js变量可获取,也可以由dom获取,并你对从哪获取数据犹豫不决

⑤ 在你的页面中,click事件分散到一个页面的各个地方

⑥ 当你的js文件超过1000行,并且你觉得没法拆分

以上种种迹象表明,哟!这个页面好像要被玩坏了,好像可以用MVC的思想重构一下啦!

什么是MVC

其实MVC这个东西有点悬,一般人压根都不知道他是干嘛的,就知道一个model-view-controller;

知道一点的又说不清楚;

真正懂的人要么喜欢东扯西扯,要么不愿意写博客或者博客一来便很难,曲高和寡。

所以前端MVC这个东西一直是一个玄之又玄的东西,很多开发了很久的朋友都不能了解什么是MVC。

今天我作为一个自认为懂得一点的人,便来说一说我对MVC在前端的认识,希望对大家有帮助。

前端给大家的认识便是页面,页面由HTML+CSS实现,如果有交互便需要JS的介入,其中:

对于真实的业务来说,HTML&CSS是零件,JS是搬运工,数据是设计图与指令。
JS要根据数据指令将零件组装为玩具,用户操作了玩具导致了数据变化,于是JS又根据数据指令重新组装玩具
我们事实上不写代码,我们只是数据的搬运工

上述例子可能不一定准确,但他可以表达一些中心思想,那就是:

对于页面来说,要展示的只是数据

所以,数据才是我们应该关注的核心,这里回到我们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 var AbstractModel = _.inherit({
  2   initialize: function (opts) {
  3     this.propertys();
  4     this.setOption(opts);
  5   },
  6 
  7   propertys: function () {
  8     //只取页面展示需要数据
  9     this.data = {};
 10 
 11     //局部数据改变对应的响应程序,暂定为一个方法
 12     //可以是一个类的实例,如果是实例必须有render方法
 13     this.controllers = {};
 14 
 15     //全局初始化数据时候调用的控制器
 16     this.initController = null;
 17 
 18     this.scope = null;
 19 
 20   },
 21 
 22   addController: function (k, v) {
 23     if (!k || !v) return;
 24     this.controllers[k] = v;
 25   },
 26 
 27   removeController: function (k) {
 28     if (!k) return;
 29     delete this.controllers[k];
 30   },
 31 
 32   setOption: function (opts) {
 33     for (var k in opts) {
 34       this[k] = opts[k];
 35     }
 36   },
 37 
 38   //首次初始化时,需要矫正数据,比如做服务器适配
 39   //@override
 40   handleData: function () { },
 41 
 42   //一般用于首次根据服务器数据源填充数据
 43   initData: function (data) {
 44     var k;
 45     if (!data) return;
 46 
 47     //如果默认数据没有被覆盖可能有误
 48     for (k in this.data) {
 49       if (data[k]) this.data[k] = data[k];
 50     }
 51 
 52     this.handleData();
 53 
 54     if (this.initController && this.get()) {
 55       this.initController.call(this.scope, this.get());
 56     }
 57 
 58   },
 59 
 60   //验证data的有效性,如果无效的话,不应该进行以下逻辑,并且应该报警
 61   //@override
 62   validateData: function () {
 63     return true;
 64   },
 65 
 66   //获取数据前,可以进行格式化
 67   //@override
 68   formatData: function (data) {
 69     return data;
 70   },
 71 
 72   //获取数据
 73   get: function () {
 74     if (!this.validateData()) {
 75       //需要log
 76       return {};
 77     }
 78     return this.formatData(this.data);
 79   },
 80 
 81   _update: function (key, data) {
 82     if (typeof this.controllers[key] === 'function')
 83       this.controllers[key].call(this.scope, data);
 84     else if (typeof this.controllers[key].render === 'function')
 85       this.controllers[key].render.call(this.scope, data);
 86   },
 87 
 88   //数据跟新后需要做的动作,执行对应的controller改变dom
 89   //@override
 90   update: function (key) {
 91     var data = this.get();
 92     var k;
 93     if (!data) return;
 94 
 95     if (this.controllers[key]) {
 96       this._update(key, data);
 97       return;
 98     }
 99 
100     for (k in this.controllers) {
101       this._update(k, data);
102     }
103   }
104 });
View Code

然后我们开始设计真正的博客相关model:

 1 //博客的model模块应该是完全独立与页面的主流层的,并且可复用
 2 var Model = _.inherit(AbstractModel, {
 3   propertys: function () {
 4     this.data = {
 5       blogs: []
 6     };
 7   },
 8   //新增博客
 9   add: function (title, type, label) {
10     //做数据校验,具体要多严格由业务决定
11     if (!title || !type) return null;
12 
13     var blog = {};
14     blog.id = 'blog_' + _.uniqueId();
15     blog.title = title;
16     blog.type = type;
17     if (label) blog.label = label.split(',');
18     else blog.label = [];
19 
20     this.data.blogs.push(blog);
21 
22     //通知各个控制器变化
23     this.update();
24 
25     return blog;
26   },
27   //删除某一博客
28   remove: function (id) {
29     if (!id) return null;
30     var i, len, data;
31     for (i = 0, len = this.data.blogs.length; i < len; i++) {
32       if (this.data.blogs[i].id === id) {
33         data = this.data.blogs.splice(i, 1)
34         this.update();
35         return data;
36       }
37     }
38     return null;
39   },
40   //获取所有类型映射表
41   getTypeInfo: function () {
42     var obj = {};
43     var i, len, type;
44     for (i = 0, len = this.data.blogs.length; i < len; i++) {
45       type = this.data.blogs[i].type;
46       if (!obj[type]) obj[type] = 1;
47       else obj[type] = obj[type] + 1;
48     }
49     return obj;
50   },
51   //获取标签映射表
52   getLabelInfo: function () {
53     var obj = {}, label;
54     var i, len, j, len1, blog, label;
55     for (i = 0, len = this.data.blogs.length; i < len; i++) {
56       blog = this.data.blogs[i];
57       for (j = 0, len1 = blog.label.length; j < len1; j++) {
58         label = blog.label[j];
59         if (!obj[label]) obj[label] = 1;
60         else obj[label] = obj[label] + 1;
61       }
62     }
63     return obj;
64   },
65   //获取总数
66   getNum: function () {
67     return this.data.blogs.length;
68   }
69 
70 });

这个时候再附上业务代码:

 1 var AbstractView = _.inherit({
 2   propertys: function () {
 3     this.$el = $('#main');
 4     //事件机制
 5     this.events = {};
 6   },
 7   initialize: function (opts) {
 8     //这种默认属性
 9     this.propertys();
10   },
11   $: function (selector) {
12     return this.$el.find(selector);
13   },
14   show: function () {
15     this.$el.show();
16     this.bindEvents();
17   },
18   bindEvents: function () {
19     var events = this.events;
20 
21     if (!(events || (events = _.result(this, 'events')))) return this;
22     this.unBindEvents();
23 
24     // 解析event参数的正则
25     var delegateEventSplitter = /^(\S+)\s*(.*)$/;
26     var key, method, match, eventName, selector;
27 
28     // 做简单的字符串数据解析
29     for (key in events) {
30       method = events[key];
31       if (!_.isFunction(method)) method = this[events[key]];
32       if (!method) continue;
33 
34       match = key.match(delegateEventSplitter);
35       eventName = match[1], selector = match[2];
36       method = _.bind(method, this);
37       eventName += '.delegateUIEvents' + this.id;
38 
39       if (selector === '') {
40         this.$el.on(eventName, method);
41       } else {
42         this.$el.on(eventName, selector, method);
43       }
44     }
45     return this;
46   },
47 
48   unBindEvents: function () {
49     this.$el.off('.delegateUIEvents' + this.id);
50     return this;
51   }
52 
53 });
View的基类
 1 //页面主流程
 2 var View = _.inherit(AbstractView, {
 3   propertys: function ($super) {
 4     $super();
 5     this.$el = $('#main');
 6 
 7     //统合页面所有点击事件
 8     this.events = {
 9       'click .js_add': 'blogAddAction',
10       'click .js_blog_del': 'blogDeleteAction'
11     };
12 
13     //实例化model并且注册需要通知的控制器
14     //控制器务必做到职责单一
15     this.model = new Model({
16       scope: this,
17       controllers: {
18         numController: this.numController,
19         typeController: this.typeController,
20         labelController: this.labelController,
21         blogsController: this.blogsController
22       }
23     });
24   },
25   //总博客数
26   numController: function () {
27     this.$('.js_num').html(this.model.getNum());
28   },
29   //分类数
30   typeController: function () {
31     var html = '';
32     var tpl = document.getElementById('js_tpl_kv').innerHTML;
33     var data = this.model.getTypeInfo();
34     html = _.template(tpl)({ objs: data });
35     this.$('.js_type_wrapper').html(html);
36 
37 
38   },
39   //label分类
40   labelController: function () {
41     //这里的逻辑与type基本一致,但是真实情况不会这样
42     var html = '';
43     var tpl = document.getElementById('js_tpl_kv').innerHTML;
44     var data = this.model.getLabelInfo();
45     html = _.template(tpl)({ objs: data });
46     this.$('.js_label_wrapper').html(html);
47 
48   },
49   //列表变化
50   blogsController: function () {
51     console.log(this.model.get());
52     var html = '';
53     var tpl = document.getElementById('js_tpl_blogs').innerHTML;
54     var data = this.model.get();
55     html = _.template(tpl)(data);
56     this.$('.js_blogs_wrapper').html(html);
57   },
58   //添加博客点击事件
59   blogAddAction: function () {
60     //此处未做基本数据校验,因为校验的工作应该model做,比如字数限制,标签过滤什么的
61     //这里只是往model中增加一条数据,事实上这里还应该写if预计判断是否添加成功,略去
62     this.model.add(
63       this.$('.js_title').val(),
64       this.$('.js_type').val(),
65       this.$('.js_label').val()
66     );
67 
68   },
69   blogDeleteAction: function (e) {
70     var el = $(e.currentTarget);
71     this.model.remove(el.attr('data-id'));
72   }
73 });
74 
75 var view = new View();
76 view.show();

完整代码&示例

  1 <!doctype html>
  2 <html>
  3 <head>
  4   <meta charset="UTF-8">
  5   <title>前端MVC</title>
  6   <script src="zepto.js" type="text/javascript"></script>
  7   <script src="underscore.js" type="text/javascript"></script>
  8   <style>
  9     li {
 10       list-style: none;
 11       margin: 5px 0;
 12     }
 13     fieldset {
 14       margin: 5px 0;
 15     }
 16   </style>
 17 </head>
 18 <body>
 19   <div id="main">
 20     <fieldset>
 21       <legend>文章总数</legend>
 22       <div class="js_num">
 23         0
 24       </div>
 25     </fieldset>
 26     <fieldset>
 27       <legend>分类</legend>
 28       <div class="js_type_wrapper">
 29       </div>
 30     </fieldset>
 31     <fieldset>
 32       <legend>标签</legend>
 33       <div class="js_label_wrapper">
 34       </div>
 35     </fieldset>
 36     <fieldset>
 37       <legend>博客列表</legend>
 38       <div class="js_blogs_wrapper">
 39       </div>
 40     </fieldset>
 41     <fieldset>
 42       <legend>新增博客</legend>
 43       <ul>
 44         <li>标题 </li>
 45         <li>
 46           <input type="text" class="js_title" />
 47         </li>
 48         <li>类型 </li>
 49         <li>
 50           <input type="text" class="js_type" />
 51         </li>
 52         <li>标签(逗号隔开) </li>
 53         <li>
 54           <input type="text" class="js_label" />
 55         </li>
 56         <li>
 57           <input type="button" class="js_add" value="新增博客" />
 58         </li>
 59       </ul>
 60     </fieldset>
 61   </div>
 62   <script type="text/template" id="js_tpl_kv">
 63     <ul>
 64       <%for(var k in objs){ %>
 65         <li><%=k %>(<%=objs[k] %>)</li>
 66       <%} %>
 67     </ul>
 68   </script>
 69   <script type="text/template" id="js_tpl_blogs">
 70     <ul>
 71       <%for(var i = 0, len = blogs.length; i < len; i++ ){ %>
 72         <li><%=blogs[i].title %> - <span class="js_blog_del" data-id="<%=blogs[i].id %>">删除</span></li>
 73       <%} %>
 74     </ul>
 75   </script>
 76   <script type="text/javascript">
 77 
 78     //继承相关逻辑
 79     (function () {
 80 
 81       // 全局可能用到的变量
 82       var arr = [];
 83       var slice = arr.slice;
 84       /**
 85       * inherit方法,js的继承,默认为两个参数
 86       *
 87       * @param  {function} origin  可选,要继承的类
 88       * @param  {object}   methods 被创建类的成员,扩展的方法和属性
 89       * @return {function}         继承之后的子类
 90       */
 91       _.inherit = function (origin, methods) {
 92 
 93         // 参数检测,该继承方法,只支持一个参数创建类,或者两个参数继承类
 94         if (arguments.length === 0 || arguments.length > 2) throw '参数错误';
 95 
 96         var parent = null;
 97 
 98         // 将参数转换为数组
 99         var properties = slice.call(arguments);
100 
101         // 如果第一个参数为类(function),那么就将之取出
102         if (typeof properties[0] === 'function')
103           parent = properties.shift();
104         properties = properties[0];
105 
106         // 创建新类用于返回
107         function klass() {
108           if (_.isFunction(this.initialize))
109             this.initialize.apply(this, arguments);
110         }
111 
112         klass.superclass = parent;
113 
114         // 父类的方法不做保留,直接赋给子类
115         // parent.subclasses = [];
116 
117         if (parent) {
118           // 中间过渡类,防止parent的构造函数被执行
119           var subclass = function () { };
120           subclass.prototype = parent.prototype;
121           klass.prototype = new subclass();
122 
123           // 父类的方法不做保留,直接赋给子类
124           // parent.subclasses.push(klass);
125         }
126 
127         var ancestor = klass.superclass && klass.superclass.prototype;
128         for (var k in properties) {
129           var value = properties[k];
130 
131           //满足条件就重写
132           if (ancestor && typeof value == 'function') {
133             var argslist = /^\s*function\s*\(([^\(\)]*?)\)\s*?\{/i.exec(value.toString())[1].replace(/\s/g, '').split(',');
134             //只有在第一个参数为$super情况下才需要处理(是否具有重复方法需要用户自己决定)
135             if (argslist[0] === '$super' && ancestor[k]) {
136               value = (function (methodName, fn) {
137                 return function () {
138                   var scope = this;
139                   var args = [
140                 function () {
141                   return ancestor[methodName].apply(scope, arguments);
142                 }
143               ];
144                   return fn.apply(this, args.concat(slice.call(arguments)));
145                 };
146               })(k, value);
147             }
148           }
149 
150           //此处对对象进行扩展,当前原型链已经存在该对象,便进行扩展
151           if (_.isObject(klass.prototype[k]) && _.isObject(value) && (typeof klass.prototype[k] != 'function' && typeof value != 'fuction')) {
152             //原型链是共享的,这里处理逻辑要改
153             var temp = {};
154             _.extend(temp, klass.prototype[k]);
155             _.extend(temp, value);
156             klass.prototype[k] = temp;
157           } else {
158             klass.prototype[k] = value;
159           }
160         }
161 
162         //静态属性继承
163         //兼容代码,非原型属性也需要进行继承
164         for (key in parent) {
165           if (parent.hasOwnProperty(key) && key !== 'prototype' && key !== 'superclass')
166             klass[key] = parent[key];
167         }
168 
169         if (!klass.prototype.initialize)
170           klass.prototype.initialize = function () { };
171 
172         klass.prototype.constructor = klass;
173 
174         return klass;
175       };
176 
177     })();
178   </script>
179   <script type="text/javascript">
180     //基类view设计
181     var AbstractView = _.inherit({
182       propertys: function () {
183         this.$el = $('#main');
184         //事件机制
185         this.events = {};
186       },
187       initialize: function (opts) {
188         //这种默认属性
189         this.propertys();
190       },
191       $: function (selector) {
192         return this.$el.find(selector);
193       },
194       show: function () {
195         this.$el.show();
196         this.bindEvents();
197       },
198       bindEvents: function () {
199         var events = this.events;
200 
201         if (!(events || (events = _.result(this, 'events')))) return this;
202         this.unBindEvents();
203 
204         // 解析event参数的正则
205         var delegateEventSplitter = /^(\S+)\s*(.*)$/;
206         var key, method, match, eventName, selector;
207 
208         // 做简单的字符串数据解析
209         for (key in events) {
210           method = events[key];
211           if (!_.isFunction(method)) method = this[events[key]];
212           if (!method) continue;
213 
214           match = key.match(delegateEventSplitter);
215           eventName = match[1], selector = match[2];
216           method = _.bind(method, this);
217           eventName += '.delegateUIEvents' + this.id;
218 
219           if (selector === '') {
220             this.$el.on(eventName, method);
221           } else {
222             this.$el.on(eventName, selector, method);
223           }
224         }
225         return this;
226       },
227 
228       unBindEvents: function () {
229         this.$el.off('.delegateUIEvents' + this.id);
230         return this;
231       }
232 
233     });
234 
235     //基类Model设计
236     var AbstractModel = _.inherit({
237       initialize: function (opts) {
238         this.propertys();
239         this.setOption(opts);
240       },
241 
242       propertys: function () {
243         //只取页面展示需要数据
244         this.data = {};
245 
246         //局部数据改变对应的响应程序,暂定为一个方法
247         //可以是一个类的实例,如果是实例必须有render方法
248         this.controllers = {};
249 
250         //全局初始化数据时候调用的控制器
251         this.initController = null;
252 
253         this.scope = null;
254 
255       },
256 
257       addController: function (k, v) {
258         if (!k || !v) return;
259         this.controllers[k] = v;
260       },
261 
262       removeController: function (k) {
263         if (!k) return;
264         delete this.controllers[k];
265       },
266 
267       setOption: function (opts) {
268         for (var k in opts) {
269           this[k] = opts[k];
270         }
271       },
272 
273       //首次初始化时,需要矫正数据,比如做服务器适配
274       //@override
275       handleData: function () { },
276 
277       //一般用于首次根据服务器数据源填充数据
278       initData: function (data) {
279         var k;
280         if (!data) return;
281 
282         //如果默认数据没有被覆盖可能有误
283         for (k in this.data) {
284           if (data[k]) this.data[k] = data[k];
285         }
286 
287         this.handleData();
288 
289         if (this.initController && this.get()) {
290           this.initController.call(this.scope, this.get());
291         }
292 
293       },
294 
295       //验证data的有效性,如果无效的话,不应该进行以下逻辑,并且应该报警
296       //@override
297       validateData: function () {
298         return true;
299       },
300 
301       //获取数据前,可以进行格式化
302       //@override
303       formatData: function (data) {
304         return data;
305       },
306 
307       //获取数据
308       get: function () {
309         if (!this.validateData()) {
310           //需要log
311           return {};
312         }
313         return this.formatData(this.data);
314       },
315 
316       _update: function (key, data) {
317         if (typeof this.controllers[key] === 'function')
318           this.controllers[key].call(this.scope, data);
319         else if (typeof this.controllers[key].render === 'function')
320           this.controllers[key].render.call(this.scope, data);
321       },
322 
323       //数据跟新后需要做的动作,执行对应的controller改变dom
324       //@override
325       update: function (key) {
326         var data = this.get();
327         var k;
328         if (!data) return;
329 
330         if (this.controllers[key]) {
331           this._update(key, data);
332           return;
333         }
334 
335         for (k in this.controllers) {
336           this._update(k, data);
337         }
338       }
339     });
340 
341   </script>
342   <script type="text/javascript">
343 
344     //博客的model模块应该是完全独立与页面的主流层的,并且可复用
345     var Model = _.inherit(AbstractModel, {
346       propertys: function () {
347         this.data = {
348           blogs: []
349         };
350       },
351       //新增博客
352       add: function (title, type, label) {
353         //做数据校验,具体要多严格由业务决定
354         if (!title || !type) return null;
355 
356         var blog = {};
357         blog.id = 'blog_' + _.uniqueId();
358         blog.title = title;
359         blog.type = type;
360         if (label) blog.label = label.split(',');
361         else blog.label = [];
362 
363         this.data.blogs.push(blog);
364 
365         //通知各个控制器变化
366         this.update();
367 
368         return blog;
369       },
370       //删除某一博客
371       remove: function (id) {
372         if (!id) return null;
373         var i, len, data;
374         for (i = 0, len = this.data.blogs.length; i < len; i++) {
375           if (this.data.blogs[i].id === id) {
376             data = this.data.blogs.splice(i, 1)
377             this.update();
378             return data;
379           }
380         }
381         return null;
382       },
383       //获取所有类型映射表
384       getTypeInfo: function () {
385         var obj = {};
386         var i, len, type;
387         for (i = 0, len = this.data.blogs.length; i < len; i++) {
388           type = this.data.blogs[i].type;
389           if (!obj[type]) obj[type] = 1;
390           else obj[type] = obj[type] + 1;
391         }
392         return obj;
393       },
394       //获取标签映射表
395       getLabelInfo: function () {
396         var obj = {}, label;
397         var i, len, j, len1, blog, label;
398         for (i = 0, len = this.data.blogs.length; i < len; i++) {
399           blog = this.data.blogs[i];
400           for (j = 0, len1 = blog.label.length; j < len1; j++) {
401             label = blog.label[j];
402             if (!obj[label]) obj[label] = 1;
403             else obj[label] = obj[label] + 1;
404           }
405         }
406         return obj;
407       },
408       //获取总数
409       getNum: function () {
410         return this.data.blogs.length;
411       }
412 
413     });
414 
415     //页面主流程
416     var View = _.inherit(AbstractView, {
417       propertys: function ($super) {
418         $super();
419         this.$el = $('#main');
420 
421         //统合页面所有点击事件
422         this.events = {
423           'click .js_add': 'blogAddAction',
424           'click .js_blog_del': 'blogDeleteAction'
425         };
426 
427         //实例化model并且注册需要通知的控制器
428         //控制器务必做到职责单一
429         this.model = new Model({
430           scope: this,
431           controllers: {
432             numController: this.numController,
433             typeController: this.typeController,
434             labelController: this.labelController,
435             blogsController: this.blogsController
436           }
437         });
438       },
439       //总博客数
440       numController: function () {
441         this.$('.js_num').html(this.model.getNum());
442       },
443       //分类数
444       typeController: function () {
445         var html = '';
446         var tpl = document.getElementById('js_tpl_kv').innerHTML;
447         var data = this.model.getTypeInfo();
448         html = _.template(tpl)({ objs: data });
449         this.$('.js_type_wrapper').html(html);
450 
451 
452       },
453       //label分类
454       labelController: function () {
455         //这里的逻辑与type基本一致,但是真实情况不会这样
456         var html = '';
457         var tpl = document.getElementById('js_tpl_kv').innerHTML;
458         var data = this.model.getLabelInfo();
459         html = _.template(tpl)({ objs: data });
460         this.$('.js_label_wrapper').html(html);
461 
462       },
463       //列表变化
464       blogsController: function () {
465         console.log(this.model.get());
466         var html = '';
467         var tpl = document.getElementById('js_tpl_blogs').innerHTML;
468         var data = this.model.get();
469         html = _.template(tpl)(data);
470         this.$('.js_blogs_wrapper').html(html);
471       },
472       //添加博客点击事件
473       blogAddAction: function () {
474         //此处未做基本数据校验,因为校验的工作应该model做,比如字数限制,标签过滤什么的
475         //这里只是往model中增加一条数据,事实上这里还应该写if预计判断是否添加成功,略去
476         this.model.add(
477       this.$('.js_title').val(),
478       this.$('.js_type').val(),
479       this.$('.js_label').val()
480     );
481 
482       },
483       blogDeleteAction: function (e) {
484         var el = $(e.currentTarget);
485         this.model.remove(el.attr('data-id'));
486       }
487     });
488 
489     var view = new View();
490     view.show();
491 
492   </script>
493 </body>
494 </html>
View Code

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 公共样式文件

各个业务频道引入公共频道的产品便可解决重用问题,但这样也同时发生了耦合,如果公共频道的页面做的不够灵活可配置,业务团队使用起来会是一个噩梦!

于是更好的方案似乎是页面模块化,尽可能的将页面分为一个个可重用的小模块,有兴趣的朋友请到这里看看:

【前端优化之拆分CSS】前端三剑客的分分合合

【shadow dom入UI】web components思想如何应用于实际项目

网站慢了

关于系统优化的建议我之前写了很多文章,有兴趣的朋友可以移驾至这里看看:

浅谈移动前端的最佳实践

我这里补充一点业务优化点:

① ajax请求剥离无意义的请求,命名使用短拼

这条比较适用于新团队,服务器端的同事并不会关注网络请求的耗时,所以请求往往又臭又长,一个真实的例子就是,上周我推动服务器端同事将城市列表的无意义字段删除后容量由90k降到了50k,并且还有优化空间!!!

② 工程化打包时候最好采用MD5的方式,这样可做到比较舒服的application cache效果,十分推崇!

③ ......

结语&核心点

半年了,项目由最初的无趣到现在可以在上面玩MVC、玩ABTesting等高端东西了,而看着产品订单破一,破百,破千,破万,虽然很累,但是这个时候还是觉得是值得的。

只可惜我厂的一些制度有点过于恶心,跨团队交流跟吃屎一样,工作量过大,工资又低,这些点滴还是让人感到失望的。

好了,抱怨结束,文章浅谈了一些自己对移动端从0到1做业务开发的一些经验及建议,没有什么高深的知识,也许还有很多错误的地方,请各位不吝赐教,多多指点,接下来时间学习的重点应该还是IOS,偶尔会穿插MVVM框架(angularJS等)的相关学习,有兴趣的朋友可以一起关注,也希望自己尽快打通端到端吧,突破自身瓶颈。

最后,我的微博粉丝及其少,如果您觉得这篇博客对您哪怕有一丝丝的帮助,微博求粉博客求赞!!!

posted on 2015-09-28 06:11  叶小钗  阅读(21921)  评论(55编辑  收藏  举报