前端的架構設計與演化實例
前言
本文介紹我在實際的前端項目中的架構設計,展示因為需求變化而導致架構變化的過程。
全文分為三個階段,分別對應三次需求的變化,給出了對應的架構設計。
在第一個階段中,我使用面向過程設計;在第二個階段和在第三個階段中,我使用面向對象設計。
本文內容
策略
為了方便討論,本文的涉及的項目是經過簡化的示例項目。
本文重點展現領域模型和架構的變化,對于具體的方法/屬性級別的重構不進行詳細討論。
本文會給出核心的實現代碼,但不會討論單元測試。
本文會在具體的上下文中討論架構的設計。詳見下面的討論:
-
本文應該給出一個具體的上下文環境,還是構造一個抽象的上下文?
具體的上下文示例
這是一個貼子后臺管理的數據統計平臺,用戶可在該平臺中查看“發貼審核”選項的“貼子審核量”數據項的數據。
優點
便于讀者理解討論的上下文,從而能夠更好地理解本文討論的架構的設計和演變。
缺點
不能為了演示架構演變而隨意構造用戶的需求,需求必須約束在具體的上下文中
抽象的上下文示例
這是一個數據統計平臺,用戶可在該平臺中查看tabA選項的item1數據項的數據。
優點
可以圍繞架構設計和演變最大限度地構造用戶的需求,可以充分在各種假設需求下討論架構的演變。
缺點
由于沒有具體的上下文,讀者很難理解本文的架構設計和演變與需求的關系。
結論
為了讓讀者更好地理解架構的設計和演變,本文會在具體的上下文中討論,但也會將需求最簡化,從而讓讀者把精力集中到關注架構設計上。
依賴項
正文
第一個階段
需求
這是一個后臺管理系統的數據統計平臺,其中后臺管理系統可以對網站的貼子進行審核,平臺則記錄并顯示后臺管理系統操作的數據。
現后臺接口已開發完成,我負責前端邏輯實現。
現在用戶可在該平臺中查看“發貼審核”選項的“貼子審核量”數據項的數據。
有以下兩個要求:
用戶可以選擇日期,查看指定日期的貼子審核量數據。
用戶可點擊“趨勢”,查看指定日期范圍(指定日期前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插件交互
領域模型

項目示例代碼
序列圖
選擇日期

查看趨勢

進一步重構
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。
項目示例代碼
序列圖
切換選項

選擇日期

查看趨勢

分析具體實現
為了便于讀者理解設計,此處對具體實現中重要的內容作一些說明和分析。
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中。
領域模型

項目示例代碼
分析當前設計
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>
領域模型和分層
現在可以對系統進行分層,如下所示:
-
系統交互層
負責與頁面交互
-
業務邏輯層
負責系統的業務邏輯
-
數據層
放置接口數據
-
輔助層
放置通用類
領域模型

項目示例代碼
分析當前設計
優點
1、相對于第二階段的架構,新架構分離出了“選項”和“數據項”,這樣能夠適應“選項”、“數據項”、“一級頁面”、“二級頁面”各自獨立的變化,因而更加靈活了。
- 例如“一級頁面”或“二級頁面”發生了變化:
1)“發貼審核”和“評論審核”的“審核量”在一級頁面中增加“星期審核量總和增加百分比”(當前星期審核量總和相對于前一個星期增加的百分比)。
那么只需要對應修改“審核量”數據項CheckItem使用的PercentFirstLevelPage類即可。
2)“發貼審核”和“評論審核”的“審核量”增加二級頁面的數據下載功能。
那么可以增加一個下載類Download,負責二級頁面數據下載。
因為它屬于二級頁面的邏輯,所以由“二級頁面”SecnondLevelPage組合Download。
領域模型

- 又比如現在“數據項”發生了變化:
1)“發貼審核”和“評論審核”的“刪除量”增加最近二個月范圍的“審核量增加百分比”數據的圖表,該圖表顯示在二級頁面中。
因為該圖表也顯示在二級頁面中,因此現在“刪除量”這個數據項應該包含二個“二級頁面”,一個“二級頁面”負責日期范圍刪除量的圖表,另一個“二級頁面”負責最近二個月范圍審核量增加百分比的圖表。
因此可以增加“二級頁面”SecondLevelPage的子類QuShiSecondLevelPage和PercentSecondLevelPage。QuShiSecondLevelPage負責顯示日期范圍刪除量的圖表,PercentSecondLevelPage負責顯示二個月范圍審核量增加百分比的圖表。
領域模型

缺點
1、如果選項所有數據項的一級頁面或二級頁面統一發生變化,則修改起來沒有第二階段架構方便。
- 如現在“發貼審核”的所有數據項的一級頁面的邏輯發生了變化。
如果是第二階段的架構,則只需修改PostFirstLevelPage及其父類即可。
如果是當前的架構,則需要增加Item的子類PostCheckItem、PostDeleteItem、CommentCheckItem、CommentDeleteItem,分別對應兩個選項的四個數據項。然后修改屬于“發貼審核”的數據項類PostCheckItem、PostDeleteItem。
對比第二階段架構和第三階段架構
| 比較 | 第二階段架構 | 第三階段架構 |
|---|---|---|
| 適應的變化點 | “一級頁面”、“二級頁面” | “選項”、“數據項”、“一級頁面”、“二級頁面” |
| 層次結構 | 縱向層次結構 ![]() |
橫向層次結構 ![]() |
總結
在本文中可以看到,我并沒有一開始就給出一個完善的架構設計,這也是不可能的。隨著需求的不斷變化和我對需求理解的不斷深入,對應的架構也在不斷的演化。
在第一個階段中,我從功能點的實現出發,將需求分割為一個個模塊,負責實現對應的功能。因為當時需求比較簡單,因此直接用面向過程的思維來設計是適合當時場景的,也是最簡單的方式。
在第二個階段中,我通過重構進行了由下而上的分析,采用面向對象思維對需求進行了初步建模,提取出了“一級頁面”和“二級頁面”的模型。
在第三個階段中,由于需求的進一步變化,導致原有設計中出現了壞味道。因此我及時重構,提取出了“選項”和“數據項”的概念,分解了Page繼承樹,減少了復雜度,適應了更多的變化點。
在實際的工程中,應該根據需求來設計架構。對于容易變化的需求,常常采用敏捷設計,先給出初步的設計,然后在堅實的測試保證下不斷地迭代、重構、集成。
參考資料
《Java面向對象編程》
《重構:改善既有代碼的設計》
演化架構與緊急設計系列


浙公網安備 33010602011771號