前言
我还在携程的做业务的时候,每个看似简单的移动页面背后往往会隐藏5个以上的数据请求,其中最过复杂的当属机票与酒店的订单填写业务代码
这里先看看比较“简单”的机票代码:
然后看看稍微复杂的酒店业务逻辑:
机票一个页面的代码量达到了5000行代码,而酒店的代码竟然超过了8000行,这里还不包括模板(html)文件!!!
然后初略看了机票的代码,就该页面可能发生的接口请求有19个之多!!!而酒店的的交互DOM事件基本多到了令人发指的地步:
当然,机票团队的交互DOM事件已经多到了我笔记本不能截图了:
View Code
就这种体量的页面,如果需要迭代需求、打BUG补丁的话,我敢肯定的说,一个BUG的修复很容易引起其它BUG,而上面还仅仅是其中一个业务页面,后面还有强大而复杂的前端框架呢!如此复杂的前端代码维护工作可不是开玩笑的!
PS:说道此处,不得不为携程的前端水平点个赞,业内少有的单页应用,一套代码H5&Hybrid同时运行不说,还解决了SEO问题,嗯,很赞。
如何维护这种页面,如何设计这种页面是我们今天讨论的重点,而上述是携程合并后的代码,他们两个团队的设计思路不便在此处展开。
今天,我这里提供一个思路,认真阅读此文可能在以下方面对你有所帮助:
1 ① 如何将一个复杂的页面拆分为一个个独立的页面组件模块
2 ② 如何将分拆后的业务组件模块重新合为一个完整的页面
3 ③ 从重构角度看组件化开发带来的好处
4 ④ 从前端优化的角度看待组件化开发
文中是我个人的一些框架&业务开发经验,希望对各位有用,也希望各位多多支持讨论,指出文中不足以及提出您的一些建议。
由于该项目涉及到了项目拆分与合并,基本属于一个完整的前端工程化案例了,所以将之放到了github上:https://github.com/yexiaochai/mvc
其中工程化一块的代码,后续会由另一位小伙伴持续更新,如果该文对各位有所帮助的话请各位给项目点个赞、加颗星:)
我相信如果是中级水平的前端,认真阅读此文一定会对你有一点帮助滴。
一个实际的场景
演示地址
http://yexiaochai.github.io/mvc/webapp/bus/list.html
代码仓促,可能会有BUG哦:)
代码地址:https://github.com/yexiaochai/mvc/
页面基本构成
因为订单填写页一般有密度,我这里挑选相对复杂而又没有密度的产品列表页来做说明,其中框架以及业务代码已经做过抽离,不会包含敏感信息,一些优化后续会同步到开源blade框架中去。
我们这里列表页的首屏页面如下:
简单来说组成如下:
① 框架级别UI组件UIHeader,头部组件
② 点击日期会出框架级别UI,日历组件UICalendar
③ 点击出发时段、出发汽车站、到达汽车站,皆会出框架级别UI
④ header下面的日期工具栏需要作为独立的业务模块
⑤ 列表区域可以作为独立的业务模块,但是与主业务靠太近,不太适合
⑥ 出发时段、出发汽车站、到达汽车站皆是独立的业务模块
一个页面被我们拆分成了若干个小模块,我们只需要关注模块内部的交互实现,而包括业务模块的通信,业务模块的样式,业务模块的重用,暂时有以下约定:
① 单个页面的样式全部写在一个文件中,比如list里面所有模块对应的是list.css
② 模块之间采用观察者模式观察数据实体变化,以数据为媒介通信
③ 一般来说业务模块不可重用,如果有重用的模块,需要分离到common目录中,因为我们今天不考虑common重用,这块暂时不予理睬
这里有些朋友可能认为单个模块的CSS以及image也应该参与独立,我这里不太同意,业务页面样式粒度太细的话会给设计带来不小的麻烦,这里再以通俗的话来说:尼玛,我CSS功底一般,拆分的太细,对我来说难度太高......
不好的做法
不好的这个事情其实是相对的,因为不好的做法一般是比较简单的做法,对于一次性项目或者业务比较简单的页面来说反而是好的做法,比如这里的业务逻辑可以这样写:
复制代码
1 define(['AbstractView', 'list.layout.html', 'list.html', 'BusModel', 'BusStore', 'UICalendarBox', 'UILayerList', 'cUser', 'UIToast'],
2 function (AbstractView, layoutHtml, listTpl, BusModel, BusStore, UICalendarBox, UILayerList, cUser, UIToast) {
3 return _.inherit(AbstractView, {
4 propertys: function ($super) {
5 $super();
6 //一堆基础属性定义
7 //......
8 //交互业务逻辑
9 this.events = {
10 'click .js_pre_day': 'preAction', //点击前一天触发
11 'click .js_next_day': 'nextAction', //点击后一天触发
12 'click .js_bus_list li': 'toBooking', //点击列表项目触发
13 'click .js_show_calendar': 'showCalendar', //点击日期项出日历组件
14 'click .js_show_setoutdate': 'showSetoutDate', //筛选出发时段
15 'click .js_show_setstation': 'showStation', //筛选出发站
16 'click .js_show_arrivalstation': 'showArrivalStation', //筛选到达站
17 //迭代需求,增加其它频道入口
18 'click .js-list-tip': function () {}
19 };
20 },
21 //初始化头部标题栏
22 initHeader: function (t) { },
23 //首次dom渲染后,初始化后续会用到的所有dom元素,以免重复获取
24 initElement: function () {},
25 showSetoutDate: function () {},
26 showStation: function () {},
27 showArrivalStation: function () {},
28 showCalendar: function () {},
29 preAction: function (e) {},
30 nextAction: function () {},
31 toBooking: function (e) {},
32 listInit: function () {},
33 bindScrollEvent: function () {},
34 unbindScrollEvent: function () { },
35 addEvent: function () {
36 this.on('onShow', function () {
37 //当页面渲染结束,需要做的初始化操作,比如渲染页面
38 this.listInit();
39 //......
40 });
41 this.on('onHide', function () {
42 this.unbindScrollEvent();
43 });
44 }
45 });
46 });
复制代码
根据之前的经验,如果仅仅包含这些业务逻辑,这样写代码问题不是非常大,代码量预计在800行左右,但是为了完成完整的业务逻辑,我们这里马上产生了新的需求。
需求迭代
因为我这里的班次列表,最初是没有URL参数,所以根本无法产出班次列表,页面上所有组件模块都是摆设,于是这里新增一个需求:
当url没有出发-到达相关参数信息时,默认弹出出发城市到达城市选择框
于是,我们这里会新增一个简单的弹出层:
这个看似简单的弹出层,背后却隐藏了一个巨大的陷阱,因为点击出发或者到达时会出城市列表,而城市列表本身就是一个比较复杂的业务:
于是页面的组成发生了改变:
① 本身业务逻辑约800行代码
② 新增出发到达筛选弹出层
③ 出发城市页面,预计300行代码
而弹出层的新增对业务本身造成了深远的影响,本来url是不带有业务参数的,但是点击了弹出层的确定按钮,需要改变URL参数,并且刷新本身页面的数据,于是简单的一个弹出层新增直接将页面的复杂程度提升了一倍。
于是该页面代码轻轻松松破千了,后续需求迭代js代码量破2000仅仅是时间问题,到时候维护便复杂了,页面复杂无规律的DOM操作将会令你焦头烂额,这个时候组件化开发的优势便得以体现了,于是下面进入组件化开发的设计。
准备工作
总体架构
这次的代码依赖于blade骨架,包括:
① MVC模块,完成通过url获取正确的page控制器,从而通过view.js完成渲染页面的功能
② 数据请求模块,完成接口请求
全站依赖于javascript的继承功能,详情见:【一次面试】再谈javascript中的继承,如果不太了解面向对象编程,文中代码可能会有点吃力,也请各位多多了解。
总体业务架构如图:
框架架构图:
下面分别介绍下各个模块,帮助各位在下文中能更好的了解代码,首先是基本MVC的介绍,这里请参考我这篇文章:简单的MVC介绍
全局控制器
其实控制器可谓是变化万千的一个对象,对于服务器端来说,控制器完成的功能是将本次请求分发到具体的代码模块,由代码模块处理后返回字符串给前端;
对于请求已经来到浏览器的前端来说,根据这次请求URL(或者其它判断条件),判断该次请求应该由哪个前端js控制器执行,这是前端控制器干的事情;
当真的这次处理逻辑进入一个具体的page后,这个page事实上也可以作为一个控制器存在......
我们这里的控制器,主要完成根据当前请求实例化View的功能,并且会提供一些view级别希望单例使用的接口:
abstract.app
这里属于框架控制器层面的代码,与今天的主题不是非常相关,有兴趣的朋友可以详细读读。
页面基类
这里的核心是页面级别的处理,这里会做比较多的介绍,首先我们为所有的业务级View提供了一个继承的View:
abstract.view
一个Page级别的View会有以下几个关键属性&方法:
① template,html字符串,不包含请求的基础模块,会构成页面的html骨架层
② events,所有的DOM事件定义处,以事件代理的方式定义,所以不必担心执行顺序
③ addEvent,用于页面级别各个阶段的监控事件注册点,一般来说用户只需要关注很少几个事件,比如:
复制代码
1 //写法
2 addEvent: function () {
3 //页面渲染结束,并显示时候触发的事件
4 this.on('onShow', function () {
5 });
6 //离开页面,页面隐藏时候触发的事件
7 this.on('onHide', function () {
8 });
9 }
复制代码
一个页面的基本写法:
复制代码
1 define(['AbstractView'], function (AbstractView) {
2 return _.inherit(AbstractView, {
3 propertys: function ($super) {
4 $super();
5 //一堆基础属性定义
6 //......
7 //交互业务逻辑
8 this.events = {
9 'click .js_pre_day': 'preAction'
10 };
11 },
12 preAction: function (e) { },
13 addEvent: function () {
14 this.on('onShow', function () {
15 //当页面渲染结束,需要做的初始化操作,比如渲染页面
16 //......
17 });
18 this.on('onHide', function () {
19 });
20 }
21 });
22 });
复制代码
只要按照这种规则写,便能展示页面,并且具备DOM交互事件。
页面模块类
所谓页面模块类,便是用于拆分一个页面为单个组件模块所用类,这里有这些约定:
① 一个模块类实例一定会依赖一个Page的基类实例
② 模块类实例通过this.view可以访问到依赖类的一切资源
③ 模块类实例与模块之间通过数据entity做通信
这里代码可以再优化,但不是我们这里关注的重点:
module.view
数据实体类
这里的数据实体对应着,MVC中的Model,因为之前已经使用model用作了数据请求相关的命名,这里便使用Entity做该工作:
abstract.entity
这里的数据实体会以实例的方式注入给模块类实例,他的工作是起一个中枢左右,完成模块之间的通信,反正非常重要就是了
其它
数据请求统一使用abstract.model,数据前端缓存使用abstract.store,这里因为目标是做页面拆分,请求模块不是关键,各位可以把这段代码看层一个简单的ajax即可:
1 this.model.setParam({});
2 this.model.execute(function (data) {
3 });
业务入口
最后简单说下业务入口文件:
复制代码
1 (function () {
2 var project = './';
3 var viewRoot = 'pages';
4 require.config({
5 paths: {
6 //BUS相关模板根目录
7 IndexPath: project + 'pages/index',
8 ListPath: project + 'pages/list',
9
10 BusStore: project + 'model/bus.store',
11 BusModel: project + 'model/bus.model'
12 }
13 });
14 require(['AbstractApp', 'UIHeader'], function (APP, UIHeader) {
15 window.APP = new APP({
16 UIHeader: UIHeader,
17 viewRootPath: viewRoot
18 });
19 window.APP.initApp();
20 });
21 })();
复制代码
很简单的代码,指定了下require的path配置,最后我们看看入口页面的调用:
list.html
复制代码
webapp
├─blade //框架目录
│ ├─data
│ ├─libs
│ ├─mvc
│ └─ui
├─bus
│ ├─model //数据请求模块,完全可以使用zepto ajax替换
│ └─pages
│ ├─booking
│ ├─index
│ └─list //demo代码模块
└─static
复制代码
接下来,让我们真实的开始拆分页面吧。
组件式编程
骨架设计
首先,我们进行最简单的骨架设计,这里依次是其js代码与模板代码:
复制代码
1 define(['AbstractView', 'text!ListPath/list.css', 'text!ListPath/tpl.layout.html'], function (AbstractView, style, layoutHtml) {
2 return _.inherit(AbstractView, {
3 propertys: function ($super) {
4 $super();
5 this.style = style;
6 this.template = layoutHtml;
7 },
8
9 initHeader: function (name) {
10 var title = '班次列表';
11 this.header.set({
12 view: this,
13 title: title
14 });
15 },
16
17 addEvent: function () {
18 this.on('onShow', function () {
19 console.log('页面渲染结束');
20 });
21 }
22 });
23 });
复制代码
tpl.layout
页面展示如图:
日历工具栏的实现
这里要做的第一步是将日历工具栏模块实现,以数据为先的思考,我们先实现了一个与日历业务有关的数据实体:
en.date
里面完成日期工具栏所有相关数据操作,并且不包含实际的业务逻辑。
然后这里开始设计日期工具栏的模块View:
mod.date
这个组件模块干了几个事情:
① 首先,dateEntity实体需要由list.js这个主view注入
② 这里为dateEntity注册了两个数据响应事件:
1 this.dateEntity.subscribe('init', this.render, this);
2 this.dateEntity.subscribe(this.render, this);
render方法继承至基类,使用template与数据生成html,其中数据产生必须重写父类一个方法:
复制代码
1 getViewModel: function () {
2 var data = this.dateEntity.get();
3 data.formatStr = this.dateEntity.getDateStr();
4 data.canPreDay = this.dateEntity.canPreDay();
5 return data;
6 },
复制代码
因为这里的日历数据,默认取当前时间,但是url参数可能传递日期参数,所以定义了一个数据初始化方法:
复制代码
1 initDate: function () {
2 var t = new Date().getTime();
3 //默认情况下获取当前日期,也有过了18.00就设置为第二天日期
4 //当时一旦url上有startdatetime参数的话,便需要使用之
5 if (_.getUrlParam().startdatetime) t = _.getUrlParam().startdatetime;
6 this.dateEntity.initData({
7 date: t
8 });
9 },
复制代码
该方法在主页面渲染结束后会第一时间调用,这个时候日历工具栏便渲染出来,其中日历组件的使用便不予理睬了,主控制器的代码改变如下:
list.js
复制代码
1 _initEntity: function () {
2 this.dateEntity = new DateEntity();
3 },
4
5 _initModule: function () {
6 this.dateModule = new DateModule({
7 view: this,
8 selector: '.js_calendar_wrapper',
9 dateEntity: this.dateEntity
10 });
11 },
复制代码
复制代码
1 addEvent: function () {
2 this.on('onShow', function () {
3 //初始化date数据
4 this.dateModule.initDate();
5
6 });
7 }
复制代码
于是,整个界面变成了这个样子:
这里是对应的日历工具模板文件tpl.calendar.html:
1 <ul class="bus-tabs calendar-bar">
2 <li class="tabs-item js_pre_day <%=!canPreDay ? 'disabled' : '' %>">前一天</li>
3 <li class="tabs-item js_show_calendar" style="-webkit-flex: 2; flex: 2;"><%=formatStr %></li>
4 <li class="tabs-item js_next_day">后一天</li>
5 </ul>
搜索工具栏的实现
我们现在的页面,就算不传任何URL参数,已经能渲染出部分页面了,但是下面出发站汽车等业务数据必须等待班次列表数据请求结束才能替换数据,但是这些数据如果没有出发城市和到达城市是不能发起请求的,所以这里先实现搜索工具栏功能:
在出发城市或者到达城市不存在的话便弹出搜索工具栏,引导用户选择城市,这里新增弹出层需要在主页面控制器(检测主控制器)中使用一个UI组件:
list.js
对应搜索弹出层html模板:
tpl.search.box.html
这里核心代码是:
复制代码
1 //搜索工具弹出层
2 showSearchBox: function () {
3 var scope = this;
4 if (!this.searchBox) {
5 this.searchBox = new UIScrollLayer({
6 title: '请选择搜索条件',
7 html: searchBoxHtml,
8 events: {
9 'click .js-start': function () {
10
11 },
12 'click .js-arrive': function () {
13
14 },
15 'click .js_search_list': function () {
16
17 console.log('查询列表');
18 }
19 }
20 });
21 }
22 this.searchBox.show();
23 },
复制代码
于是当URL什么参数都没有的时候,就会弹出这个搜索框
这里也迎来了一个难点,因为城市列表事实上应该是一个独立的可访问的页面,但是这里是想用弹出层的方式调用他,所以我在APP层实现了一个方法可以用弹出层的方式调起一个独立的页面。
注意:
这里city城市列表未完全采用组件化的方式开发,有兴趣的朋友可以自己尝试着开发
这里有一个不同的地方是,因为我们点击查询的时候才会做实体数据更新,这里是单纯的做DOM操作了,这里不设置数据实体一个原因就是:
这个搜索弹出层是一个页面级DOM之外的部分,数据实体变化一般只应该影响Page级别的DOM,除非真的有两个页面级View会公用一个数据实体。
list.js
搜索功能完成后,我们这里便可以进入真正的数据请求功能渲染列表了。
其余模块
在实现数据请求之前,我按照日期模块的方式将下面三个模块的功能也一并完成了,这里唯一不同的是,这些模块的DOM已经存在,我们不需要渲染了,完成后的代码大概是这样的:
list.js
这个时候整个逻辑结构大概出来了:
注意:
因为该文耗时过长,导致我现在体力有点虚脱,所以这里的代码不一定最优
最后功能:
到此,demo结束了,最后形成的目录:
一个js便可以拆分成这么多的小组件模块,如果是更加复杂的页面,这里的文件会很多,比如订单填写页的组件模块是这里的三倍。
组件化的优缺点
组件化带来的几个优点十分明显:
① 组件化拆分,使得主控制业务逻辑清晰简单
② 各个业务组件模块功能相对独立,可维护性可测试性大大提升
③ 组件之间可以任意组合,有一定可重用性
④ 增删模块不会怕打断骨头连着筋
⑤ 一个业务模块所需代码全部在一个目录,比较好操作(有点凑数嫌疑)
缺点
事实上,组件化不会带来什么不足,对于不了解的朋友可能会认为代码复杂度有所增加,其实不这样做代码才真正叫一个难呢!
真正的美中不足的要挑一个毛病的话,这种分拆可能会比单个文件代码量稍大
从性能优化角度看组件化
无论什么前端优化,最后的瓶颈一定是在请求量上做文章:压缩、缓存、仅仅做首屏渲染、将jQuery缓存zepto......
说都会说,但是很多场景由不得你那样做,项目足够复杂,而UI又提供给了不同团队使用的话,有一天前端做了一次UI优化,而如何将这次UI优化反应到线上才是考验架构设计的时候,如果是不好的设计的话,想将这次优化推上线,会发生两个事情:
① 业务团队大改代码
② 框架资源(js&css)膨胀
这种头疼的问题是一般人做优化考虑不到的,而业务团队不会因为你的更新而去修改代码,所以一般会以代码膨胀为代价将这次优化强推上线,那往往会让情况更加复杂:
新老代码融合,半年后你根本不知道哪些代码可以删,哪些代码可以留,很大时候这个问题会体现在具有公共特性的CSS中
如果你的CSS同时服务于多个团队,而各个团队的框架版本不一致,那么UI升级对你来说可能是一个噩梦!
如果你想做第三轮的UI升级,那还是算了吧......
事实上,我评价一个前端是否足够厉害,往往就会从这里考虑:
当一个项目足够复杂后,你私下做好了优化,但是你的优化代码不能无缝的让业务团队使用,而需要业务团队做很多改变,你如何解决这种问题
很多前端做一个优化,便是重新做了一个东西,刚开始肯定比线上的好,但半年后,那个代码质量还未必有以前的好呢,所以我们这里应该解决的是:
如何设计一个机制,让业务团队以最小的修改,而可以用上新的UI(样式、特性),而不会增加CSS(JS)体积
这个可能是组件化真正要解决的事情!
理想情况下,一个H5的资源组成情况是这样的:
① 公共核心CSS文件(200行左右)
② 框架核心文件(包含框架核心和第三方库)
③ UI组件(有很多独立的UI组件组成,每个UI组件又包含完整的HTML&CSS)
④ 公共业务模块(提供业务级别公共服务,比如登录、城市列表等业务相关功能)
⑤ 业务频道一个页面,也就是我们这里的list页的代码
因为框架核心一般来说是不经常改变的,就算改变也是对表现层透明的,UI采用增量与预加载机制,这样做会对后续样式升级,UI升级有莫大的好处,而业务组件化后本身要做什么滚动加载也是轻而易举
好的前端架构设计应该满足不停的UI升级需求,而不增加业务团队下载量
结语
本文就如何分解复杂的前端页面提出了一些自己的想法,并且给予了实现,希望对各位有所帮助。
关于合并
前端代码有分拆就有合并,因为最终一个完整的页面需要所有资源才能运行,但考虑到此文已经很长了,关于合并一块的工作留待下文分析吧
关于代码
为了方便各位理解组件化开发的思想,我这里写了一个完整的demo帮助各位分析,由于精力有限,代码难免会有BUG,各位多多包涵:
https://github.com/yexiaochai/mvc
可能会浏览的代码:
https://github.com/yexiaochai/blade
本文转自叶小钗博客园博客,原文链接:http://www.cnblogs.com/yexiaochai/p/4876099.html如需转载请自行联系原作者