【譯】尋找頭緒:編寫可維護的 JavaScript
幾乎每個程序員都有接手維護別人遺留項目的經歷。或者,有可能一個老項目某一天又被重新啟動。通常情況下,接手老項目都會讓人恨不得拋棄掉整個代碼庫從頭開始。老代碼凌亂、文檔缺失、需要研究很多天才能完全搞明白它。然而,通過合適的規劃、分解和好的工作流,項目代碼可以變得干凈、有組織和可擴展。
我曾經接手清理許多項目的代碼,讓我不得不重頭開始的項目真心不多。事實上我學到了很多 JavaScript 相關的內容,學到了如何保持代碼庫有條理,以及最重要的是冷靜,不要為你的前任抓狂。在這篇文章里,我想要讓你們知道我是怎么一步步處理項目代碼的,告訴你我積累的經驗。
分析項目
第一步是先概覽整個項目,弄明白問題所在。如果這是一個網站,通過點擊測試所有功能點:打開模塊、提交表單以及其他的。在做這件事的時候,打開開發者工具,看看是否有任何報錯,看看控制臺有沒有日志。如果這是一個Node.js項目,打開 命令行界面 然后檢查各個API。最好的情況是項目通過統一的入口(例如: main.js , index.js , app.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.js 、 main.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 countCharacters (text) {
const removeWhitespace = /\s+/gi;
return text.trim().replace(removeWhitespace, "").length;
}
// 這一部分,我們也獨立成一個模塊,使用 DOM API 來創建 HTML
// 取代之前直接插入字符串的做法
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:
-
分析項目
- 先忘掉自己的開發者身份,以一個用戶的身份來看清它的全貌。
- 瀏覽代碼庫,列出項目使用的工具。
- 閱讀項目相關工具的文檔和最佳實踐。
- 瀏覽單元測試,從更高層面上了解項目。
-
創建基線
- 引入 .editorconfig 以保證在所有的編輯器和 IDE 下保持代碼風格一致。
- 使縮進風格一致,至于是用 tab 還是空格,無所謂。
- 執行命名約定。
- 如果代碼檢查工具不存在, 添加一個,可以是 ESLint 、 JSLint 或者 JSHint 。
- 升級依賴,但是需要格外小心,弄清楚到底升級了什么。
-
清理代碼
- 建立單元測試與瀏覽器自動化測試,可以使用一些工具,例如 Karma 、 Jasmine 、或者 Nightwatch.js 。
- 確保架構和設計模式保持一致。
- 不要混用 設計模式 ,堅持使用已經存在的設計模式。
- 決定你是否需要將代碼庫拆分成模塊。每一個模塊應當具有單一的目的,模塊不用關心自身之外的其他邏輯。
- 如果你不想拆分模塊,把重點放在可測試的代碼上,把它們分解成更簡單的代碼塊。
- 恰當地命名你的函數,為代碼適當撰寫文檔,保持可讀和可擴展的平衡。
- 使用 JSDoc 來生成文檔。
- 定期提交代碼,特別在有重要改變時。這樣如果有什么改錯了,可以方便回滾。
-
別失去理智
- 別為你的前任開發者抓狂。負面情緒只會導致過度重構而浪費時間。
- 一段代碼為什么寫成這樣總是有原因的。要牢記我們都是過來人。
我非常希望這篇文章能幫到你。如果你正在為代碼重構做這些努力,或者你有一些我沒有提到過的好建議,我希望你可以告訴我。
來自: https://www.h5jun.com/post/untangling-spaghetti-code-writing-maintainable-javascript.html