減少前端代碼耦合
https://zhuanlan.zhihu.com/p/24495650
原作者:李銀城
什么是代碼耦合?代碼耦合的表現(xiàn)是改了一點毛發(fā)而牽動了全身,或者是想要改點東西,需要在一堆代碼里面找半天。由于前端需要組織js/css/html,耦合的問題可能會更加明顯,下面按照耦合的情況分別說明:
1. 避免全局耦合
這應(yīng)該是比較常見的耦合。全局耦合就是幾個類、模塊共用了全局變量或者全局數(shù)據(jù)結(jié)構(gòu),特別是一個變量跨了幾個文件。例如下面,在html里面定義了一個變量:
<script>
var PAGE = 20;
</script>
<script src="main.js"></script>
上面在head標簽里面定義了一個PAGE的全局變量,然后在main.js里面使用。這樣子PAGE就是一個全局變量,并且跨了兩個文件,一個html,一個js。然后在main.js里面突然冒出來了個PAGE的變量,后續(xù)維護這個代碼的人看到這個變量到處找不到它的定義,最后找了半天發(fā)現(xiàn)原來是在xxx.html的head標簽里面定義了。這樣就有點egg pain了,并且這樣的變量容易和本地的變量發(fā)生命名沖突。
所以如果需要把數(shù)據(jù)寫在頁面上的話,一個改進的辦法是在頁面寫一個form,數(shù)據(jù)寫成form里面的控件數(shù)據(jù),如下:
<form id="page-data">
<input type="hidden" name="page" value="2">
<textarea name="list" style="display:none">[{"userName": ""yin"},{}]</textarea>
</form>
上面使用了input和textarea,使用textarea的優(yōu)點是支持特殊符號。再把form的數(shù)據(jù)序列化,序列化也是比較簡單的,可以查看Effective前端2:優(yōu)化html標簽
第二種是全局數(shù)據(jù)結(jié)構(gòu),這種可能會使用模塊化的方法,如下:
//data.js
module.exports = {
houseList: null
}
//search.js 獲取houseList的數(shù)據(jù)
var data = require("data");
data.houseList = ajax();
require("format-data").format();
//format-data.js 對houseList的數(shù)據(jù)做格式化
function format(){
var data = require("data");
process(data);
require("show-result").show();
}
//show-result.js 將數(shù)據(jù)顯示出來
function show(){
showData(require("data").houseList)
}
上面四個模塊各司其職,乍一眼看上去好像沒什么問題,但是他們都用了一個data的模塊共用數(shù)據(jù)。這樣確實很方便,但是這樣就全局耦合了。因為用的同一個data,所以你無法保證,其它人也會加載了這個模塊然后做了些修改,或者是在你的某一個業(yè)務(wù)的異步回調(diào)也改了這個。第二個問題:你不知道這個data是從哪里來的,誰可能會對它做了修改,這個過程對于后續(xù)的模塊來說都是不透明的。
所以這種應(yīng)該考慮使用傳參的方式,降低耦合度,把data作為一個參數(shù)傳遞:
//去掉data.js
//search.js 獲取數(shù)據(jù)并傳遞給下一個模塊
var houseList = ajax();
require("format-data").format(houseList);
//format-data.js 對houseList的數(shù)據(jù)做格式化
function format(houseList){
process(houseList);
require("show-result").show(houseList);
}
//show-result.js 將數(shù)據(jù)顯示出來
function show(houseList){
showData(houseList)
}
可以看到,search里面獲取到data后,交給format-data處理,format-data處理完之后再給show-result。這樣子就很清楚地知道數(shù)據(jù)的處理流程,并且保證了houseList不會被某個異步回調(diào)不小心改了。如果單獨從某個模塊來說,show-result這個模塊并不需要關(guān)心houseList的經(jīng)過了哪些流程和處理,它只需要關(guān)心輸入是符合它的格式要求的就可以。
這個時候你可能會有一個問題:這個data被逐層傳遞了這么多次,還不如像最上面的那樣寫一個data的模塊,大家都去改那里,豈不是簡單了很多?對,這樣是簡單了,但是一個數(shù)據(jù)結(jié)構(gòu)被跨了幾個文件使用,這樣會出現(xiàn)我上面說的問題。有時候可能出現(xiàn)一些意想不到的情況,到時候可能得找bug找個半天。所以這種解耦是值得的,除非你定義的變量并不會跨文件,它的作用域只在它所在的文件,這樣會好很多。或者是data是常量的,data里面的數(shù)據(jù)定義好之后值就再也不會改變,這樣應(yīng)當也是可取的。
2. js/css/html的耦合
這種耦合在前端里面應(yīng)該最常見,因為這三者通常具有交集,需要使用js控制樣式和html結(jié)構(gòu)。如果使用js控制樣式,很多人都喜歡在js里面寫樣式,例如當頁面滑動到某個地方之后要把某個條吸頂:

頁面滑到下面那個灰色的條再繼續(xù)往下滑的時候,那個灰色條就要保持吸頂狀態(tài):

可能不少人會這么寫:
$(".bar").css({
position: fixed;
top: 0;
left: 0;
});
然后當用戶往上滑的時候取消fixed:
$(".bar").css({
position: static;
});
如果你用react,你可能會設(shè)置一個style的state數(shù)據(jù),但其實這都一樣,都把css雜合到j(luò)s里面了。某個想要檢查你樣式的人,想要給你改個bug,他檢查瀏覽器發(fā)現(xiàn)有個標簽style里的屬性,然后他找半天找不到是在哪里設(shè)置的,最后他發(fā)現(xiàn)是在某個js的某個隱蔽的角落設(shè)置了。你在js里面設(shè)置了樣式,然后css里面也會有樣式,在改css的時候,如果不知道js里面也有設(shè)置了樣式,那么可能會發(fā)生沖突,在某種條件下觸發(fā)了js里面設(shè)置樣式。
所以不推薦直接在js里面更改樣式屬性,而應(yīng)該通過增刪類來控制樣式,這樣子樣式還是回歸到css文件里面。例如上面可以改成這樣:
//增加fixed
$(".bar").addClass("fixed");
//取消fixed
$(".bar").removeClass("fixed");
fixed的樣式:
.bar.fixed{
position: fixed;
left: 0;
top: 0;
}
可以看到,這樣的邏輯就非常清晰,并且回滾fixed,不需要把它的position還原為static,因為它不一定是static,也有可能是relative,這種方式在取消掉一個類的時候,不需要去關(guān)心原本是什么,該是什么就會是什么。
但是有一種是避免不了的,就是監(jiān)聽scroll事件或者mousemove事件,動態(tài)地改變位置。
這種通過控制類的方式還有一個好處,就是當你給容器動態(tài)地增刪一個類時,你可以借助子元素選擇器,用這個類控制它的子元素的樣式,也是很方便。
還有很多人可能會覺得html和css/js脫耦,那就是不能在html里面寫style,不能在html里面寫script標簽,但是凡事都不是絕對的,如果有一個標簽,它和其它標簽就一個font-size不一樣,那你直接給它寫一個font-size的內(nèi)聯(lián)樣式,又何嘗不可呢,在性能上來說,如果你寫個class,它還得去匹配這個class,比不上style高效吧?;蛘呤悄氵@個html文件就那么20、30行css,那直接在head標簽加個style,直接寫在head里面好了,這樣你就少管理了一個文件,并且瀏覽器不用去加載一個外鏈的文件。
有時候直接在html寫script標簽是必要的,它的優(yōu)勢也是不用加載外鏈文件,處理速度會很快,幾乎和dom渲染同時,這個在解決頁面閃動的時候比較有用。因為如果要用js動態(tài)地改變已經(jīng)加載好的dom,放在外鏈里面肯定會閃一下,而直接寫的script就不會有這個問題,即使這個script是放在了body的后面。例如下面:

原始數(shù)據(jù)是帶p標簽的,但是在textarea里面展示的時候需要把p改成換行\(zhòng)r\n,如果在dom渲染之后再在外鏈里面更新dom就會出現(xiàn)上面的閃動的情況。你可能會說我用react,數(shù)據(jù)都是動態(tài)渲染的,渲染前已經(jīng)處理好了,不會出現(xiàn)上面的情況。那么,好吧,至少你了解一下吧。
和耦合相對的是內(nèi)聚,寫代碼的原則就是低耦合、高聚合。所謂內(nèi)聚就是說一個模塊的職責功能十分緊密,不可分割,這個模塊就是高內(nèi)聚的。我們先從重復(fù)代碼說起:
3. 減少重復(fù)代碼
假設(shè)有一段代碼在另外一個地方也要被用到,但又不太一樣,那么最簡單的方法當然是copy一下,然后改一改。這也是不少人采取的辦法,這樣就導(dǎo)致了:如果以后要改一個相同的地方就得同時改好多個地方,就很麻煩了。
例如有一個搜索的界面:

用戶可以通過點擊search按鈕觸發(fā)搜索,也可以通過點擊下拉或者通過輸入框的change觸發(fā)搜索,所以你可能會這么寫:
$("#search").on("click", function(){
var formData = getFormData();
$.ajax({
url: '/search',
data: formData,
success: function(data){
showResult(data);
}
});
});
在change里面又重新發(fā)請求:
$("input").on("change", function(){
//把用戶的搜索條件展示進行改變
changeInputFilterShow();
var formData = getFormData();
$.ajax({
url: '/search',
data: formData,
success: function(data){
showResult(data);
}
});
});
change里面需要對搜索條件的展示進行更改,和click事件不太一樣,所以圖一時之快就把代碼拷了一下。但是這樣是不利于代碼的維護的,所以你可能會想到把獲取數(shù)據(jù)和發(fā)請求的那部分代碼單獨抽離封裝在一個函數(shù),然后兩邊都調(diào)一下:
function getAndShowData(){
var formData = getFormData();
$.ajax({
url: '/search',
data: formData,
success: function(data){
showResult(data);
}
});
}
$("#search").on("click", getAndShowData);
$("input").on("change", function(){
changeInputFilterShow();
getAndShowData();
});
在抽成一個函數(shù)的基礎(chǔ)上,又發(fā)現(xiàn)這個函數(shù)其實有點大,因為這里面要獲取表單數(shù)據(jù),還要對數(shù)據(jù)進行格式化,用做請求的參數(shù)。如果用戶觸發(fā)得比較快,還要記錄上次請求的xhr,在每次發(fā)請求前cancle掉上一次的xhr,并且可能對請求做一個loading效果,增加用戶體驗,還要對出錯的情況進行處理,全部都要在ajax里面。所以最好對getAndShowData繼續(xù)拆分,很自然地會想到把它分離成一個模塊,一個單獨的文件,叫做search-ajax。所有發(fā)請求的處理都在這個模塊里面統(tǒng)一操作。對外只提供一個search.ajax的接口,傳的參數(shù)為當前的頁數(shù)即可。所有需要發(fā)請求的都調(diào)一下這個模塊的這個接口就好了,除了上面的兩種情況,還有點擊分頁的情景。這樣不管哪種情景都很方便,我不需要關(guān)心請求是怎么發(fā)的,結(jié)果是怎么處理的,我只要傳一個當前的頁數(shù)給你就好了。
再往下,會發(fā)現(xiàn),在顯示結(jié)果那里,即上面代碼的第7行,需要對有結(jié)果、無結(jié)果的情況分別處理,所以又搞了一個函數(shù)叫做showResult,這個函數(shù)有點大,它里面的邏輯也比較復(fù)雜,有結(jié)果的時候除了更新列表結(jié)果,還要更新結(jié)果總數(shù)、更新分頁的狀態(tài)。因此這個showResult一個函數(shù)難以擔當大任。所以把這個show-result也當獨分離出一個模塊,負責結(jié)果的處理。
到此,我們整一個search的UML圖應(yīng)該是這樣的:

注意上面把發(fā)請求的又再單獨封裝成了一個模塊,因為這個除了搜索發(fā)請求外,其它的請求也可以用到。同時search-result會用到兩個展示的模板。
由于不只一個頁面會用到搜索的功能,所以再把上面繼續(xù)抽象,把它封裝成一個search-app的模塊,需要用到的頁面只需require這個search-app,調(diào)一下它的init函數(shù),然后傳些定制的參數(shù)就可以用了。這個search-app就相當于一個搜索的插件。
所以整一個的思路是這樣的:出現(xiàn)了重復(fù)代碼 -> 封裝成一個函數(shù) -> 封裝成一個模塊 -> 封裝成一個插件,抽象級別不斷提高,將共有的特性和有差異的地方分離出來。當你走在抽象與封裝的路上的時候,那你應(yīng)該也是走在了大神的路上。
當然,如果兩個東西并沒有共同點,但是你硬是要搞在一起,那是不可取的。
我這里說的封裝并不是說,你一定要使用requirejs、es6的import或者是webpack的require,關(guān)鍵在于你要有這種模塊化的思想,并不是指工具上的,不管你用的哪一個,只要你有這種抽象的想法,那都是可取的。
模塊化的極端是拆分粒度太細,一個簡單的功能,明明十行代碼寫在一起就可以搞定的事情,硬是寫了七、八層函數(shù)棧,每個函數(shù)只有兩、三行。這樣除了把你的邏輯搞得太復(fù)雜之外,并沒有太多的好處。當你出現(xiàn)了重復(fù)代碼,或者是一個函數(shù)太大、功能太多,又或是邏輯里面寫了三層循環(huán)又再嵌套了三層if,再或是你預(yù)感到你寫的這個東西其他人也可能會用到,這個時候你才考慮模塊化,進行拆分比較合適。
上面不管是search-result還是search-ajax他們在功能上都是高度內(nèi)聚的,每個模塊都有自己的職責,不可拆分,這在面向?qū)ο缶幊汤锩娼凶鰡我回熉氃瓌t,一個模塊只負責一個功能。
再舉一個例子,我在怎樣實現(xiàn)前端裁剪上傳圖片功能里面提到一個上傳裁剪的實現(xiàn),這里面包含裁剪、壓縮上傳、進度條三大功能,所以我把它拆成三個模塊:

這里提到的模塊大部分是一個單例的object,不會去實例它,一般可以滿足大部分的需求。在這個單例的模塊里面,它自己的“私有”函數(shù)一般是通過傳參調(diào)用,但是如果需要傳遞的數(shù)據(jù)比較多的時候,就有點麻煩了,這個時候可以考慮把它封裝成一個類。
3. 封裝成一個類
在上面的裁剪上傳里面的進度條progress-bar,一個頁面里可能有幾個要上傳的地方,每個上傳的地方都會有進度條,每個進度條都有自己的數(shù)據(jù),所以不能像在最上面說的,在一個文件的最上面定義一些變量然后為這個模塊里面的函數(shù)共用,只能是通過傳遞參數(shù)的形式,即在最開始調(diào)用的時候定義一些數(shù)據(jù),然后一層一層地傳遞下去。如果這些數(shù)據(jù)很多的話就有點麻煩。
所以稍微變通一下,把progress-bar封裝成一個類:
function ProgressBar($container){
this.$container = $container; //進度條外面的容器
this.$meter = null; //進度條可視部分
this.$bar = null; //進度條存放可視部分的容器
this.$barFullWidth = $container.width() * 0.9; //進度條的寬度
this.show(); //new一個對象的時候就顯示
}
或者你用ES6的class,但是本質(zhì)上是一樣的,然后這個ProgressBar的成員函數(shù)就可以使用定義的這些“私有”變量,例如設(shè)置進度條的進度函數(shù):
ProgressBar.prototype.setProgress = function(percentage, time){
time = typeof time === "undefined" ? 100 : time;
this.$meter.stop().animate({width: parseInt(this.$barFullWidth * percentage)}, time);
};
這個使用了兩個私有變量,如果再加上原先兩個,用傳參的方式就得傳四個。
使用類是模塊化的一種思想,另外一種常用的還有策略模式。
4. 使用策略模式
假設(shè)要實現(xiàn)下面三個彈框:

這三個彈框無論是在樣式上還是在功能上都是一樣的,唯一的區(qū)別是上面標題文案是不一樣的。最簡單的可能是把每個彈框的html都copy一下,然后改一改。如果你用react,你可能會用拆分組件的方式,上面一個組件,下面一個組件,那么好吧,你就這樣搞吧。如果你沒用react,你可能得想辦法組織下你的代碼。
如果你有策略模式的思想,你可能會想到把上面的標題當作一個個的策略。首先定義不同彈框的類型,一一標志不同的彈框:
var popType = ["register", "favHouse", "saveSearch"];
定義三種popType一一對應(yīng)上面的三個彈框,然后每種popType都有對應(yīng)的文案:
Data.text.pop = {
register: {
titlte: "Create Your Free Account",
subTitle: "Search Homes and Exclusive Property Listings"
},
favHouse: {title: "xxx", subTitle: "xxx" },
saveSearch: {title: "xxx", subTitle: "xxx"}
};
{tittle: “”, subtitle: “”}這個就當作是彈框文案策略,然后再寫彈框的html模板的時候引入一個占位變量:
<section>
{{title}}
{{subTitile}}
<div>
<!--其它內(nèi)容-->
</div>
</section>
在渲染這個彈框的時候,根據(jù)傳進來的popType映射到不同的文案:
function showPop(popType){
Mustache.render(popTemplate, Data.text.pop[popType])
}
這里用Data.text.pop[popType]映射到了對應(yīng)的文案,如果用react你把一個個的標題封裝成一個組件,其實思想是一樣的。
但是這個并不是嚴格的策略模式,因為策略就是要有執(zhí)行的東西嘛,我們這里其實是一個寫死的文案,但是我們借助了策略模式的思想。接下來繼續(xù)說使用策略模式做一些執(zhí)行的事情。
在上面的彈框的觸發(fā)機制分別是:用戶點擊了注冊、點擊了收藏房源、點擊了保存搜索條件。如果用戶沒有登陸就會彈一個注冊框,當用戶注冊完之后,要繼續(xù)執(zhí)行用戶原本的操作,例如該收藏還是收藏,所以必須要有一個注冊后的回調(diào),并且這個回調(diào)做的事情還不一樣。
當然,你可以在回調(diào)里面寫很多的if else或者是case:
function popCallback(popType){
switch(popType){
case "register":
//do nothing
break;
case: "favHouse":
favHouse();
break;
case: "saveSearch":
saveSearch();
break;
}
}
但是當你的case很多的時候,看起來可能就不是特別好了,特別是if else的那種寫法。這個時候就可以使用策略模式,每個回調(diào)都是一個策略:
var popCallback = {
favHouse: function(){
//do sth.
},
saveSearch: function(){
//do sth.
}
}
然后根據(jù)popType映射調(diào)用相應(yīng)的callback,如下:
var popCallback = require("pop-callback");
if(typeof popCallback[popType] === "function"){
popCallback[popType]();
}
這樣它就是一個完整的策略模式了,這樣寫有很多好處。如果以后需要增加一個彈框類型popType,那么只要在popCallback里面添加一個函數(shù)就好了,或者要刪掉一個popType,相應(yīng)地注釋掉某個函數(shù)即可。并不需要去改動原有代碼的邏輯,而采用if else的方式就得去修改原有代碼的邏輯,所以這樣對擴展是開放的,而對修改是封閉的,這就是面向?qū)ο缶幊汤锩娴拈_閉原則。
在js里面實現(xiàn)策略模式或者是其它設(shè)計模式都是很自然的方式,因為js里面function可以直接作為一個普通的變量,而在C++/Java里面需要用一些技巧,玩一些OO的把戲才能實現(xiàn)。例如上面的策略模式,在Java里面需要先寫一個接口類,里面定義一個接口函數(shù),然后每個策略都封裝成一個類,分別實現(xiàn)接口類的接口函數(shù)。而在js里面的設(shè)計模式往往幾行代碼就寫出來,這可能也是做為函數(shù)式編程的一個優(yōu)點。
前端和設(shè)計模式經(jīng)常打交道的還有訪問者模式
4. 訪問者模式
事件監(jiān)聽就是一個訪問者模式,一個典型的訪問者模式可以這么實現(xiàn),首先定義一個Input的類,初始化它的訪問者列表
function Input(inputDOM){
//用來存放訪問者的數(shù)據(jù)結(jié)構(gòu)
this.visitiors = {
"click": [],
"change": [],
"special": [] //自定義事件
}
this.inputDOM = inputDOM;
}
然后提供一個對外的添加訪問者的接口:
Input.prototype.on = function(eventType, callback){
if(typeof this.visitiors[eventType] !== "undefined"){
this.visitiors[eventType].push(callback);
}
};
使用者調(diào)用on,傳遞兩個參數(shù), 一個是事件類型,即訪問類型,另外一個是具體的訪問者,這里是回調(diào)函數(shù)。Input就會將訪問者添加到它的訪問者列表。
同時Input還提供了一個刪除訪問者的接口:
Input.prototype.off = function(eventType, callback){
var visitors = this.visitiors[eventType];
if(typeof visitiors !== "undefined"){
var index = visitiors.indexOf(callback);
if(index >= 0){
visitiors.splice(index, 1);
}
}
};
這樣子,Input就和訪問者建立起了關(guān)系,或者說訪問者已經(jīng)成功地向接收者都訂閱了消息,一旦接書者收到了消息會向它的訪問者一一傳遞:
Input.prototype.trigger = function(eventType, event){
var visitors = this.visitiors[eventType];
var eventFormat = processEvent(event); //獲取消息并做格式化
if(typeof visitors !== "undefined"){
for(var i = 0; i < visitors.length; i++){
visitors[i](eventFormat);
}
}
};
trigger可能是用戶調(diào)的,也可能是底層的控件調(diào)用的。在其它領(lǐng)域,它可能是一個光感控件觸發(fā)的。不管怎樣,一旦有人觸發(fā)了trigger,接收者就會一一下發(fā)消息。
如果你知道了事件監(jiān)聽的模式是這樣的,可能對你寫代碼會有幫助。例如點擊下面的搜索條件的X,要把上面的搜索框清空,同時還要觸發(fā)搜索,并把輸入框右邊的X去掉。要附帶著做幾件事情。

這個時候你可能會這樣寫:
$(".icon-close").on("click", function(){
$(this).parent().remove(); //刪除本身的展示
$("#search-input").val("");
searchAjax.ajax(); //觸發(fā)搜索
$("#clear-search").hide(); //隱藏輸入框x
});
但其實這樣有點累贅,因為在上面的搜索輸入框肯定也會相應(yīng)的操作,當用戶輸入為空時,自動隱藏右邊的x,并且輸入框change的時候會自動搜索,也就是說所有附加的事情輸入框那邊已經(jīng)有了,所以其實只需要觸發(fā)下輸入框的change事件就好了:
$(".icon-close").on("click", function(){
$(this).parent().remove(); //刪除本身的展示
$("#search-input").val("").trigger("change");
});
輸入框為空時,該怎么處理,search輸入框會相應(yīng)地處理,下面那個條件展示的x不需要去關(guān)心。觸發(fā)了change之后,會把相應(yīng)的消息下發(fā)給search輸入框的訪問者們。
當然,你用react你可能不會這樣想了,你應(yīng)該是在研究組件間怎么通信地好。
上文提及使用傳參避免全局耦合,然后在js里面通過控制class減少和css的耦合,和耦合相對的是內(nèi)聚,出發(fā)點是重復(fù)代碼,減少拷貝代碼會有一個抽象和封裝的過程:function -> 模塊 -> 插件/框架,封裝常用的還有封裝成一個類,方便控制私有數(shù)據(jù)。這樣可實現(xiàn)高內(nèi)聚,除此方法,還有設(shè)計模式的思想,上面介紹了策略模式和訪問者模式的原理和應(yīng)用,以及在寫代碼的啟示。
希望上文能對你有所啟迪,如有不對之處還請指出。