測(cè)試JavaScript函數(shù)的性能
在軟件中,性能一直扮演著重要的角色。在Web應(yīng)用中,性能變得更加重要,因?yàn)槿绻?yè)面速度很慢的話,用戶就會(huì)很容易轉(zhuǎn)去訪問(wèn)我們的競(jìng)爭(zhēng)對(duì)手的網(wǎng)站。作為專業(yè)的web開(kāi)發(fā)人員,我們必須要考慮這個(gè)問(wèn)題。有很多“古老”的關(guān)于性能優(yōu)化的***實(shí)踐在今天依然可行,例如最小化請(qǐng)求數(shù)目,使用CDN以及不編寫(xiě)阻塞頁(yè)面渲染的代碼。然而,隨著越來(lái)越多的web應(yīng)用都在使用JavaScript,確保我們的代碼運(yùn)行的很快就變得很重要。
假設(shè)你有一個(gè)正在工作的函數(shù),但是你懷疑它運(yùn)行得沒(méi)有期望的那樣快,并且你有一個(gè)改善它性能的計(jì)劃。那怎么去證明這個(gè)假設(shè)呢?在今天,有什么***實(shí)踐可以用來(lái)測(cè)試JavaScript函數(shù)的性能呢?一般來(lái)說(shuō),完成這個(gè)任務(wù)的***方式是使用內(nèi)置的performance.now()函數(shù),來(lái)衡量函數(shù)運(yùn)行前和運(yùn)行后的時(shí)間。
在這篇文章中,我們會(huì)討論如何衡量代碼運(yùn)行時(shí)間,以及有哪些技術(shù)可以避免一些常見(jiàn)的“陷阱”。
Performance.now()
高分辨率時(shí)間API提供了一個(gè)名為now()的函數(shù),它返回一個(gè)DOMHighResTimeStamp對(duì)象,這是一個(gè)浮點(diǎn)數(shù)值,以毫秒級(jí)別(精確到千分之一毫秒)顯示當(dāng)前時(shí)間。單獨(dú)這個(gè)數(shù)值并不會(huì)為你的分析帶來(lái)多少價(jià)值,但是兩個(gè)這樣的數(shù)值的差值,就可以精確描述過(guò)去了多少時(shí)間。
這個(gè)函數(shù)除了比內(nèi)置的Date對(duì)象更加精確以外,它還是“單調(diào)”的,簡(jiǎn)單說(shuō),這意味著它不會(huì)受操作系統(tǒng)(例如,你筆記本上的操作系統(tǒng))周期性修改系統(tǒng)時(shí)間影響。更簡(jiǎn)單的說(shuō),定義兩個(gè)Date實(shí)例,計(jì)算它們的差值,并不代表過(guò)去了多少時(shí)間。
“單調(diào)性”的數(shù)學(xué)定義是“(一個(gè)函數(shù)或者數(shù)值)以從不減少或者從不增加的方式改變”。
我們可以從另外一種途徑來(lái)解釋它,即想象使用它來(lái)在一年中讓時(shí)鐘向前或者向后改變。例如,當(dāng)你所在國(guó)家的時(shí)鐘都同意略過(guò)一個(gè)小時(shí),以便***化利用白天的時(shí)間。如果你在時(shí)鐘修改之前創(chuàng)建了一個(gè)Date實(shí)例,然后在修改之后創(chuàng)建了另外一個(gè),那么查看這兩個(gè)實(shí)例的差值,看上去可能像“1小時(shí)零3秒又123毫秒”。而使用兩個(gè)performance.now()實(shí)例,差值會(huì)是“3秒又123毫秒456789之一毫秒”。
在這一節(jié)中,我不會(huì)涉及這個(gè)API的過(guò)多細(xì)節(jié)。如果你想學(xué)習(xí)更多相關(guān)知識(shí)或查看更多如何使用它的示例,我建議你閱讀這篇文章:Discovering the High Resolution Time API。
既然你知道高分辨率時(shí)間API是什么以及如何使用它,那么讓我們繼續(xù)深入看一下它有哪些潛在的缺點(diǎn)。但是在此之前,我們定義一個(gè)名為makeHash()的函數(shù),在這篇文章剩余的部分,我們會(huì)使用它。
- 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;
- }
我們可以通過(guò)下面的代碼來(lái)衡量這個(gè)函數(shù)的執(zhí)行效率:
- var t0 = performance.now();
- var result = makeHash('Peter');
- var t1 = performance.now();
- console.log('Took', (t1 - t0).toFixed(4), 'milliseconds to generate:', result);
如果你在瀏覽器中運(yùn)行這些代碼,你應(yīng)該看到類似下面的輸出:
- Took 0.2730 milliseconds to generate: 77005292
這段代碼的在線演示如下所示:
記住這個(gè)示例后,讓我們開(kāi)始下面的討論。
缺陷1 – 意外衡量不重要的事情
在上面的示例中,你可以注意到,我們?cè)趦纱握{(diào)用performance.now()中間只調(diào)用了makeHash()函數(shù),然后將它的值賦給result變量。這給我們提供了函數(shù)的執(zhí)行時(shí)間,而沒(méi)有其他的干擾。我們也可以按照下面的方式來(lái)衡量代碼的效率:
- var t0 = performance.now();
- console.log(makeHash('Peter')); // bad idea!
- var t1 = performance.now();
- console.log('Took', (t1 - t0).toFixed(4), 'milliseconds');
這個(gè)代碼片段的在線演示如下所示:
但是在這種情況下,我們將會(huì)測(cè)量調(diào)用makeHash(‘Peter’)函數(shù)花費(fèi)的時(shí)間,以及將結(jié)果發(fā)送并打印到控制臺(tái)上花費(fèi)的時(shí)間。我們不知道這兩個(gè)操作中每個(gè)操作具體花費(fèi)多少時(shí)間, 只知道總的時(shí)間。而且,發(fā)送和打印輸出的操作所花費(fèi)的時(shí)間會(huì)依賴于所用的瀏覽器,甚至依賴于當(dāng)時(shí)的上下文。
或許你已經(jīng)***的意識(shí)到console.log方式是不可以預(yù)測(cè)的。但是執(zhí)行多個(gè)函數(shù)同樣是錯(cuò)誤的,即使每個(gè)函數(shù)都不會(huì)觸發(fā)I/O操作。例如:
- 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);
同樣,我們不會(huì)知道執(zhí)行時(shí)間是怎么分布的。它會(huì)是賦值操作、調(diào)用toLowerCase()函數(shù)或者toString()函數(shù)嗎?
缺陷 #2 – 只衡量一次
另外一個(gè)常見(jiàn)的錯(cuò)誤是只衡量一次,然后匯總花費(fèi)的時(shí)間,并以此得出結(jié)論。很可能執(zhí)行不同的次數(shù)會(huì)得出完全不同的結(jié)果。執(zhí)行時(shí)間依賴于很多因素:
- 編輯器熱身的時(shí)間(例如,將代碼編譯成字節(jié)碼的時(shí)間)
- 主線程可能正忙于其它一些我們沒(méi)有意識(shí)到的事情
- 你的電腦的CPU可能正忙于一些會(huì)拖慢瀏覽器速度的事情
持續(xù)改進(jìn)的方法是重復(fù)執(zhí)行函數(shù),就像這樣:
- 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');
這個(gè)示例的在線演示如下所示:
這種方法的風(fēng)險(xiǎn)在于我們的瀏覽器的JavaScript引擎可能會(huì)使用一些優(yōu)化措施,這意味著當(dāng)我們第二次調(diào)用函數(shù)時(shí),如果輸入時(shí)相同的,那么JavaScript引擎可能會(huì)記住了***次調(diào)用的輸出,然后簡(jiǎn)單的返回這個(gè)輸出。為了解決這個(gè)問(wèn)題,你可以使用很多不同的輸入字符串,而不用重復(fù)的使用相同的輸入(例如‘Peter’)。顯然,使用不同的輸入進(jìn)行測(cè)試帶來(lái)的問(wèn)題就是我們衡量的函數(shù)會(huì)花費(fèi)不同的時(shí)間。或許其中一些輸入會(huì)花費(fèi)比其它輸入更長(zhǎng)的執(zhí)行時(shí)間。
缺陷 #3 – 太依賴平均值
在上一節(jié)中,我們學(xué)習(xí)到的一個(gè)很好的實(shí)踐是重復(fù)執(zhí)行一些操作,理想情況下使用不同的輸入。然而,我們要記住使用不同的輸入帶來(lái)的問(wèn)題,即某些輸入的執(zhí)行時(shí)間可能會(huì)花費(fèi)所有其它輸入的執(zhí)行時(shí)間都長(zhǎng)。這樣讓我們退一步來(lái)使用相同的輸入。假設(shè)我們發(fā)送同樣的輸入十次,每次都打印花費(fèi)了多長(zhǎng)時(shí)間。我們會(huì)得到像這樣的輸出:
- 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
請(qǐng)注意***次時(shí)間和其它九次的時(shí)間完全不一樣。這很可能是因?yàn)闉g覽器中的JavaScript引擎使用了優(yōu)化措施,需要一些熱身時(shí)間。我們基本上沒(méi)有辦法避免這種情況,但是會(huì)有一些好的補(bǔ)救措施來(lái)阻止我們得出一些錯(cuò)誤的結(jié)論。
一種方式是去計(jì)算后面9次的平均時(shí)間。另外一種更加使用的方式是收集所有的結(jié)果,然后計(jì)算“中位數(shù)”?;旧希鼤?huì)將所有的結(jié)果排列起來(lái),對(duì)結(jié)果進(jìn)行排序,然后取中間的一個(gè)值。這是performance.now()函數(shù)如此有用的地方,因?yàn)闊o(wú)論你做什么,你都可以得到一個(gè)數(shù)值。
讓我們?cè)僭囈淮?,這次我們使用中位數(shù)函數(shù):
- 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 – 以可預(yù)測(cè)的方式比較函數(shù)
我們已經(jīng)理解衡量一些函數(shù)很多次并取平均值總會(huì)是一個(gè)好主意。而且,上面的示例告訴我們使用中位數(shù)要比平均值更好。
在實(shí)際中,衡量函數(shù)執(zhí)行時(shí)間的一個(gè)很好的用處是來(lái)了解在幾個(gè)函數(shù)中,哪個(gè)更快。假設(shè)我們有兩個(gè)函數(shù),它們的輸入?yún)?shù)類型一致,輸出結(jié)果相同,但是它們的內(nèi)部實(shí)現(xiàn)機(jī)制不一樣。
例如,我們希望有一個(gè)函數(shù),當(dāng)特定的字符串在一個(gè)字符串?dāng)?shù)組中存在時(shí),函數(shù)返回true或者false,但這個(gè)函數(shù)在比較字符串時(shí)不關(guān)心大小寫(xiě)。換句話說(shuō),我們不能直接使用Array.prototype.indexOf方法,因?yàn)檫@個(gè)方法是大小寫(xiě)敏感的。下面是這個(gè)函數(shù)的一個(gè)實(shí)現(xiàn):
- 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
我們可以立刻發(fā)現(xiàn)這個(gè)方法有改進(jìn)的地方,因?yàn)閔aystack.forEach循環(huán)總會(huì)遍歷所有的元素,即使我們可以很快找到一個(gè)匹配的元素?,F(xiàn)在讓我們使用for循環(huán)來(lái)編寫(xiě)一個(gè)更好的版本。
- 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
現(xiàn)在我們來(lái)看哪個(gè)函數(shù)更快一些。我們可以分別運(yùn)行每個(gè)函數(shù)10次,然后收集所有的測(cè)量結(jié)果:
- 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);
我們運(yùn)行上面的代碼, 可以得出如下的輸出:
- true
- false
- true
- false
- isIn1 took 0.0050
- isIn2 took 0.0150
這個(gè)示例的在線演示如下所示:
到底發(fā)生了什么?***個(gè)函數(shù)的速度要快3倍!那不是我們假設(shè)的情況。
其實(shí)假設(shè)很簡(jiǎn)單,但是有些微妙。***個(gè)函數(shù)使用了haystack.forEach方法,瀏覽器的JavaScript引擎會(huì)為它提供一些底層的優(yōu)化,但是當(dāng)我們使用數(shù)據(jù)索引技術(shù)時(shí),JavaScript引擎沒(méi)有提供對(duì)應(yīng)的優(yōu)化。這告訴我們:在真正測(cè)試之前,你永遠(yuǎn)不會(huì)知道。
結(jié)論
在我們?cè)噲D解釋如何使用performance.now()方法得到JavaScript精確執(zhí)行時(shí)間的過(guò)程中,我們偶然發(fā)現(xiàn)了一個(gè)基準(zhǔn)場(chǎng)景,它的運(yùn)行結(jié)果和我們的直覺(jué)相反。問(wèn)題在于,如果你想要編寫(xiě)更快的web應(yīng)用,我們需要優(yōu)化JavaScript代碼。因?yàn)橛?jì)算機(jī)(幾乎)是一個(gè)活生生的東西,它很難預(yù)測(cè),有時(shí)會(huì)帶來(lái)“驚喜”,所以如果了解我們代碼是否運(yùn)行更快,最可靠的方式就是編寫(xiě)測(cè)試代碼并進(jìn)行比較。
當(dāng)我們有多種方式來(lái)做一件事情時(shí),我們不知道哪種方式運(yùn)行更快的另一個(gè)原因是要考慮上下文。在上一節(jié)中,我們執(zhí)行一個(gè)大小寫(xiě)不敏感的字符串查詢來(lái)尋找1個(gè)字符串是否在其它26個(gè)字符串中。當(dāng)我們換一個(gè)角度來(lái)比較1個(gè)字符串是否在其他100,000個(gè)字符串中時(shí),結(jié)論可能是完全不同的。
上面的列表不是很完整的,因?yàn)檫€有更多的缺陷需要我們?nèi)グl(fā)現(xiàn)。例如,測(cè)試不現(xiàn)實(shí)的場(chǎng)景或者只在JavaScript引擎上測(cè)試。但是確定的是對(duì)于JavaScript開(kāi)發(fā)者來(lái)說(shuō),如果你想編寫(xiě)更好更快的Web應(yīng)用,performance.now()是一個(gè)很棒的方法。***但并非最不重要,請(qǐng)謹(jǐn)記衡量執(zhí)行時(shí)間只是“更好的代碼”的一反面。我們還要考慮內(nèi)存消耗以及代碼復(fù)雜度。
怎么樣?你是否曾經(jīng)使用這個(gè)函數(shù)來(lái)測(cè)試你的代碼性能?如果沒(méi)有,那你是怎么來(lái)測(cè)試性能的?請(qǐng)?jiān)谙旅娴脑u(píng)論中分享你的想法,讓我們開(kāi)始討論吧!