Javascript 效能優化

Sue1024發表於2019-02-16

Javascript最初是解釋型語言,現在,主流瀏覽器內建的Javascript引擎基本上都實現了Javascript的編譯執行,即使如此,我們仍需要優化自己寫的Javascript程式碼,以獲得最佳效能。

注意作用域

避免全域性作用域

在之前的文章Javascript 變數、作用域和記憶體問題提到過,由於訪問變數需要在作用域鏈上進行查詢,相比於區域性變數,訪問全域性變數的開銷更大,因此以下程式碼:

var person = {
    name: "Sue",
    hobbies: ["Yoga", "Jogging"]
};
function hobby() {
    for(let i=0; i<person.hobbies.length; i++) {
        console.log(person.hobbies[i]);
    }
}

可以進行如下優化:

function hobby() {
    let hobbies = person.hobbies;
    for(let i=0; i<hobbies.length; i++) {
        console.log(hobbies[i]);
    }
}

把需要頻繁訪問的全域性變數賦值到區域性變數中,可以減小查詢深度,進而優化效能。
當然,上述優化過的程式碼仍然有不足的地方,後面的部分會提到。

避免使用with

為什麼避免使用with?

  1. with並不是必須的,使用區域性變數可以達到同樣的目的
  2. with建立了自己的作用域,相當於增加了作用域內部查詢變數的深度

舉一個例子:

function test() {
    var innerW = "";
    var outerW = "";
    with(window) {
        innerW = innerWidth;
        outerW = outerWidth;
    }
    return "Inner W: " + innerW + ", Outer W: " + outerW;
}
test()
// "Inner W: 780, Outer W: 795"

上述程式碼中,with作用域減小了對全域性變數window的查詢深度,不過與此同時,也增加了作用域中區域性變數innerWouterW的查詢深度,功過相抵。
因此我們不如使用區域性變數替代with

function test() {
    var w = window;
    var innerW = w.innerWidth;
    var outerW = w.outerWidth;
    return "Inner W: " + innerW + ", Outer W: " + outerW;
}

上述程式碼仍然不是最優的。

演算法複雜度

一下表格列出了幾種演算法複雜度:

複雜度 名稱 描述
O(1) 常數 無論多少值,執行時間恆定,比如使用簡單值或訪問存貯在變數中的值
O(lg n) 對數 總執行時間與值的數量相關,但不一定需要遍歷每一個值
O(n) 線性 總執行時間與值的數量線性相關
O(n2) 平方 總執行時間與值的數量相關,每個值要獲取n次

O(1)

如果我們直接使用字面量,或者訪問儲存在變數中的值,時間複雜度為O(1),比如:

var value = 5;
var sum = 10 + value;

上述程式碼進行了三次常量查詢,分別是5,10,value,這段程式碼整體複雜度為O(1)
訪問陣列也是時間複雜度為O(1)的操作,以下程式碼整體複雜度為O(1):

var values = [1, 2];
var sum = values[0] + values[1];

避免不必要的屬性查詢

在物件上訪問屬性是一個O(n)的操作,Javascript 物件導向的程式設計(原型鏈與繼承)文中提到過,訪問物件中的屬性時,需要沿著原型鏈追溯查詢,屬性查詢越多,執行時間越長,比如:

var persons = ["Sue", "Jane", "Ben"];
for(let i=0; i<persons.length; i++) {
    console.log(persons[i]);
}

上述程式碼中,每次迴圈都會比較i<persons.length,為了避免頻繁的屬性查詢,可以進行如下優化:

var persons = ["Sue", "Jane", "Ben"];
for(let i=0, len = persons.length; i<len ; i++) {
    console.log(persons[i]);
}

即如果迴圈長度在迴圈開始時即可確定,就將要迴圈的長度在初始化的時候宣告為一個區域性變數。

優化迴圈

由於迴圈時反覆執行的程式碼,動輒上百次,因此優化迴圈時效能優化中很重要的部分。

減值迭代

為什麼要進行減值迭代,我們比較如下兩個迴圈:

var nums = [1, 2, 3, 4];
for(let i=0; i<nums.length; i++) {
    console.log(nums[i]);
}
for(let i=nums.length-1; i>-1; i--) {
    console.log(nums[i]);
}

二者有如下區別:

  1. 迭代順序不同
  2. 前者支援動態增減陣列元素,後者不支援
  3. 後者效能優於前者,前者每次迴圈都會計算nums.length,頻繁的屬性查詢降低效能

因此,出於效能的考慮,如果不在乎順序,迭代長度初始即可確定,使用減值迭代更優。

簡化終止條件

上述情況,我們也可以不使用減值迭代,即像上文提到過的,在初始化時即將迭代長度賦值給一個區域性變數。

簡化迴圈體

迴圈體應最大程度地被優化,避免進行不必要的密集的計算

使用while迴圈

為什麼使用while迴圈,我們可以比較如下兩個迴圈:

var len = nums.length;
for(let i=0; i<len; i++) {
    console.log(nums[i]);
}
var i = nums.length ;
while(--len > -1) {
    console.log(nums[len]);
}

以上兩個迴圈有一個很明顯的不同點:while迴圈將每次迴圈終止條件的判斷和index的自增合併為一個語句,在後續部分會講解語句數量與效能優化的關係。

展開迴圈

由於建立迴圈和處理終止條件需要額外的開銷,因此如果迴圈次數比較少,而且可以確定,我們可以將其展開,比如:

process(nums[0]);
process(nums[1]);

如果迭代次數不能事先確定,可以使用Duff裝置,其中比較著名的是Andrew B. King提出的一種Duff技術,通過計算迭代次數是否為8的倍數將迴圈展開,將“零頭”與“整數”分成兩個單獨的do-while迴圈,在處理大資料集時優化效果顯著:

var iterations = Math.floor(values.length / 8);
var leftover = values.length % 8;
var i = 0;
if (leftover > 0){
do {
process(values[i++]);
} while (--leftover > 0);
}
do {
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
} while (--iterations > 0);

避免雙重解釋

eval() Function() setTimeout()可以傳入字串,Javascript引擎會將其解析成可以執行的程式碼,意味著,Javascript執行到這裡需要額外開一個直譯器來解析字串,會明顯降低效能,因此:

  1. 儘量避免使用eval()
  2. 避免使用Function建構函式,用一般function來代替
  3. setTimeout()傳入函式作為引數

其他

使用原生方法

原生方法都是用C/C++之類的編譯語言寫出來的,比Javascript快得多。

使用switch語句

多個if-else可以轉換為switch語句,還可以按照最可能到最不可能排序case

使用位運算子

當進行數學運算的時候,位運算操作要比任何布林運算或者算數運算快。選擇性地用位運算替換算數運算可以極大提升複雜計算的效能。諸如取模,邏輯與和邏輯或都可
以考慮用位運算來替換。

書中的這段話筆者表示不能理解,由於使用&& ||做邏輯判斷時,有的時候只需要求得第一個表示式的結果便可以結束運算,而& |無論如何都要求得兩個表示式的結果才可以結束運算,因此後者的效能沒有佔太大優勢。
這裡,補充一下位運算子如何發揮邏輯運算子的功能,首先看幾個例子:

7 === 7 & 6 === 6
1
7 === 7 & 5 === 4
0
7 === 7 | 6 ===6
1
7 === 7 | 7 ===6
1
7 === 6 | 6 === 5
0

也許你會恍然大悟,位運算子並沒有產生truefalse,它只是利用了Number(true) === 1 Number(false) === 0 Boolean(1) === true Boolean(0) === false

最小化語句數

Javascript程式碼中的語句數量會影響執行的速度,儘量組合語句,可以減少指令碼的執行時間。

多個變數宣告

當我們需要宣告多個變數,比如:

var name = "";
var age = 18;
var hobbies = [];

可以做如下優化:

var name = "",
    age = 18,
    hobbies = [];

合併迭代值

上文中我們提到一個例子,使用while迴圈可以合併自減和判斷終止條件,我們還可以換一種寫法:

var i = nums.length ;
while(len > -1) {
    console.log(nums[len--]);
}

即將自減與使用index取值合併為一個語句。

使用字面量建立陣列和物件

即將如下程式碼:

var array = new Array();
array[0] = 1;
array[1] = 2;

var person = new Object();
person.name = "Sue";
person.age = 18;

替換成:

var array = [1, 2];
var person = { name:"Sue", age:18 };

省了4行程式碼。

優化DOM操作

DOM操作是最拖累效能的一方面,優化DOM操作可以顯著提高效能。

最小化現場更新的次數

如果我們要修改的DOM已經顯示在頁面,那麼我們就是在做現場更新,由於每次更新瀏覽器都要重新計算,重新渲染,非常消耗效能,因此我們應該最小化現場更新的次數,比如我們要向頁面新增一個列表:

var body = document.getElementsByTagName("body")[0];
for(let i=0; i<10; i++) {
    item = document.createElement("span");
    body.appendChild(item);
    item.appendChild(document.createTextNode("Item" + i));
}

每次迴圈時都會進行兩次現場更新,新增div,為div新增文字,總共需要20次現場更新,頁面要重繪20次。
現場更新的效能瓶頸不在於更新的大小,而在於更新的次數,因此,我們可以將所有的更新一次繪製到頁面上,有以下兩個方法:

文件片段

可以使用文件片段先收集好要新增的元素,最後在父節點上呼叫appendChild()將片段的子節點新增到父節點中,注意,片段本身不會被新增。

<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <div id="container" style="with: 100px; height: 100px; border: 1px solid black;">
            <div id="child">this</div>
        </div>
        <script>
            var container = document.getElementById("container"),
                fragment = document.createDocumentFragment(),
                item,
                i;
            for (i=0; i < 10; i++) {
              item = document.createElement("li");
              fragment.appendChild(item);
              item.appendChild(document.createTextNode("Item " + i));
            }
            container.appendChild(fragment);
        </script>
    </body>
</html>
innerHTML

使用innerHTML與使用諸如createElement() appendChild()方法有一個顯著的區別,前者使用內部的DOM來建立DOM結構,後者使用JavaScript的DOM來建立DOM結構,前者要快得多,之前的例子用innerHTML改寫為:

var ul = document.getElementById("ul"),
    innerHTML = "";
for(let i=0; i<10; i++) {
    innerHTML += "<li>Item " + i + "</li>";
}
ul.innerHTML = innerHTML;

整合冒泡事件處理

頁面上的事件處理程式數量與頁面相應使用者互動的速度之間存在負相關,具體原因有多方面:

  1. 建立函式會佔用記憶體
  2. 繫結事件處理方法時,需要訪問DOM

因此對於冒泡事件,儘可能由父元素甚至祖先元素代子元素處理,這樣一個事件處理方法可以負責多個目標的事件處理,比如:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <div id="container" style="with: 100px; height: 100px; border: 1px solid black;">
            <div id="child">this</div>
        </div>
        <script>
            var container = document.getElementById("container");
            container.addEventListener("click", function(e) {
                switch(e.target.id) {
                    case "container":
                        console.log("container clicked");
                        break;
                    case "child":
                        console.log("child clicked");
                        break;
                }
            },false);
        </script>
    </body>
</html>

注意HTMLCollection

訪問HTMLCollection的代價非常昂貴。
下面的每個專案(以及它們指定的屬性)都返回 HTMLCollection:

  1. Document (images, applets, links, forms, anchors)
  2. form (elements)
  3. map (areas)
  4. select (options)
  5. table (rows, tBodies)
  6. tableSection (rows)
  7. row (cells)

相關文章