《高性能javascript》一書要點和延伸(上)

前些天收到了HTML5中國送來的《高性能javascript》一書,便打算將其作爲假期消遣,順便也寫篇文章記錄下書中一些要點。javascript

我的以爲本書很值得中低級別的前端朋友閱讀,會有不少意想不到的收穫。html

 

第一章 加載和執行前端

基於UI單線程的邏輯,常規腳本的加載會阻塞後續頁面腳本甚至DOM的加載。以下代碼會報錯:html5

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title></title>
  <script src="http://libs.baidu.com/jquery/1.11.1/jquery.min.js"></script>
</head>
<body>
  <script>
    console.log($);
    document.querySelector('div').innerText='中秋快樂';
  </script>
  <div>9999999999999</div>
</body>
</html>

緣由是 div 被置於腳本以後,它還沒被頁面解析到就先執行了腳本(固然這屬於最基礎的知識點了)java

書中說起了使用 defer 屬性能夠延遲腳本到DOM加載完成以後才執行。jquery

咱們常規喜歡把腳本放到頁面的末尾,並裹上 DOMContentLoaded 事件,事實上只須要給 script 標籤加上 defer 屬性會比前者作法更簡單也更好(只要沒有兼容問題),畢竟連 DOMContentLoaded 的事件綁定都先繞過了。web

書中沒有說起 async 屬性,其加載執行也不會影響頁面的加載,跟 defer 相比,它並不會等到 DOM 加載完才執行,而是腳本自身加載完就執行(但執行是異步的,不會阻塞頁面,腳本和DOM加載完成的前後沒有一個絕對順序)。面試

第二章 數據存儲正則表達式

本章在一開始說起了做用域鏈,告訴了讀者「對瀏覽器來講,一個標識符(變量)所在的位置越深,它的讀寫速度也就越慢(性能開銷越大)」。算法

咱們知道不少庫都喜歡這麼作封裝:

(function(win, doc, undefined) {

  // TODO

})(window, document, undefined)

以IIFE的形式造成一個局部做用域,這種作法的優點之一固然是可避免產生污染全局做用域的變量,不過留意下,咱們還把 window、document、undefined 等頂層做用域對象傳入該密封的做用域中,可讓瀏覽器只檢索當層做用域既能正確取得對應的頂層對象,減小了層層向上檢索對象的性能花銷,這對於相似 jQuery 這種動輒幾千處調用全局變量的腳本庫而言是個重要的優化點。

咱們常規被告知要儘可能避免使用 with 來改變當前函數做用域,本書的P22頁介紹了該緣由,這裏來個簡單的例子:

function a(){
   var foo = 123;
   with (document){
       var bd = body;
       console.log(bd.clientHeight + foo)
   }
}

在 with 的做用域塊裏面,執行環境(上下文)的做用域鏈被指向了 document,所以瀏覽器能夠在 with 代碼塊中更快讀取到 document 的各類屬性(瀏覽器最早檢索的做用域鏈層對象變爲了 document)。

但當咱們須要獲取局部變量 foo 的時候,瀏覽器會先檢索一遍 document,檢索不到再往上一層做用域鏈檢索函數 a 來取得正確的 foo,由此一來會增長了瀏覽器檢索做用域對象的開銷。

書中說起的對一樣會改變做用域鏈層的 try-catch 的處理,但我以爲不太受用

try {
    methodMightCauseError();
} catch (ex){
    handleError(ex)  //留意此處
}

書中的意思是,但願在 catch 中使用一個獨立的方法 handleError 來處理錯誤,減小對 catch 外部的局部變量的訪問(catch代碼塊內的做用域首層變爲了ex做用域層)

咱們來個例子:

    (function(){
        var t = Date.now();
        function handleError(ex){
            alert(t + ':' +ex.message)
        }
        try {
            //TODO:sth
        } catch (ex){
            handleError(ex);
        }
    })()

我以爲不太受用的緣由是,當 handleError 被執行的時候,其做用域鏈首層指向了 handleError 代碼塊內的執行環境,第二層的做用域鏈才包含了變量t。

因此當在 handleError 中檢索 t 時,事實上瀏覽器仍是依舊翻了一層做用域鏈(固然檢索該層的速度仍是會比檢索ex層的要快一些,畢竟ex默認帶有一些額外屬性)

後續說起的原型鏈也是很是重要的一環,不管是本書抑或《高三》一書均有很是詳盡的介紹,本文不贅述,不過你們能夠記住這麼一點:

對象的內部原型 __proto__ 總會指向其構造對象的原型 prototype,腳本引擎在讀取對象屬性時會先按以下順序檢索:

對象實例屬性 → 對象prototype  → 對象__proto__指向的上一層prototype  → ....  → 最頂層(Object.prototype)

想進一步瞭解原型鏈生態的,能夠查看這篇我收藏已久的文章

在第二章最後說起的「避免屢次讀取同一個對象屬性」的觀點,其實在JQ源碼裏也很常見:

這種作法一來在最終構建腳本的時候能夠大大減少文件體積,二來能夠提高對這些對象屬性的讀取速度,一石二鳥。

第三章 DOM編程

本章說起的不少知識點在其它書籍上其實都有描述或擴展的例子。如在《Webkit內核技術內幕》的開篇(第18頁)就提到JS引擎與DOM引擎是分開的,致使腳本對DOM樹的訪問很耗性能;在曾探的《javascript設計模式》一書中也說起了對大批量DOM節點操做應作節流處理來減小性能花銷,有興趣的朋友能夠購入這兩本書看一看。

本章在選擇器API一處建議使用 document.querySelectorAll 的原生DOM方法來獲取元素列表,說起了一個挺重要的知識點——僅返回一個 NodeList 而非HTML集合,所以這些返回的節點集不會對應實時的文檔結構,在遍歷節點時能夠比較放心地使用該方法。

本章重排重繪的介紹能夠參考阮一峯老師的《網頁性能管理詳解》一文,本章很多說起的要點在阮老師的文章裏也被說起到。

咱們須要留意的一點是,當咱們調用瞭如下屬性/方法時,瀏覽器會「不得不」刷新渲染隊列並觸發重排以返回正確的值:

offsetTop/offsetLeft/offsetWidth/offsetHeight
scrollTop/scrollLeft/scrollWidth/scrollHeight
clientTop/clientLeft/clientWidth/clientHeight
getComputedStyle()

所以若是某些計算須要頻繁訪問到這些偏移值,建議先把它緩存到一個變量中,下次直接從變量讀取,可有效減小冗餘的重排重繪。

本章在介紹批量修改DOM如何減小重排重繪時,說起了三種讓元素脫離文檔流的方案,值得記錄下:

方案⑴:先隱藏元素(display:none),批量處理完畢再顯示出來(適用於大部分狀況);

方案⑵:建立一個文檔片斷(document.createDocumentFragment),將批量新增的節點存入文檔片斷後再將其插入要修改的節點(性能最優,適用於新增節點的狀況);

方案⑶:經過 cloneNode 克隆要修改的節點,對其修改後再使用 replaceChild 的方法替換舊節點。

在這裏提個擴展,即DOM大批量操做節流的,指的是當咱們須要在一個時間單位內作很大數量的重複的DOM操做時,應主動減小DOM操做處理的數量。

打個比方,在手Q公會大廳首頁使用了iscroll,用於在頁面滾動時能實時吸附導航條,大體代碼以下:

    var myscroll = new iScroll("wrapper",
            {
                onScrollMove : dealNavBar,
                onScrollEnd : dealNavBar
            }
    );

其中的 dealNavBar 方法用於處理導航條,讓其保持吸附在viewport頂部。

這種方式的處理致使了頁面滾動時出現了很是嚴重的卡頓問題,緣由是每次 iscroll 的滾動就會執行很是屢次的 dealNavBar 方法計算(固然咱們還須要獲取容器的scrollTop來計算導航條的吸附位置,致使不斷重排重繪,這就更加悲劇了)。

對於該問題有一個可行的解決方案—— 節流,在iscroll容器滾動時捨得在某個時間單位(好比300ms)裏才執行一次 dealNavBar:

    var throttle = function (fn, delay) {
        var timer = null;
        return function () {
            var context = this, args = arguments;
            clearTimeout(timer);
            timer = setTimeout(function () {
                fn.apply(context, args);
            }, delay);
        };
    };
    var myscroll = new iScroll("wrapper",
            {
                onScrollMove : throttle.bind(this, dealNavBar, 300)
            }
    );

固然這種方法會致使導航條的頂部吸附不在那麼實時穩固了,會一閃一閃的看着不舒服,我的仍是傾向於只在 onScrollEnd 裏對其作處理便可。

那麼何時須要節流呢?

常規在會頻繁觸發回調的事件裏咱們推薦使用節流,好比 window.onscroll、window.onresize 等,另外在《設計模式》一書裏說起了一個場景 —— 須要往頁面插入大量內容,這時候與其一口氣插入,不妨節流分幾回(好比每秒最多插入80個)來完成整個操做。

第四章 算法和流程控制

本章主要介紹了一些循環和迭代的算法優化,適合仔細閱讀,感受也沒多餘可講解或擴展的地方,不過本章說起了「調用棧/Call Stack」,想起了我面試的時候遇到的一道和調用棧相關的問題,這裏就講個題外話。

當初的問題是,若是某個函數的調用出錯了,我要怎麼知道該函數是被誰調用了呢?注意只容許在 chrome 中調試,不容許修改代碼。

答案其實也簡單,就是給被調用的函數設斷點,而後在 Sources 選項卡查看「Call Stack」區域信息:

另外關於本章最後說起的 Memoization 算法,實際上屬於一種代理模式,把每次的計算緩存起來,下次則繞過計算直接到緩存中取,這點對性能的優化仍是頗有幫助的,這個理念也不只僅是運用在算法中,好比在個人 smartComplete 組件裏就運用了該緩存理念,每次從服務器得到的響應數據都緩存起來,下次一樣的請求參數則直接從緩存裏取響應,減小冗餘的服務器請求,也加快了響應速度。

第五章 字符串和正則表達式

開頭說起的「經過一個循環向字符串末尾不斷添加內容」來構建最終字符串的方法在「某些瀏覽器」中性能糟糕,並推薦在這些瀏覽器中使用數組的形式來構建字符串。

要留意的是在主流瀏覽器裏,經過循環向字符串末尾添加內容的形式已經獲得很大優化,性能比數組構建字符串的形式還來的要好。

接着文章說起的字符串構建原理很值得了解:

var str = "";
str += "a"; //沒有產生臨時字符串
str += "b" + "c";  //產生了臨時字符串!
/* 上一行建議更改成
str = str + "b" + "c";
避免產生臨時字符串 */
str = "d" + str + "e"  //產生了臨時字符串!

「臨時字符串」的產生會影響字符串構建過程的性能,加大內存開銷,而是否會分配「臨時字符串」仍是得看「基本字符串」,若「基本字符串」是字符串變量自己(棧內存裏已爲其分配了空間),那麼字符串構建的過程就不會產生多餘的「臨時字符串」,從而提高性能。

以上方代碼爲例,咱們看看每一行的「基本字符串」都是誰:

var str = "";
str += "a";  //「基本字符串」是 str
str += "b" + "c";  //「基本字符串」是"b"
/* 上一行建議更改成
str = str + "b" + "c"; //「基本字符串」是 str
避免產生臨時字符串 */
str = "d" + str + "e"  //「基本字符串」是"d"

以最後一行爲例,計算時瀏覽器會分配一處臨時內存來存放臨時字符串"b",而後依次從左到右把 str、"e"的值拷貝到"b"的右側(拷貝的過程當中瀏覽器也會嘗試給基礎字符串分配更多的內存便於擴展內容)

至於前面提到的「某些瀏覽器中構建字符串很糟糕」的狀況,咱們能夠看看《高三》一書(P33)是怎麼描述這個「糟糕」的緣由:

var lang = "Java"; //在內存開闢一個空間存放"Java"
lang = lang + "script";  //建立一個能容納10個字符的空間,
//拷貝字符串"Java"和"script"(注意這兩個字符串也都開闢了內存空間)到這個空間,
//接着銷燬原有的"Java"和"script"字符串

咱們繼續擴展一個基礎知識點——字符串的方法是如何被調用到的?

咱們知道字符串屬於基本類型,它不是對象爲什麼我們能夠調用 concat、substring等字符串屬性方法呢?

別忘了萬物皆對象,在前面咱們說起原型鏈時也提到了最頂層是 Object.prototype,而每一個字符串,實際上都屬於一個包裝對象。

咱們分析下面的例子,整個過程發生了什麼:

var s1 = "some text";
var s2 = s1.substring(2);
s1.color = "red";
alert(s1.color);

在每次調用 s1 的屬性方法時,後臺總會在這以前默默地先作一件事——執行 s1=new String('some text') ,從而讓咱們能夠順着原型鏈調用到String對象的屬性(好比第二行調用了 substring)

在調用完畢以後,後臺又回默默地銷燬這個先前建立了的包裝對象。這就致使了在第三行咱們給包裝對象新增屬性color後,該對象當即被銷燬,最後一行再次建立包裝對象的時候再也不有color屬性,從而alert了undefined。

在《高三》一書裏是這麼描述的:

「引用類型與基本包裝類型的主要區別就是對象的生存期。使用new操做符建立的引用類型的實例,在執行流離開當前做用域以前都一直保存在內存中。而自動建立的基本包裝類型的對象,則只存在於一行代碼的執行瞬間,而後當即被銷燬。這意味着咱們不能在運行時爲基本類型值添加屬性和方法。」

正則的部分說起了「回溯法」,在維基百科裏是這樣描述的:

回溯法採用試錯的思想,它嘗試分步的去解決一個問題。在分步解決問題的過程當中,當它經過嘗試發現現有的分步答案不能獲得有效的正確的解答的時候,它將取消上一步甚至是上幾步的計算,再經過其它的可能的分步解答再次嘗試尋找問題的答案。回溯法一般用最簡單的遞歸方法來實現,在反覆重複上述的步驟後可能出現兩種狀況:
1. 找到一個可能存在的正確的答案
2. 在嘗試了全部可能的分步方法後宣告該問題沒有答案
在最壞的狀況下,回溯法會致使一次複雜度爲指數時間的計算。

常規咱們應當儘量減小正則的回溯,從而提高匹配性能:

var str = "<p>123</p><img src='1.jpg' /><p>456</p>";
var r1 = /<p>.*<\/p>/i.test(str);  //貪婪匹配會致使較多回溯
var r2 = /<p>.*?<\/p>/i.test(str);   //推薦,惰性匹配減小回溯

對於書中建議對正則匹配優化的部分,我總結了一些比較重要的點,也補充對應的例子:

1. 讓匹配失敗更快結束

正則匹配中最耗時間的部分每每不是匹配成功,而是匹配失敗,若是能讓匹配失敗的過程更早結束,能夠有效減小匹配時間:

var str = 'eABC21323AB213',
    r1 = /\bAB/.test(str),   //匹配失敗的過程較長
    r2 = /^AB/.test(str);    //匹配失敗的過程很短

2. 減小條件分支+具體化量詞

前者指的是儘量避免條件分支,好比 (.|\r|\n) 可替換爲等價的 [\s\S];

具體化量詞則是爲了讓正則更精準匹配到內容,好比用特定字符來取代抽象的量詞。

這兩種方式都能有效減小回溯。來個示例:

var str = 'cat 1990';  //19XX年出生的貓或蝙蝠
var r1 = /(cat|bat)\s\d{4}/.test(str);  //不推薦
var r1 = /[bc]at\s19\d{2}/.test(str);  //推薦

3. 使用非捕獲組

捕獲組會消耗時間和內存來記錄反向引用,所以當咱們不須要一個反向引用的時候,利用非捕獲組能夠避免這些開銷:

var str = 'teacher VaJoy';
var r1 = /(teacher|student)\s(\w+)/.exec(str)[2];  //不推薦
var r2 = /(?:teacher|student)\s(\w+)/.exec(str)[1];  //推薦

4. 只捕獲感興趣的內容以減小後處理

不少時候能夠利用分組來直接取得咱們須要的部分,減小後續的處理:

var str = 'he says "I do like this book"';
var r1 = str.match(/"[^"]*"/).toString().replace(/"/g,'');  //不推薦
var r2 = str.replace(/^.*?"([^"]*)"/, '$1');  //推薦
var r3 = /"([^"]*)"/.exec(str)[1];  //推薦

5. 複雜的表達式可適當拆開

可能會有個誤區,以爲能儘可能在單條正則表達式裏匹配到結果總會優於分多條匹配。

本章則告訴讀者應「避免在一個正則表達式中處理太多任務。複雜的搜索問題須要條件邏輯,拆分紅兩個或多個正則表達式更容易解決,一般也會更高效」。

這裏就不舉複雜的例子了,直接用書上去除字符串首尾空白的兩個示例:

//trim1
String.prototype.trim = function(){
  return this.replace(/^\s+/, "").replace(/\s+$/, "")
}

//trim2
String.prototype.trim = function(){
  return this.replace(/^\s+|\s+$/, "")
}

事實上 trim2 比 trim1 還要慢,由於 trim1 只需檢索一遍原字符串,並再檢索一遍去除了了頭部空白符的字符串。而 trim2 須要檢索兩遍原字符串。

主要仍是條件分支致使的回溯問題,常規復雜的正則表達式總會帶有許多條件分支,這時候就頗有必要對其進行拆解了。

固然去掉了條件分支的話,單條正則匹配結果仍是一個優先的選擇,例如書中給出 trim 的建議方案爲:

String.prototype.trim = function(){
  return this.replace(/^\s*([\s\S]*\S)?\s*$/, "$1")
}

 

本書上半部分就先總結到這裏,共勉~

donate

相關文章
相關標籤/搜索