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

發表於2016-01-04

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

個人覺得本書很值得中低階別的前端朋友閱讀,會有很多意想不到的收穫。

第一章 載入和執行

基於UI單執行緒的邏輯,常規指令碼的載入會阻塞後續頁面指令碼甚至DOM的載入。如下程式碼會報錯:

原因是 div 被置於指令碼之後,它還沒被頁面解析到就先執行了指令碼(當然這屬於最基礎的知識點了)

書中提及了使用 defer 屬性可以延遲指令碼到DOM載入完成之後才執行。

我們常規喜歡把指令碼放到頁面的末尾,並裹上 DOMContentLoaded 事件,事實上只需要給 script 標籤加上 defer 屬性會比前者做法更簡單也更好(只要沒有相容問題),畢竟連 DOMContentLoaded 的事件繫結都先繞過了。

書中沒有提及 async 屬性,其載入執行也不會影響頁面的載入,跟 defer 相比,它並不會等到 DOM 載入完才執行,而是指令碼自身載入完就執行(但執行是非同步的,不會阻塞頁面,指令碼和DOM載入完成的先後沒有一個絕對順序)。

第二章 資料儲存

本章在一開始提及了作用域鏈,告訴了讀者“對瀏覽器來說,一個識別符號(變數)所在的位置越深,它的讀寫速度也就越慢(效能開銷越大)”。

我們知道很多庫都喜歡這麼做封裝:

以IIFE的形式形成一個區域性作用域,這種做法的優勢之一當然是可避免產生汙染全域性作用域的變數,不過留意下,我們還把 window、document、undefined 等頂層作用域物件傳入該密封的作用域中,可以讓瀏覽器只檢索當層作用域既能正確取得對應的頂層物件,減少了層層向上檢索物件的效能花銷,這對於類似 jQuery 這種動輒幾千處呼叫全域性變數的指令碼庫而言是個重要的優化點。

我們常規被告知要儘量避免使用 with 來改變當前函式作用域,本書的P22頁介紹了該原因,這裡來個簡單的例子:

在 with 的作用域塊裡面,執行環境(上下文)的作用域鏈被指向了 document,因此瀏覽器可以在 with 程式碼塊中更快讀取到 document 的各種屬性(瀏覽器最先檢索的作用域鏈層物件變為了 document)。

但當我們需要獲取區域性變數 foo 的時候,瀏覽器會先檢索一遍 document,檢索不到再往上一層作用域鏈檢索函式 a 來取得正確的 foo,由此一來會增加了瀏覽器檢索作用域物件的開銷。

書中提及的對同樣會改變作用域鏈層的 try-catch 的處理,但我覺得不太受用:

書中的意思是,希望在 catch 中使用一個獨立的方法 handleError 來處理錯誤,減少對 catch 外部的區域性變數的訪問(catch程式碼塊內的作用域首層變為了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集合,因此這些返回的節點集不會對應實時的文件結構,在遍歷節點時可以比較放心地使用該方法。

本章重排重繪的介紹可以參考阮一峰老師的《網頁效能管理詳解》一文,本章不少提及的要點在阮老師的文章裡也被提及到。

我們需要留意的一點是,當我們呼叫了以下屬性/方法時,瀏覽器會“不得不”重新整理渲染佇列並觸發重排以返回正確的值:

因此如果某些計算需要頻繁訪問到這些偏移值,建議先把它快取到一個變數中,下次直接從變數讀取,可有效減少冗餘的重排重繪。

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

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

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

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

在這裡提個擴充套件,即DOM大批量操作節流的,指的是當我們需要在一個時間單位內做很大數量的重複的DOM操作時,應主動減少DOM操作處理的數量。

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

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

這種方式的處理導致了頁面滾動時出現了非常嚴重的卡頓問題,原因是每次 iscroll 的滾動就會執行非常多次的 dealNavBar 方法計算(當然我們還需要獲取容器的scrollTop來計算導航條的吸附位置,導致不斷重排重繪,這就更加悲劇了)。

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

當然這種方法會導致導航條的頂部吸附不在那麼實時穩固了,會一閃一閃的看著不舒服,個人還是傾向於只在 onScrollEnd 裡對其做處理即可。

那麼什麼時候需要節流呢?

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

第四章 演算法和流程控制

本章主要介紹了一些迴圈和迭代的演算法優化,適合仔細閱讀,感覺也沒多餘可講解或擴充套件的地方,不過本章提及了“呼叫棧/Call Stack”,想起了我面試的時候遇到的一道和呼叫棧相關的問題,這裡就講個題外話。

當初的問題是,如果某個函式的呼叫出錯了,我要怎麼知道該函式是被誰呼叫了呢?注意只允許在 chrome 中除錯,不允許修改程式碼。

答案其實也簡單,就是給被呼叫的函式設斷點,然後在 Sources 選項卡檢視“Call Stack”區域資訊:

另外關於本章最後提及的 Memoization 演算法,實際上屬於一種代理模式,把每次的計算快取起來,下次則繞過計算直接到快取中取,這點對效能的優化還是很有幫助的,這個理念也不僅僅是運用在演算法中,比如在我的smartComplete 元件裡就運用了該快取理念,每次從伺服器獲得的響應資料都快取起來,下次同樣的請求引數則直接從快取裡取響應,減少冗餘的伺服器請求,也加快了響應速度。

第五章 字串和正規表示式

開頭提及的“通過一個迴圈向字串末尾不斷新增內容”來構建最終字串的方法在“某些瀏覽器”中效能糟糕,並推薦在這些瀏覽器中使用陣列的形式來構建字串。

要留意的是在主流瀏覽器裡,通過迴圈向字串末尾新增內容的形式已經得到很大優化,效能比陣列構建字串的形式還來的要好。

接著文章提及的字串構建原理很值得了解:

“臨時字串”的產生會影響字串構建過程的效能,加大記憶體開銷,而是否會分配“臨時字串”還是得看“基本字串”,若“基本字串”是字串變數本身(棧記憶體裡已為其分配了空間),那麼字串構建的過程就不會產生多餘的“臨時字串”,從而提升效能。

以上方程式碼為例,我們看看每一行的“基本字串”都是誰:

以最後一行為例,計算時瀏覽器會分配一處臨時記憶體來存放臨時字串”b”,然後依次從左到右把 str、”e”的值拷貝到”b”的右側(拷貝的過程中瀏覽器也會嘗試給基礎字串分配更多的記憶體便於擴充套件內容)

至於前面提到的“某些瀏覽器中構建字串很糟糕”的情況,我們可以看看《高三》一書(P33)是怎麼描述這個“糟糕”的原因:

我們繼續擴充套件一個基礎知識點——字串的方法是如何被呼叫到的?

我們知道字串屬於基本型別,它不是物件為何我們們可以呼叫 concat、substring等字串屬性方法呢?

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

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

在每次呼叫 s1 的屬性方法時,後臺總會在這之前默默地先做一件事——執行 s1=new String(‘some text’) ,從而讓我們可以順著原型鏈呼叫到String物件的屬性(比如第二行呼叫了 substring)

在呼叫完畢之後,後臺又回默默地銷燬這個先前建立了的包裝物件。這就導致了在第三行我們給包裝物件新增屬性color後,該物件立即被銷燬,最後一行再次建立包裝物件的時候不再有color屬性,從而alert了undefined。

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

“引用型別與基本包裝型別的主要區別就是物件的生存期。使用new操作符建立的引用型別的例項,在執行流離開當前作用域之前都一直儲存在記憶體中。而自動建立的基本包裝型別的物件,則只存在於一行程式碼的執行瞬間,然後立即被銷燬。這意味著我們不能在執行時為基本型別值新增屬性和方法。”

正則的部分提及了“回溯法”,在維基百科裡是這樣描述的:

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

常規我們應當儘可能減少正則的回溯,從而提升匹配效能:

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

1. 讓匹配失敗更快結束

正則匹配中最耗時間的部分往往不是匹配成功,而是匹配失敗,如果能讓匹配失敗的過程更早結束,可以有效減少匹配時間:

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

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

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

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

3. 使用非捕獲組

捕獲組會消耗時間和記憶體來記錄反向引用,因此當我們不需要一個反向引用的時候,利用非捕獲組可以避免這些開銷:

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

很多時候可以利用分組來直接取得我們需要的部分,減少後續的處理:

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

可能會有個誤區,覺得能儘量在單條正規表示式裡匹配到結果總會優於分多條匹配。

本章則告訴讀者應“避免在一個正規表示式中處理太多工。複雜的搜尋問題需要條件邏輯,拆分成兩個或多個正規表示式更容易解決,通常也會更高效”。

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

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

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

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

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

相關文章