前些天收到了HTML5中國送來的《高性能javascript》一書,便打算將其做為假期消遣,順便也寫篇文章記錄下書中一些要點。
個人覺得本書很值得中低級別的前端朋友閱讀,會有很多意想不到的收獲。
第一章 加載和執行
基于UI單線程的邏輯,常規腳本的加載會阻塞后續頁面腳本甚至DOM的加載。如下代碼會報錯:
<!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 被置于腳本之后,它還沒被頁面解析到就先執行了腳本(當然這屬于最基礎的知識點了)。
書中提及了使用 defer 屬性可以延遲腳本到DOM加載完成之后才執行。
我們常規喜歡把腳本放到頁面的末尾,并裹上 DOMContentLoaded 事件,事實上只需要給 script 標簽加上 defer 屬性會比前者做法更簡單也更好(只要沒有兼容問題),畢竟連 DOMContentLoaded 的事件綁定都先繞過了。
書中沒有提及 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") }
本書上半部分就先總結到這里,共勉~
文章列表