在軟體中,效能一直扮演著重要的角色。在Web應用中,效能變得更加重要,因為如果頁面速度很慢的話,使用者就會很容易轉去訪問我們的競爭對手的網站。作為專業的web開發人員,我們必須要考慮這個問題。有很多“古老”的關於效能優化的最佳實踐在今天依然可行,例如最小化請求數目,使用CDN以及不編寫阻塞頁面渲染的程式碼。然而,隨著越來越多的web應用都在使用JavaScript,確保我們的程式碼執行的很快就變得很重要。
假設你有一個正在工作的函式,但是你懷疑它執行得沒有期望的那樣快,並且你有一個改善它效能的計劃。那怎麼去證明這個假設呢?在今天,有什麼最佳實踐可以用來測試JavaScript函式的效能呢?一般來說,完成這個任務的最佳方式是使用內建的performance.now()函式,來衡量函式執行前和執行後的時間。
在這篇文章中,我們會討論如何衡量程式碼執行時間,以及有哪些技術可以避免一些常見的“陷阱”。
Performance.now()
高解析度時間API提供了一個名為now()的函式,它返回一個DOMHighResTimeStamp物件,這是一個浮點數值,以毫秒級別(精確到千分之一毫秒)顯示當前時間。單獨這個數值並不會為你的分析帶來多少價值,但是兩個這樣的數值的差值,就可以精確描述過去了多少時間。
這個函式除了比內建的Date物件更加精確以外,它還是“單調”的,簡單說,這意味著它不會受作業系統(例如,你筆記本上的作業系統)週期性修改系統時間影響。更簡單的說,定義兩個Date例項,計算它們的差值,並不代表過去了多少時間。
“單調性”的數學定義是“(一個函式或者數值)以從不減少或者從不增加的方式改變”。
我們可以從另外一種途徑來解釋它,即想象使用它來在一年中讓時鐘向前或者向後改變。例如,當你所在國家的時鐘都同意略過一個小時,以便最大化利用白天的時間。如果你在時鐘修改之前建立了一個Date例項,然後在修改之後建立了另外一個,那麼檢視這兩個例項的差值,看上去可能像“1小時零3秒又123毫秒”。而使用兩個performance.now()例項,差值會是“3秒又123毫秒456789之一毫秒”。
在這一節中,我不會涉及這個API的過多細節。如果你想學習更多相關知識或檢視更多如何使用它的示例,我建議你閱讀這篇文章:Discovering the High Resolution Time API。
既然你知道高解析度時間API是什麼以及如何使用它,那麼讓我們繼續深入看一下它有哪些潛在的缺點。但是在此之前,我們定義一個名為makeHash()的函式,在這篇文章剩餘的部分,我們會使用它。
1 2 3 4 5 6 7 8 9 10 |
function makeHash(source) { var hash = 0; if (source.length === 0) return hash; for (var i = 0; i < source.length; i++) { var char = source.charCodeAt(i); hash = ((hash<<5)-hash)+char; hash = hash & hash; // Convert to 32bit integer } return hash; } |
我們可以通過下面的程式碼來衡量這個函式的執行效率:
1 2 3 4 |
var t0 = performance.now(); var result = makeHash('Peter'); var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result); |
如果你在瀏覽器中執行這些程式碼,你應該看到類似下面的輸出:
1 |
Took 0.2730 milliseconds to generate: 77005292 |
這段程式碼的線上演示如下所示:
記住這個示例後,讓我們開始下面的討論。
缺陷1 – 意外衡量不重要的事情
在上面的示例中,你可以注意到,我們在兩次呼叫performance.now()中間只呼叫了makeHash()函式,然後將它的值賦給result變數。這給我們提供了函式的執行時間,而沒有其他的干擾。我們也可以按照下面的方式來衡量程式碼的效率:
1 2 3 4 |
var t0 = performance.now(); console.log(makeHash('Peter')); // bad idea! var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds'); |
這個程式碼片段的線上演示如下所示:
但是在這種情況下,我們將會測量呼叫makeHash(‘Peter’)函式花費的時間,以及將結果傳送並列印到控制檯上花費的時間。我們不知道這兩個操作中每個操作具體花費多少時間, 只知道總的時間。而且,傳送和列印輸出的操作所花費的時間會依賴於所用的瀏覽器,甚至依賴於當時的上下文。
或許你已經完美的意識到console.log方式是不可以預測的。但是執行多個函式同樣是錯誤的,即使每個函式都不會觸發I/O操作。例如:
1 2 3 4 5 |
var t0 = performance.now(); var name = 'Peter'; var result = makeHash(name.toLowerCase()).toString(); var t1 = performance.now(); console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result); |
同樣,我們不會知道執行時間是怎麼分佈的。它會是賦值操作、呼叫toLowerCase()函式或者toString()函式嗎?
缺陷 #2 – 只衡量一次
另外一個常見的錯誤是隻衡量一次,然後彙總花費的時間,並以此得出結論。很可能執行不同的次數會得出完全不同的結果。執行時間依賴於很多因素:
- 編輯器熱身的時間(例如,將程式碼編譯成位元組碼的時間)
- 主執行緒可能正忙於其它一些我們沒有意識到的事情
- 你的電腦的CPU可能正忙於一些會拖慢瀏覽器速度的事情
持續改進的方法是重複執行函式,就像這樣:
1 2 3 4 5 6 |
var t0 = performance.now(); for (var i = 0; i < 10; i++) { makeHash('Peter'); } var t1 = performance.now(); console.log('Took', ((t1 - t0) / 10).toFixed(4), 'milliseconds to generate'); |
這個示例的線上演示如下所示:
這種方法的風險在於我們的瀏覽器的JavaScript引擎可能會使用一些優化措施,這意味著當我們第二次呼叫函式時,如果輸入時相同的,那麼JavaScript引擎可能會記住了第一次呼叫的輸出,然後簡單的返回這個輸出。為了解決這個問題,你可以使用很多不同的輸入字串,而不用重複的使用相同的輸入(例如‘Peter’)。顯然,使用不同的輸入進行測試帶來的問題就是我們衡量的函式會花費不同的時間。或許其中一些輸入會花費比其它輸入更長的執行時間。
缺陷 #3 – 太依賴平均值
在上一節中,我們學習到的一個很好的實踐是重複執行一些操作,理想情況下使用不同的輸入。然而,我們要記住使用不同的輸入帶來的問題,即某些輸入的執行時間可能會花費所有其它輸入的執行時間都長。這樣讓我們退一步來使用相同的輸入。假設我們傳送同樣的輸入十次,每次都列印花費了多長時間。我們會得到像這樣的輸出:
1 2 3 4 5 6 7 8 9 10 |
Took 0.2730 milliseconds to generate: 77005292 Took 0.0234 milliseconds to generate: 77005292 Took 0.0200 milliseconds to generate: 77005292 Took 0.0281 milliseconds to generate: 77005292 Took 0.0162 milliseconds to generate: 77005292 Took 0.0245 milliseconds to generate: 77005292 Took 0.0677 milliseconds to generate: 77005292 Took 0.0289 milliseconds to generate: 77005292 Took 0.0240 milliseconds to generate: 77005292 Took 0.0311 milliseconds to generate: 77005292 |
請注意第一次時間和其它九次的時間完全不一樣。這很可能是因為瀏覽器中的JavaScript引擎使用了優化措施,需要一些熱身時間。我們基本上沒有辦法避免這種情況,但是會有一些好的補救措施來阻止我們得出一些錯誤的結論。
一種方式是去計算後面9次的平均時間。另外一種更加使用的方式是收集所有的結果,然後計算“中位數”。基本上,它會將所有的結果排列起來,對結果進行排序,然後取中間的一個值。這是performance.now()函式如此有用的地方,因為無論你做什麼,你都可以得到一個數值。
讓我們再試一次,這次我們使用中位數函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var numbers = []; for (var i=0; i < 10; i++) { var t0 = performance.now(); makeHash('Peter'); var t1 = performance.now(); numbers.push(t1 - t0); } function median(sequence) { sequence.sort(); // note that direction doesn't matter return sequence[Math.ceil(sequence.length / 2)]; } console.log('Median time', median(numbers).toFixed(4), 'milliseconds'); |
缺陷 #4 – 以可預測的方式比較函式
我們已經理解衡量一些函式很多次並取平均值總會是一個好主意。而且,上面的示例告訴我們使用中位數要比平均值更好。
在實際中,衡量函式執行時間的一個很好的用處是來了解在幾個函式中,哪個更快。假設我們有兩個函式,它們的輸入引數型別一致,輸出結果相同,但是它們的內部實現機制不一樣。
例如,我們希望有一個函式,當特定的字串在一個字串陣列中存在時,函式返回true或者false,但這個函式在比較字串時不關心大小寫。換句話說,我們不能直接使用Array.prototype.indexOf方法,因為這個方法是大小寫敏感的。下面是這個函式的一個實現:
1 2 3 4 5 6 7 8 9 10 11 12 |
function isIn(haystack, needle) { var found = false; haystack.forEach(function(element) { if (element.toLowerCase() === needle.toLowerCase()) { found = true; } }); return found; } console.log(isIn(['a','b','c'], 'B')); // true console.log(isIn(['a','b','c'], 'd')); // false |
我們可以立刻發現這個方法有改進的地方,因為haystack.forEach迴圈總會遍歷所有的元素,即使我們可以很快找到一個匹配的元素。現在讓我們使用for迴圈來編寫一個更好的版本。
1 2 3 4 5 6 7 8 9 10 11 |
function isIn(haystack, needle) { for (var i = 0, len = haystack.length; i < len; i++) { if (haystack[i].toLowerCase() === needle.toLowerCase()) { return true; } } return false; } console.log(isIn(['a','b','c'], 'B')); // true console.log(isIn(['a','b','c'], 'd')); // false |
現在我們來看哪個函式更快一些。我們可以分別執行每個函式10次,然後收集所有的測量結果:
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 |
function isIn1(haystack, needle) { var found = false; haystack.forEach(function(element) { if (element.toLowerCase() === needle.toLowerCase()) { found = true; } }); return found; } function isIn2(haystack, needle) { for (var i = 0, len = haystack.length; i < len; i++) { if (haystack[i].toLowerCase() === needle.toLowerCase()) { return true; } } return false; } console.log(isIn1(['a','b','c'], 'B')); // true console.log(isIn1(['a','b','c'], 'd')); // false console.log(isIn2(['a','b','c'], 'B')); // true console.log(isIn2(['a','b','c'], 'd')); // false function median(sequence) { sequence.sort(); // note that direction doesn't matter return sequence[Math.ceil(sequence.length / 2)]; } function measureFunction(func) { var letters = 'a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z'.split(','); var numbers = []; for (var i = 0; i < letters.length; i++) { var t0 = performance.now(); func(letters, letters[i]); var t1 = performance.now(); numbers.push(t1 - t0); } console.log(func.name, 'took', median(numbers).toFixed(4)); } measureFunction(isIn1); measureFunction(isIn2); |
我們執行上面的程式碼, 可以得出如下的輸出:
1 2 3 4 5 6 |
true false true false isIn1 took 0.0050 isIn2 took 0.0150 |
這個示例的線上演示如下所示:
到底發生了什麼?第一個函式的速度要快3倍!那不是我們假設的情況。
其實假設很簡單,但是有些微妙。第一個函式使用了haystack.forEach方法,瀏覽器的JavaScript引擎會為它提供一些底層的優化,但是當我們使用資料索引技術時,JavaScript引擎沒有提供對應的優化。這告訴我們:在真正測試之前,你永遠不會知道。
結論
在我們試圖解釋如何使用performance.now()方法得到JavaScript精確執行時間的過程中,我們偶然發現了一個基準場景,它的執行結果和我們的直覺相反。問題在於,如果你想要編寫更快的web應用,我們需要優化JavaScript程式碼。因為計算機(幾乎)是一個活生生的東西,它很難預測,有時會帶來“驚喜”,所以如果瞭解我們程式碼是否執行更快,最可靠的方式就是編寫測試程式碼並進行比較。
當我們有多種方式來做一件事情時,我們不知道哪種方式執行更快的另一個原因是要考慮上下文。在上一節中,我們執行一個大小寫不敏感的字串查詢來尋找1個字串是否在其它26個字串中。當我們換一個角度來比較1個字串是否在其他100,000個字串中時,結論可能是完全不同的。
上面的列表不是很完整的,因為還有更多的缺陷需要我們去發現。例如,測試不現實的場景或者只在JavaScript引擎上測試。但是確定的是對於JavaScript開發者來說,如果你想編寫更好更快的Web應用,performance.now()是一個很棒的方法。最後但並非最不重要,請謹記衡量執行時間只是“更好的程式碼”的一反面。我們還要考慮記憶體消耗以及程式碼複雜度。
怎麼樣?你是否曾經使用這個函式來測試你的程式碼效能?如果沒有,那你是怎麼來測試效能的?請在下面的評論中分享你的想法,讓我們開始討論吧!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式