【前端效能優化】高效能JavaScript讀書筆記

番茄沙司發表於2019-03-22

曾經看過一篇文章,有一句話這樣說:

只有在大學的圖書館裡,你才能真正賺回你交的學費。

臨近畢業,還想再去圖書館多轉轉。偶然在架子上發現了這本書,一看作者是寫大名鼎鼎的紅寶書的人,就很感興趣。再者,最近用 JavaScript 刷 LeetCode 發現,提交顯示 JavaScript 要比 Go 語言或 Python 有更大的時間和記憶體消耗,也使我把了解 JavaScript 記憶體機制和效能優化提上了日程。

本書雖然有部分章節涉及到的問題有一定年代感,比如最後一章[工具],由於前端技術的快速迭代和瀏覽器的不斷支援下,已經不適用了。但是這本書的前面章節詳細地從js執行、訪問、程式碼結構優化、非同步程式設計等多方面講解了 JavaScript 優化的策略,解答了我刷題時的疑惑,也讓我認識到之前秋招面試的時候遇到的一些坑,還是有許多需要引起重視的知識體系需要不斷擴容。

總之,這本書不僅是對前端效能優化基礎知識的一種補充掌握,還有很多底層原理的實現值得學習,推薦有專案經驗並希望提升Web應用效能的前端開發人員閱讀。

前端效能優化

下面是我認知的前端效能優化的策略,本書主要著手 JavaScript 優化展開闡述。

  • JavaScript優化

  • 非核心程式碼非同步載入

  • 瀏覽器快取

  • 使用CDN

  • DNS預解析

  • 優化資源

  • 清理不必要的依賴 

高效能JavaScript

早期,IE瀏覽器的JS引擎基於“靜態垃圾回收機制(Static Garbage Collection)”,該引擎監視記憶體中固定數量的物件來確定何時進行垃圾回收。隨著Web應用的日益發展,JS引擎吃不消了。

雖然其他瀏覽器有著更加完善的GC和更好的效能,但大多數都是使用JS直譯器來執行。

這也正解釋了開篇刷 LeetCode 題時的困惑,解釋型程式碼為什麼沒有編譯型程式碼快?

因為,解釋型程式碼必須經歷把程式碼轉換成計算機指令的過程。無論直譯器多麼智慧,都會帶來一些效能的消耗。 【前端效能優化】高效能JavaScript讀書筆記 而編譯器已經有了各種各樣的優化,可以基於詞法分析去判斷程式碼想實現什麼,產生完成任務的執行最快的機器碼。直譯器很少有這樣的優化,往往程式碼怎麼寫就怎麼被執行。

2008年,JS引擎收穫最大的一次效能升級,該引擎的研發代號為V8。V8是一款為 JavaScript 打造的實時(JIT)編譯引擎,它把 JavaScript 程式碼轉化為機器碼來執行。緊接著其他瀏覽器也優化了JS引擎,這些只是編譯器層面的優化,程式碼的效能依然需要開發人員關注。

【前端效能優化】高效能JavaScript讀書筆記

一、瀏覽器中的 JavaScript

瀏覽器中js程式碼的執行可能會阻塞瀏覽器的其他程式,下邊列出了幾點棘手的問題以及優化方式。

  1. 指令碼阻塞:將<script>標籤放在頁面底部,</body>閉合標籤之前。

  2. 延遲時間:

    1. 內嵌<script>不緊跟<link>標籤

    2. 運用打包工具,合併js檔案

  3. 無阻塞載入js:關鍵是在window物件的load事件觸發後再下載指令碼

    1. 使用<script>標籤的defer屬性 注意:defer屬性僅當src屬性宣告時才生效

    2. 動態指令碼載入:使用動態建立的<script>元素來下載並執行程式碼 注意:需要通過偵聽事件,跟蹤並確保指令碼下載完成並準備就緒 優勢是跨瀏覽器相容性和易用,也是最通用的無阻塞載入的策略。

    3. 使用 XHR 物件下載 JavaScript 程式碼並注入頁面中

      侷限性:JavaScript 檔案必須和所請求的頁面同域,不適用大型Web專案。

無阻礙指令碼載入工具:YUI3、LazyLoad、LABjs

通過以上策略,可以極大地提高JavaScript的Web應用的效能。 此外,還有一些策略例如:減少js檔案的大小、限制HTTP請求數。這兩點策略,隨著Web應用的日益複雜,可行性也隨之降低,也不是做的越極致效果越好,需要實際情況具體分析。

二、資料儲存的位置

資料儲存的位置關係到資料的檢索速度,直接影響程式碼執行的效率。JavaScript 有以下四種基本的資料儲存位置:

  1. 字面量:值的記法,包括:字串、數字、布林值、物件、陣列、函式、正規表示式,還有特殊的 null 和 undefined 值

  2. 本地變數:使用 var/let/const 關鍵字定義的資料儲存單元

  3. 陣列元素:以數字為索引,儲存在 JavaScript 陣列物件內部

  4. 物件成員:以字串作為索引,儲存在 JavaScript 物件內部

識別符號解析的效能

在函式的執行過程中,每遇到一個變數,都會經歷一次識別符號解析過程以決定從哪裡獲取或儲存資料。該過程的搜尋執行環境是作用域鏈,這個搜尋過程會影響效能。

注意:總的趨勢是,識別符號所在位置越深,它的讀寫速度越慢。若採用優化過的 JavaScript 引擎的瀏覽器效能損失會大大減少。

原型鏈和巢狀成員也遵從此關係。

注意作用域鏈的改變

可以在執行時改變作用域鏈,影響效能的語句:

  1. with 語句:會導致一個新的變數物件被置於作用域鏈的首位,造成訪問特定物件的屬性非常快,而訪問區域性變數則變慢。 建議:棄用

  2. try-catch語句中的 catch 子句:會把異常物件推入一個變數物件並置於作用域的首位。 建議:將錯誤委託給一個函式處理

閉包

閉包的[[scope]]屬性包含了與執行環境作用域鏈相同的物件的引用,同時會影響記憶體開銷和執行速度,應小心使用閉包。

策略

可以通過把常用的陣列元素、跨域變數儲存在區域性變數中來改善效能。

這種策略不推薦用於物件的成員方法,會改變this的值。

三、DOM 程式設計

瀏覽器中通常會把 DOM 和 JavaScript 獨立實現,所以訪問DOM元素消耗很大。

策略:減少訪問DOM的次數,把運算留給ECMAScript一端。

innerHTML 對比 DOM 方法

舊版瀏覽器中,使用innerHTML會更快一些。在基於 WebKit 核心的新版瀏覽器中,用DOM略勝一籌。

策略:根據可讀性、穩定性、團隊習慣、程式碼風格來綜合決定。

節點克隆

節點克隆element.cloneNode()比建立新元素document.createElement更有效率,但不明顯。

HTML集合

返回值是集合的方法:

  • document.getElementByName()

  • document.getElementByClassName()

  • Document.getElementByTagName()

返回值是集合的屬性:

  • document.images

  • document.links

  • document.forms

  • document.forms[0].elements

HTML集合是包含DOM節點引用的類陣列物件。和陣列的區別是沒有push和slice方法,有length屬性和數字索引的方式訪問元素。

HTML集合低效之源:假定實時態 assumed to be live

策略:

  1. 把集合的長度快取到一個區域性變數中,在迴圈條件的退出語句中使用該變數。

  2. 使用陣列拷貝。

    function toArr() {    for (var i = 0, arr = [], len = coll.length; i < len; i++) {        arr[i] = coll[i];    }    return arr;}複製程式碼

遍歷DOM

屬性名被替代的屬性
childrenchildNodes
childElementCountchildNodes.length
firstElementChildfirstChild
lastElementChildlastChild
nextElementSiblingnextSibling
previousElementSiblingpreviousSibling

選擇器API

queryAelectorAll()firstElementChild()方法使用CSS選擇器作為引數並返回一個NodeList,不會返回HTML集合。適合處理大量組合查詢。

重排和重繪

在瀏覽器的渲染過程中,瀏覽器會在下載完頁面所有元件之後,解析並生成兩個資料結構:

  • DOM Tree(DOM樹)

  • Render Tree(渲染樹)

一旦上述兩種結構構建完成,瀏覽器就開始繪製(paint)頁面元素。

注:對重排和重繪的理解是非常必要的

重排 Reflow

定義:當DOM結構的變化影響了元素的幾何屬性,瀏覽器需要根據樣式來重新計算元素出現的位置。瀏覽器會使渲染樹中受到影響的部分失效,並重新構造渲染樹。

觸發Reflow的條件:

  • 新增或刪除可見的DOM元素

  • 元素位置改變:如,新增動畫效果

  • 元素尺寸改變:如,改變邊框寬高、內外邊距等

  • 內容改變:如,改變段落文字行數、圖片替換等

  • 瀏覽器Resize視窗(移動端不會出現)

  • 修改預設字型

  • 頁面渲染器初始化

特別的:當滾動條出現時,會觸發整個頁面的重排

重繪 Repaint

定義:完成重排後,瀏覽器會根據渲染樹重新繪製受影響的部分到螢幕中。

不是所有的DOM變化都會影響幾何屬性,例如改變一個元素的背景色只會發生一次重繪。

特別的,要注意分析改變所影響的階段是重排還是重繪。

綜上,重排和重繪都是昂貴的操作,會導致Web應用反應遲鈍。所以,應該儘可能減少這類過程的發生。

渲染樹的變化的排隊和重新整理

瀏覽器會通過佇列化批量執行來優化重排過程。

以下獲取佈局的操作會導致佇列重新整理:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight

  • scrollTop、scrollLeft、scrollWidth、scrollHeight

  • clientTop、clientLeft、clientWidth、clientHeight

  • getComputedStyle()

修改樣式時,應避免以上屬性。

策略:不要在佈局資訊改變時操作它。

最小化重排和重繪

策略:

  1. 合併多次對DOM和樣式的修改,然後一次處理掉。(n -> 1)

    如:cssText屬性,className屬性等。

  2. 儘量減少offsets等佈局資訊的獲取次數,方法是獲取一次起始位置的值,在動畫迴圈中,直接使用變數。

  3. 讓元素脫離動畫流:拖放代理

    • 使用絕對定位頁面上的動畫元素,將其脫離文件流。

    • 讓元素動起來,這時會臨時覆蓋部分頁面,只會發生小規模重繪。

    • 當動畫結束時恢復定位,從而只會下移一次文件的其他元素。

  4. 在元素很多時,避免使用:hover

批量修改DOM

關鍵:“離線”操作DOM樹,使用快取,減少訪問佈局資訊的次數。 策略:

  1. 使元素脫離文件流

    • 隱藏元素(display:none),應用修改,重新顯示。

    • 使用文件片段在當前DOM之外構建一個子樹(document.createDocumentFragment()),再把它拷貝迴文件。(推薦)

    • 將原始元素拷貝到一個脫離文件流的節點中,修改副本,完成後再替換原始元素。

  2. 對其應用多重改變

  3. 把元素帶回文件中

事件委託

之前寫過一篇理解DOM事件處理程式和事件委託的文章,涉及事件模式的基本概念、事件流、事件委託的實現等的闡述,如果大家對以上概念有所遺忘,歡迎點選連結檢視原文。

每繫結一個事件處理器都會加重頁面負擔、延長執行時間、消耗更多的記憶體(因為瀏覽器會跟蹤每個事件處理器)。

一個優雅的策略就是利用事件委託

可以將冗長的瀏覽器相容性程式碼移入可重用的類庫:

  • 訪問事件物件,判斷事件源

  • 取消文件樹中的冒泡

  • 阻止預設動作

四、演算法和流程控制

大部分效能問題的來源是低效的演算法或工具編寫出的糟糕程式碼

迴圈

程式碼執行的大部分消耗在迴圈

JS迴圈的型別:

  1. for迴圈 【前端效能優化】高效能JavaScript讀書筆記

注:for迴圈初始化中var語句會建立一個函式級的變數,應儘可能使用ES6中的let語句定義迴圈級變數。

  1. while 迴圈:和for類似,是最簡單的前測迴圈

  2. do-while 迴圈:唯一的後測迴圈,迴圈體至少執行一次。

  3. for-in 迴圈:列舉任何物件的屬性名key

  4. for-of 迴圈:ES6新特性,列舉任何物件的值value

擴充知識:for-in 和 for-of 區別

所返回的屬性:

  1. 物件的例項屬性

  2. 從原型鏈中繼承的屬性

迴圈效能:for-in 明顯慢

由於每次操作會同時搜尋例項和原型屬性,查詢雜湊鍵,會產生更多開銷。所以,除了明確需要迭代一個屬性數量未知的物件,其他情況應避免使用for-in。

若其他迴圈的效能都差不多,其實只有兩個因素可以提升整體效能:

  1. 減少每次迭代的工作量:限制迴圈中的耗時操作總數

    • 最小化屬性查詢 關鍵:減少物件成員及陣列項的查詢次數 策略:只查詢一次屬性,並把值存到一個區域性變數中。例如:var len = items.length;

    • 倒序迴圈 通常,陣列項的順序與所要執行的任務無關。倒序迴圈是程式語言中一種通用的效能優化方式。

當迴圈複雜度為O(n)時,減少每次迭代的工作量是最有效的。當複雜度大於O(n),建議著重減少迭代次數。

  1. 減少迭代的次數 達夫裝置(Duff's Device):迴圈體展開技術,一次迭代中實際執行了多次迭代的操作。

迭代數超過1000,使用 Duff's Device 的執行效率將明顯提升。

基於函式的迭代 forEach() 明顯慢

原因:對每個陣列項呼叫外部方法所帶來的額外開銷。

條件語句

if-else 對比 switch 基於測試條件的數量選擇:條件數量越大,越傾向於使用switch,易讀性強且速度快。

大多數語言對 switch 語句的實現都採用了 branch table(分支表)索引進行優化。

優化 if-else

  1. 最小化到達正確分支前所需條件判斷的次數 策略:條件語句按照從大概率到小概率的順序排列

  2. 把 if-else 組織成一系列巢狀的if-else 語句 策略:二分法把值域分成一系列區間,逐步縮小範圍。 適用範圍:有多個值域需要測試。 查詢表 當條件語句數量很大或有大量散離值需要測試時,使用陣列普通物件構建查詢表訪問資料比較快。

優點:當單個鍵和單個值之間存在邏輯對映時,隨著候選值增加,幾乎不產生額外開銷。

遞迴

傳統演算法的遞迴實現:階層函式 潛在問題;

  1. 假死 策略:為了安全在瀏覽器工作,可以迭代和Memoization結合使用。

  2. 瀏覽器呼叫棧大小限制 Call stack size limites 當超過最大呼叫棧容量時,瀏覽器會報錯,可以用try-catch定位。 策略:ES6中使用尾遞迴就不會發生棧溢位,相對節省效能。

五、字串和正規表示式

字串連線

方法示例
The + operatorstr = "a" + "b" + "c";
The += operatorstr = "a"; str += "b"; str += "c";
array.join()str = ["a", "b", "c"].join("");
string.concat()str = "a"; str = str.concat("b","c");
轉義字元""在每一行的最後,都加上轉義斜線 \
使用es6模版字串使用鍵盤1左邊的字元 ` 拼接

字串連線優化

str += 'zhu' + 'yue'; //2個以上的字串拼接,會在記憶體中產生臨時字串str = str + 'zhu' + 'yue'; //推薦,直接附加內容給str,提速10%~40% 複製程式碼

瀏覽器合併字串時分配的方法:除IE外,為表示式左側的字串分配更多的記憶體,然後簡單地將第二個字串拷貝至它的末尾。

正規表示式優化

基本概念:正規表示式 注意避免:回溯失控

使用正規表示式和倒序迴圈可以簡單實現trim方法,去首尾空白。

優化正規表示式的策略:

  1. 具體化分隔符之間的字串匹配模式

  2. 使用預查和反向引用的模擬原子組

  3. 避免巢狀量詞與回溯失控

  4. 關注如何讓匹配更快失敗

  5. 以簡單必需的字元開始

  6. 使用量詞模式,使它們後面的字元互斥

  7. 較少分支數量,縮小分支範圍

  8. 把正規表示式賦值給變數並重用

  9. 化繁為簡

何時不使用正規表示式

  1. 在特定位置上提取並檢查字串的值:slice、substr、substring

  2. 查詢特定字串位置,或者判斷它們是否存在:indexOf、lastIndexOf

六、快速響應的使用者介面

Web Workers 引入了一個介面,能使程式碼執行且不佔用瀏覽器執行緒的時間。

Worker的執行環境:

  • 一個 navigator 物件,只包括四個屬性:appName、appVersion、user Agent 和 platform

  • 一個 location 物件(與window.location 相同,不過所有屬性都是隻讀的)

  • 一個 importScripts() 方法,用來載入 Worker 所用到的外部 JavaScript 檔案

  • 所有的 ECMAScript 物件

  • XMLHTTPRequest 構造器

  • setTimeout() 和 setInterval() 方法

  • 一個 close() 方法,可以立即停止 Worker 執行。

Web Workers 實際應用

Web Workers 適用於:

  1. 處理純資料

  2. 與瀏覽器無關的長時間執行指令碼

  3. 編碼/解碼大字串

  4. 複雜數學運算,如:影象和視訊

  5. 大陣列排序

例子:解析一個很大的JSON字串

var worker = new Worker("jsonParser.js");//資料就位時,呼叫事件處理器worker.onmessage = function (event) {    //JSON結構被回傳回來    var jsonData = event.data;    //使用JSON結構    evaluateData(jsonData);};//傳入要解析的大段JSON字串worker.postMessage(jsonText);複製程式碼

jsonParser.js檔案中 Worker 中負責解析JSON的程式碼:

//當JSON資料存在時,該事件處理器會被呼叫self.onmessage = function (event) {    //JSON字串由event.data傳入    var jsonText = event.data;    //解析    var jsonData = JSON.parse(jsonText);    //回傳結果    self.postMessage(jsonData);}複製程式碼

超過100毫秒的處理過程,應該考慮 Worker 方案。

七、AJAX

常常使用XMLHttpRequest(XHR)、Dynamic script tag insertion、multipart XHR技術向伺服器請求資料。

XMLHttpRequest:可以參考之前寫過的文章 用原生JS封裝AJAX Dynamic script tag insertion:可以跨域請求資料 multipart XHR:將服務端資源打包成約定好的字串分割的長字串,併傳送到客戶端。

資料格式:JSON

此章節優化主要是有效的利用瀏覽器快取,還有本章沒有提及的現在逐漸開始流行的 fetch API也值得討論。

八、程式設計實踐

  1. 避免雙重求值,即在JavaScript程式碼中執行另一段JavaScript程式碼,是JavaScript執行期效能優化的關鍵。

  2. 使用 Object/Array 直接量

  3. 通過延遲載入和條件預載入,避免重複工作

  4. 使用語言中速度快的部分,如:位操作(& | ^ ~)、原生方法

九、構建並部署高效能JavaScript應用

構建和部署的過程對基於js的web應用的效能有著巨大影響。這個過程中最重要的步驟有:

  1. 使用Gzip合併、壓縮js檔案,能夠減少約70%的體積。

  2. 通過正確設定HTTP響應頭來快取js檔案,通過向檔名增加時間戳來避免快取問題。

  3. 使用CDN提供js檔案;CDN不僅可以提升效能,也幫助管理檔案的壓縮與快取。

  4. 使用Webpack構建。

擴充:前端構建工具的發展

十、工具

主要分析方面:

  1. 效能分析

  2. 網路分析

總結

JavaScript 在不斷髮展和擴充它的邊界,我們也要不斷學習大量的優化技術和方法。當把這些策略應用在專案中時,將會看到效能的明顯提升,這也就是細節決定成敗

最後,培養和保持良好的開發習慣,對於個人發展和團隊合作都是很有必要的,推薦閱讀《高效能JavaScript》這本小薄書。?


相關文章