減少前端代碼耦合

什麼是代碼耦合?代碼耦合的表現是改了一點毛髮而牽動了全身,或者是想要改點東西,需要在一堆代碼裡面找半天。由於前端需要組織js/css/html,耦合的問題可能會更加明顯,下面按照耦合的情況分別說明:

1. 避免全局耦合

這應該是比較常見的耦合。全局耦合就是幾個類、模塊共用了全局變數或者全局數據結構,特別是一個變數跨了幾個文件。例如下面,在html裡面定義了一個變數:

<script> var PAGE = 20;</script> <script src="main.js"></script>

上面在head標籤裡面定義了一個PAGE的全局變數,然後在main.js裡面使用。這樣子PAGE就是一個全局變數,並且跨了兩個文件,一個html,一個js。然後在main.js裡面突然冒出來了個PAGE的變數,後續維護這個代碼的人看到這個變數到處找不到它的定義,最後找了半天發現原來是在xxx.html的head標籤裡面定義了。這樣就有點egg pain了,並且這樣的變數容易和本地的變數發生命名衝突。

所以如果需要把數據寫在頁面上的話,一個改進的辦法是在頁面寫一個form,數據寫成form裡面的控制項數據,如下:

<form id="page-data"> <input type="hidden" name="page" value="2"> <textarea name="list" style="display:none">[{"userName": ""yin"},{}]</textarea></form>

上面使用了input和textarea,使用textarea的優點是支持特殊符號。再把form的數據序列化,序列化也是比較簡單的,可以查看Effective前端2:優化html標籤

第二種是全局數據結構,這種可能會使用模塊化的方法,如下:

//data.jsmodule.exports = { houseList: null} //search.js 獲取houseList的數據var data = require("data");data.houseList = ajax();require("format-data").format(); //format-data.js 對houseList的數據做格式化function format(){ var data = require("data"); process(data); require("show-result").show();} //show-result.js 將數據顯示出來function show(){ showData(require("data").houseList)}

上面四個模塊各司其職,乍一眼看上去好像沒什麼問題,但是他們都用了一個data的模塊共用數據。這樣確實很方便,但是這樣就全局耦合了。因為用的同一個data,所以你無法保證,其它人也會載入了這個模塊然後做了些修改,或者是在你的某一個業務的非同步回調也改了這個。第二個問題:你不知道這個data是從哪裡來的,誰可能會對它做了修改,這個過程對於後續的模塊來說都是不透明的。

所以這種應該考慮使用傳參的方式,降低耦合度,把data作為一個參數傳遞:

//去掉data.js//search.js 獲取數據並傳遞給下一個模塊var houseList = ajax();require("format-data").format(houseList); //format-data.js 對houseList的數據做格式化function format(houseList){ process(houseList); require("show-result").show(houseList);} //show-result.js 將數據顯示出來function show(houseList){ showData(houseList)}

可以看到,search裡面獲取到data後,交給format-data處理,format-data處理完之後再給show-result。這樣子就很清楚地知道數據的處理流程,並且保證了houseList不會被某個非同步回調不小心改了。如果單獨從某個模塊來說,show-result這個模塊並不需要關心houseList的經過了哪些流程和處理,它只需要關心輸入是符合它的格式要求的就可以。

這個時候你可能會有一個問題:這個data被逐層傳遞了這麼多次,還不如像最上面的那樣寫一個data的模塊,大家都去改那裡,豈不是簡單了很多?對,這樣是簡單了,但是一個數據結構被跨了幾個文件使用,這樣會出現我上面說的問題。有時候可能出現一些意想不到的情況,到時候可能得找bug找個半天。所以這種解耦是值得的,除非你定義的變數並不會跨文件,它的作用域只在它所在的文件,這樣會好很多。或者是data是常量的,data裡面的數據定義好之後值就再也不會改變,這樣應當也是可取的。

2. js/css/html的耦合

這種耦合在前端裡面應該最常見,因為這三者通常具有交集,需要使用js控制樣式和html結構。如果使用js控制樣式,很多人都喜歡在js裡面寫樣式,例如當頁面滑動到某個地方之後要把某個條吸頂:

頁面滑到下面那個灰色的條再繼續往下滑的時候,那個灰色條就要保持吸頂狀態:

可能不少人會這麼寫:

$(".bar").css({ position: fixed; top: 0; left: 0;});

然後當用戶往上滑的時候取消fixed:

$(".bar").css({ position: static;});

如果你用react,你可能會設置一個style的state數據,但其實這都一樣,都把css雜合到js裡面了。某個想要檢查你樣式的人,想要給你改個bug,他檢查瀏覽器發現有個標籤style里的屬性,然後他找半天找不到是在哪裡設置的,最後他發現是在某個js的某個隱蔽的角落設置了。你在js裡面設置了樣式,然後css裡面也會有樣式,在改css的時候,如果不知道js裡面也有設置了樣式,那麼可能會發生衝突,在某種條件下觸發了js裡面設置樣式。

所以不推薦直接在js裡面更改樣式屬性,而應該通過增刪類來控制樣式,這樣子樣式還是回歸到css文件裡面。例如上面可以改成這樣:

//增加fixed$(".bar").addClass("fixed"); //取消fixed$(".bar").removeClass("fixed");

fixed的樣式:

.bar.fixed{ position: fixed; left: 0; top: 0;}

可以看到,這樣的邏輯就非常清晰,並且回滾fixed,不需要把它的position還原為static,因為它不一定是static,也有可能是relative,這種方式在取消掉一個類的時候,不需要去關心原本是什麼,該是什麼就會是什麼。

但是有一種是避免不了的,就是監聽scroll事件或者mousemove事件,動態地改變位置。

這種通過控制類的方式還有一個好處,就是當你給容器動態地增刪一個類時,你可以藉助子元素選擇器,用這個類控制它的子元素的樣式,也是很方便。

還有很多人可能會覺得html和css/js脫耦,那就是不能在html裡面寫style,不能在html裡面寫script標籤,但是凡事都不是絕對的,如果有一個標籤,它和其它標籤就一個font-size不一樣,那你直接給它寫一個font-size的內聯樣式,又何嘗不可呢,在性能上來說,如果你寫個class,它還得去匹配這個class,比不上style高效吧。或者是你這個html文件就那麼20、30行css,那直接在head標籤加個style,直接寫在head裡面好了,這樣你就少管理了一個文件,並且瀏覽器不用去載入一個外鏈的文件。

有時候直接在html寫script標籤是必要的,它的優勢也是不用載入外鏈文件,處理速度會很快,幾乎和dom渲染同時,這個在解決頁面閃動的時候比較有用。因為如果要用js動態地改變已經載入好的dom,放在外鏈裡面肯定會閃一下,而直接寫的script就不會有這個問題,即使這個script是放在了body的後面。例如下面:

原始數據是帶p標籤的,但是在textarea裡面展示的時候需要把p改成換行
,如果在dom渲染之後再在外鏈裡面更新dom就會出現上面的閃動的情況。你可能會說我用react,數據都是動態渲染的,渲染前已經處理好了,不會出現上面的情況。那麼,好吧,至少你了解一下吧。

和耦合相對的是內聚,寫代碼的原則就是低耦合、高聚合。所謂內聚就是說一個模塊的職責功能十分緊密,不可分割,這個模塊就是高內聚的。我們先從重複代碼說起:

3. 減少重複代碼

假設有一段代碼在另外一個地方也要被用到,但又不太一樣,那麼最簡單的方法當然是copy一下,然後改一改。這也是不少人採取的辦法,這樣就導致了:如果以後要改一個相同的地方就得同時改好多個地方,就很麻煩了。

例如有一個搜索的界面:

用戶可以通過點擊search按鈕觸發搜索,也可以通過點擊下拉或者通過輸入框的change觸發搜索,所以你可能會這麼寫:

$("#search").on("click", function(){ var formData = getFormData(); $.ajax({ url: "/search", data: formData, success: function(data){ showResult(data); } });});

在change裡面又重新發請求:

$("input").on("change", function(){ //把用戶的搜索條件展示進行改變 changeInputFilterShow(); var formData = getFormData(); $.ajax({ url: "/search", data: formData, success: function(data){ showResult(data); } });});

change裡面需要對搜索條件的展示進行更改,和click事件不太一樣,所以圖一時之快就把代碼拷了一下。但是這樣是不利於代碼的維護的,所以你可能會想到把獲取數據和發請求的那部分代碼單獨抽離封裝在一個函數,然後兩邊都調一下:

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();});

在抽成一個函數的基礎上,又發現這個函數其實有點大,因為這裡面要獲取表單數據,還要對數據進行格式化,用做請求的參數。如果用戶觸發得比較快,還要記錄上次請求的xhr,在每次發請求前cancle掉上一次的xhr,並且可能對請求做一個loading效果,增加用戶體驗,還要對出錯的情況進行處理,全部都要在ajax裡面。所以最好對getAndShowData繼續拆分,很自然地會想到把它分離成一個模塊,一個單獨的文件,叫做search-ajax。所有發請求的處理都在這個模塊裡面統一操作。對外只提供一個search.ajax的介面,傳的參數為當前的頁數即可。所有需要發請求的都調一下這個模塊的這個介面就好了,除了上面的兩種情況,還有點擊分頁的情景。這樣不管哪種情景都很方便,我不需要關心請求是怎麼發的,結果是怎麼處理的,我只要傳一個當前的頁數給你就好了。

再往下,會發現,在顯示結果那裡,即上面代碼的第7行,需要對有結果、無結果的情況分別處理,所以又搞了一個函數叫做showResult,這個函數有點大,它裡面的邏輯也比較複雜,有結果的時候除了更新列表結果,還要更新結果總數、更新分頁的狀態。因此這個showResult一個函數難以擔當大任。所以把這個show-result也當獨分離出一個模塊,負責結果的處理。

到此,我們整一個search的UML圖應該是這樣的:

注意上面把發請求的又再單獨封裝成了一個模塊,因為這個除了搜索發請求外,其它的請求也可以用到。同時search-result會用到兩個展示的模板。

由於不只一個頁面會用到搜索的功能,所以再把上面繼續抽象,把它封裝成一個search-app的模塊,需要用到的頁面只需require這個search-app,調一下它的init函數,然後傳些定製的參數就可以用了。這個search-app就相當於一個搜索的插件。

所以整一個的思路是這樣的:出現了重複代碼 -> 封裝成一個函數 -> 封裝成一個模塊 -> 封裝成一個插件,抽象級別不斷提高,將共有的特性和有差異的地方分離出來。當你走在抽象與封裝的路上的時候,那你應該也是走在了大神的路上。

當然,如果兩個東西並沒有共同點,但是你硬是要搞在一起,那是不可取的。

我這裡說的封裝並不是說,你一定要使用requirejs、es6的import或者是webpack的require,關鍵在於你要有這種模塊化的思想,並不是指工具上的,不管你用的哪一個,只要你有這種抽象的想法,那都是可取的。

模塊化的極端是拆分粒度太細,一個簡單的功能,明明十行代碼寫在一起就可以搞定的事情,硬是寫了七、八層函數棧,每個函數只有兩、三行。這樣除了把你的邏輯搞得太複雜之外,並沒有太多的好處。當你出現了重複代碼,或者是一個函數太大、功能太多,又或是邏輯裡面寫了三層循環又再嵌套了三層if,再或是你預感到你寫的這個東西其他人也可能會用到,這個時候你才考慮模塊化,進行拆分比較合適。

上面不管是search-result還是search-ajax他們在功能上都是高度內聚的,每個模塊都有自己的職責,不可拆分,這在面向對象編程裡面叫做單一責職原則,一個模塊只負責一個功能。

再舉一個例子,我在怎樣實現前端裁剪上傳圖片功能裡面提到一個上傳裁剪的實現,這裡面包含裁剪、壓縮上傳、進度條三大功能,所以我把它拆成三個模塊:

這裡提到的模塊大部分是一個單例的object,不會去實例它,一般可以滿足大部分的需求。在這個單例的模塊裡面,它自己的「私有」函數一般是通過傳參調用,但是如果需要傳遞的數據比較多的時候,就有點麻煩了,這個時候可以考慮把它封裝成一個類。

3. 封裝成一個類

在上面的裁剪上傳裡面的進度條progress-bar,一個頁面里可能有幾個要上傳的地方,每個上傳的地方都會有進度條,每個進度條都有自己的數據,所以不能像在最上面說的,在一個文件的最上面定義一些變數然後為這個模塊裡面的函數共用,只能是通過傳遞參數的形式,即在最開始調用的時候定義一些數據,然後一層一層地傳遞下去。如果這些數據很多的話就有點麻煩。

所以稍微變通一下,把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,但是本質上是一樣的,然後這個ProgressBar的成員函數就可以使用定義的這些「私有」變數,例如設置進度條的進度函數:

ProgressBar.prototype.setProgress = function(percentage, time){ time = typeof time === "undefined" ? 100 : time; this.$meter.stop().animate({width: parseInt(this.$barFullWidth * percentage)}, time);};

這個使用了兩個私有變數,如果再加上原先兩個,用傳參的方式就得傳四個。

使用類是模塊化的一種思想,另外一種常用的還有策略模式。

4. 使用策略模式

假設要實現下面三個彈框:

這三個彈框無論是在樣式上還是在功能上都是一樣的,唯一的區別是上面標題文案是不一樣的。最簡單的可能是把每個彈框的html都copy一下,然後改一改。如果你用react,你可能會用拆分組件的方式,上面一個組件,下面一個組件,那麼好吧,你就這樣搞吧。如果你沒用react,你可能得想辦法組織下你的代碼。

如果你有策略模式的思想,你可能會想到把上面的標題當作一個個的策略。首先定義不同彈框的類型,一一標誌不同的彈框:

var popType = ["register", "favHouse", "saveSearch"];

定義三種popType一一對應上面的三個彈框,然後每種popType都有對應的文案:

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> <!--其它內容--> </div></section>

在渲染這個彈框的時候,根據傳進來的popType映射到不同的文案:

function showPop(popType){ Mustache.render(popTemplate, Data.text.pop[popType])}

這裡用Data.text.pop[popType]映射到了對應的文案,如果用react你把一個個的標題封裝成一個組件,其實思想是一樣的。

但是這個並不是嚴格的策略模式,因為策略就是要有執行的東西嘛,我們這裡其實是一個寫死的文案,但是我們藉助了策略模式的思想。接下來繼續說使用策略模式做一些執行的事情。

在上面的彈框的觸發機制分別是:用戶點擊了註冊、點擊了收藏房源、點擊了保存搜索條件。如果用戶沒有登陸就會彈一個註冊框,當用戶註冊完之後,要繼續執行用戶原本的操作,例如該收藏還是收藏,所以必須要有一個註冊後的回調,並且這個回調做的事情還不一樣。

當然,你可以在回調裡面寫很多的if else或者是case:

function popCallback(popType){ switch(popType){ case "register": //do nothing break; case: "favHouse": favHouse(); break; case: "saveSearch": saveSearch(); break; }}

但是當你的case很多的時候,看起來可能就不是特別好了,特別是if else的那種寫法。這個時候就可以使用策略模式,每個回調都是一個策略:

var popCallback = { favHouse: function(){ //do sth. }, saveSearch: function(){ //do sth. }}

然後根據popType映射調用相應的callback,如下:

var popCallback = require("pop-callback");if(typeof popCallback[popType] === "function"){ popCallback[popType]();}

這樣它就是一個完整的策略模式了,這樣寫有很多好處。如果以後需要增加一個彈框類型popType,那麼只要在popCallback裡面添加一個函數就好了,或者要刪掉一個popType,相應地注釋掉某個函數即可。並不需要去改動原有代碼的邏輯,而採用if else的方式就得去修改原有代碼的邏輯,所以這樣對擴展是開放的,而對修改是封閉的,這就是面向對象編程裡面的開閉原則。

在js裡面實現策略模式或者是其它設計模式都是很自然的方式,因為js裡面function可以直接作為一個普通的變數,而在C++/Java裡面需要用一些技巧,玩一些OO的把戲才能實現。例如上面的策略模式,在Java裡面需要先寫一個介面類,裡面定義一個介面函數,然後每個策略都封裝成一個類,分別實現介面類的介面函數。而在js裡面的設計模式往往幾行代碼就寫出來,這可能也是做為函數式編程的一個優點。

前端和設計模式經常打交道的還有訪問者模式

4. 訪問者模式

事件監聽就是一個訪問者模式,一個典型的訪問者模式可以這麼實現,首先定義一個Input的類,初始化它的訪問者列表

function Input(inputDOM){ //用來存放訪問者的數據結構 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); }};

使用者調用on,傳遞兩個參數, 一個是事件類型,即訪問類型,另外一個是具體的訪問者,這裡是回調函數。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就和訪問者建立起了關係,或者說訪問者已經成功地向接收者都訂閱了消息,一旦接書者收到了消息會向它的訪問者一一傳遞:

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可能是用戶調的,也可能是底層的控制項調用的。在其它領域,它可能是一個光感控制項觸發的。不管怎樣,一旦有人觸發了trigger,接收者就會一一下發消息。

如果你知道了事件監聽的模式是這樣的,可能對你寫代碼會有幫助。例如點擊下面的搜索條件的X,要把上面的搜索框清空,同時還要觸發搜索,並把輸入框右邊的X去掉。要附帶著做幾件事情。

這個時候你可能會這樣寫:

$(".icon-close").on("click", function(){ $(this).parent().remove(); //刪除本身的展示 $("#search-input").val(""); searchAjax.ajax(); //觸發搜索 $("#clear-search").hide(); //隱藏輸入框x});

但其實這樣有點累贅,因為在上面的搜索輸入框肯定也會相應的操作,當用戶輸入為空時,自動隱藏右邊的x,並且輸入框change的時候會自動搜索,也就是說所有附加的事情輸入框那邊已經有了,所以其實只需要觸發下輸入框的change事件就好了:

$(".icon-close").on("click", function(){ $(this).parent().remove(); //刪除本身的展示 $("#search-input").val("").trigger("change");});

輸入框為空時,該怎麼處理,search輸入框會相應地處理,下面那個條件展示的x不需要去關心。觸發了change之後,會把相應的消息下發給search輸入框的訪問者們。

當然,你用react你可能不會這樣想了,你應該是在研究組件間怎麼通信地好。

上文提及使用傳參避免全局耦合,然後在js裡面通過控制class減少和css的耦合,和耦合相對的是內聚,出發點是重複代碼,減少拷貝代碼會有一個抽象和封裝的過程:function -> 模塊 -> 插件/框架,封裝常用的還有封裝成一個類,方便控制私有數據。這樣可實現高內聚,除此方法,還有設計模式的思想,上面介紹了策略模式和訪問者模式的原理和應用,以及在寫代碼的啟示。

希望上文能對你有所啟迪,如有不對之處還請指出。

擴展閱讀:

  1. Effective前端1:能使用html/css解決的問題就不要使用JS
  2. Effective前端2:優化html標籤
  3. Effective前端3:用CSS畫一個三角形
  4. Effective前端4:儘可能地使用偽元素
  5. Effective前端5:減少前端代碼耦合

推薦閱讀:

基於正態分布的前端性能數據分析(一)
U4 2.0 新特性 —— Web Push
【 js 基礎 】【讀書筆記】作用域和閉包
前端技術體系大局觀
React Fiber是什麼

TAG:前端性能优化 | 前端开发 | 代码质量 |