博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
【移动前端开发实践】从无到有(统计、请求、MVC、模块化)H5开发须知
阅读量:5952 次
发布时间:2019-06-19

本文共 37624 字,大约阅读时间需要 125 分钟。

前言

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

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

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

① H5站点

② PC站点

③ Mis后台管理系统

④ 各种百度渠道接入

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

这个情况下根本无暇思考其它框架,熟悉的就是最好的!便将自己git上的开源框架直接拿来用了起来:

因为之前的经验积累,工程化、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': true123       };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: true147 },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: indate162       };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.sign199         }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 += ''; 7         str += '
';12 break;13 }14 if (str !== '')15 html.push('
');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

这里依旧用到了我们的继承机制,如果对这个不熟悉的朋友烦请看看我之前的博客:

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.blogsController22       }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   2   3   4   
5 前端MVC 6 7 8 17 18 19
20
21
文章总数 22
23 0 24
25
26
27
分类 28
29
30
31
32
标签 33
34
35
36
37
博客列表 38
39
40
41
42
新增博客 43
    44
  • 标题
  • 45
  • 46 47
  • 48
  • 类型
  • 49
  • 50 51
  • 52
  • 标签(逗号隔开)
  • 53
  • 54 55
  • 56
  • 57 58
  • 59
60
61
62 69 76 179 342 493 494
View Code

分析

这里注释写的很详细,例子也很简单很完整,其实并不需要太多的分析,对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 公共样式文件

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

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

网站慢了

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

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

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

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

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

③ ......

结语&核心点

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

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

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

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

转载地址:http://efoxx.baihongyu.com/

你可能感兴趣的文章
【温故而知新-Javascript】使用canvas元素(第一部分)
查看>>
【求助】测试XCode v8.0的正向反向功能
查看>>
使用Python代码处理Excel
查看>>
【温故而知新-Javascript】窗口效果 (全屏显示窗口、定时关闭窗口)
查看>>
四种方法下载网络文本数据到本地内存
查看>>
pl/sql配置连接远程数据库oracle,本地没有安装oracle数据库的情况下
查看>>
SharePoint 2013 Step by Step—— 为终端用户提供故障恢复的解决方案 Part I
查看>>
如何保护你的linux操作系统
查看>>
Linux gcc编译简介、常用命令
查看>>
[] ubuntu 14.04 搜狗拼音输入法安装
查看>>
澳大利推出NB-IOT网络 加速物联网应用
查看>>
codeforces 112APetya and Strings(字符串水题)
查看>>
Xshell 连接CentOS服务器解密
查看>>
必须要掌握的七种谈话技巧
查看>>
GNU make manual 翻译(三十)
查看>>
线性表简介
查看>>
0基础搭建Hadoop大数据处理-初识
查看>>
ASP.NET Core MVC请求超时设置解决方案
查看>>
CentOS 7 安装Mono 和 MonoDevelop
查看>>
Easyui 让Window弹出居中与最大化后居中
查看>>