編寫可維護的 JavaScript

幾乎每個程序員都有接手維護別人遺留項目的經歷。或者,有可能一個老項目某一天又被重新啓動。 通常情況下,接手老項目都會讓人恨不得拋棄掉整個代碼庫從頭開始。老代碼凌亂、文檔缺失、需要研究很多天才能完全搞明白它。然而,通過合適的規劃、分解和 好的工作流,項目代碼可以變得乾淨、有組織和可擴展。

我曾經接手清理許多項目的代碼,讓我不得不重頭開始的項目真心不多,不過我最近就遇到了一個。我從中學到了很多關於 JavaScript 代碼組織的內容,以及最重要的是冷靜,不要爲你的前任抓狂。在這篇文章裏,我想要讓你知道我是怎麼一步步處理項目代碼的,告訴你我的經驗。

分析項目

第一步是先概覽整個項目,弄明白問題所在。如果這是一個網站,通過點擊測試所有功能點:打開模塊、提交表單以及其他的。在做這件事的時候,打開開發者工具,看看是否有任何報錯,看看控制檯有沒有日誌。如果這是一個Node.js項目,打開命令行界面然後檢查各個API。最好的情況是項目通過統一的入口(例如:main.jsindex.jsapp.js, ……)來初始化所有的模塊,最差的情況是整個業務邏輯散落於各處。

搞清楚項目採用了哪些工具。是 jQuery、React 或是 Express?將所有重要的信息整理在一個清單裏。假設這個項目是用 Angular 2 寫的,你之前對 Angular 2 不熟悉,先去查看文檔,對它有一個初步的瞭解,並尋找最佳實踐的範例。

從更高層面上理解項目

瞭解技術點是一個好開端,但要進一步深入理解,我們需要看一看它的單元測試。單元測試通過測試代碼的功能函數與方法來保證代碼如預期的那樣運行。不同於僅僅閱讀代碼,查看和運行單元測試能讓你更加深入理解代碼的期望運行結果。如果接手的項目沒寫單元測試,沒關係,我們可以自己來寫。

創建基線

這樣做是爲了確立一致性。你已經瞭解了項目的工具鏈、代碼結構、模塊的邏輯關係,現在該爲項目創建基線了。我推薦添加一個 .editorconfig 文件讓代碼風格在不同的編輯器、IED和不同的開發者之間保持一致。

一致的縮進

使用 tab 還是空格來縮進是一個老問題 ,常引發程序員爭論不休,不過沒關係,不管項目用的是空格還是 tab,繼續使用之前的就好了。除非代碼庫既有空格縮進又有 tab 縮進的代碼,那就只好在兩者中做出一個取捨。每個人都可以保持自己的觀點,但一個好的項目要保證所有的開發者都可以無爭議地協同工作。

爲什麼這個很重要?因爲每一個人都有自己使用編輯器或 IDE 的習慣。比如,我是個代碼摺疊控,要是編輯器沒有正確的代碼摺疊功能,我整個人都會迷失在文件裏。如果代碼的縮進不一致,就會影響到摺疊功能,因此每一次我打開一個文件,不得不先修復那些縮進然後才能開始工作,這十分浪費時間。

// 雖然這段 JavaScript 代碼是合法的, 但這段代碼塊沒辦法正常摺疊// 因爲這段代碼的縮進不一致
 function foo (data) {  let property = String(data);if (property === 'bar') {
   property = doSomething(property);
  }  //... more logic.
 }// 修復縮進後,這段代碼纔可以被正確摺疊// 從而獲得更好的編碼體驗和更整潔的代碼庫function foo (data) { let property = String(data); if (property === 'bar') {
  property = doSomething(property);
 } //... more logic.}

命名

確保項目中使用命名約定是值得推崇的做法。駝峯命名通常在 JavaScript 代碼中使用,但是我看到過許多不一致的命名。例如:jQuery 項目經常會有代碼在命名上混淆 jQuery 對象和其他對象。

// 不一致的命名讓代碼變得難以檢查和理解// 它還會誤導維護者const $element = $('.element');function _privateMethod () {  const self = $(this);  const _internalElement = $('.internal-element');  let $data = element.data('foo');  //... more logic.}// 這樣改就更易於理解了const $element = $('.element');function _privateMethod () {  const $this = $(this);  const $internalElement = $('.internal-element');  let elementData = $element.data('foo');  //... more logic.}

代碼檢查

之前所做的一切是在美化代碼,主要是讓它變得更易於檢查。接下來我們介紹保證代碼質量的通用最佳實踐。ESLint,JSLint,還有 JSHint 是目前最受歡迎的三個 JavaScript 代碼檢查工具。我個人用 JSHint 最多,但我現在最喜歡 ESLint,主要是因爲它可以自定義規則,也較早地支持了 ES2015。

一旦你開始代碼檢查,如果有很多錯誤信息出現,立即修復它們。別跳過這些步驟,直到你的代碼檢查工具對你的代碼徹底滿意了。

升級依賴

升級依賴需要仔細些,如果你不注意依賴本身的升級帶來的一些變化,就很容易導致錯誤。一些項目可能只能依賴某些庫的固定版本(例如:v1.12.5),而另一些則使用版本通配符(例如:v1.12.x)。如果你要快速升級依賴,你需要知道依賴模塊的版本號通常按如下規則建立:主版本.小版本.補丁。如果你對 semantic versioning 的方式不熟悉,我推薦你先閱讀 Tim Oxley 的這篇文章。

升級依賴沒有通用方法。每個項目是不一樣的,需要區別對待。升級依賴的補丁版本通常不會出什麼問題,小版本一般也還OK。但如果你要升級依賴的主版本,你就需要仔細檢查版本升級帶來的改變。有可能 API 完全改變了,那樣你就得重寫你項目的一大堆代碼。如果非必要,我一般避免將依賴升級到下一個主版本。

如果你的項目使用 npm 來管理依賴,你可以很方便地使用 npm outdated 命令來檢查你的依賴是否已經過時了。我用一個項目 FrontBook 來舉例說明,在這個項目裏,我經常升級所有的依賴:

 

如你所見,我這個項目裏的依賴有很多主版本升級。我不會一次將他們全部升級,但是會一次升級一個。雖然這會耗費許多時間,但這是確保不會出問題的唯一辦法(尤其是如果這個項目沒有任何測試)。

下面該幹髒活了

我必須讓你知道的非常重要的一點是,清理代碼並不意味着需要移除和重寫大量的代碼片段。當然,有時候這可能是唯一的解決辦法,但是這不應該是你的首選方案。JavaScript 特別靈活,因此難以給出一般性的建議,通常情況下你必須對症下藥。

建立單元測試

單元測試能保證你理解代碼是如何工作的,這樣避免一些意外導致錯誤。JavaScript 單元測試的內容足夠寫另一篇文章,所以我在這裏不能詳細介紹。目前被廣泛使用的單元測試框架有 Karma、Jasmine、Mocha 以及 Ava。如果你還要測試你的用戶界面,Nightwatch.js 和 DalekJS 是適合瀏覽器自動化測試的工具。

單元測試和瀏覽器自動化測試的區別是,前者測試你的 JavaScript 代碼本身,來確保你所有的模塊和主要邏輯運行無誤。後者,測試用戶界面,確保界面元素在正確的位置,且如預期地工作。

在你開始動手重構代碼之前,認真對待單元測試,那樣你的項目的穩定性將得到改善,而你甚至還沒有開始考慮可擴展性。單元測試帶來的另一個好處是你不再需要無時無刻擔心你的改動會無意中破壞原有功能。

Rebecca Murphey 寫了一篇很棒的文章關於如何爲現有代碼寫單元測試。

架構

JavaScript 架構是另一個大話題。重構和清理架構歸結於你在這方面積累了多少經驗。我們可以選擇許多不同的設計模式,但不是所有的模式都適合於提升可擴展性。限於篇幅,我不能涵蓋所有模式,但我至少可以給你一些通用的建議。

首先,你需要找出哪些設計模式在你的項目中已經使用到了。閱讀有關這些模式的部分,確保它們在項目中使用上保持一致性。可擴展性的關鍵之一便是堅持一致的模式,避免混搭。當然,你可以針對項目中的不同目的採用不同的設計模式(例如,將單例模式用於數據結構和短命名空間的輔助功能函數,以及將觀察者模式用於與模塊),但是別對一個模塊使用了一種設計模式,對另一個模塊又用另一種不同的設計模式。

如果你的項目沒有任何架構(可能一切都堆在一個巨大的app.js文件裏),從現在開始讓它有架構。不過別指望一口吃成 胖子,需要一點一點來。再次強調,沒有對任何項目都適用的萬精油方案,每一個項目的情況都是不同的。根據規模和複雜度不同,項目文件目錄結構各有不同。通 常,最基本的原則是,目錄結構應當將第三方庫、模塊、數據以及負責初始化模塊與邏輯的入口文件(比如:index.jsmain.js)分開來。

簡而言之就是模塊化

將一切模塊化?

模塊化不是解決 JavaScript 擴展性問題的唯一選擇。模塊化增加了一層 API,開發者不得不額外去熟悉它們。這雖然增加了麻煩,但是值得去做的。模塊化的基本原則是將所有功能拆分爲小模塊。這麼做了以後,不僅讓你更容易解決 代碼裏的問題,也讓項目組的其他成員更容易協同工作。每個模塊只做一件事,它們不用關心外部邏輯,可以被複用在不同的地方。

如何將一大堆功能拆分成許多邏輯關聯的小模塊?讓我們來做做看:

// 這個例子使用 Fetch API 來請求一個服務器的 API// 讓我們假設它返回一個 JSON 文件,包含一些基本信息// 然後我們創建一個新的元素,統計 json 所有屬性的// content 字段中的字符數,然後將結果插入 DOM 的某個位置。fetch('https://api.somewebsite.io/post/61454e0126ebb8a2e85d', { method: 'GET' })
  .then(response => {    if (response.status === 200) {      return response.json();
    }
  })
  .then(json => {    if (json) {      Object.keys(json).forEach(key => {        const item = json[key];        const count = item.content.trim().replace(/\s+/gi, '').length;        const el = `
          <div class="foo-${item.className}">
            <p>Total characters: ${count}</p>
          </div>
        `;        const wrapper = document.querySelector('.info-element');

        wrapper.innerHTML = el;
      });
    }
  })
  .catch(error => console.error(error));

上面的代碼不是模塊化的。所有的功能都耦合在一起。想象一下,如果這是更復雜的函數,由於出了一些錯誤你必須調試它們,可能 API 沒返回,可能某些原因 JSON 內部的值被改變或者別的什麼問題。調試這一大坨代碼如同噩夢般,不是嗎?

讓我們將代碼按不同的職責拆分開來:

function createWrapperElement (cssClass, content) {  const className = cssClass || 'default';  const wrapperElement = document.createElement('div');  const textElement = document.createElement('p');  const textNode = document.createTextNode(`Total characters: ${content}`);

  wrapperElement.classList.add(className);
  textElement.appendChild(textNode);
  wrapperElement.appendChild(textElement);  return wrapperElement;
}// 前一個例子 .forEach 中的匿名函數也被我們抽出來形成一個模塊function appendCharacterCount (config) {  const wordCount = countCharacters(config.content);  const wrapperElement = createWrapperElement(config.className, wordCount);  const infoElement = document.querySelector('.info-element');

  infoElement.appendChild(wrapperElement);
}

好了,我們現在有了三個新模塊,讓我們看看重構之後的 fetch 調用:

fetch('https://api.somewebsite.io/post/61454e0126ebb8a2e85d', { method: 'GET' })
  .then(response => {    if (response.status === 200) {      return response.json();
    }
  })
  .then(json => {    if (json) {      Object.keys(json).forEach(key => appendCharacterCount(json[key]))
    }
  })
  .catch(error => console.error(error));

當然我們還可以進一步將 .then( ) 中的邏輯也抽出來形成模塊,不過我想我已經充分表達了模塊化的含義。

如果不想模塊化呢?

如我前面提到的,將你的代碼拆成小模塊會增加額外的一層 API。如果你不想這麼做,但是又想要讓代碼易於與其他開發者一起維護,不拆函數也沒問題,你依然可以將你的代碼分解成更簡單的部分並把重點放在可測試的代碼上。

爲你的代碼撰寫文檔

文檔化是一個老生常談的話題。程序員社區的一部分人提倡將一切文檔化,而另一部分人認爲好代碼即是文檔。我奉行中庸之道,我覺得代碼的可讀和可擴展之間需要保持平衡。你可以使用 JSDoc 來幫助你實現文檔化。

JSDoc 是一個 JavaScript 的 API 文檔生成器。常用的編輯器和 IDE 都有支持它的插件。我們看一個例子:

function properties (name, obj = {}) {  if (!name) return;  const arr = [];  Object.keys(obj).forEach(key => {    if (arr.indexOf(obj[key][name]) <= -1) {
      arr.push(obj[key][name]);
    }
  });  return arr;
}

這個函數有兩個參數,遍歷一個對象,返回一個數組。這也許不是一個過於複雜的方法,但是對於沒寫過這段代碼的人來說,搞懂它還是有點費勁。此外,這個方法具體的作用也不是很明確。讓我們對它文檔化:

/**
 * 遍歷一個對象,將將所有屬性對象的 'name' push 到一個新數組中
 * 如果有重複,只 push 一次
 * @param  {String}  propertyName - 屬性的名字
 * @param  {Object}  obj          - 你想要遍歷的對象
 * @return {Array}
 */function getArrayOfProperties (propertyName, obj = {}) {  if (!propertyName) return;  const properties = [];
  Object.keys(obj).forEach(child => {    if (properties.indexOf(obj[child][propertyName]) <= -1) {
      properties.push(obj[child][propertyName]);
    }
  });  return properties;
}

我沒有改變任何代碼,只是改了一下函數名,添加了一段簡短的註釋,就已經讓這段代碼的可讀性變得好多了。

有條理的代碼提交工作流

重構本身是一項艱鉅的任務。爲了能隨時回滾你的修改(假如你破壞了一些原有功能,過了一會才意識到,你可能就需要回滾代碼到之前版本),你的每一部 分修改,比如重寫了一個方法、重命名了一個名字空間,都應該及時提交到 git (或者 svn)。這麼做也許會讓你覺得麻煩,但這麼做有助於讓你的清理工作更有條理。

爲你的重構工作開一個新的分支,千萬別總是在主線 (master) 上改。因爲主線版本你有可能需要臨時做一些更新或者隨時發佈一些 bug fixes 到線上環境,而你又不能將你沒有經過測試的和未完成的重構一同發佈到線上,因此我建議你還是應該在不同的分支上工作。

在 GitHub 上有一份有趣的指導,是關於如何使用他們的版本控制流程的。

別失去理智

除了用技術解決問題之外,有一個很重要的點很少被人提及:別爲你的前任抓狂。我無意指責任何人,但是我知道一些人經歷過這種情況。我花了很多年的時間去理解和克服心理上的不爽。我曾經對前任開發者們留下的代碼、解決方案感到有些抓狂,他們做的一切在我眼裏看來都造成混亂。

結果,這些消極情緒沒帶給我任何好處。消極情緒會導致你過度重構,浪費你的時間,而且可能破壞一些原有功能,而這一切又導致你越來越惱怒。你可能會 花費額外的時間去重寫一個本來毫無問題的模塊,沒有人會因此感謝你,因爲你在做無用功。先分析狀況,然後做有價值的重構。在任何時候,你隨時可以對一個模 塊做一些細節的改進。

一段代碼爲什麼寫成這樣往往是有歷史原因的,也許前任程序員沒有足夠的時間將代碼寫得足夠好、或者不知道有更好的寫法,或者別的什麼原因。我們都是過來人。

整理一下

讓我們從頭回顧一下所有的步驟,爲你的下一個項目創建一個 checklist:

  1. 分析項目

  • 先忘掉自己的開發者身份,以一個用戶的身份來看清它的全貌。

  • 瀏覽代碼庫,列出項目使用的工具。

  • 閱讀項目相關工具的文檔和最佳實踐。

  • 瀏覽單元測試,從更高層面上了解項目。

  • 創建基線

    • 引入 .editorconfig 以保證在所有的編輯器和 IDE 下保持代碼風格一致。

    • 使縮進風格一致,至於是用 tab 還是空格,無所謂。

    • 執行命名約定。

    • 如果代碼檢查工具不存在, 添加一個,可以是 ESLint、 JSLint 或者 JSHint。

    • 升級依賴,但是需要格外小心,弄清楚到底升級了什麼。

  • 清理代碼

    • 建立單元測試與瀏覽器自動化測試,可以使用一些工具,例如 Karma、Jasmine、或者 Nightwatch.js。

    • 確保架構和設計模式保持一致。

    • 不要混用 設計模式,堅持使用已經存在的設計模式。

    • 決定你是否需要將代碼庫拆分成模塊。每一個模塊應當具有單一的目的,模塊不用關心自身之外的其他邏輯。

    • 如果你不想拆分模塊,把重點放在可測試的代碼上,把它們分解成更簡單的代碼塊。

    • 恰當地命名你的函數,爲代碼適當撰寫文檔,保持可讀和可擴展的平衡。

    • 使用 JSDoc 來生成文檔。

    • 定期提交代碼,特別在有重要改變時。這樣如果有什麼改錯了,可以方便回滾。


    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章