精通模組化JavaScript
近日讀了一本名為《精通模組化JavaScript》的書,並記錄了其中的精髓。
一、模組化思維
精通模組化開發並不是指要遵循一套定義明確的規則,而是指能夠將自己置身於使用者的角度,為可能即將到來的特性開發做好規劃(但不能過於廣泛),並且要像對待介面設計一樣重視和關心文檔的撰寫。
系統按粒度劃分:將系統分為幾個項目,一個項目由多個應用組成,每個應用又包含幾個層級,其中有數百個模組,由數千個函數組成。
編寫健壯的、有詳細文檔的介面是隔離一段複雜程式碼的最佳方法之一。
將健壯的介面系統地組織在一起可以形成一個層,例如企業應用中的服務層或數據層。將邏輯隔離並限制在其中的一層,同時將表現層的程式,與嚴格的業務程式或者持久性相關的程式分開。
採用一致的API形態是提高生產力的好方法。
1)模組化的優勢
- 有助於避免變數名中的意外衝突,減少了處理特定功能時必須注意的複雜性。
- 可維護性或對程式碼庫進行變更的能力也得到了顯著的提高,更容易構建和擴展。
- 讓程式碼段簡單易讀,並遵循單一職責原則(SRP),即每段程式碼只實現一個目標,再將程式碼段組合成複雜組件,最終整合成一個完整的應用程式。
- 若介面設計的好,就可以進行非破壞性升級,既滿足新需求又不好影響當前的使用,還能隱藏薄弱的實現,在日後重構成更為健壯的實現。
2)模組化的粒度
對於單個組件:將它們分成兩個或更多個較小的組件,由另一個小組件連接起來,這個小組件充當組合層,唯一職責就是將幾個底層組件組合在一起。
在模組層面,努力使函數簡潔、有表現力、命名具有描述性,而且功能盡量少。
將函數體中不需要立即處理的複雜性推遲到這些程式碼被調用之時再處理。
重點是將程式碼有序地組織起來,使開發人員能夠高效地工作,快速理解甚至修改他們以前從未遇到過的程式碼。
確保未來的開發與應用程式一直所採用的方法保持一致,使開發人員在各種約定和實踐的軟約束下工作,處於一種平穩狀態,完成一個閉環。
如果過早地去做抽象,最終會發現這些抽象都是錯誤的抽象,而且我們將為這些錯誤付出代價。糟糕的抽象會迫使整個應用程式順從它的意願。
Web應用程式正變得越來越複雜,它們的範圍、目標和要求也越來越複雜。它們周圍的生態系統也將不斷演進以適應那些擴張的需求,包括更好的工具、庫、編碼實踐、架構、標準、模式以及更多的選擇。
二、模組化原則
1)單一職責原則
當組件有一個唯一的精確目標時,就稱它們遵循SRP。
2)API優先原則
專註於公共介面的設計是開發可維護組件系統的關鍵。好的介面設計可以使訪問組件的最基本或最常見的用例變得簡單,而且有足夠的靈活性來支援出現的其他用例。
API設計的解決之道在於弄清楚使用者需要哪些屬性和方法,同時使介面儘可能小。
關注的重點是可改進的空間、邊緣用例、如何更改API,以及現有的API是否能夠在不破壞向後兼容性的情況下具有更多的用途。
3)揭示模式
如果一個組件中的所有功能都被公開,就沒有什麼可以被視為實現的細節,因此很難對該組件進行更改。
將所有內容封裝在一個閉包中,這樣就不會暴露全局變數,並且功能的實現是私有的,返回一個公共API。
與其暴露好幾個接觸點讓使用者選擇,不如只暴露一個接觸點,這個接觸點可以根據使用者提供的輸入執行恰當的程式碼路徑。
4)尋找正確的抽象
深入挖掘,在該需求的特性,為路線圖規劃的特性,以及希望在未來調整組件以支援的特性之間找到共同點。
抽象極大地減少了程式碼重複的問題,同時還能一致地處理所有用例,限制了複雜性的產生。
如果合併一開始並不相關的用例,實際上是增加了複雜性,最終會創建比實際需要更緊密的耦合。
最好是等到出現一個可區分的模式之後再行動,這時才能明顯地看到,引入抽象將有助於降低複雜性。
抽象會產生複雜性,因為它引入了新的中間層,削弱了跟蹤程式周圍不同程式碼流的能力。
5)狀態管理
狀態就是用戶輸入的函數:當用戶與應用程式進行交互時,狀態會增長並發生變化。
模組化設計的目標之一是讓狀態的數量儘可能少。
模組化設計通過將狀態樹劃分成可管理的小塊來解決這個問題,樹的每個分支都處理狀態的一個特定子集。
6)CRUST原則
1、一致(consistent)意味著冪等。程式碼風格一致能減少開發者之間的摩擦以及合併程式碼時的衝突;函數形態一致,提高可讀性,符合直覺;命名和架構一致則能減少意外,保持程式碼的一致性。
2、彈性(resilient)意味著靈活,並且接受幾種不同的方式的輸入,包括可選參數和重載。
3、明確(unambiguous)即對於如何使用API的功能、如何提供輸入或理解其輸出,沒有多種不同解釋。對於相同類型的結果,應該返回相同類型的輸出。
4、簡單(simple)是指使用,處理一般用例幾乎不需要配置,對於高級用例允許其訂製。通過對常見用例進行優化,可以了解在保持介面簡單性方面還有多大空間。
5、小巧(tiny)即夠用但沒有過度設計,包含儘可能小的表面積,同時為未來的非破壞性拓展留有空間。更少的失敗測試用例、BUG、濫用介面的方式、需要的文檔。
三、模組設計
1)構建模組
要實現整潔的模組設計。關鍵在於要實現一組小且作用唯一的函數。
1、可組合性和可擴展性,如果在設計介面時就考慮到可擴展性,可能就會根據功能對相似用例進行分組,在此過程中也許就能避免不必要的API表層的擴展。
如果新的用例與設計抽象時所設想的情況足夠類似,那麼這個抽象方案可能就能滿足那些突然出現的需求。
2、現代化設計,一開始不要試圖讓介面滿足每一個可能的用例。不僅要將開發工時集中在當下需要的功能上,而且還要避免產生不必要的複雜性。
要牢牢抓住這種心態,努力將功能保持在所需的絕對最低限度。
3、一點點抽象,當不確定是否將一些用例與某個抽象捆綁在一起時,最好的方法是先等一等,看是否有更多的用例能被歸入到這個抽象的範圍內。
用了錯誤的抽象會嚴重損毀組件的介面。保證API表層儘可能小,不讓使用者知道完成相同的任務有多種方法。
4.謹慎地行動和嘗試,程式碼不能脫離產品而存在。對於同樣的產品,程式碼越簡單越好。謹慎行動是指所做的一切都是有道理可循的。
不提倡草率開發內部結構,鼓勵經過充分的思考周全地設計介面。
2)CURST原則
1、適度使用DRY原則,如果始終努力壓縮重複性的東西,反而更難找到正確的抽象。
如果一段程式碼變得簡潔後卻導致程式更難讀懂,DRY原則可能就是一個壞主意。
2、特性分離,將小組件移到不同的文件中仍然是值得的,因為幾乎不費力氣就從父組件中消解了構成子組件的複雜性。
但複雜性沒有消失,而是隱藏在這些子組件與父組件之間的相互關聯中。
考慮實現一個服務層,把業務邏輯處理放在該層,或者實現一個持久層,所有快取和持久化存儲操作都發生在這一層。
越是把模組化做到極致,在文檔和測試上花費的時間就越多。如果模組要依賴其所屬程式碼庫中的其他程式碼,那麼分離模組比較有挑戰,因為該模組所依賴的部分也必須被分離出來。
3、設計內部結構時進行權衡。
試圖預測使用者的需求往往會增加程式碼規模和複雜性,還會浪費時間,對於改善使用者的體驗幾乎沒什麼用。
3)修剪模組
1、對錯誤的處理、緩解、檢測和解決。
測試的目的主要是防止回歸,防止在已經修復的BUG上又一次栽跟頭,也防止因為用錯誤的方式調整程式碼而出現可預料的錯誤。
2、文檔是一門藝術,文檔不僅是幫助使用者在設計時查閱介面使用示例和高級配置選項的指南,還可以看作實現者提供發一種參考資料。
其實測試和程式碼注釋也是某種意義上的文檔,甚至變數名或函數名也應該被視為一種文檔。
用正式文檔描述公共介面,用測試用例描述值得注意的使用示例,以及用注釋解釋異常情況。
3、刪除程式碼,如果部分程式變得陳舊,不再被使用,最好將其刪除,而不是推遲這種不可避免的命運。
開發人員可能會因為不確定無用程式碼是否在其他地方被使用而不願意將其刪除,隨著時間的流逝,破窗理論開始生效。
很快程式碼庫里就會到處都是不再被使用的程式碼,沒有人知道其用途,沒人知道程式碼庫是如何變得混亂的。
4、考慮自己的情況(上下文),在分析某個依賴項、工具或建議是否適合你的情況,第一步是先找到相關的參考資料仔細閱讀,並考慮它們所解決的問題是否就是自己要解決的問題。
永遠不要執著於你不確定是否符合自己需求的東西,要多嘗試。規則本來就是用來打破的。
四、內部構造
1)內部複雜性
1、包含嵌套的複雜性,在JavaScript中,深度嵌套是複雜性最明顯的標誌之一,複雜性存在於程式碼的銜接處。
如果一個程式中出現一系列嵌套回調,回調之間還有邏輯,那就表明流程式控制制和業務的關注點混成一團了。也就是將流程和業務邏輯分離,程式會更好。
2、功能雜糅與緊耦合,隨著模組越來越大,模組中不同特性就會越來越容易因其程式碼交織而錯誤地套在一起,導致很難被獨立地重用、調試和維護,或者彼此分離。
慢慢地從裡到外構建,將這個過程的每個階段保持在同一級別的函數中,而不是深層嵌套。讓函數的作用域更小,以參數的形式獲取他們所需之物。
3、框架:精華、糟粕與毒瘤,缺乏完善的設計指導和約定來描述應該如何構造應用程式的各個部分,混亂就會隨之而來。
在構建應用時,這些約定和抽象對於限制應用程式的複雜性十分有用。通常可以將程式碼重構為更小的組件,然後使用一個大組件包含它們,以保證關注點分離和對複雜性的控制。
通過使用層以及使用函數參數而非作用域來傳遞上下文,可以將幾個正交的組件放在一起來引入水平伸縮,而不會讓它們碰觸彼此的關注點。
2)重構複雜程式碼
1、多用變數,少寫巧妙程式碼,把重點放在程式的易讀性上,讓日後的自己或共事的開發者閱讀起來更容易懂。
將if語句中冗長的條件語句抽離到一個函數中,使得在分析程式碼時能更專註。為所創建的每一個函數、變數、目錄或數據結構的名稱進行周全的考慮至關重要。
if(hasValidToken(auth)) return
2、守衛語句與分支翻轉,翻轉條件語句,將所有處理失敗的語句放到頂部附近,能減少嵌套,消滅 else 分支,還能更加註重錯誤處理。
提早退出的方法通常是指守衛語句,通過閱讀函數或一段程式碼的前幾行就得知所有的失敗情況。
if(!response) return false; if(response.error) return false; //....
3、依賴金字塔,將高層流程放在函數的頂部附近,在後面給出詳情,以這樣一種方式設計複雜的功能,可以讓讀者一開始對函數功能有一個宏觀的印象。
按照使用者閱讀的順序(隊列)而不是按照被執行的順序(棧)呈現程式碼庫中的函數。將實現細節放到其他函數或子程式中。
double(6) function double(x) { return x * 2; }
4、抽出函數,將阻擋當前流程的一切移到函數底部,是增強程式碼易讀性的一個有效方法。
將選擇應用狀態的那部分程式碼和根據所選狀態執行操作的邏輯分離開來。
function getUserModels(err, users, done) { if (err) { done(err); return; } const models = users.map((user) => { const { name, email } = user; const model = { name, email }; if (user.type.includes("admin")) { model.admin = true; } return model; }); done(models); } //重構 function getUserModels(err, users, done) { if (err) { done(err); return; } const models = users.map(toUserModel); done(models); } function toUserModel(user) { const { name, email } = user; const model = { name, email }; if (user.type.includes("admin")) { model.admin = true; } return model; }
5、扁平化嵌套回調,耦合可以通過給回調函數命名並把它們放到同樣的嵌套層級中來解決。
例如傳遞箭頭函數。使用 async 語法。
6、重構相似任務,如果一個並發流程在多個函數之間或多或少有些一致性,可以考慮將該流程放在一個函數中,然後把在各種情況下皆不相同的實際處理邏輯作為回調傳遞給這個函數。
底層的程式碼還不夠成熟,不適合抽象。當不確定某個抽象是否必要時,回頭看看未建立抽象前的程式碼,對抽象前後的程式碼進行比較。
7、分割大型函數,按步驟或按同一個任務的不同方面來分割函數的功能。所有這些函數仍然需要依賴守衛語句檢查錯誤,保證狀態是受約束的。
保證接收的輸入與預期的是一致的:檢查必傳的參數是否存在、數據類型是否正確、數據範圍是否正常等。
減少複雜性的方法不是把幾百行程式碼壓縮成幾十行,而是把每一段長長的程式碼放到獨立函數中,讓它們處理數據的某個方面。
小函數可能會接收大函數的一部分輸入,或大函數產生的中間值。小函數可以使用自己的輸入處理邏輯,還可以被進一步分解。
識別一個函數的三到四個部分,將它們拆分出來。
- 第一部分可能是過濾不感興趣的輸入。
- 第二部分可能是把輸入映射為其他東西。
- 第三部分可能是將所有數據整合在一起。
3)像熵一樣的狀態
熵(entropy)可以被定義為對無序性或不可預測性的一種度量。系統中的熵越大,系統就越無序和不可預測。
1、複雜的當前狀態,將單個大函數分解為一堆小函數,易於關注其各部分功能。
2、消除偶髮狀態,如果一份數據在應用的多個地方都被使用,並且是從其他數據派生出來的,就可能出現偶髮狀態。
當持久化派生狀態時,原始數據與派生數據就會有失去同步的風險。
3、包裝狀態,當所有中間狀態都被包含到一個組件中而不是暴露給外部時,組件或函數交互時的摩擦就會減少。
對外暴露的狀態數壓縮得越少,函數就封裝的越好,而且介面也會因此變得更加易於使用。在函數體中修改輸入,可能引入 bug,令人困惑,而且難以追蹤修改源頭。
4、利用不可變性,通過引入不可變性,可將函數維持為純函數。函數的輸出僅僅依賴函數的輸入,並且沒有任何諸如改變函數輸入之類的副作用。
4)數據結構為王
所選的數據結構制約和決定了API能採取的形態。程式的複雜性往往是由於本來數據結構就糟糕,而新的或者未預見的需求又與這些數據結構無法充分匹配所造成的。
當選擇使數據結構適應程式不斷變化的需求這條路時,就會發現以數據驅動的方式編寫程式比僅依賴於邏輯驅動程式的行為更好。
1、分離數據與邏輯,當數據沒有與功能混雜在一起時,它就能從功能中分離,並因此更加易讀、易懂以及易於序列化。
介面設計一開始應該是強約束的,隨著新用例和需求出現而慢慢放開限制。從小的用例著手,才能讓介面自然而然擴展為適於處理特定的各種實際用例。
function add(current, value) { return current + value; } function multiply(current, value) { return current * value; } multiply(add(5, 3), 2)
2、限制於聚合邏輯,如果數據結構或使用數據結構的程式碼需要改變,而相關聯的邏輯遍佈於程式碼庫中,此時的漣漪效應可能是毀滅性的。
將邏輯拆分到同一個目錄下的多個文件中,有助於防止功能爆炸,這些功能很大程式上只有數據結構是相同的,可以將其與功能緊密相關的程式碼放在一起。
如果將大量邏輯分散到不相關的組件中,在對程式碼進行大規模更新時,可能有遺漏功能關鍵部分的風險。
- 當不清楚功能是否會增加或會如何增加時,一開始將邏輯直接放到需要它的地方是可以接受的。
- 一旦初期探索階段結束,並且知道該功能會繼續存在而且還會增加之後,出於前述的原因,最好將功能分離開來。
- 之後,隨著功能的規模和需要解決的關注點增加,可以將其各個部分組件化後置於不同的模組中。
- 這些模組在文件系統中邏輯上仍然聚集在一起,在需要考慮所有相關的關注點時就很方便。
五、開發的方法論與哲學
1)安全的配置管理
將所有敏感資訊集中放在一個不參與版本控制的文件中,而不是將這些變數硬編碼到使用它們的地方,或者將它們放在模組開頭的常量中。
這種方法有助於模組之間共享私密資訊,使其更容易進行更新之外,還促使隔離那些以前認為不敏感的資訊,例如加鹽密碼的工作因子。
可以將應用程式關聯到另一個私密資訊倉庫,取決於該應用程式是處於生產、預發、測試還是本地開發環境。
2)顯式依賴管理
應用程式中的每個依賴項都應該在清單文件(package.json)中顯式聲明,而且儘可能少地依賴全局安裝的包或全局變數。
3)作為黑盒的介面
改進介面的一種途徑是撰寫一份詳細的文檔,描述介面接觸點期望的輸入,以及它是如何影響在每種情況下的輸出。
在寫文檔的過程中,會發現介面設計的局限性,可能因此而決定對介面做一些改動。
無論開發什麼樣的應用程式,都不能信任用戶的輸入,除非輸入已被處理過。
設身處地為介面使用者著想,是防止寫出不成熟介面的最佳方法。
4)構建、部署與運行
構建過程有多個方面,從宏觀上來說,共享邏輯中安裝和編譯資源,以便運行時應用程式可以使用它們。
- 在開發時,關注增強的調試工具、使用庫的開發環境版本、源程式碼映射和詳細的日誌記錄級別。
- 在預發時,需要有一個與生產環境非常相似的環境,因此會避免使用大多數調試功能。
- 在生產時,更側重於縮減程式碼,比如優化靜態影像,採用基於路由的打包拆分等高級技術。
5)無狀態
未加控制的狀態會直接導致應用程式奔潰,而儘可能減少狀態的數量可使應用程式易於調試。
全局狀態越少,應用程式在當前狀態下一時刻所出現的不可預測性越小,並且在調試時遇到的意外就越少。
使用像 Redux 或 Mobx 這樣的狀態管理解決方案,將所有狀態與應用程式的其餘部分隔離。
6)開發與生產的平等性
開發環境和生產環境是存在差異的,忽視了這樣的差異會導致沒有發現新組件中的限制。
儘可能將這些差異控制到最小,如果不這樣做,生產環境可能會冒出很多 bug,而用戶最終可能會報告它們。
7)抽象問題
匆忙做出來的抽象會導致災難。反過來,如果沒有識別並抽象出主要的複雜性來源,代價也會非常高。
在複雜介面的前面創建一個中間層,提供一個更簡單的介面,配置項更少,並且重要用例的易用性更高。