1. 程式人生 > 其它 >課外加餐:5 | 效能分析工具:如何分析Performance中的Main指標?

課外加餐:5 | 效能分析工具:如何分析Performance中的Main指標?

前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄

  上節介紹瞭如何使用 Performance,而且還提到了效能指標面板中的 Main 指標,它詳細的記錄了渲染主執行緒上的任務執行記錄,通過分析 Main 指標,就能夠定位到頁面中所存在的效能問題,本節就來介紹如何分析 Main 指標。

  

任務 VS 過程

  開始前先講清楚兩個概念,那就是 Main  指標中的任務和過程,在《15 | 訊息佇列和事件迴圈:頁面是怎麼活起來的?》和 《加餐二 | 任務排程:有了 setTimeout ,為什麼還要使用 rAF?》這兩節中分析過,渲染程序中維護了訊息佇列,如果通過 SetTimeout 設定的回撥函式,通過滑鼠點選的訊息事件,都會以任務的形式新增訊息佇列中,然後任務排程器會按照一定規則從訊息佇列中取出合適的任務,並讓其在渲染主執行緒中執行。

  而今天所分析的 Main 指標就記錄渲染主執行緒上所執行的全部任務,以及每個任務的詳細執行過程

  開啟 Chrome 的開發者工具,選擇 Performance 標籤,然後記錄載入階段任務執行記錄,然後關注 Main 指標,如下圖所示:

 任務和過程

  觀察上圖,圖上方有很多一段一段灰色橫條,每個灰色橫條就對應了一個任務,灰色長條的長度對應了任務的執行時長。通常,渲染主執行緒上的任務都是比較複雜的,如果只單純記錄任務執行的時長,那麼依然很難定位問題,因此,還需要將任務執行過程中的一些關鍵的細節記錄下來,這些細節就是任務的過程,灰線下面的橫條就是一個個過程,同樣這些橫條的長度就代表這些過程執行的時長。

  直觀地理解,你可以把任務看成是一個 Task 函式,在執行 Task 函式的過程中,它會呼叫一系列的子函式,這些子函式就是所提到的過程。為了很好地理解,我們來分析下面這個任務的圖形:

 單個任務

  觀察上面這個任務記錄的圖形,可以把該圖形看成是下面 Task 函式的執行過程:

function A() {
    A1()
    A2()
}

function Task() {
    A()
    B()
}

Task()

  結合程式碼和上面的圖形,可以得出以下資訊:

  • Task 任務會首先呼叫 A 過程;
  • 隨後 A 過程又依次呼叫了 A1  和 A2 過程,然後 A 過程執行完畢;
  • 隨後 Task 任務又執行了 B 過程;
  • B 過程執行結束,Task 任務執行完成;
  • 從圖中可以看出,A過程執行時間最長,所以在 A1 過程中,拉長了整個任務的執行時長。

分析頁面載入過程

  通過以上介紹,相信你已經掌握瞭如何解讀 Main 指標中的任務了,那麼接下來就可以結合 Main 指標來分析頁面的載入過程。先來分析一個簡單的頁面,程式碼如下:

<html>
<head>
    <title>Main</title>
    <style>
        area {
            border: 2px ridge;
        }

        box {
            background-color: rgba(106, 24, 238, 0.26)
            height: 5em;
            margin: 1em;
            width: 5em;
        }
    </style>
</head>

<body>
    <div class="area">
        <div class="box rAF"></div>
    </div>
    <br>
    <script>
        function setNewArea() {
            let el = document.createElement('div')
            el.setAttribute('class', 'area')
            el.innerHTML = '<div class="box rAF"></div>'
            document.body.append(el)
        }
        setNewArea()
    </script>
</body>
</html>

  觀察這段程式碼可以看出,它只是包含了一段 CSS 樣式和一段 JS 內嵌程式碼,其中在 JS 中還執行了 DOM 操作了,我們就結合這段程式碼來分析頁面的載入流程。

  首先生成報告頁,再觀察報告頁中的 Main 指標,由於閱讀實際指標比較費勁,所以我手動繪製了一些關鍵的任務和其執行過程,如下圖所示:

 Main 指標

  通過上面的圖形可以看出,載入過程主要分為三個階段,它們分別是:、

  1. 導航階段,該階段主要是從網路程序接收 HTML 響應頭和 HTML 響應體。
  2. 解析 HTML 資料階段,該階段主要是將接收到的 HTML 資料轉換為 DOM 和 CSSOM。
  3. 生成可顯示的點陣圖階段,該階段主要是利用 DOM 和 CSSOM ,經過計算佈局、生成層樹(LayerTree)、生成繪製列表(Paint)、完成合成等操作,生成最終的圖片。

  那麼接下來就按照這三個步驟來介紹如何解讀 Main 指標上的資料。

導航階段

  在分析這個階段之前,先簡要地回顧下導航流程,大致的流程是這樣的:

  當點選了 Performance 上的重新錄製按鈕之後,瀏覽器程序會通知網路程序去請求對應的 URL 資源;一旦網路程序從伺服器接收到 URL 的響應頭,便立即判斷該響應頭中的 content-type 欄位是否屬於 text/html 型別;如果是,那麼瀏覽器程序會讓當前的頁面執行退出前的清理操作,比如執行 JS 中的 beforunload 事件,清理操作執行結束之後就準備顯示新頁面了,這包括解析、佈局、合成、顯示等一系列操作。

  因此,在導航階段,這些任務實際上是在老頁面的渲染主執行緒上執行的。如果想要了解導航流程的詳細細節可以回顧下《04 | 導航流程:從輸入 URL 到頁面展示,這中間發生了什麼?》這篇文章,在這篇文中有介紹導航流程,而導航階段和導航流程又有著密切的關聯。

  回顧了導航流程之後,接著來分析第一個階段的任務圖形,為了讓你更加清晰觀察上圖中的導航階段,我將其放大了,最終效果如下圖所示:

 請求 HTML 資料階段

  觀察上圖,如果你熟悉了導航流程,那麼就很容易根據圖形分析出這些任務的執行流程了。

  具體地講,當點選重新載入按鈕後,當前的頁面會執行上圖中的這個任務:

  • 該任務的第一個子過程就是 Send request,該過程表示網路請求已被髮送。然後該任務進入了等待狀態。
  • 接著由網路程序負責下載資源,當接收到響應頭的時候,該任務便執行 Receive Response 過程,該過程表示接收到 HTTP 的響應頭了。
  • 接著執行 DOM 事件:pageIndex、visibilitychange 和 unload 等事件,如果註冊了這些事件的回撥函式,那麼這些回撥函式會依次在該任務中被呼叫。
  • 這些事件被處理完成之後,那麼接下來就接收 HTML 資料了,這體現在了 Receive Data 過程,Receive Data 過程表示請求的資料已被接收,如果 HTML 資料過多,會存在多個 Receive Data 過程。

  等到所有的資料都接收完成之後,渲染程序會觸發另外一個任務,該任務主要執行 Finish load 過程,該過程表示網路請求已經完成。

解析 HTML 資料階段

  好了,導航階段結束之後,就進入到了解析 HTML 資料階段了,這個階段的主要任務就是通過解析 HTML 資料、解析 CSS 資料、執行 JS 來生成 DOM 和 CSSOM。那麼下面繼續來分析這個階段的圖形,看看它到底是怎麼執行的?同樣,我也放大了這個階段的圖形,觀看下圖:

 解析 HTML 資料階段

  觀察上圖這個圖形可以看出,其中一個主要的過程是 HTMLParser,顧名思義,這個過程是用來解析 HTML 檔案,解析的就是上個階段接收到的 HTML 資料。

  1. 在 ParserHTML 的過程中,如果解析到了 script 標籤,那麼便進入了指令碼執行過程,也就是圖中的 Evalute Script。
  2. 我們知道,要執行一段指令碼需要首先編譯該指令碼,於是在 Evalute Script 過程中,先進入了指令碼編譯過程,也就是圖中的 Complie Script。指令碼編譯好之後,就進入程式執行過程,執行全域性程式碼時,V8 會先構造一個 anonymous 過程,在執行 anonymous 過程中,會呼叫 setNewArea 過程,setNewArea 過程中又呼叫了 createElement,由於之後呼叫了 document.append 方法,該方法會觸發 DOM 內容的修改,所以又強制執行了 ParserHTML 過程生成新的 DOM。
  3. DOM 生成完成之後,會觸發相關的 DOM 事件,比如典型的 DOMContentLoaded,還有 readyStateChanged。

  DOM 生成之後,ParserHTML 過程繼續計算樣式表,也就是 Reculate Style,這就是生成 CSSOM 的過程,關於 Reculate Style 過程,你可以參考《05 | 渲染流程(上):HTML、CSS 和 JS,是如何變成頁面的?》的內容,倒了這裡一個完整的 ParserHTML 任務就執行結束了。

生成可顯示點陣圖階段

  生成了 DOM 和 CSSOM 之後,就進入了第三個階段:生成頁面上的點陣圖。通常這需要經歷佈局(Layout)、分層、繪製、合成 等一系列操作,同樣,我將第三個階段的流程也放大了,如下圖所示:

 生成可顯示的點陣圖

  結合上圖可以發現,在生成完了 DOM 和 CSSOM 之後,渲染主執行緒首先執行了一些 DOM 事件,諸如 readyStateChange、load、pageshow。具體地講,如果使用 JS 監聽了這些事件,那麼這些監聽的函式會被渲染主執行緒依次呼叫。

  接下來就正式進入顯示流程了,大致過程如下所示:

  1. 首先執行佈局,這個過程對應圖中的 Layout。
  2. 然後更新層樹(LayerTree),這個過程對應圖中的 Update LayerTree。
  3. 有了層樹之後,就需要為層樹中的每一層準備繪製列表了,這個過程就稱為 Paint。
  4. 準備每層的繪製列表之後,就需要利用繪製列表來生成相應圖層的點陣圖了,這個過程對應圖中的 Composite Layers。

  走到了 Composite Layers 這步,主執行緒的任務就完成了,接下來主執行緒會將合成的任務完成教給合成執行緒來執行,下面是具體的過程,你也可以對照著 Composite、Raster 和 GPU 這三個指標來分析,參考下圖:

 顯示流程

  結合渲染流水線和上圖,再來梳理下最終影象是怎麼顯示出來的。

  1. 首先主執行緒執行到 Composite Layers 過程之後,便會將繪製列表等資訊提交給合成執行緒,合成執行緒的執行記錄可以通過 Compositor 指標來檢視。
  2. 合成執行緒維護了一個 Raster 執行緒池,執行緒池中的每個執行緒稱為 Rasterize,用來執行光柵化操作,對應的任務就是 Rasterize Paint。
  3. 當然光柵化操作並不是在 Rasterize 執行緒中直接執行的,而是在 GPU 程序中執行的,因此 Rasterize 執行緒需要和 GPU 執行緒保持通訊。
  4. 然後 GPU 生成影象,最終這些圖層會被提交給瀏覽器程序,瀏覽器程序將其合成並最終顯示在頁面上。

通用分析流程

  通過對 Main 指標的分析,我們把導航流程,解析流程和最終的顯示流程都串起來了,通過 Main 指標的分析,我們把頁面的載入過程執行流程又有了新的認識,雖然實際情況比這個複雜,但是萬變不離其宗,所有的流程都是圍繞這條線來展開的,也就是說,先經歷導航階段,然後經歷 HTML 解析,最後生成最終的頁面。

總結

  本文主要的目的是讓我們學會如何分析 Main 指標。通過頁面載入過程的分析,就能掌握一套標準的分析 Main 指標的方法,在該方法中,將載入過程劃分為三個階段:

  1. 導航階段;
  2. 解析 HTML 檔案階段;
  3. 生成點陣圖階段。

  在導航流程中,主要是處理響應頭的資料,並執行一些老頁面退出之前的清理操作。在解析 HTML 資料階段,主要是解析 HTML 資料、解析 CSS 資料、執行 JS 來生成 DOM 和 CSSOM。最後在生成最終顯示點陣圖的階段,主要是將生成的 DOM 和 CSSOM 合併,這包括了佈局(Layout)、分層、繪製、合成等一系列操作。

  通過 Main 指標,完整地分析了一個頁面從載入到顯示的過程,瞭解這個流程,自然就會去分析頁面的效能瓶頸,比如可以通過 Main 指標來分析 JS 是否執行時間過久,或者通過 Main 指標分析程式碼裡面是否存在強制同步佈局等操作,分析出來這些原因之後,可以有針對性地去優化我們的程式。

思考題

  在《18 | 巨集任務和微任務:不是所有任務都是一個待遇》這節中介紹微任務時,我們提到過,在一個任務的執行過程中,會在一些特定的時間點來檢查是否有微任務需要執行,我們把這些特定的檢查時間點稱為檢查點。瞭解了檢查點之後,可以通過 Performance 的 Main 指標來分析下面這兩段程式碼:

<body>
    <script>
        let p = new Promise(function (resolve, reject)) {
            resolve('成功!')
        });

        p.then(function (successMessage) {
            console.log('p! ' + successMessage);
        })

        let p1 = new Promise(fucntion (resolve, reject) {
            resolve('成功!');
        });

        p1.then(function (successMessage) {
            console.log('p1!' + successMessage);
        })
    </script>
</body>

第一段程式碼

<body>
    <script>
        let p = new Promise(function (resolve, reject)) {
            resolve('成功!');
        });

        p.then(function (successMessage) {
            console.log('p! ' + successMessage);
        })
    </script>
    <script>
        let p1 = new Promise(function (resolve, reject) {
            resolve('成功!')
        });
        
        p1.then(function (successMessage) {
            console.log('p1! ' + successMessage);
        })
    </script>
</body>

第二段程式碼

  今天的任務是結合 Main 指標來分析上面這兩段程式碼中微任務執行的時間點有何不同,並給出分析結果和原因。