前端效能優化之 JavaScript
前言
本文為 《高效能 JavaScript》 讀書筆記,是利用中午休息時間、下班時間以及週末整理出來的,此書雖有點老舊,但談論的效能優化話題是每位同學必須理解和掌握的,業務響應速度直接影響使用者體驗。
一、載入和執行
大多數瀏覽器使用單程序處理
UI
更新和JavaScript
執行等多個任務,而同一時間只能有一個任務被執行
指令碼位置
將所有script
標籤放在頁面底部,緊靠</body>
上方,以保證頁面指令碼執行之前完成解析
<html> <head> </head> <body> <p>Hello World</p> <!-- --> <script type="text/javascript" src="file.js"></script> </body> </html>
defer & async
常規script
指令碼瀏覽器會立即載入並執行,非同步載入使用async
與defer
二者區別在於aysnc
為無序,defer
會非同步根據指令碼位置先後依次載入執行
<!-- file1、file2依次載入 --> <script type="text/javascript" src="file1.js" defer></script> <script type="text/javascript" src="file2.js" defer></script>
<!-- file1、file2無序載入 -->
<script type="text/javascript" src="file1.js" async></script>
<script type="text/javascript" src="file2.js" async></script>
動態指令碼
無論在何處啟動下載,檔案的下載和執行都不會阻塞其他頁面處理過程。你甚至可以將這些程式碼放在<head>
部分而不會對其餘部分的頁面程式碼造成影響(除了用於下載檔案的 HTTP
var script = document.createElement("script");
script.type = "text/javascript";
script.src = "file1.js";
document.getElementsByTagName("head")[0].appendChild(script);
監聽載入函式
function loadScript(url, callback) {
var script = document.createElement("script");
script.type = "text/javascript";
if (script.readyState) {
//IE
script.onreadystatechange = function() {
if (script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
callback();
}
};
} else {
//Others
script.onload = function() {
callback();
};
}
script.src = url;
document.getElementsByTagName("head")[0].appendChild(script);
}
XHR 注入
前提條件為同域,此處與非同步載入一樣,只不過使用的是 XMLHttpRequest
總結
- 將所有
script
標籤放在頁面底部,緊靠 body 關閉標籤上方,以保證頁面指令碼執行之前完成解析 - 將指令碼成組打包,頁面 script 標籤越少載入越快,響應也就更迅速。不論外部指令碼檔案或者內聯程式碼都是如此
二、資料訪問
資料儲存在哪裡,關係到程式碼執行期間資料被檢索到的速度.每一種資料儲存位置都具有特定的讀寫操作負擔。大多數情況下,對一個直接量和一個區域性變數資料訪問的效能差異是微不足道的。
在 JavaScript 中有四種基本的資料訪問位置:
- 直接量
直接量僅僅代表自己,而不儲存於特定位置。 JavaScript 的直接量包括:字串,數字,布林值,物件,陣列,函式,正則表示式,具有特殊意義的空值,以及未定義 - 變數
使用 var / let 關鍵字建立用於儲存資料值 - 陣列項
具有數字索引,儲存一個 JavaScript 陣列物件 - 物件成員
具有字串索引,儲存一個 JavaScript 物件
總結
直接量與區域性變數訪問速度非常快,陣列項和物件成員需要更長時間
區域性變數比域外變數訪問速度快,因為它位於作用域鏈的第一個物件中。變數在作用域鏈的位置越深,訪問所需要的時間越長。全域性變數總是最慢的,因為它們總位於作用域鏈的最後一環。
避免使用 with 表示式,因為它改變了執行期上下文的作用域鏈,謹慎對待 try-catch 表示式中 catch 子句,因為它具有同樣的效果
巢狀物件成員會造成重大效能影響,儘量少用
屬性在原型鏈中的位置越深,訪問速度越慢
將物件成員、陣列項、域外變數存入區域性變數能提高 js 程式碼的效能
三、dom 程式設計
對 DOM 操作代價昂貴,在富網頁應用中通常是一個性能瓶頸。通常處理以下三點
- 訪問和修改 DOM 元素
- 修改 DOM 元素的樣式,造成重繪和重新排版
通過 DOM 事件處理使用者響應
一個很形象的比喻是把 DOM 看成一個島嶼,把 JavaScript(ECMAScript)看成另一個島嶼,兩者之間以一座收費橋連線(參見 John Hrvatin,微軟,MIX09,http://videos.visitmix.com/MIX09/T53F)。每次 ECMAScript 需要訪問 DOM 時,你需要過橋,交一次“過橋費”。你操作 DOM 次數越多,費用就越高。一般的建議是儘量減少過橋次數,努力停留在 ECMAScript 島上。
DOM 訪問和修改
訪問或修改元素最壞的情況是使用迴圈執行此操作,特別是在 HTML 集合中使用迴圈
function innerHTMLLoop() {
for (var count = 0; count < 15000; count++) {
document.getElementById("here").innerHTML += "a";
}
}
此函式在迴圈中更新頁面內容。這段程式碼的問題是,在每次迴圈單元中都對 DOM 元素訪問兩次:一次
讀取 innerHTML 屬效能容,另一次寫入它
優化如下
function innerHTMLLoop2() {
var content = "";
for (var count = 0; count < 15000; count++) {
content += "a";
}
document.getElementById("here").innerHTML += content;
}
你訪問 DOM 越多,程式碼的執行速度就越慢。因此,一般經驗法則是:輕輕地觸控 DOM,並儘量保持在 ECMAScript 範圍內
節點克隆
使用 DOM 方法更新頁面內容的另一個途徑是克隆已有 DOM 元素,而不是建立新的——即使用 element.cloneNode()(element 是一個已存在的節點)代替 document.createElement();
當佈局和幾何改變時發生重排版,下述情況會發生:
- 新增或刪除可見的 DOM 元素
- 元素位置改變
- 元素尺寸改變(邊距、填充、邊框寬度、寬、高等屬性)
- 內容改變(文字或者圖片被另一個不同尺寸的所替代)
- 最初的頁面渲染
- 瀏覽器視窗尺寸改變
減少重排次數
- 改變 display 屬性,臨時從文件上移除然後再恢復
- 在文件之外建立並更新一個文件片段,然後將它進行附加
- 先建立更新節點的副本,再操作副本,最後用副本更新老節點
總結
- 最小化 DOM 訪問,在 JavaScript 端做盡可能多的事情
- 在反覆訪問的地方使用區域性變數存放 dom 引用
- 謹慎處理 HTML 集合,因為它們表現‘存在性’,總對底層文件重新查詢。將 length 屬性快取到一個變數中,在迭代中使用這個變數。如果經常操作這個集合,可以將集合拷貝到陣列中
- 如果可以,使用速度更快的 API,比如 document.querySelectorAll()和 firstElementChild()
- 注意重繪和重排,批量修改風格,離線操作 DOM,快取或減少對佈局資訊的訪問
- 動畫中使用絕對座標,使用拖放代理
- 使用事件託管技術中的最小化事件控制代碼數量
四、演算法與流程控制
程式碼整體結構是執行速度的決定因素之一。程式碼量少不一定執行快,程式碼量多,也不一定執行慢,效能損失與程式碼組織方式和具體問題解決辦法直接相關。
Loops
在大多數程式語言中,程式碼執行時間多數在迴圈中度過。在一系列程式設計模式中,迴圈是最常見的模式之一,提高效能必須控制好迴圈,死迴圈和長時間迴圈會嚴重影響使用者體驗。
Types of Loops
- for
- while
- do while
- for in
前三種迴圈幾乎所有程式語言都能通用,for in 迴圈遍歷物件命名屬性(包括自有屬性和原型屬性)
Loop Performance
迴圈效能爭論的源頭是應當選用哪種迴圈,在 JS 中 for-in 比其他迴圈明顯要慢(每次迭代都要搜尋例項或原型屬性),除非對數目不詳的物件屬性進行操作,否則避免使用 for-in。除開 for-in,選擇迴圈應當基於需求而不是效能
減少每次迭代的操作總數可以大幅提高迴圈的整體效能
優化迴圈:
- 減少物件成員和陣列項的查詢,比如快取陣列長度,避免每次查詢陣列 length 屬性
- 倒序迴圈是程式語言中常用的效能優化方法
程式設計中經常會聽到此說法,現在來驗證一下,測試樣例
var arr = [];
for (var i = 0; i < 100000000; i++) {
arr[i] = i;
}
var start = +new Date();
for (var j = arr.length; j > -1; j--) {
arr[j] = j;
}
console.log("倒序迴圈耗時:%s ms", Date.now() - start); //約180 ms
var start = +new Date();
for (var j = 0; j < arr.length; j++) {
arr[j] = j;
}
console.log("正序序迴圈耗時:%s ms", Date.now() - start); //約788 ms
基於函式的迭代
儘管基於函式的迭代顯得更加便利,它還是比基於迴圈的迭代要慢一些。每個陣列項要關聯額外的函式呼叫是造成速度慢的原因。在所有情況下,基於函式的迭代佔用時間是基於迴圈的迭代的八倍,因此在關注執行時間的情況下它並不是一個合適的辦法。
條件表示式
if-else VS switch
使用 if-else 或者 switch 的流行理論是基於測試條件的數量:條件數量較大,傾向使用 switch,更易於閱讀
當條件體增加時,if-else 效能負擔增加的程度比 switch 更多。
一般來說,if-else 適用於判斷兩個離散的值或者幾個不同的值域,如果判斷條件較多 switch 表示式將是更理想的選擇
優化 if-else
最小化找到正確分支:將最常見的條件放在首位
查表法 當使用查表法時,必須完全消除所有條件判斷,操作轉換成一個數組項查詢或者一個物件成員查詢。
遞迴
會受瀏覽器呼叫棧大小的限制
迭代
任何可以用遞迴實現的演算法可以用迭代實現。使用優化的迴圈替代長時間執行的遞迴函式可以提高效能,因為執行一個迴圈比反覆呼叫一個函式的開銷要低
斐波那契
function fibonacci(n) {
if (n === 1) return 1;
if (n === 2) return 2;
return fibonacci(n - 1) + fibonacci(n - 2);
}
製表
//製表
function memorize(fundamental, cache) {
cache = cache || {};
var shell = function(args) {
if (!cache.hasOwnProperty(args)) {
cache[args] = fundamental(args);
}
return cache[args];
};
return shell;
}
//動態規劃
function fibonacciOptimize(n) {
if (n === 1) return 1;
if (n === 2) return 2;
var current = 2;
var previous = 1;
for (var i = 3; i <= n; i++) {
var temp = current;
current = previous + current;
previous = temp;
}
return current;
}
//計算階乘
var res1 = fibonacci(40);
var res2 = memorize(fibonacci)(40);
var res3 = fibonacciOptimize(40);
//計算出來的res3優於res2,res2優於res1
總結
執行程式碼的總量越大,優化帶來的效能提升越明顯
正如其他程式語言,程式碼的寫法與演算法選用影響 JS 的執行時間,與其他程式語言不同,JS 可用資源有限,所以優化固然重要
- for, while, do while 迴圈的效能特性相似,誰也不比誰更快或更慢
- 除非要迭代遍歷一個屬性未知的物件,否則不要使用 for-in 迴圈
- 改善迴圈的最佳方式減少每次迭代中的運算量,並減少迴圈迭代次數
- 一般來說 switch 總比 if-else 更快,但總不是最好的解決方法
- 當判斷條件較多,查表法優於 if-else 和 switch
- 瀏覽器的呼叫棧大小限制了遞迴演算法在 js 中的應用,棧溢位導致其他程式碼不能正常執行
- 如果遇到棧溢位,將方法修改為製表法,可以避免重複工作
五、字串和正則表示式 String And Regular Expression
在 JS 中,正則是必不可少的東西,它的重要性遠遠超過煩瑣的字串處理
字串連結 Stirng Concatenation
字串連線表現出驚人的效能緊張。通常一個任務通過一個迴圈,向字串末尾不斷地新增內容,來建立一個字串(例如,建立一個 HTML 表或者一個 XML 文件),但此類處理在一些瀏覽器上表現糟糕而遭人痛恨
Method | Example |
---|---|
+ | str = 'a' + 'b' + 'c'; |
+= | str = 'a'; str += 'b'; str += 'c'; |
array.join() | str = ['a','b','c'].join(''); |
string.concat() | str = 'a'; str = str.concat('b', 'c'); |
當連線少量的字串,上述的方式都很快,可根據自己的習慣使用;
當合並字串的長度和數量增加之後,有些函式就開始發揮其作用了
+ & +=
str += "a" + "b";
此程式碼執行時,發生四個步驟
- 記憶體中建立了一個臨時字串
- 臨時字串的值被賦予'ab'
- 臨時串與 str 進行連線
- 將結果賦予 str
下面的程式碼通過兩個離散的表示式直接將內容附加在 str 上避免了臨時字串
str += "a";
str += "b";
事實上用一行程式碼就可以解決
str = str + "a" + "b";
賦值表示式以 str 開頭,一次追加一個字串,從左至右依次連線。如果改變了連線順序(例如:str = 'a' + str + 'b'
),你會失去這種優化,這與瀏覽器合併字串時分配記憶體的方法有關。除 IE 外,瀏覽器嘗試擴充套件表示式左端字串的記憶體,然後簡單地將第二個字串拷貝到它的尾部。如果在一個迴圈中,基本字串在左端,可以避免多次複製一個越來越大的基本字串。
Array.prototype.join
Array.prototype.join 將陣列的所有元素合併成一個字串,並在每個元素之間插入一個分隔符字串。若傳遞一個空字串,可將陣列的所有元素簡單的拼接起來
var start = Date.now();
var str = "I'm a thirty-five character string.",
newStr = "",
appends = 5000000;
while (appends--) {
newStr += str;
}
var time = Date.now() - start;
console.log("耗時:" + time + "ms"); //耗時:1360ms
var start = Date.now();
var str = "I'm a thirty-five character string.",
strs = [],
newStr = "",
appends = 5000000;
while (appends--) {
strs[strs.length] = str;
}
newStr = strs.join("");
var time = Date.now() - start;
console.log("耗時:" + time + "ms"); //耗時:414ms
這一難以置信的改進結果是因為避免了重複的記憶體分配和拷貝越來越大的字串。
String.prototype.concat
原生字串連線函式接受任意數目的引數,並將每一個引數都追加在呼叫函式的字串上
var str = str.concat(s1);
var str = str.concat(s1, s2, s3);
var str = String.prototype.concat.apply(str, array);
大多數情況下 concat 比簡單的+或+=慢一些
Regular Expression Optimization 正則表示式優化
許多因素影響正則表示式的效率,首先,正則適配的文字千差萬別,部分匹配時比完全不匹配所用的時間要長,每種瀏覽器的正則引擎也有不同的內部優化
正則表示式工作原理
編譯
當你建立了一個正則表示式物件之後(使用一個正則表示式直接量或者 RegExp 構造器),瀏覽器檢查你的模板有沒有錯誤,然後將它轉換成一個本機程式碼例程,用執行匹配工作。如果你將正則表示式賦給一個變數,你可以避免重複執行此步驟。設定起始位置
當一個正則表示式投入使用時,首先要確定目標字串中開始搜尋的位置。它是字串的起始位置,或者由正則表示式的 lastIndex 屬性指定,但是當它從第四步返回到這裡的時候(因為嘗試匹配失敗),此位置將位於最後一次嘗試起始位置推後一個字元的位置上匹配每個正則表示式的字元
正則表示式一旦找好起始位置,它將一個一個地掃描目標文字和正則表示式模板。當一個特定字元匹配失敗時,正則表示式將試圖回溯到掃描之前的位置上,然後進入正則表示式其他可能的路徑上匹配成功或失敗
如果在字串的當前位置上發現一個完全匹配,那麼正則表示式宣佈成功。如果正則表示式的所有可能路徑都嘗試過了,但是沒有成功地匹配,那麼正則表示式引擎回到第二步,從字串的下一個字元重新嘗試。只有字串中的每個字元(以及最後一個字元後面的位置)都經歷了這樣的過程之後,還沒有成功匹配,那麼正則表示式就宣佈徹底失敗。
理解回溯
在大多數現代正則表示式實現中(包括 JavaScript 所需的),回溯是匹配過程的基本組成部分。它很大程度上也是正則表示式如此美好和強大的根源。然而,回溯計算代價昂貴,如果你不夠小心的話容易失控。雖然回溯是整體效能的唯一因素,理解它的工作原理,以及如何減少使用頻率,可能是編寫高效正則表示式最重要的關鍵點。
正則表示式匹配過程
- 當一個正則表示式掃描目標字串時,它從左到右逐個掃描正則表示式的組成部分,在每個位置上測試能不能找到一個匹配。對於每一個量詞和分支,都必須決定如何繼續進行。如果是一個量詞(諸如*,+?,或者{2,}),正則表示式必須決定何時嘗試匹配更多的字元;如果遇到分支(通過|操作符),它必須從這些選項中選擇一個進行嘗試。
- 每當正則表示式做出這樣的決定,如果有必要的話,它會記住另一個選項,以備將來返回後使用。如果所選方案匹配成功,正則表示式將繼續掃描正則表示式模板,如果其餘部分匹配也成功了,那麼匹配就結束了。但是如果所選擇的方案未能發現相應匹配,或者後來的匹配也失敗了,正則表示式將回溯到最後一個決策點,然後在剩餘的選項中選擇一個。它繼續這樣下去,直到找到一個匹配,或者量詞和分支選項的所有可能的排列組合都嘗試失敗了,那麼它將放棄這一過程,然後移動到此過程開始位置的下一個字元上,重複此過程。
示例分析
/h(ello|appy) hippo/.test("hello there, happy hippo");
此正則表示式匹配“hello hippo”或“happy hippo”。測試一開始,它要查詢一個 h,目標字串的第一個字母恰好就是 h,它立刻就被找到了。接下來,子表示式(ello|appy)提供了兩個處理選項。正則表示式選擇最左邊的選項(分支選擇總是從左到右進行),檢查 ello 是否匹配字串的下一個字元。確實匹配,然後正則表示式又匹配了後面的空格。然而在這一點上它走進了死衚衕,因為 hippo 中的 h 不能匹配字串中的下一個字母 t。此時正則表示式還不能放棄,因為它還沒有嘗試過所有的選擇,隨後它回溯到最後一個檢查點(在它匹配了首字母 h 之後的那個位置上)並嘗試匹配第二個分支選項。但是沒有成功,而且也沒有更多的選項了,所以正則表示式認為從字串的第一個字元開始匹配是不能成功的,因此它從第二個字元開始,重新進行查詢。它沒有找到 h,所以就繼續向後找,直到第 14 個字母才找到,它匹配 happy 的那個 h。然後它再次進入分支過程。這次 ello 未能匹配,但是回溯之後第二次分支過程中,它匹配了整個字串“happy hippo”(如圖 5-4)。匹配成功了。
回溯失控
當一個正則表示式佔用瀏覽器上秒,上分鐘或者更長時間時,問題原因很可能是回溯失控。正則表示式處理慢往往是因為匹配失敗過程慢,而不是匹配成功過程慢。
var reg = /<html>[\s\S]*?<head>[\s\S]*?<title>[\s\S]*?<\/title>[\s\S]*?<\/head>[\s\S]*?<body>[\s\S]*?<\/body>[\s\S]*?<\/html>/;
//優化如下
var regOptimize = /<html>(?=([\s\S]*?<head>))\1(?=([\s\S]*?<title>))\2(?=([\s\S]*?<\/title>))\3(?=([\s\S]*?<\/head>))\4(?=([\s\S]*?<body>))\5(?=([\s\S]*?<\/body>))\6[\s\S]*?<\/html>/;
現在如果沒有尾隨的