前端的架構設計與演化實例

前言

本文介紹我在實際的前端項目中的架構設計,展現由於需求變化而致使架構變化的過程。
全文分爲三個階段,分別對應三次需求的變化,給出了對應的架構設計。
在第一個階段中,我使用面向過程設計;在第二個階段和在第三個階段中,我使用面向對象設計。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面向對象編程》
《重構:改善既有代碼的設計》
演化架構與緊急設計系列

相關文章
相關標籤/搜索