大家好,我是Raymond,我寫了很多糟糕的程式碼。好吧,其實並沒有很糟糕,只是我沒有遵循所謂的“最佳實踐”罷了。我敢打賭看這篇文章的很多人也都沒有遵循最佳實踐。在這篇文章中,我將談一談在最近的一個專案中,我使用了一些簡單的工具幫我完成了自己非常滿意的程式碼。下面我就跟大家分享這個故事。
故事背景
假期中我儘量讓自己遠離工作,甚至連電腦都不去看一眼,但最後徹底失敗了。
當時我饒有興致正準備玩一輪電子遊戲(見笑了,是手眼協調訓練的遊戲),忽然有人跟我分享了一個讓人有點小激動的訊息——Star Wars API釋出了。即使它並不是“官方”釋出的,這個“非官方”的API提供了抓取人物角色、電影、星際飛船、交通工具、物種、星球等內容的方法。Star Wars API提供免費服務,沒有認證要求。它不提供搜尋功能,對於一項免費服務來說,它已經很好了。如果你瞭解我的話,就知道我對跟Star Wars相關的東西是很著迷的。
我一時心血來潮,快速寫了一個JavaScript庫來整合這個API。最簡單的,你可以使用它來抓取某一型別資源的全部內容:
1 2 3 4 5 6 7 |
//get all starships swapiModule.getStarships(function(data) { console.log("Result of getStarships", data); }); |
或者抓取一個特定的專案:
1 2 3 4 5 6 7 |
//get one starship (assumes 2 works) swapiModule.getStarship(2,function(data) { console.log("Result of getStarship/2", data); }); |
實際的包裝器是一個js檔案,我也寫了相應的test.html,並把它們放到了GitHub:https://github.com/cfjedimaster/SWAPI-Wrapper/tree/v1.0.(注意:該連結指向專案的原始版本,最新版本在這裡https://github.com/cfjedimaster/SWAPI-Wrapper )。這種打發時間的方式很有意思,坦誠來講,每寫一行JavaScript程式碼,我就覺得自己技能在(慢慢地)提高。
但是接下來狀況就出現了,有個細小的聲音在我腦袋裡喋喋不休:我是不是還可以把程式碼寫的更好一點?我應該寫一些測試單元,不是嗎?我怎麼忘了加上精簡的版本?這些事情我知道不是必須要做的,但是沒有做到自己最好的水平,我對此感到內疚(也並沒有內疚到立即去看程式碼,畢竟現在還是假期嘛!)。
我被這糾纏了好幾天,於是開始在頭腦裡思索能改進專案的事情,讓它更符合最佳實踐。我這裡列出的可能會跟你的大有不同,但是它們的確使這個專案有了很大的改進。
- 在寫JavaScript的時候,我發現一些程式碼反覆出現並且可以進行優化。但當時我專注於程式碼功能的實現,有意地忽略了這些。提前進行優化多少讓我有點不樂意。然而現在程式碼釋出了,我覺得此時回頭再對它做一些改進,這還是很合理的。
- 顯然,在優化之前做一下單元測試或許更合理些。由於專案依賴遠端服務,做測試可能會有一些問題,但還是可以假設遠端服務執行順利,做一下測試,這樣有總比沒有強。另外,如果先寫這些測試,我就能檢視程式碼的變化,從而確保自己沒有破壞掉什麼事情。
- 我是JSHint的擁躉,我喜歡把程式碼放到JSHint裡面跑一遍,確保程式碼能通過測試。
- 我也希望發行一個精簡版本的庫。老實講,我以前沒有做過程式碼壓縮,但是直覺告訴我肯定有這麼一個指令碼,我只需在命令列裡跑一下就可完成程式碼壓縮 了。
- 最後一點,我確信我能處理好單元測試,JSHint檢查,以及使用Grunt或Gulp的自動化工具來完成所有步驟。
最終我將擁有一個讓自己倍感自信的專案,這個專案能更好地服務我的終端使用者,我將把這個專案從Jar Jar一樣的東西變為Jedi一樣(譯者注:Jar Jar是星戰系列中的一個二貨角色,Jedi指星戰系列中的絕地武士)。在這篇文章中,我將回顧這其中的每一步,並且描述我是怎麼做來改進我的專案的。第一條所講的程式碼優化,由於它是最含糊也是最開放的,我將放在最後再講。
增加單元測試
現在是2015年,我假設大家都知道什麼是單元測試。萬一你不知道,那麼最簡單的方法是把它們看成能讓你的程式碼正常執行的一套測試。想象一個庫有兩個函式:getPeople 和getPerson,針對每個函式寫一個測試,這樣你就有兩個測試。現在假設getPeople可以讓你有進行選擇性的搜尋,那麼你就要寫第三個測試來確保搜尋功能正常執行。如果getPeople 也可以讓你給返回結果分頁並且為返回結果指定起始點,那你就要寫更多的測試來涵蓋這些功能。你應該懂了吧,寫的測試越多,就越能確保程式碼正確地執行。
我的庫有3類函式呼叫。第一類是getResources,它用於返回其他API的端點(end points)列表,從使用者的實際使用來看,這其實並非是必不可少的東西,但為了完整性我還是保留了它。接著是獲取某一專案的函式呼叫,和獲取所有專案的函式呼叫。比如對於星球這一項,我們就有getPlanet 和getPlanets。但是光有這些還不夠,因為獲取所有專案的函式呼叫返回的是分了頁的資料。於是我在API裡提供了getPlanets 和getPlanets(n),其中n表示資料的第幾頁。
這就意味著我要對四種情況進行測試:
- 呼叫getResources
- 呼叫getSingular 獲取每種資源
- 呼叫getPlural 獲取每種資源
- 呼叫getPlural 獲取每種資源的返回結果中的某一頁資料
由於我們有一個常規方法和三個遍歷資源的方法,這就是說需要進行1+(3*資源數目)次測試。現在有6種型別的資源,我就需要19個測試。這還不是很糟糕,我的一個最喜歡的庫Moment.js有43399個測試!
我決定用Jasmine來做單元測試,我覺得Jasmine的語法很友好,並且它是我最熟悉的一個JavaScript測試框架。
我喜歡Jasmine的一個地方是它包含一個“spec runner”以及測試示例,你可以快速修改它並且馬上開始測試。spec runner只是一個包含你的庫和測試程式碼的HTML檔案,當開啟它,它就執行程式碼並且將測試結果漂亮地展示出來。我開始時寫了一個getResources的單元測試,即使你以前沒接觸過Jasmine,我相信你也能弄明白這裡發生了什麼事情:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
it("should be able to get Resources", function(done) { swapiModule.getResources(function(data) { expect(data.films).toBeDefined(); expect(data.people).toBeDefined(); expect(data.planets).toBeDefined(); expect(data.species).toBeDefined(); expect(data.starships).toBeDefined(); expect(data.vehicles).toBeDefined(); done(); }); }); |
getResource返回一個含有鍵值集合的簡單物件,這些鍵值表示API所支援的每一種資源。因此我僅僅用toBeDefined就可以作一種宣告:“我希望這個鍵值存在”。程式碼末尾的done()是Jasmine測試非同步呼叫的方式。現在來看看其他三種型別的呼叫,首先來看一下獲取一種資源的呼叫。
我先來對getPerson做測試。正如你想的那樣,它會從Star Wars的世界裡獲取一個人,跟getResource一樣它返回一個物件。為避免輸入麻煩,我建立了一個鍵值集合,這樣我就能通過迴圈的方法來利用toBeDefined()。這種測試會有一些問題。我假設這個人的ID為2,並且鍵值所代表的這個人不會發生變化,我覺得這麼做是可以的。我希望從API返回的資料都是一致的,如果以後不一致了,我需要更新一下測試,並且更新起來也簡單。還有,我現在所做的測試也許並不成熟。現在只是初步這麼做,以後肯定會再回來做修改,讓它們變得更聰明。現在來看一下獲取所有人的呼叫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
it("should be able to get People", function(done) { swapiModule.getPeople(function(people) { var keys = ["count", "next", "previous", "results"]; for(var i=0, len=keys.length; i<len; i++) { expect(people[keys[i]]).toBeDefined(); } done(); }); }); |
這個跟getPerson很相似,主要的不同在於返回的鍵值,下面測試獲取第二頁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
it("should be able to get the second page of People", function(done) { swapiModule.getPeople(2, function(people) { var keys = ["count", "next", "previous", "results"]; for(var i=0, len=keys.length; i<len; i++) { expect(people[keys[i]]).toBeDefined(); } expect(people.previous).toMatch("page=1"); done(); }); }); |
這跟前面的測試非常相似,只是增加了一個指向前一頁的連結。同樣,我認為這裡能做一些改進。我真的有必要在“頁面”的測試裡檢查物件的鍵值嗎?沒必要吧?我現在先把它放在一邊。
這樣就差不多了。接下來,我只是對另外5種資源重複寫這三類的呼叫。在寫測試的時候,我發現了程式碼裡的一些有趣的問題,這更加讓我覺得寫測試是非常有幫助的。我發現自己在API中並沒有充分處理某些情況下的錯誤,比如,getFilms只返回一頁的電影資料,那麼getFilms(2)到底應該返回什麼呢?返回一個物件?JavaScript 異常?我現在還不知道怎麼辦,不過這個問題我最後是會處理的(我決定等到下一次重大改動時再來解決這個問題)。當所有的測試寫好了,我就執行頁面,確保所有的測試都通過。
用JSHint檢測程式碼質量
我的優化列表的下一項是使用程式碼檢測工具。它能檢測程式碼中存在的問題,這些問題可能是程式碼中的bug,或者是程式碼的效能優化,或者只是檢測程式碼有沒有做到最佳的實踐。最開始的JavaScript程式碼質量檢測工具是JSLint,但我用了另外一種叫JSHint的。JSHint在使用上比JSLint更方便,由於我是個喜歡簡便的人,所以覺得前者更適合我。
有很多種方式使用JSHint,包括在你最喜歡的編輯器中。我用的是Brackets (目前是我最喜歡的編輯器),它提供了JSHint的擴充套件,在我敲程式碼的時候就能看到問題。但在這篇文章中我用的是JSHint的命令列版本。只要你安裝了npm,你就可以用npm install -g jshint命令將JSHint CLI新增到自己的環境中。
如果安裝好了,你就可以用它來檢測你的程式碼。例如:
1 |
jshint swapi.js |
當我輸入這個以後,命令列什麼也沒有輸出。我於是花了十多分鐘來研究命令列的引數,最後發現我這個簡單的庫實際上已經通過了JSHint預設設定的測試。是的,它通過了,但我仍然感到吃驚。在我看來,命令列什麼都沒輸出說明程式碼有錯誤。但是對於JSHint,命令列沒有輸出就表示程式碼通過檢測,其實是好訊息。
為了驗證一下,我故意在程式碼中放了一些糟糕的程式碼。
1 2 3 4 5 6 7 |
var swapiModule = function () { var rootURL = "http://swapi.co/api/"; y=1 <bold> |
我寫了一行沒有分號的程式碼(很恐怖吧!),也加了一個HTML標籤。JSHint都檢測出來了,並且把語法錯誤也標識了出來。下面是命令列的輸出結果:
問題錯誤都報告得很準確,但是注意看上面截圖中輸出的行數,第一行正確,但是第二行就把行數完全弄錯了。我的猜測是JSHint不能識別HTML標籤。由於初學者一般不會這麼做,所以我也就不擔心這事。如果你把HTML標籤去掉了,它就會報出分號的錯誤,並且也會報出正確的行數。為滿足你的求知慾,JSHint支援一系列設定,包括選擇去掉分號的檢測。但是切記,每當你省略一個分號,上帝就殺死一隻喵咪,真的(我是從網上看到的)。
壓縮程式碼庫
我下一步要做的是壓縮庫的大小。雖然這個庫現在不大(有128行),但我也沒指望以後它自己會變小,坦誠地講,如果壓縮程式碼很容易,那麼沒有理由不去做。壓縮主要是去掉空格,減少變數名,讓檔案越小越好。我選擇UglifyJS 來完成這個任務。一旦安裝好,我就可以這麼做:
1 |
uglifyjs swapi.js -c -m -o swapi.min.js |
你可以查詢相關的文件來獲取各個引數的詳細解釋,這裡我只簡單地用這個工具來縮短變數名及去掉空格。很棒的一點是,當我執行它時,它真的提醒了我曾忘記的一件事:
1 2 3 4 5 |
//generic for ALL calls, todo, why optimize now! function getResource(u, cb) { } |
這個函式雖說不妨礙正常執行,但它佔用了空間,UglifyJS 馬上就注意到它了:
自動化這個過程
好了,到目前為止一切順利。我使用了單元測試來保障程式碼的正確執行,用了程式碼質量檢測工具,最後也對程式碼進行了壓縮。雖然所有這些事都相對比較簡單,但我是個非常懶的人,我還想把這些事情全部自動化。在我心中,這個過程最好是這樣子的:
- 進行單元測試,如果它們通過了…
- 進行JSHint測試,如果它通過了…
- 生成一個庫的精簡版本.
為了使這個過程自動化,我打算用Grunt。它並不是唯一的Web 開發任務執行器,但是由於我還沒用過Gulp ,所以就預設用Grunt了。
Grunt讓你定義一系列的任務,並用命令列來執行它。你可以定義一條任務鏈,一旦其中一個任務執行失敗,整個過程就停止。基本上來說,如果你發現自己要執行多個任務,並重復多次,那麼你就可以使用像Grunt這樣的任務執行器來簡化你的過程。
如果你以前從沒用過Grunt,可以去看一下這個入門教程。當新增package.json檔案來載入我所需要的Grunt外掛(支援Jasmine, JSHint, 和 Uglify)之後,我就建了這個Gruntfile.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
module.exports = function(grunt) { // Project configuration. grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), uglify: { build: { src: 'lib/swapi.js', dest: 'lib/swapi.min.js' } }, jshint: { all: ['lib/swapi.js'] }, jasmine: { all: { src:"lib/swapi.js", options: { specs:"tests/spec/swapiSpec.js", '--web-security':false } } } }); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-jasmine'); grunt.registerTask('default', ['jasmine','jshint','uglify']); }; |
基本上是按照Jasmine、 JSHint 、uglify的順序執行,我在命令列只需敲入grunt就能讓三個任務執行起來。
假如我對程式做點破壞,比如新增一些會讓JSHint中斷的程式碼,Grunt就會報告出這個問題並且停止執行:
結果如何?
綜上所述,我的原始庫並沒有發生功能性的改進,因為我還沒有針對程式碼做優化,但是看一下我所做的:
- 我進行了單元測試來檢查現有的所有功能,當新增新的功能時,我能確信不會破壞使用者正在使用的功能,真的確信。
- 我使用了程式碼質量檢測工具來確保我的程式碼符合人們所認可的最佳實踐。儘管不是很嚴格,但它就像另外一雙眼睛在看我的程式碼,我喜歡它這樣。
- 我增加了一個精簡版本的庫。儘管它並沒帶來多大的節省,但是當以後我的庫變大了,它就可以派上用場。
- 我讓所有的這些事自動執行。現在我只用一個快捷命令就可以執行上面所有的任務。生活真美妙!我現在就是一個超級程式碼忍者!
現在我擁有一個絕好的專案,這是一件很棒的事情!你可以去這兒看專案的最終版本。