高效能JavaScript閱讀簡記(三)

前端老李發表於2017-08-16

四、Aligorithms and Flow Control 演算法和流程控制

1、Loops 迴圈

  • a、避免使用for/in迴圈
    在JavaScript標準中,有四種型別迴圈。for、for/in、while、do/while,其中唯一一個效能比其他明顯慢的是for/in。對於for/in迴圈的使用場景,更多的是針對不確定內部結構的物件的迴圈。for/in會列舉物件的命名屬性,只有完全遍歷物件的所有屬性之後包括例項屬性和從原型鏈繼承的屬性,迴圈才會返回。正因為for/in迴圈需要搜尋例項或者原型的屬性,所以for/in的效能要差很多,因此我們需要儘量避免使用for/in迴圈,對於那些已知屬性列表的物件,更需要避免使用for/in。

  • b、Decreasing the work per iteration 減少迭代的工作量
    一個標準的for迴圈組成:

(初始化體; 前側條件/控制條件; 後執行體){
    迴圈體;
}

可能對於單次迴圈操作,我們所做的效能優化看起來沒什麼用,但是對於多次迴圈,這些效能優化加起來是很明顯的。

for (var i = 0; i < items.length; i++){
    eventHandler(items[i]);
}

對於上面這樣一個經典的for迴圈,它的單次操作中,需要做這些工作:

①在控制條件中讀一次屬性(items.length)
②在控制條件中進行一次比較(i < items.length)
③比較操作,判斷條件控制體的結果是否為true(i < items.length == true)
④一次自加操作(i++)
⑤一次陣列查詢(items[i])
⑥一次函式呼叫(eventHandler(items[i]);)

大家都知道的一個優化就是在第一步,每一次的迴圈中都會查詢一次items.length,這個操作會首先查詢items,然後計算長度,一方面,查詢items時的效能開銷是浪費的,另一方面,訪問一個區域性變數或者是字面量,顯然更快。因此在這裡,我們可以通過一個變數快取items.length,對於較長的陣列,可以節約25%的總迴圈時間(ie中可達到50%)。
另一種提升迴圈體效能的方法是改變迴圈的順序,這常用於陣列元素的處理順序和任務無關的情況,從最後一個元素開始,直到處理完第一個元素。

for(var i = items.length; i--){
    eventHandler(items[i]);
}

在一個for迴圈中,可以省略初始化體和後執行體,這裡省略了後執行體,也就是當i -- 之後, i!= flase,則執行eventHandler(items[i]);這裡的ii--之後的值。這裡優化地方是將我們前面說的2、3優化了成一步,i是否是true;如果是則執行i--,也就是i = i - 1;
進行這兩方面的優化後,迴圈體的效能會得到顯著提升。

  • c、Decreasing the number of iterations 減少迭代次數
    除了在設計迴圈之前周密考慮,使用最優的迴圈模式,減少迭代次數,另一個減少迭代次數的有名的方法是達夫裝置Duff`s Device),達夫裝置最早出現於C中,他的設計理念,是將整個迴圈每8個一份分成o份並取餘數p,第一次迴圈執行n次迴圈體,然後執行m次迴圈,每次迴圈中執行8次迴圈體中的操作,這樣原本是m * 8 + n 次迴圈就變成了m + 1次迴圈。這對於那些迴圈體耗時很短的迴圈來講,降低了在判斷條件上浪費的時間,從而提升效能。移植到javascript中的一個典型的達夫裝置的例子:

var m = [1,2,3,...];        //為一個很長很長的陣列。
var o = Math.floor(m.length/8);
var p = m.length % 8 ;
var i = 0;
do{
switch(p){
    case 0 : console.log(m[i++]);
    case 7 : console.log(m[i++]);
    case 6 : console.log(m[i++]);
    case 5 : console.log(m[i++]);
    case 4 : console.log(m[i++]);
    case 3 : console.log(m[i++]);
    case 2 : console.log(m[i++]);
    case 1 : console.log(m[i++]);
}
p = 0;
}while(--o);

書上的達夫裝置的程式碼如上,但是在我看來這段程式碼是有問題的,除非m也就是初始迴圈次數是8的整倍數,否則迴圈會少執行一輪,也就是8次。不過沒有找到這本書的勘誤,自行完善了一下這裡的程式碼:

var m = [1,2,3,...];
var o = Math.floor(m.length/8);
var p = m.length % 8 ;
p === 0 ? `` : o++;
var i = 0;
do{
switch(p){
    case 0 : console.log(m[i++]);
    case 7 : console.log(m[i++]);
    case 6 : console.log(m[i++]);
    case 5 : console.log(m[i++]);
    case 4 : console.log(m[i++]);
    case 3 : console.log(m[i++]);
    case 2 : console.log(m[i++]);
    case 1 : console.log(m[i++]);
}
p = 0;
}while(--o);

理解上面的程式碼需要首先明確一點,不管是在C中還是JavaScript中,如果switch語句中沒有break,則會在匹配到第一個case並執行後執行下一個case中的操作,不管下一個case是否匹配,直到遇到break或者結束的大括號,當然,return也可以。
上面switch版本的達夫裝置的改進版是去掉了switch而變得更快,書中的程式碼是個死迴圈(難道因為我看的是pdf版本的有誤,原書是對的嗎),就不貼出來禍害人了,我這梳理後重寫的程式碼如下:

var m = [1,2,3,...];
var p = m.length % 8 ;
while(p){
    console.log(m[--p]);
}
var i = m.length;
var o = Math.floor(m.length/8);
while(o--){
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
    console.log(m[--i]);
}

這個版本中的達夫裝置將主迴圈和餘數處理的部分分開,並將原陣列進行倒序處理。程式碼很易懂就不多說。
在實測中,達夫裝置的效能並不比傳統的for迴圈快多少,甚至普遍會慢那麼一點點(FireFox不管處理什麼樣的迴圈,都比Chrome慢三倍,這個必須吐槽一下),這是因為在現代瀏覽器中,隨著裝置效能的提升,瀏覽器的實現對迴圈的演算法優化的越來越好,在瀏覽器內部處理迴圈時也會採用自己獨特的演算法提升迴圈的效能,程式設計時達夫裝置帶來的效能提升已經慢慢的變得不足為道;加上達夫裝置這種寫法,對於程式碼可讀性很不友好,因此現在已經慢慢越來越少會有人採用這樣的方式來做效能優化。但是達夫裝置最初這種詭異的寫法和思路,還是驚豔了很多人的,值得我們思考。

2、Function-Based Iteration 基於函式的迭代

在多數現代瀏覽器的實現中,forEach可作為一個原生的方法去使用,此方法相當於遍歷陣列的所有成員,並在每個成員上執行一個函式,每個成員上執行的函式作為forEach()的引數傳進去。這種情況下,每一個陣列成員都被掛載了一個函式,在執行迭代時呼叫,這種基於函式的迭代比基於迴圈的迭代要慢很多,在實測中,會慢20%左右。複雜的函式處理的時候,效能上的問題會更突出。

3、Conditionals 條件表示式

  • a、if-else Versus switch if-else與switch比較
    大家約定俗稱的一點是,在條件數量較少時傾向於使用if-else,在條件數量較大時使用switch,不管從程式碼可讀性考慮,還是從效能方面考慮,這種做法都是正確的。儘管在實際上,較少條件數量時,使用switch多數情況下也比if-else快,但也只是快的微不足道,因此這種約定俗稱的使用方式是沒有問題的。

  • b、Optimzing if-else 優化if-else
    if-else決定了JavaScript執行流的走向,讓JavaScript執行流盡快找到執行條件並執行顯然會提高函式的執行效率,因此在有多個條件數量時,讓最可能出現的條件排在前面。例如,用js設定中獎概率,一等獎概率10%,二等獎概率20%;三等獎概率30%;不中獎概率40%;更多人的習慣寫法是:

var result = Math.random() * 10;
if(result <= 1){
    //一等獎
}else if(result > 1 && result <= 3){
    //二等獎
}else if(result > 3 && result <= 6){
    //三等獎
}else{
    //不中獎
}

實際上,最可能出現的是不中獎,但是每次在判斷為不中獎之前需要先進行前三次判斷,此時可以做的優化就是將上述的寫法反過來:

var result = Math.random() * 10;
if(result <= 4){
    //不中獎
}else if(result > 4 && result <= 7){
    //三等獎
}else if(result > 7 && result <= 9){
    //二等獎
}else{
    //一等獎
}

當然,較真效能的話,這裡用switch更好,不過我們考慮的是優化if-else的效能。
另外一種減少條件判斷的長度的辦法是將並列的if-else判斷,組織成巢狀的if-else減少平均的條件判斷長度,例如下面的例子:

var result = Math.floor(Math.random() * 10);
if(result === 0){
    return 0;
}else if(result === 1){
    return 1;
}else if(result === 2){
    return 2;
}else if(result === 3){
    return 3;
}else if(result === 4){
    return 4;
}else if(result === 5){
    return 5;
}else if(result === 6){
    return 6;
}else if(result === 7){
    return 7;
}else if(result === 8){
    return 8;
}else if(result === 9){
    return 9;
}

這時候計算條件體的最大數目是9,我們可以通過巢狀判斷的辦法減少計算判斷體的數目:

if(result < 6){
    if(result < 3){
        if(result === 0){
            return 0;
        }else if(result === 1){
            return 1;
        }else{
            return 2;
        }
    }else{
        if(result === 3){
            return 3;
        }else if(result === 4){
            return 4;
        }else{
            return 5;
        }
    }
}else{
    if(result < 8){
        if(result === 6){
            return 6;
        }else{
            return 7;
        }
    }else{
        if(result === 8){
            return 8;
        }else{
            return 9;
        }
    }
}

看起來程式碼是多了,但是最大的條件判斷數變成了4,一定程度上提升了效能。當然,這種情況下,一般會使用swtich處理的。

  • c、Lookup Tables 查表法
    當有大量的離散值需要測試時,使用if-else或者switch不論在可讀性上和效能上都不應該去選擇,比如下面的情況:

var array = [0,1,2,3,4,5,6,7...];
switch(result){
    case 0: return array[0];
    case 1: return array[1];
    case 2: return array[2];
    case 3: return array[3];
    case 4: return array[4];
    case 5: return array[5];
    case 6: return array[6];
    ...
}

當陣列有數十個上百個資料時,switch語句會是一段很龐大的程式碼。這時候可以使用查表法:

var array = [0,1,2,3,4,5,6,7...];
return array[result];

查表法一般適用於資料量稍大的場合,在實際程式設計中,還是經常會用到這種方法的。

  • d、Recursion 遞迴
    某些場合,比如說階乘函式,遞迴呼叫無疑是最優的實現方式:

function calc(n){
    if(n === 0){
        return 1;
    }else{
        return n * calc(n-1);
    }
}
  • e、Memoization 製表
    製表的原理是通過快取已經執行的計算結果,避免後續的重複計算從而提升效能。也常用於遞迴運算中,例如上面的階乘函式的呼叫:

var a = calc(10);
var b = calc(9);
var c = calc(8);

calc(10)被呼叫時,就已經計算過了calc(9)calc(8)的值,這裡calc(9)就重複計算了兩次,而calc(8)重複計算了三次,我們可以通過快取計算結果的辦法去優化:

function m(n){
    if(!m.c){
        m.c = {
            "0": 1,
            "1": 1
        };
    }
    if(!m.c.hasOwnProperty(n)){
        m.c[n] = n * m(n-1);
    }
    return m.c[n];
}
var e = m(10);
var f = m(9);
var g = m(8);

優化後的函式中,m(9)m(8)並沒有再去計算,從而避免了重複計算。

高效能JavaScript閱讀簡記(一)
高效能JavaScript閱讀簡記(二)
高效能JavaScript閱讀簡記(三)

相關文章