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

前言 本文介绍我在实际的前端项目中的架构设计,展示因为需求变化而导致架构变化的过程。 全文分为三个阶段,分别对应三次需求的变化,给出了对应的架构设计。 在第一

前言

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

本文内容

策略

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

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

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

依赖项

Javascript OOP框架YOOP

正文

第一个阶段

需求

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

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


用户点击趋势后,会弹出一个二级页面,显示日期范围(指定日期前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字段对应该日期的贴子审核量。

架构设计

技术选型

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

技术方案

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

  • main

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

  • showData

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

  • qushi

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

  • chartHelper

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

  • controlDatePicker

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

领域模型

项目示例代码

详见GitHub地址

序列图

选择日期

查看趋势

进一步重构

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

重构后的领域模型

2、重构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、每个模块的职责没有重复,对需求变化具有良好的封闭性
一个需求的变化只会影响负责该需求的模块的变化,其它模块不会受到影响。
2、能较好地适应功能点的增加
如果需要增加新的功能,则增加对应的模块,并对应修改入口模块main即可,其余模块不用修改。
缺点
1、数据与业务逻辑耦合
当前场景下还不是什么问题,可先保留当前设计,到需要分离数据时再分离。

第二个阶段

需求变更

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

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

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

需求分析

每个数据项的功能都一样,只是对应的后台接口不同或从接口数据中取出的字段不同
如“发贴审核”的“贴子审核量”需要从/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模块?

因为用户可切换选项,显示不同的选项页面,所以两个选项应该相互独立,各自的模块和数据也应该相互独立。

分析当前设计

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

因此,需要使用面向对象思维来重新设计。

重构

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

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

领域模型

升级为类,提出基类

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

增加FirstLevelPage的子类

因为两个选项的后台接口数据不同,所以增加FirstLevelPage的子类PostFirstLevelPage、CommentFirstLevelPage,放置各自选项的接口数据。
因为两个选项的二级页面逻辑都相同,并且SecondLevelPage从FirstLevelPage中获得接口数据,本身并没有数据,因此SecondLevelPage不需要提出子类。

新的领域模型

没有画出main模块,因为它与几乎所有的类都有关联,如果画出来模型就看不清楚了。后面的领域模型中也不会画出main。

项目示例代码

详见GitHub地址

序列图

切换选项

选择日期

查看趋势

分析具体实现

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

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)。
这是因为:
1、“共享dom”虽然会造成相互干扰,但可以减少dom的数量,而且目前相互之间只有很轻微的干扰。
2、如果同一个选项的“审核量”和“删除量”的二级页面相互独立,那么它们的dom的id就要加上数据项id前缀。但是现在的Page类族无法访问包含数据项id前缀的dom(见“解决Page类族无法访问有数据项id前缀的dom的问题”),在SecondLevelPage中访问对应数据项的二级页面dom比较麻烦!

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

在前面的“dom的id与类的对应关系”讨论中,我们看到选项id前缀和页面id前缀都可以注入到类中,而数据项id前缀现在却没有注入,因此Page类族无法访问包含数据项id前缀的dom!
有两个方案解决该问题:
1、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
            }
        }
    };
}

2、增加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);
            …
        }

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

因此,此处选择方案1,满足当前需求即可。以后如果数据项要变化,再考虑采用方案2来解决。

继续重构,提出ui模块

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

领域模型

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

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

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

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

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

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

分析当前设计

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

第三个阶段

需求变化

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

“发贴审核”的“审核量”增加百分比

“评论审核”的“审核量”增加百分比

架构设计

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

领域模型

项目示例代码

详见GitHub地址

分析当前设计

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

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

因此,需要对Page继承树进行重构,减少层次数量。
2、多余代码
FirstLevelPage的P_showPercent对于PostDeleteFirstLevelPage和CommentDeleteFirstLevelPage来说是多余的。
多余代码在继承中是一个常见的问题。继承层次越多,问题越严重。

重构

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

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

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

确定交互关系

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

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

确定职责

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

删除controlTab模块,提出Controller类

将controlTab升级为单例容器类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。

领域模型

提出接口数据itemData

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

领域模型

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

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

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

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

重构一级页面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负责“审核量”数据项的一级页面逻辑,加入了显示百分比数据的逻辑。

重构后的领域模型

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对1的包含关系,因此数据项的二级页面从逻辑上来看已经是相互独立的了,所以为了避免数据项操作各自的二级页面dom时相互干扰,应该将二级页面dom改为相互独立。
2、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>

领域模型和分层

现在可以对系统进行分层,如下所示:

  • 系统交互层

    负责与页面交互

  • 业务逻辑层

    负责系统的业务逻辑

  • 数据层

    放置接口数据

  • 辅助层

    放置通用类

领域模型

项目示例代码

详见GitHub地址

分析当前设计

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

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

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

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

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

那么可以增加一个下载类Download,负责二级页面数据下载。
因为它属于二级页面的逻辑,所以由“二级页面”SecnondLevelPage组合Download。

领域模型

  • 又比如现在“数据项”发生了变化:

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

因为该图表也显示在二级页面中,因此现在“删除量”这个数据项应该包含二个“二级页面”,一个“二级页面”负责日期范围删除量的图表,另一个“二级页面”负责最近二个月范围审核量增加百分比的图表。

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

领域模型

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

  • 如现在“发贴审核”的所有数据项的一级页面的逻辑发生了变化。

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

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

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

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

总结

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

参考资料

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