前端的架构设计与演化实例

前言

本文介绍我在实际的前端项目中的架构设计,展现由于需求变化而致使架构变化的过程。
全文分为三个阶段,分别对应三次需求的变化,给出了对应的架构设计。
在第一个阶段中,我使用面向过程设计;在第二个阶段和在第三个阶段中,我使用面向对象设计。javascript

本文内容

策略

为了方便讨论,本文的涉及的项目是通过简化的示例项目。
本文重点展示领域模型和架构的变化,对于具体的方法/属性级别的重构不进行详细讨论。
本文会给出核心的实现代码,但不会讨论单元测试。
本文会在具体的上下文中讨论架构的设计。详见下面的讨论:html

  • 本文应该给出一个具体的上下文环境,仍是构造一个抽象的上下文?前端

    具体的上下文示例
    这是一个贴子后台管理的数据统计平台,用户可在该平台中查看“发贴审核”选项的“贴子审核量”数据项的数据。
    优势
    便于读者理解讨论的上下文,从而可以更好地理解本文讨论的架构的设计和演变。
    缺点
    不能为了演示架构演变而随意构造用户的需求,需求必须约束在具体的上下文中
    抽象的上下文示例
    这是一个数据统计平台,用户可在该平台中查看tabA选项的item1数据项的数据。
    优势
    能够围绕架构设计和演变最大限度地构造用户的需求,能够充分在各类假设需求下讨论架构的演变。
    缺点
    因为没有具体的上下文,读者很难理解本文的架构设计和演变与需求的关系。
    结论
    为了让读者更好地理解架构的设计和演变,本文会在具体的上下文中讨论,但也会将需求最简化,从而让读者把精力集中到关注架构设计上。java

依赖项

Javascript OOP框架YOOPgit

正文

第一个阶段

需求

这是一个后台管理系统的数据统计平台,其中后台管理系统能够对网站的贴子进行审核,平台则记录并显示后台管理系统操做的数据。
现后台接口已开发完成,我负责前端逻辑实现。
如今用户可在该平台中查看“发贴审核”选项的“贴子审核量”数据项的数据。
有如下两个要求:
用户能够选择日期,查看指定日期的贴子审核量数据。
用户可点击“趋势”,查看指定日期范围(指定日期前7天)内的贴子审核量数据,以图表形式显示。github

1_
用户可在页面右上角选择日期,“贴子审核量”下面会显示对应日期的审核量数据ajax

1_
用户点击趋势后,会弹出一个二级页面,显示日期范围(指定日期前7天)内的贴子审核量数据图表编程

需求分析

“审核量”数据项对应后台接口“/postCheck/get_check_data“,可从该接口得到指定日期范围的审核量数据:
如接口“/postCheck/get_check_data? begin_date=20140525&end_date=20140724“可得到2014年5月25日到2014年7月24日的json数组,“/postCheck/get_check_data? begin_date=20140724&end_date=20140724”可得到2014年7月24日的json数组(只有1条数据)。
须要从接口返回的json数据中提取出date和num字段的数据,其中date字段对应日期,num字段对应该日期的贴子审核量。
json

架构设计

技术选型

使用datepicker插件实现日历功能
使用highchart插件实现绘制图表功能数组

技术方案

使用模块化设计,一个模块负责一个功能。

  • main

    入口模块,负责封装内部逻辑,提供一个外观方法给页面
  • showData

    负责显示数据项指定日期的数据
  • qushi

    负责显示数据项指定日期范围的趋势图表
  • chartHelper

    负责构建highchart的配置项,与highcharts插件交互
  • controlDatePicker

    负责管理日期选择,与datepicker插件交互

领域模型

1

项目示例代码

详见GitHub地址

序列图

选择日期

1

查看趋势

1

进一步重构

一、重构qushi与controlDatePicker的关联方向
问题说明
qushi负责显示审核量日期范围的数据图表,其中日期范围的截止日期应该为用户选择的日期。然而在当前模型中,用户点击“趋势”后,qushi才会去访问保存在controlDatePicker中的日期,该日期值可能在用户选择日期与用户点击“趋势”的间隔时间中发生了变化,于是可能与用户实际选择的日期不一样
缘由分析
这是因为用户选择日期和qushi访问日期数据是异步进行的。
解决方案
将二者改成同步进行。
具体为:
qushi增长_selectDate属性,
用户选择日期后,触发controlDatePicker的onchange函数,该函数通知qushi,更新它的_selectDate。用户查看趋势时,qushi调用本身的getAndShowChart方法访问属性_selectDate,从而得到用户选择的日期。
重构后的选择日期和查看趋势序列图

1_selectDate

重构后的领域模型

1_selectDate

二、重构showData、qushi
如今showData和qushi中的ajaxData接口数据都同样,所以须要去掉重复数据。
有两个方案:
1)showData和qushi改成委托关系,使用同一个接口数据
那么关联方向应该如何肯定呢?
引用自《重构:改善既有代码的设计》:

1.若是二者都是引用对象,而期间的关联是“一对多”关系,那么就由“拥有单一引用”的那一方承担“控制者”角色。
2.若是某个对象是组成另外一对象的部件,那么由后者负责控制关联关系。
3.若是二者都是引用对象,而期间关联是“多对多”关系,那么随便其中哪一个对象来控制关联关系,都无所谓。

此处showData和qushi在概念上相互独立,二者没有映射关系,所以没办法肯定关联方向。
2)提出一个数据模块data,将接口数据移到其中,showData、qushi经过访问data来得到接口数据。
结论
虽然第2个方案能够将数据与业务逻辑分离,可是考虑到当前数据与业务逻辑还不是很复杂,并且将接口数据直接写到模块中的话修改数据比较方便(如要修改showData数据,则直接能够修改showData的_ajaxData,而不用去data中先查找showData的数据,而后再修改),所以采用第1个方案。
至于关联方向,此处直接设置qushi关联showData。

重构后的领域模型

1_showData_qushi

重构后总的领域模型

1

分析当前设计

优势
一、每一个模块的职责没有重复,对需求变化具备良好的封闭性
一个需求的变化只会影响负责该需求的模块的变化,其它模块不会受到影响。
二、能较好地适应功能点的增长
若是须要增长新的功能,则增长对应的模块,并对应修改入口模块main便可,其他模块不用修改。
缺点
一、数据与业务逻辑耦合
当前场景下还不是什么问题,可先保留当前设计,到须要分离数据时再分离。

第二个阶段

需求变动

如今“发贴审核”选项增长一个“贴子删除量”数据项,该数据项的功能与“贴子审核量”同样,要显示用户指定日期的数据和日期范围的数据趋势图。
另外增长“评论审核”选项,它有“评论审核量”和“评论删除量”两个数据项,与“发贴审核”数据项的功能同样。
用户能够切换选项,分别查看“发贴审核”或“评论审核”的数据
可显示选项趋势图:每一个选项可显示选项页面中全部数据项的指定日期范围(指定日期前7天)的数据趋势图。

2_
“发贴审核”增长“贴子删除量”,页面下方显示两个数据项的趋势图

2_
增长“评论审核”选项,该选项有“评论审核量”和“评论删除量”两个数据项,页面下方显示两个数据项的趋势图

需求分析

每一个数据项的功能都同样,只是对应的后台接口不一样或从接口数据中取出的字段不一样
如“发贴审核”的“贴子审核量”须要从/postCheck/get_check_data接口取出date、num字段,“贴子删除量”须要从/postCheck/get_delete_data接口取出date、delete字段;“评论审核”的“评论审核量”须要从/commentCheck/get_check_data接口取出date、num字段,“贴子删除量”须要从/ commentCheck /get_delete_data接口取出date、delete字段。

架构设计

通过上面的需求分析后,能够给出下面的架构设计:

  • 1个main模块

    负责封装内部逻辑,提供一个外观方法给页面
  • 1个选项控制模块controlTab

    负责管理选项的切换

  • 2个showChart模块

    对应两个选项,负责显示选项趋势图。

  • 2个showData模块

    对应两个选项,负责显示数据项的指定日期数据

  • 2个qushi模块

    对应两个选项,负责显示数据项的指定日期范围的趋势图

  • 1个chartHelper和1个controlDatePicker模块

    由于两个选项的图表的配置和日期管理逻辑都同样,所以两个选项共用1个chartHelper和1个controlDatePicker模块。

为何分别须要2个而不是1个showChart、showData、qushi模块?

由于用户可切换选项,显示不一样的选项页面,因此两个选项应该相互独立,各自的模块和数据也应该相互独立。

分析当前设计

一、模块之间有共同模式
showChart与qushi之间都要负责绘制图表,有共同的模式能够提出。
另外2个showChart/showData/qushi模块之间也有不少共同模式。
二、模块数量太多
每增长一个功能需求,就要增长一个模块,这样会致使模块太多难以管理。

所以,须要使用面向对象思惟来从新设计。

重构

提出“一级页面”和“二级页面”

让咱们来从新分析下需求:
“用户指定日期的数据项数据”和“选项趋势图”都是显示在选项页面中,而“显示数据项指定日期范围的趋势图”则显示在弹出层页面中,所以能够提出“一级页面”, 对应选项页面,逻辑由模块firstLevelPage负责;能够提出“二级页面”,对应选项的弹出层页面,逻辑由模块secondLevelPage负责。
由于“显示数据项指定日期数据”和“显示选项趋势图”属于选项页面的职责,“显示指定日期范围的趋势图”属于弹出层页面的职责,因此将对应的模块showChart和showData合并为firstLevelPage,将qushi重命名为secondLevelPage。

领域模型

2_FirstLevelPage_SecondLevelPage

升级为类,提出基类

如今firstLevelPage与secondLevelPage有共同的模式,而且它们概念相近,都属于“页面”这个概念,所以将firstLevelPage与secondLevelPage模块升级为类FirstLevelPage和SecondLevelPage,并提出基类Page,将二者的共同模式提到基类中。
本文使用个人YOOP库来实现javascript的OOP编程。

增长FirstLevelPage的子类

由于两个选项的后台接口数据不一样,因此增长FirstLevelPage的子类PostFirstLevelPage、CommentFirstLevelPage,放置各自选项的接口数据。
由于两个选项的二级页面逻辑都相同,而且SecondLevelPage从FirstLevelPage中得到接口数据,自己并无数据,所以SecondLevelPage不须要提出子类。

新的领域模型

2
没有画出main模块,由于它与几乎全部的类都有关联,若是画出来模型就看不清楚了。后面的领域模型中也不会画出main。

项目示例代码

详见GitHub地址

序列图

切换选项

2

选择日期

2

查看趋势

2

分析具体实现

为了便于读者理解设计,此处对具体实现中重要的内容做一些说明和分析。

dom的id与类的对应关系

dom的id前缀
“发贴审核”和“评论审核”的id前缀分别为“post”、“comment”, “审核量”和“删除量”的id前缀分别为“check”、“delete”,一级页面和二级页面的id前缀分别为“firstLevelPage”、“secondLevelPage”。
dom的id前缀为:选项id前缀+“”+(数据项id前缀)+“”+(页面id前缀)。
若是dom属于选项,则加上选项id前缀;若是dom属于数据项,则加上数据项id前缀;若是dom属于一级页面(选项页面)或二级页面(弹出层页面),则加上对应的页面id前缀
dom的id前缀与类对应
dom的id前缀与Page类族对应,id前缀由对应的Page类注入。
如选项id前缀在PostFirstLevelPage和CommentFirstLevelPage类的构造函数中注入;页面id前缀在FirstLevelPage、SecondLevelPage类的构造函数中注入。
相关代码
index.html

<div class="container">
   …
    <section id="post">
       …
                        <span id="post_check_firstLevelPage_num"></span>
                            …
                        <span id="post_delete_firstLevelPage_num"></span>
       …
    </section>
        <section id="post_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
            …
                        <div id="post_secondLevelPage_chart"></div>
            …
        <section id="post_firstLevelPage_chartBody" class="chartContainer">
            …
                <div id="post_firstLevelPage_chart"></div>
            …
        </section>
</section>

    <section id="comment">
       …
                        <span id="comment_check_firstLevelPage_num"></span>
                            …
                        <span id="comment_delete_firstLevelPage_num"></span>
       …
    </section>
        <section id="comment_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
            …
                        <div id="comment_secondLevelPage_chart"></div>
            …
        <section id="comment_firstLevelPage_chartBody" class="chartContainer">
            …
                <div id="comment_firstLevelPage_chart"></div>
            …
        </section>
</section>

Page

Init: function (tab, level) {
    this._tab = tab;    //选项id前缀
    this._level = level;    //页面id前缀
},

FirstLevelPage

Init: function (tab) {
    this.base(tab, "firstLevelPage"); //传入页面id前缀
},

PostFirstLevelPage

Init: function () {
    this.base("post");  //传入选项前缀

CommentFirstLevelPage

Init: function () {
    this.base("comment");  //传入选项前缀

SecondLevelPage

Init: function (tab, firstLevelPage) {
    this.base(tab, "secondLevelPage");  //传入页面id前缀

    this._firstLevelPage = firstLevelPage;
},

main

init: function () {
    …
    //在建立SecondLevelPage实例时传入二级页面的选项id前缀和firstLevelPage实例
    window.postSecondLevelPage = new SecondLevelPage("post", window.postFirstLevelPage);
    window.commentSecondLevelPage = new SecondLevelPage("comment",window.commentFirstLevelPage);

为何要这样设计
Page类族可经过注入的id前缀访问对应的dom。
相关代码为:
Page

Protected: {
    …
//根据子类传入的id前缀,构造dom的id的前缀
    P_getPrefixId: function () {
        return this._tab + "_" + this._level + "_";
    },
    …
},
Public: {
    getChartDom:function(){
        return $(this.P_getPrefixId() + "chartBody");
    },

main

window.main = {
        init: function () {
            window.postFirstLevelPage = new PostFirstLevelPage();
            window.commentFirstLevelPage = new CommentFirstLevelPage();

            window.postSecondLevelPage = new SecondLevelPage("post", window.postFirstLevelPage);
            window.commentSecondLevelPage = new SecondLevelPage("comment",window.commentFirstLevelPage);

调用main.init()后,调用window.postFirstLevelPage.getChartDom()可得到“发贴审核”的选项趋势图的dom(id为post_firstLevelPage_chartBody),而调用window. postSecondLevelPage.getChartDom()则可得到“发贴审核”的二级页面的dom(id为post_secondLevelPage_chartBody)。

共享二级页面dom

上面的代码中能够看到,不一样的选项、不一样选项的选项趋势图、数据项指定日期的数据显示的dom相互独立,而同一个选项的“审核量”和“删除量”的二级页面则共享同一个容器dom(如选项post只有一个post_secondLevelPage_chartBody,选项comment只有一个comment_secondLevelPage_chartBody)。
这是由于:
一、“共享dom”虽然会形成相互干扰,但能够减小dom的数量,并且目前相互之间只有很轻微的干扰。
二、若是同一个选项的“审核量”和“删除量”的二级页面相互独立,那么它们的dom的id就要加上数据项id前缀。可是如今的Page类族没法访问包含数据项id前缀的dom(见“解决Page类族没法访问有数据项id前缀的dom的问题”),在SecondLevelPage中访问对应数据项的二级页面dom比较麻烦!

解决“Page类族没法访问有数据项id前缀的dom”的问题

在前面的“dom的id与类的对应关系”讨论中,咱们看到选项id前缀和页面id前缀均可以注入到类中,而数据项id前缀如今却没有注入,所以Page类族没法访问包含数据项id前缀的dom!
有两个方案解决该问题:
一、FirstLevelPage子类的P_ajaxData中直接指定包含数据项id前缀的dom的id,从而Page类族可经过访问P_ajaxData的dom id来得到对应的包含数据项id前缀的dom。
相关代码
PostFirstLevelPage

Init: function () {
    this.base("post");  //传入选项前缀

    this.P_ajaxData = {
        "发贴审核贴子审核量": {
            url: "/postCheck/get_check_data",
            name: "贴子审核量",
            field: "num",
            domId: {
                num: "#post_check_firstLevelPage_num" //“发贴审核”的“审核量”的指定日期数据显示对应的domId
            }
        },
        "发贴审核贴子删除量": {
            url: "/postCheck/get_delete_data",
            name: "贴子删除",
            field: "delete" ,
            domId: {
                num: "#post_delete_firstLevelPage_num"  //“发贴审核”的“删除量”的指定日期数据显示对应的domId
            }
        }
    };
}

CommentFirstLevelPage

Init: function () {
    this.base("comment");  //传入选项前缀

    this.P_ajaxData = {
        "评论审核贴子审核量": {
            url: "/commentCheck/get_check_data",
            name: "评论审核量",
            field: "num",
            domId: {
                num: "#comment_firstLevel_check_num" //“评论审核”的“审核量”的指定日期数据显示对应的domId
            }
        },
        "评论审核贴子删除量": {
            url: "/commentCheck/get_delete_data",
            name: "评论删除",
            field: "delete" ,
            domId: {
                num: "#comment_firstLevel_delete_num"  //“评论审核”的“删除量”的指定日期数据显示对应的domId
            }
        }
    };
}

二、增长PostFirstLevelPage的子类PostCheckFirstLevelPage、PostDeleteFirstLevelPage,分别对应数据项“审核量”和“删除量”,而后在构造函数中注入数据项id前缀。
还能够将PostFirstLevelPage中的选项接口数据分解为各个数据项的接口数据,放到对应的子类。
(CommentFirstLevelPage也要进行相似的修改,此处省略)

相关代码
PostCheckFirstLevelPage

(function () {
    var PostCheckFirstLevelPage = YYC.Class(PostFirstLevelPage, {
        Init: function () {
            this.base("check");  //传入数据项id前缀

            this.P_ajaxData = {
                url: "/postCheck/get_check_data",
                name: "贴子审核量",
                field: "num"
            };
        }
    });

    window.PostCheckFirstLevelPage = PostCheckFirstLevelPage;
}());

PostDeleteFirstLevelPage

(function () {
    var PostDeleteFirstLevelPage = YYC.Class(PostFirstLevelPage, {
        Init: function () {
            this.base("delete ");  //传入数据项id前缀

            this.P_ajaxData = {
                url: "/postCheck/get_delete_data",
                name: "贴子删除量",
                field: "delete"
            };
        }
    });

    window.PostDeleteFirstLevelPage = PostDeleteFirstLevelPage;
}());

而后再对应修改它的父类PostFirstLevelPage、FirstLevelPage、Page以及main和controlDatePicker模块。
PostFirstLevelPage

Init: function (item) {
    this.base("post", item);  //传入选项前缀
}

FirstLevelPage

Init: function (tab, item) {
    this.base(tab, "firstLevelPage", item); //传入页面id前缀
},

Page

Init: function (tab, level, item) {
    this._tab = tab;
    this._level = level;
    this._item = item;
},

main

window.main = {
    init: function () {
//        window.postFirstLevelPage = new PostFirstLevelPage();
        window.postCheckFirstLevelPage = new PostCheckFirstLevelPage();
        window.postDeleteFirstLevelPage = new PostDeleteFirstLevelPage();

        window.postCheckFirstLevelPage.init();
        window.postDeleteFirstLevelPage.init();

controlDatePicker

function _onchange() {
//            window.postFirstLevelPage.refreshData(_selectDate); //更新一级页面
            window.postCheckFirstLevelPage.refreshData(_selectDate);
            window.postDeleteFirstLevelPage.refreshData(_selectDate);
            …
        }

考虑到:
一、采用方案2代价比较大。
二、当前场景下 “审核量”和“删除量”只有id前缀和接口数据不一样,其他都同样,所以仅仅为了实现不一样的id前缀和接口数据而大费周折地提出PostCheckFirstLevelPage、PostDeleteFirstLevelPage子类是没有必要的。

所以,此处选择方案1,知足当前需求便可。之后若是数据项要变化,再考虑采用方案2来解决。

继续重构,提出ui模块

增长ui模块,放置表现层逻辑,负责与dom的交互。
优势:
一、分离职责
表现层的逻辑与业务逻辑是正交的,应该将其分离出来
二、方便测试
测试业务逻辑时不用再受到表现层逻辑的干扰,可直接对ui模块stub。

领域模型

2_ui

思考:是否须要使用观察者模式重构

咱们看到controlDatePicker与Page的子类都有关联,这是由于用户更改日期后,controlDatePicker须要通知页面更新数据显示。
或许应该使用观察者模式重构?

使用观察者重构后的领域模型

2

咱们来看下观察者模式的应用场景:

  • 当一个对象的改变须要同时改变其它对象,而不知道具体有多少对象有待改变。
  • 当一个对象必须通知其它对象,而它又不能假定其它对象是谁。换言之,你不但愿这些对象是紧密耦合的。
  • 对象仅须要将本身的更新通知给其余对象而不须要知道其余对象的细节。

对于第1和2个观察者模式应用场景,当前场景controlDatePicker须要通知的对象是已知且固定的,所以不符合。
对于第3个场景,controlDatePicker确实须要知道通知对象的细节(须要在_onchange中调用通知对象的方法),可是考虑到通知的对象不是不少,并且_onchange中调用通知对象的逻辑也不是很复杂,所以也不须要使用观察者模式。
综上所述,不须要使用观察者模式重构。

分析当前设计

第1阶段为面向过程设计(实现各自的功能点),当前架构则为面向对象设计(识别对象,划分职责):
优势
一、消除了重复代码
因为将子类共同模式提取到父类中,子类经过实现父类的抽象成员或扩展父类的虚成员等方式来实现本身的不一样点,从而消除继承树中的重复代码。
二、封闭变换点
适应“一级页面”和“二级页面”逻辑的变化:
如要修改一级和二级页面的逻辑,则修改Page便可;如要修改一级页面的逻辑,则修改FirstLevelPage及其父类便可;如要修改“发贴审核”的一级页面的逻辑,则修改PostFirstLevelPage及其父类便可;如要修改“发贴审核”的“审核量”数据的一级页面的逻辑,则能够增长PostFirstLevelPage的子类PostCheckFirstLevelPage,修改该类及其父类便可。
缺点
一、实现较复杂
须要划分各个类的职责和相互之间的交互关系,所以实现相对要复杂点。

第三个阶段

需求变化

如今一级页面的数据项的逻辑发生了变化:
“发贴审核”和“评论审核”的“审核量”在一级页面中增长“审核量增长百分比”(当天审核量相对于前一天增长的百分比)。
计算公式:
百分比 = (指定日期的审核量 – 前一天的审核量) /前一天的审核量

3_
“发贴审核”的“审核量”增长百分比

3_
“评论审核”的“审核量”增长百分比

架构设计

提出PostFirstLevelPage的子类PostCheckFirstLevelPage、PostDeleteFirstLevelPage,分别对应“发贴审核”的“审核量”和“删除量”;提出CommentFirstLevelPage的子类CommentCheckFirstLevelPage、CommentDeleteFirstLevelPage,分别对应“评论审核”的“审核量”和“删除量”。
而后由PostCheckFirstLevelPage、CommentCheckFirstLevelPage分别实现增长百分比数据显示的逻辑,并将共同模式提到它们的基类FirstLevelPage中。

领域模型

3

项目示例代码

详见GitHub地址

分析当前设计

一、层次太多
如今Page继承树有4层,层次过多,一个变化点可能会致使多层的类的修改,复杂性增长。
引用自《Java面向对象编程》:

(1)对象模型的结构太复杂,难以理解,增长了设计和开发的难度。在继承树最底层的子类会继承上层全部直接父类或间接父类的方法和属性,假如子类和父类之间还有频繁的方法覆盖和属性被屏蔽的现象,那么会增长运用多态机制的难度,难以预计在运行时方法和属性到底和哪一个类绑定。
(2)影响系统的可扩展性。继承树的层次越多,在继承树上增长一个新的继承分支须要建立的类越多。

所以,须要对Page继承树进行重构,减小层次数量。
二、多余代码
FirstLevelPage的P_showPercent对于PostDeleteFirstLevelPage和CommentDeleteFirstLevelPage来讲是多余的。
多余代码在继承中是一个常见的问题。继承层次越多,问题越严重。

重构

提出“选项”和“数据项”

能够从现有设计中找到提示。
Page继承树的对应关系:

3

能够看到,第三层对应选项,第四层对应数据项,所以能够提取出“选项”和“数据项”,Page继承树中只保留“一级页面”和“二级页面”。

肯定交互关系

如今要考虑“选项”、“数据项”、“一级页面”、“二级页面”之间的关系。
首先分析“选项”和“数据项”的关系
“选项”对应整个选项页面,“数据项”对应页面的数据项。页面中每一个选项包含两个数据项“审核量”和“删除量”,所以它们应该为包含关系。
如今每一个选项中有两个数据项(“审核量”和“删除量”),所以目前1个选项包含两个数据项。
领域模型

3

分析“数据项”和“一级页面”、“二级页面”的关系
如今缩小了Page对应的页面范围,“一级页面”如今只对应选项页面中属于所属“数据项”的部分(以前对应整个选项页面),“二级页面”对应弹出层页面中属于所属“数据项”的部分(以前对应整个弹出层页面)。
“数据项”应该与“一级页面”、“二级页面”是包含关系。
领域模型

3

肯定职责

“选项”对应选项页面,负责“数据项”的管理和与选项有关的逻辑。
“数据项”对应选项页面的数据项,负责数据项的“一级页面”和“二级页面”的管理。
“一级页面”对应选项页面中属于所属“数据项”的部分,负责所属“数据项”的一级页面的逻辑。
“二级页面”对应弹出层页面中属于所属“数据项”的部分,负责所属“数据项”的二级页面的逻辑。

删除controlTab模块,提出Controller类

将controlTab升级为单例容器类Controller,它包含两个选项,负责选项的管理。
领域模型

3_Controller

删除main

咱们来看下main的代码:

window.main = {
    init: function () {
        window.postCheckFirstLevelPage = new PostCheckFirstLevelPage();
        window.postDeleteFirstLevelPage = new PostDeleteFirstLevelPage();
        window.commentCheckFirstLevelPage = new CommentCheckFirstLevelPage();
        window.commentDeleteFirstLevelPage = new CommentFirstLevelPage();
        //初始化一级页面
        window.postCheckFirstLevelPage.init();
        window.postDeleteFirstLevelPage.init();
        window.commentCheckFirstLevelPage.init();
        window.commentDeleteFirstLevelPage.init();

        window.postSecondLevelPage = new SecondLevelPage("post", window.postFirstLevelPage);
        window.commentSecondLevelPage = new SecondLevelPage("comment",window.commentFirstLevelPage);
        //初始化二级页面
        postSecondLevelPage.init();
        commentSecondLevelPage.init();

        //初始化tab
        controlTab.initTabEvent();

        //初始化日历
        controlDatepicker.initDatePicker();
        controlDatepicker.initScroll();
   }
};

main中的“初始化一级页面和二级页面”属于页面管理的职责,应该放到Item中;如今Controller替代了controlTab,负责选项管理,所以“初始化tab”应该放到Controller中;“初始化日历”也属于“选项管理”的职责,所以也应该放到Controller中。
通过重构后,main如今是多余的了,应该将其删除,让页面直接调用Controller。

领域模型

3_main

提出接口数据itemData

如今回头来审视Page中的接口数据:
一、后台接口数据分散在PostFirstLevelPage、CommentFirstLevelPage中,不方便管理。
二、由于FirstLevelPage、SecondLevelPage须要共享itemData数据,因此二者之间有关联关系。
所以将接口数据提出,放到itemData中。
由于接口数据属于数据项Item,因此应该由数据项负责操做itemData。
提出itemData后,一级页面、二级页面经过对应的数据项来得到对应的接口数据,它们之间再也不有关联关系。

领域模型

3_ItemData

“选项”Tab提出子类PostTab、CommentTab

由于两个选项“发贴审核”和“评论审核”相互独立,所以提出Tab的子类PostTab、CommentTab,分别对应这两个选项。
领域模型

3_Tab

“数据项”Item提出子类CheckItem、DeleteItem

两个选项的“审核量”和“删除量”数据项虽然相互独立,可是它们对“一级页面”和“二级页面”管理的逻辑分别相同,只有后台接口的不一样,其它模式都同样所以只需提出CheckItem类,对应两个选项的“审核量”;提出DeleteItem类,对应两个选项的 “删除量”。
领域模型

3_Item

重构一级页面FirstLevelPage

分解职责
分解FirstLevelPage的“绘制选项趋势图”的职责,将“选项趋势图绘制”的逻辑移到“选项”Tab的getAndShowFirstLevelChart方法中(由于“选项中绘制全部数据项的趋势图”并不该该由某个具体的“数据项”来负责,而应该由“选项”直接负责),留下与一级页面的职责相关的“得到所属数据项的趋势图数据”逻辑。
重构后相关代码以下:
Tab

getAndShowFirstLevelChart: function (selectDate) {
    var seriesDataArr = [],
        data = null;

    //Item负责得到图表数据
    this._items.forEach(function (item) {
        data = item.getFirstLevelChartData(selectDate);
        if (data) {
            seriesDataArr.push(data);
        }
    });

    //Tab负责绘制图表
    this.P_draw(seriesDataArr);
},

Item

getFirstLevelChartData: function (selectDate) {
    return this.P_firstLevelPage.getChartData(selectDate);
},

FirstLevelPage

getChartData: function (selectDate) {
    var seriesDataArr = [],
        ajaxData = null,
        self = this;

    ajaxData = this.P_ajaxData;

    $.ajax({
        url: ajaxData.url,
        data: {
            begin_date: _getStartDate(selectDate),      //得到selectDate-7的日期
            end_date: selectDate
        },
        dataType: "json",
        async: false,  //同步
        success: function (dataArr) {
            seriesData = self.P_getSeriesData(dataArr, self._item.getTitleName());
        }
    });

    return seriesDataArr;
},

对应的dom的id也要修改:

<!--删除一级页面的id前缀,由于"绘制选项趋势图"与选项Tab有关而与FirstLevelPage无关,所以该dom再也不与FirstLevelPage对应-->
    <section id="post">
        …
        <!--<section id="post_firstLevelPage_chartBody" class="chartContainer">-->
        <section id="post_chartBody" class="chartContainer">

使用策略模式,提出FirstLevelPage的子类CommonFirstLevelPage和PercentFirstLevelPage
CommonFirstLevelPage负责“删除量”数据项的一级页面逻辑;PercentFirstLevelPage负责“审核量”数据项的一级页面逻辑,加入了显示百分比数据的逻辑。

重构后的领域模型

3_FirstLevelPage

id前缀注入的修改

第二个阶段是在Page类族的构造函数中注入id前缀,而如今已经提出了“选项”、“数据项”、“一级页面”、“二级页面”这四个实体,所以能够增长id属性做为实体的标识符,保存对应的id前缀,而不用再注入id前缀了。

关于“tab与item、item与Page之间双向关联”的分析

由于Item须要访问所属选项Tab的id、name、getSelectDate等成员,Page须要访问所属数据项Item的id、itemData等成员,所以tab与item、item与Page之间为双向关联的关系。
另外Page还须要访问所属选项的id(用于构造id前缀,访问对应的dom),因此Item提供getTabId方法,使Page经过所属Item就能够得到选项的id,避免了Page依赖Tab形成的循环依赖的问题。

二级页面dom改成相互独立

第二个阶段中的“共享二级页面dom”的设计如今不合适了!
这是由于:
一、在上面的“肯定交互关系”讨论中,肯定了“数据项”与“二级页面”是1对1的包含关系,所以数据项的二级页面从逻辑上来看已是相互独立的了,因此为了不数据项操做各自的二级页面dom时相互干扰,应该将二级页面dom改成相互独立。
二、SecondLevelPage能够经过访问所属的Item来得到Item的数据项id前缀,所以可以访问包含数据项id前缀的dom。
因此应该将每一个选项的二级页面dom改成与数据项相关的、相互独立的dom(dom id包含数据项id前缀),然而这样又会形成二级页面dom冗余(html结构都同样,只是id不同)。
考虑到当前dom冗余还不是很严重,而且它们都在一个页面中,管理起来也比较容易,所以以适当的dom冗余来换取灵活性是值得的。
若是后期dom冗余过于严重,则能够考虑使用js模板来生成重复的html代码。
相关代码:

<!--发贴审核选项-->
  <section id="post">
    …

        <!--数据项的secondLevelPage容器如今相互独立了-->

        <section id="post_check_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
           …
        </section>

        <section id="post_delete_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
            …
        </section>
    …
</section>

 <!--评论审核选项-->
  <section id="comment">
    …

        <!--数据项的secondLevelPage容器如今相互独立了-->

        <section id="comment_check_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
            …
        </section>

        <section id="comment_delete_secondLevelPage_chartBody" class="secondLevelChartBody qushiBody">
            …
        </section>
    …
</section>

领域模型和分层

如今能够对系统进行分层,以下所示:

  • 系统交互层

    负责与页面交互

  • 业务逻辑层

    负责系统的业务逻辑

  • 数据层

    放置接口数据

  • 辅助层

    放置通用类

领域模型

3

项目示例代码

详见GitHub地址

分析当前设计

优势
一、相对于第二阶段的架构,新架构分离出了“选项”和“数据项”,这样可以适应“选项”、“数据项”、“一级页面”、“二级页面”各自独立的变化,于是更加灵活了。

  • 例如“一级页面”或“二级页面”发生了变化:

1)“发贴审核”和“评论审核”的“审核量”在一级页面中增长“星期审核量总和增长百分比”(当前星期审核量总和相对于前一个星期增长的百分比)。

那么只须要对应修改“审核量”数据项CheckItem使用的PercentFirstLevelPage类便可。

2)“发贴审核”和“评论审核”的“审核量”增长二级页面的数据下载功能。

那么能够增长一个下载类Download,负责二级页面数据下载。
由于它属于二级页面的逻辑,因此由“二级页面”SecnondLevelPage组合Download。

领域模型
_Download

  • 又好比如今“数据项”发生了变化:

1)“发贴审核”和“评论审核”的“删除量”增长最近二个月范围的“审核量增长百分比”数据的图表,该图表显示在二级页面中。

由于该图表也显示在二级页面中,所以如今“删除量”这个数据项应该包含二个“二级页面”,一个“二级页面”负责日期范围删除量的图表,另外一个“二级页面”负责最近二个月范围审核量增长百分比的图表。

所以能够增长“二级页面”SecondLevelPage的子类QuShiSecondLevelPage和PercentSecondLevelPage。QuShiSecondLevelPage负责显示日期范围删除量的图表,PercentSecondLevelPage负责显示二个月范围审核量增长百分比的图表。

领域模型
_

缺点
一、若是选项全部数据项的一级页面或二级页面统一发生变化,则修改起来没有第二阶段架构方便。

  • 如如今“发贴审核”的全部数据项的一级页面的逻辑发生了变化。

若是是第二阶段的架构,则只需修改PostFirstLevelPage及其父类便可。

若是是当前的架构,则须要增长Item的子类PostCheckItem、PostDeleteItem、CommentCheckItem、CommentDeleteItem,分别对应两个选项的四个数据项。而后修改属于“发贴审核”的数据项类PostCheckItem、PostDeleteItem。

对比第二阶段架构和第三阶段架构

比较 第二阶段架构 第三阶段架构
适应的变化点 “一级页面”、“二级页面” “选项”、“数据项”、“一级页面”、“二级页面”
层次结构 纵向层次结构 3 横向层次结构 3

总结

在本文中能够看到,我并无一开始就给出一个完善的架构设计,这也是不可能的。随着需求的不断变化和我对需求理解的不断深刻,对应的架构也在不断的演化。
在第一个阶段中,我从功能点的实现出发,将需求分割为一个个模块,负责实现对应的功能。由于当时需求比较简单,所以直接用面向过程的思惟来设计是适合当时场景的,也是最简单的方式。
在第二个阶段中,我经过重构进行了由下而上的分析,采用面向对象思惟对需求进行了初步建模,提取出了“一级页面”和“二级页面”的模型。
在第三个阶段中,因为需求的进一步变化,致使原有设计中出现了坏味道。所以我及时重构,提取出了“选项”和“数据项”的概念,分解了Page继承树,减小了复杂度,适应了更多的变化点。
在实际的工程中,应该根据需求来设计架构。对于容易变化的需求,经常采用敏捷设计,先给出初步的设计,而后在坚实的测试保证下不断地迭代、重构、集成。

参考资料

《Java面向对象编程》
《重构:改善既有代码的设计》
演化架构与紧急设计系列

相关文章
相关标签/搜索