首頁 > web前端 > js教程 > 主體

清洗代碼:分而治之,或合併放鬆

Linda Hamilton
發布: 2024-11-11 19:30:03
原創
920 人瀏覽過

Washing your code: divide and conquer, or merge and relax

您正在閱讀我關於乾淨程式碼的書「清洗程式碼」的摘錄。提供 PDF、EPUB、平裝本和 Kindle 版本。立即取得副本。


了解如何將程式碼組織成模組或函數,以及何時引入抽象而不是重複程式碼,是一項重要技能。編寫其他人可以有效使用的通用程式碼是另一項技能。拆分代碼的原因與將代碼保持在一起的原因一樣多。在本章中,我們將討論其中一些原因。

讓抽象成長

我們,開發者,討厭同樣的工作做兩次。 DRY 是許多人的口頭禪。然而,當我們有兩到三段程式碼做同樣的事情時,引入抽象可能還為時過早,無論它感覺多麼誘人。

訊息: 不要重複自己(DRY)原則要求“每條知識都必須在系統內有一個單一的、明確的、權威的表示”,這通常被解釋為嚴格禁止任何程式碼重複

暫時忍受程式碼重複的痛苦;也許最終並沒有那麼糟糕,而且程式碼其實也不完全相同。一定程度的程式碼重複是健康的,可以讓我們更快地迭代和改進程式碼,而不必擔心破壞某些東西。

當我們只考慮幾個用例時,也很難想出一個好的 API。

管理具有許多開發人員和團隊的大型專案中的共享程式碼很困難。一個團隊的新要求可能不適用於另一個團隊並破壞他們的程式碼,或者我們最終會得到一個具有數十個條件的無法維護的義大利麵怪物。

假設團隊 A 正在為他們的頁面添加一個評論表單:名稱、訊息和提交按鈕。然後,團隊 B 需要回饋表,因此他們找到團隊 A 的元件並嘗試重複使用它。然後,團隊 A 也想要一個電子郵件字段,但他們不知道團隊 B 使用他們的組件,因此他們添加了一個必需的電子郵件字段並破壞了團隊 B 用戶的功能。然後,團隊 B 需要電話號碼字段,但他們知道團隊 A 使用的組件沒有該字段,因此他們添加了一個選項來顯示電話號碼字段。一年後,兩個團隊因破壞對方程式碼而互相憎恨,而且組件充滿了條件,無法維護。如果兩個團隊都維護由較低層級的共用元件(例如輸入欄位或按鈕)組成的單獨元件,那麼他們將節省大量時間並擁有更健康的關係。

提示:禁止其他團隊使用我們的程式碼可能是個好主意,除非它被設計並標記為共享。 Dependency Cruiser 是一個可以幫助建立此類規則的工具。

有時,我們必須回滾抽象。當我們開始添加條件和選項時,我們應該問自己:它仍然是同一件事的變體還是應該分離的新事物?在模組中添加太多條件和參數可能會導致 API 難以使用,程式碼也難以維護和測試。

重複比錯誤的抽象更便宜、更健康。

訊息:請參閱 Sandi Metz 的文章《錯誤的抽象》以獲得很好的解釋。

程式碼的層級越高,我們在抽象化它之前需要等待的時間就越長。低階實用抽像比業務邏輯更明顯、更穩定。

尺寸並不總是重要的

程式碼重複使用並不是將一段程式碼提取到單獨的函數或模組中的唯一原因,甚至不是最重要的原因。

程式碼長度通常被用作我們何時應該拆分模組或函數的指標,但大小本身並不會使程式碼難以閱讀或維護。

將線性演算法(即使是很長的演算法)拆分為多個函數,然後依序呼叫它們,很少會使程式碼更具可讀性。在函數(甚至檔案)之間跳轉比滾動更困難,如果我們必須研究每個函數的實作來理解程式碼,那麼抽象就不正確。

訊息: Egon Elbre 寫了一篇關於程式碼可讀性心理學的好文章。

這是一個範例,改編自 Google 測驗部落格:

我對 Pizza 類別的 API 有很多疑問,但讓我們看看作者建議的改進:

原本就複雜的事情現在變得更加複雜,一半的程式碼只是函數呼叫。這並不會使程式碼更容易理解,但確實使其幾乎無法使用。文章沒有展示重構版本的完整程式碼,或許是為了讓觀點更有說服力。

Pierre “catwell” Chapuis 在他的博文中建議添加評論而不是新功能:

這已經比拆分版本好得多了。更好的解決方案是改進 API 並使程式碼更加清晰。皮埃爾建議,預熱烤箱不應該成為createPizza() 函數的一部分(我自己也烤了很多披薩,我完全同意!),因為在現實生活中,烤箱已經在那裡了,而且可能已經因為之前的披薩而熱了。皮埃爾還建議該函數應該返回盒子,而不是披薩,因為在原始程式碼中,盒子在所有切片和包裝魔法之後消失了,我們最終手裡拿著切片披薩。

烹飪披薩的方法有很多種,就像解決問題的方法有很多種一樣。結果可能看起來相同,但某些解決方案比其他解決方案更容易理解、修改、重複使用和刪除。

當所有提取的函數都是同一演算法的一部分時,命名也可能是一個問題。我們需要發明比程式碼更清晰、比註解更短的名稱——這不是一件容易的事。

訊息:我們在避免註解章節中討論註解程式碼,並在命名很難章節中討論命名。

你可能在我的程式碼中找不到很多小函數。根據我的經驗,分割程式碼最有用的原因是更改頻率更改原因

單獨的經常更改的程式碼

讓我們從更改頻率開始。業務邏輯的變化比效用函數更頻繁。將經常更改的程式碼與非常穩定的程式碼分開是有意義的。

我們在本章前面討論的評論表單就是前者的一個例子;將camelCase 字串轉換為kebab-case 的函數就是後者的一個例子。當出現新的業務需求時,評論表單可能會隨著時間的推移而發生變化和分歧;大小寫轉換函數根本不可能改變,並且可以在許多地方安全地重複使用。

想像一下我們正在製作一個漂亮的表格來顯示一些數據。我們可能認為我們永遠不會再需要這個表設計,因此我們決定將表的所有程式碼保留在單一模組中。

下一個衝刺,我們的任務是在表中新增另一列,因此我們複製現有列的程式碼並更改其中的幾行。下一個衝刺,我們需要添加另一個具有相同設計的表格。下一個衝刺,我們需要改變表格的設計......

我們的表格模組至少有三個更改原因,或職責

  • 新的業務需求,例如新的表格列;
  • UI 或行為更改,例如新增排序或調整列大小;
  • 設計更改,例如用條紋行背景替換邊框。

這使得模組更難理解並且更難更改。展示性程式碼增加了許多冗長的內容,使得業務邏輯更難理解。要更改任何職責,我們需要閱讀和修改更多程式碼。這使得迭代變得更加困難和緩慢。

將通用表作為單獨的模組可以解決這個問題。現在,要在表中新增另一列,我們只需要了解並修改兩個模組之一。除了其公共 API 之外,我們不需要了解有關通用表模組的任何資訊。要更改所有表的設計,我們只需要更改通用表模組的程式碼,並且可能根本不需要觸及各個表。

但是,根據問題的複雜性,從整體方法開始並稍後提取抽像是可以的,而且通常會更好。

甚至程式碼重複使用也可以成為分離程式碼的有效理由:如果我們在一個頁面上使用某些元件,我們可能很快就會在另一個頁面上需要它。

將同時更改的程式碼放在一起

將每個函數提取到自己的模組中可能很誘人。然而,它也有缺點:

  • 其他開發人員可能認為他們可以在其他地方重複使用該函數,但實際上,該函數可能不夠通用或經過足夠的測試而無法重複使用。
  • 當此功能僅在一個地方使用時,建立、匯入和在多個檔案之間切換會產生不必要的開銷。
  • 此類函數通常在使用它們的程式碼消失後很長一段時間內仍保留在程式碼庫中。

我更喜歡將僅在一個模組中使用的小函數保留在模組的開頭。這樣,我們不需要導入它們以在同一個模組中使用,但在其他地方重複使用它們會很尷尬。

在上面的程式碼中,我們有一個元件(FormattedAddress)和一個函數(getMapLink()),它們只在該模組中使用,因此它們定義在檔案的頂部。

如果我們需要測試這些函數(我們應該!),我們可以將它們從模組中導出並與模組的主函數一起測試它們。

這同樣適用於僅與特定函數或元件一起使用的函數。將它們放在同一個模組中可以更清楚地看出所有函數屬於同一組,並使這些函數更容易被發現。

另一個好處是,當我們刪除模組時,我們會自動刪除其依賴項。共享模組中的程式碼通常會永遠保留在程式碼庫中,因為很難知道它是否仍在使用(儘管 TypeScript 使這變得更容易)。

訊息:此類模組有時稱為深層模組:封裝複雜問題但具有簡單API的相對較大的模組。深層模組的反面是淺層模組:許多需要彼此互動的小模組。

如果我們經常需要同時更改多個模組或函數,那麼將它們合併到單一模組或函數中可能會更好。這種方法有時稱為共置

以下是幾個託管範例:

  • React 元件:將元件所需的所有內容保留在同一個檔案中,包括標記(JSX)、樣式(JS 中的CSS)和邏輯,而不是將每個元件分離到自己的檔案中,可能在單獨的資料夾中。
  • 測試:將測試放在模組檔案旁邊,而不是放在單獨的資料夾中。
  • Redux 的 Ducks 約定:將相關操作、操作建立者和減速器保留在同一個檔案中,而不是將它們放在單獨資料夾中的三個檔案中。

以下是文件樹如何隨著共置而改變:

分開 同地 標題>
Separated Colocated
React components
src/components/Button.tsx src/components/Button.tsx
styles/Button.css
Tests
src/util/formatDate.ts src/util/formatDate.ts
tests/formatDate.ts src/util/formatDate.test.ts
Ducks
src/actions/feature.js src/ducks/feature.js
src/actionCreators/feature.js
src/reducers/feature.js
反應組件 src/components/Button.tsx src/components/Button.tsx 樣式/Button.css 測試 src/util/formatDate.ts src/util/formatDate.ts 測試/formatDate.ts src/util/formatDate.test.ts 鴨子 src/actions/feature.js src/ducks/feature.js src/actionCreators/feature.js src/reducers/feature.js 表>

信息:要了解有關託管的更多信息,請閱讀 Kent C. Dodds 的文章。

關於託管的一個常見抱怨是它使元件太大。在這種情況下,最好將某些部分連同標記、樣式和邏輯一起提取到它們自己的元件中。

共置的想法也與關注點分離相衝突——這是一個過時的想法,導致Web 開發人員將HTML、CSS 和JavaScript 保存在單獨的文件中(並且通常保存在文件樹的不同部分中)太長了,迫使我們同時編輯三個文件,甚至對網頁進行最基本的更改。

資訊:更改原因也稱為單一職責原則,該原則規定「每個模組、類別或函數都應該對功能的單一部分負責」由軟體提供,並且該責任應該完全由類別封裝。 ”

把那些醜陋的程式碼隱藏起來

有時,我們必須使用特別難以使用或容易出錯的 API。例如,它需要按特定順序執行多個步驟,或呼叫具有多個始終相同的參數的函數。這是創建效用函數以確保我們始終做對的一個很好的理由。作為獎勵,我們現在可以為這段程式碼編寫測試。

字串操作——例如 URL、檔案名稱、大小寫轉換或格式——是很好的抽象候選者。最有可能的是,已經有一個庫可以滿足我們正在嘗試做的事情。

考慮這個例子:

需要一些時間才能意識到此程式碼刪除了檔案副檔名並傳回基本名稱。它不僅沒有必要且難以閱讀,而且還假設擴展名始終為三個字符,但事實可能並非如此。

讓我們使用庫(內建 Node.js 的路徑模組)重寫它:

現在,很清楚發生了什麼,沒有神奇的數字,並且它適用於任何長度的檔案副檔名。

其他抽象候選包括日期、裝置功能、表單、資料驗證、國際化等等。我建議在編寫新的實用函數之前先查找現有的庫。我們經常低估看似簡單功能的複雜性。

以下是此類庫的一些範例:

  • Lodash:各種實用函數。
  • Date-fns:處理日期的函數,例如解析、操作和格式化。
  • Zod:TypeScript 的架構驗證。

祝福內聯重構!

有時,我們會得意忘形並創建既不能簡化程式碼也不能縮短程式碼的抽象:

另一個例子:

在這種情況下我們能做的最好的事情就是應用全能的內聯重構:用它的主體替換每個函數呼叫。沒有抽象,沒問題。

第一個範例變成:

第二個例子變成:

結果不僅更短且更具可讀性;現在讀者不需要猜測這些函數的作用,因為我們現在使用 JavaScript 原生函數和特性,而不需要自製的抽象。

在很多情況下,重複一點是有好處的。考慮這個例子:

它看起來非常好,並且在程式碼審查期間不會提出任何問題。但是,當我們嘗試使用這些值時,自動補全僅顯示數字而不是實際值(請參閱插圖)。這使得選擇正確的值變得更加困難。

Washing your code: divide and conquer, or merge and relax

我們可以內嵌 baseSpacing 常數:

現在,我們的程式碼更少了,也更容易理解,並且自動補全顯示了實際值(請參閱插圖)。而且我認為這段程式碼不會經常更改——可能永遠不會。

Washing your code: divide and conquer, or merge and relax

區分“什麼”和“如何”

考慮表單驗證函數的摘錄:

很難理解這裡發生了什麼:驗證邏輯與錯誤訊息混合在一起,許多檢查都是重複的...

我們可以把這個函數分成幾個部分,每個部分只負責一件事:

  • 特定表單的驗證清單;
  • 驗證函數的集合,例如 isEmail();
  • 使用驗證清單驗證所有表單值的函數。

我們可以將驗證以宣告方式描述為陣列:

每個驗證函數和運行驗證的函數都非常通用,因此我們可以抽象化它們或使用第三方函式庫。

現在,我們可以透過描述哪些欄位需要哪些驗證以及當某個檢查失敗時顯示哪些錯誤來為任何表單新增驗證。

資訊: 有關完整程式碼和此範例的更詳細說明,請參閱避免條件章節。

我稱這個過程「什麼」和「如何」的分離

  • 「什麼」 是資料 — 特定表單的驗證清單;
  • 「如何」 是演算法 - 驗證函數和驗證運行器函數。

好處是:

  • 可讀性:通常,我們可以使用陣列和物件等基本資料結構以宣告方式定義「內容」。
  • 可維護性:我們更頻繁地更改“內容”而不是“如何”,現在它們是分開的。我們可以從檔案(例如 JSON)匯入“內容”,或從資料庫載入它,從而無需更改程式碼即可進行更新,或允許非開發人員執行這些操作。
  • 可重用性:通常,「如何」是通用的,我們可以重複使用它,甚至從第三方函式庫導入它。
  • 可測試性:每個驗證和驗證運行器函數都是隔離的,我們可以單獨測試它們。

避免怪物實用程式文件

許多專案都有一個名為 utils.js、helpers.js 或 Misc.js 的文件,開發人員在找不到更好的位置時會在其中新增實用程式函數。通常,這些函數永遠不會在其他地方重複使用,並永遠保留在實用程式檔案中,因此它不斷增長。這就是怪物實用程式檔案誕生的方式。

怪物實用程式檔案有幾個問題:

  • 可發現性差:由於所有函數都在同一個檔案中,因此我們無法使用程式碼編輯器中的模糊檔案開啟器來尋找它們。
  • 它們可能比呼叫者的壽命更長:通常這些函數永遠不會再次重複使用並保留在程式碼庫中,即使在使用它們的程式碼被刪除之後也是如此。
  • 不夠通用:此類函數通常是針對單一用例而設計的,不會涵蓋其他用例。

這些是我的經驗法則:

  • 如果函數很小且只使用一次,請將其保留在使用它的同一個模組中。
  • 如果函數很長或多次使用,請將其放在 util、shared 或 helpers 資料夾內的單獨檔案中。
  • 如果我們想要更多的組織,我們可以將相關函數(每個函數都在自己的檔案中)分組到一個資料夾中,而不是建立像 utils/validators.js 這樣的檔案。

避免預設導出

JavaScript 模組有兩種類型的匯出。第一個是命名導出

可以這樣導入:

第二個是預設導出:

可以這樣導入:

我確實沒有看到預設匯出有任何優勢,但它們有幾個問題:

  • 糟糕的重構:使用預設導出重命名模組通常會使現有導入保持不變。命名匯出不會發生這種情況,所有匯入都會在重新命名函數後更新。
  • 不一致:預設導出的模組可以使用任何名稱導入,這會降低程式碼庫的一致性和可重複性。命名匯出也可以使用 as 關鍵字使用不同的名稱匯入,以避免命名衝突,但它更明確,而且很少是偶然完成的。

資訊:我們在其他技術章節的編寫greppable程式碼部分詳細討論了greppability。

不幸的是,一些第三方 API,例如 React.lazy() 需要預設導出,但對於所有其他情況,我堅持使用命名導出。

避免桶銼

桶檔案是一個模組(通常命名為index.js 或index.ts),它重新導出一堆其他模組:

主要優點是更清潔的進口。而不是單獨導入每個模組:

我們可以從桶檔案匯入所有元件:

但是,桶文件有幾個問題:

  • 維護成本:我們需要在桶文件中添加每個新元件的匯出,以及諸如實用函數類型之類的附加項目。
  • 效能成本:設定tree shake很複雜,且桶檔案通常會導致包裝大小或運行時成本增加。這也會減慢熱重載、單元測試和 linter。
  • 循環導入:當兩個模組都從同一個桶文件導入時(例如,Button 元件導入 Box 元件),從桶文件導入可能會導致循環導入。
  • 開發者體驗:導航到函數定義導航到桶文件而不是函數的源代碼;和 autoimport 可能會混淆是否從桶文件而不是原始文件導入。

資訊: TkDodo 詳細解釋了桶文件的缺點。

桶狀銼刀的好處太小,不足以證明其使用的合理性,因此我建議避免使用它們。

我特別不喜歡的一種類型的桶文件是那些導出​​單個組件只是為了允許將其導入為 ./components/button 而不是 ./components/button/button。

保持水分

為了攻擊DRYers(從不重複程式碼的開發人員),有人創造了另一個術語:WET,將所有內容寫兩次,或我們喜歡打字,建議我們應該在以下位置複製程式碼至少兩次,直到我們用抽象替換它。這是一個笑話,我並不完全同意這個想法(有時重複一些程式碼兩次以上是可以的),但它很好地提醒我們,所有美好的事物都最好適度。

考慮這個例子:

這是程式碼乾燥的一個極端例子,它不會使程式碼更具可讀性或可維護性,特別是當大多數常數只使用一次時。在這裡看到變數名稱而不是實際字串是沒有幫助的。

讓我們內聯所有這些額外的變數。 (不幸的是,Visual Studio Code 中的內聯重構不支援內聯物件屬性,因此我們必須手動執行此操作。)

現在,我們的程式碼顯著減少,並且更容易理解正在發生的事情,也更容易更新或刪除測試。

我在測試中遇到了很多無望的抽象。例如,這種模式很常見:

此模式試圖避免在每個測試案例中重複 mount(...) 調用,但它使測試變得比實際需要的更加混亂。讓我們內聯 mount() 呼叫:

此外,beforeEach 模式僅在我們想要使用相同的值初始化每個測試案例時才起作用,但這種情況很少發生:

為了避免在測試 React 元件時一些重複,我經常添加一個 defaultProps 物件並將其傳播到每個測試案例中:

這樣,我們就不會出現太多的重複,但同時每個測試案例都是隔離且可讀的。測試案例之間的差異現在更加清晰,因為更容易看到每個測試案例的獨特屬性。

這是同一問題的更極端的變體:

我們可以像上一個範例一樣內嵌 beforeEach() 函數:

我會更進一步,使用 test.each() 方法,因為我們使用一堆不同的輸入來執行相同的測試:

現在,我們已將所有測試輸入及其預期結果收集到一個地方,從而可以更輕鬆地添加新的測試案例。

訊息:查看我的 Jest 和 Vitest 備忘單。


抽象的最大挑戰是在過於僵化和過於靈活之間找到平衡,並知道何時開始抽象事物以及何時停止。通常值得等待,看看我們是否真的需要抽象化某些東西——很多時候,最好不要這樣做。

有一個全域按鈕元件很好,但如果它太靈活並且有十幾個布林屬性在不同變體之間切換,那麼它將很難使用。但是,如果過於嚴格,開發人員將創建自己的按鈕組件,而不是使用共享的按鈕組件。

我們應該警惕讓其他人重複使用我們的程式碼。通常,這會在應該獨立的程式碼庫部分之間造成緊密耦合,從而減慢開發速度並導致錯誤。

開始思考:

  • 將相關程式碼放在相同檔案或資料夾中,以便更輕鬆地變更、移動或刪除。
  • 在為抽象添加另一個選項之前,請考慮這個新用例是否真正屬於該抽象。
  • 在合併幾段看起來相似的程式碼之前,想想它們是否實際上解決了相同的問題,或者只是碰巧看起來相同。
  • 在進行 DRY 測試之前,請考慮這是否會使它們更具可讀性和可維護性,或者一些程式碼重複不是問題。

如果您有任何回饋,請發送給我、發推文、在 GitHub 上打開問題或給我發送電子郵件至 artem@sapegin.ru。取得您的副本。

以上是清洗代碼:分而治之,或合併放鬆的詳細內容。更多資訊請關注PHP中文網其他相關文章!

來源:dev.to
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
作者最新文章
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板