1. 程式人生 > 實用技巧 >瀏覽器工作原理和實踐(三)——頁面

瀏覽器工作原理和實踐(三)——頁面

  《瀏覽器工作原理與實踐》是極客時間上的一個瀏覽器學習系列,在學習之後特在此做記錄和總結。

一、事件迴圈

  訊息佇列是一種資料結構,可以存放要執行的任務。它符合佇列“先進先出”的特點,也就是說要新增任務的話,新增到佇列的尾部;要取出任務的話,從佇列頭部去取。

  

  從上圖可以看出,改造可以分為下面三個步驟:

  (1)新增一個訊息佇列;

  (2)IO 執行緒中產生的新任務新增進訊息佇列尾部;

  (3)渲染主執行緒會迴圈地從訊息佇列頭部中讀取任務,執行任務。

1)處理其他程序傳送過來的任務

  從圖中可以看出,渲染程序專門有一個 IO 執行緒用來接收其他程序傳進來的訊息,接收到訊息之後,會將這些訊息組裝成任務傳送給渲染主執行緒,後續的步驟就和前面講解的“處理其他執行緒傳送的任務”一樣了。

  

2)訊息佇列中的任務型別

  包含很多內部訊息型別,如輸入事件(滑鼠滾動、點選、移動)、微任務、檔案讀寫、WebSocket、JavaScript 定時器等。

  除此之外,訊息佇列中還包含了很多與頁面相關的事件,如 JavaScript 執行、解析 DOM、樣式計算、佈局計算、CSS 動畫等。

  以上這些事件都是在主執行緒中執行的,所以在編寫 Web 應用時,還需要衡量這些事件所佔用的時長,並想辦法解決單個任務佔用主執行緒過久的問題。

3)安全退出

  確定要退出當前頁面時,頁面主執行緒會設定一個退出標誌的變數,在每次執行完一個任務時,判斷是否有設定退出標誌。

  如果設定了,那麼就直接中斷當前的所有任務,退出執行緒。

4)單執行緒的缺點

  (1)第一個問題是如何處理高優先順序的任務。

  如果 DOM 發生變化,採用同步通知的方式,會影響當前任務的執行效率;如果採用非同步方式,又會影響到監控的實時性。

  針對這種情況,微任務就應運而生了,下面來看看微任務是如何權衡效率和實時性的。

  通常把訊息佇列中的任務稱為巨集任務,每個巨集任務中都包含了一個微任務佇列,在執行巨集任務的過程中,如果 DOM 有變化,那麼就會將該變化新增到微任務列表中,這樣就不會影響到巨集任務的繼續執行,因此也就解決了執行效率的問題。

  等巨集任務中的主要功能都執行完成之後,這時候,渲染引擎並不著急去執行下一個巨集任務,而是執行當前巨集任務中的微任務,因為 DOM 變化的事件都儲存在這些微任務佇列中,這樣也就解決了實時性問題。

  (2)第二個是如何解決單個任務執行時長過久的問題。

  針對這種情況,JavaScript 可以通過回撥功能來規避這種問題,也就是讓要執行的 JavaScript 任務滯後執行。

二、WebAPI

1)定時器

  在 Chrome 中除了正常使用的訊息佇列之外,還有另外一個訊息佇列,這個佇列中維護了需要延遲執行的任務列表,包括了定時器和 Chromium 內部一些需要延遲執行的任務。

  所以當通過 JavaScript 建立一個定時器時,渲染程序會將該定時器的回撥任務新增到延遲佇列中。

  當通過 JavaScript 呼叫 setTimeout 設定回撥函式的時候,渲染程序將會建立一個回撥任務,包含了回撥函式 showName、當前發起時間和延遲執行時間。

  處理完訊息佇列中的一個任務之後,就開始執行延遲函式。該函式會根據發起時間和延遲時間計算出到期的任務,然後依次執行這些到期的任務。等到期的任務執行完成之後,再繼續下一個迴圈過程。通過這樣的方式,一個完整的定時器就實現了。

  使用定時器的注意事項:

  (1)如果當前任務執行時間過久,會影響定時器任務的執行。

  (2)如果 setTimeout 存在巢狀呼叫,那麼系統會設定最短時間間隔為 4 毫秒。

  (3)未啟用的頁面,setTimeout 執行最小間隔是 1000 毫秒。

  (4)延時執行時間有最大值。

  (5)使用 setTimeout 設定的回撥函式中的 this 不符合直覺,方法中的 this 關鍵字將指向全域性環境。

2)XMLHttpRequest

  XMLHttpRequest的工作過程可以參考下圖:

  

  setTimeout 是直接將延遲任務新增到延遲佇列中,而 XMLHttpRequest 發起請求,是由瀏覽器的其他程序或者執行緒去執行,然後再將執行結果利用 IPC 的方式通知渲染程序,之後渲染程序再將對應的訊息新增到訊息佇列中。

3)requestAnimationFrame

  根據實際情況,動態調整訊息佇列的優先順序。

  

  這張圖展示了 Chromium 在不同的場景下,是如何調整訊息佇列優先順序的。通過這種動態排程策略,就可以滿足不同場景的核心訴求了,同時這也是 Chromium 當前所採用的任務排程策略。

  當顯示器將一幀畫面繪製完成後,並在準備讀取下一幀之前,顯示器會發出一個垂直同步訊號(Vertical Synchronization)給 GPU,簡稱 VSync。

  具體地講,當 GPU 接收到 VSync 訊號後,會將 VSync 訊號同步給瀏覽器程序,瀏覽器程序再將其同步到對應的渲染程序,渲染程序接收到 VSync 訊號之後,就可以準備繪製新的一幀了,具體流程你可以參考下圖:

  

  在合成完成之後,合成執行緒會提交給渲染主執行緒提交完成合成的訊息,如果當前合成操作執行的非常快,比如從使用者發出訊息到完成合成操作只花了 8 毫秒,因為 VSync 同步週期是 16.66(1/60)毫秒,那麼這個 VSync 時鐘週期內就不需要再次生成新的頁面了。那麼從合成結束到下個 VSync 週期內,就進入了一個空閒時間階段,那麼就可以在這段空閒時間內執行一些不那麼緊急的任務,比如 V8 的垃圾回收,或者通過 window.requestIdleCallback() 設定的回撥任務等,都會在這段空閒時間內執行。

  CSS 動畫是由渲染程序自動處理的,所以渲染程序會讓 CSS 渲染每幀動畫的過程與 VSync 的時鐘保持一致, 這樣就能保證 CSS 動畫的高效率執行。

  但是 JavaScript 是由使用者控制的,如果採用 setTimeout 來觸發動畫每幀的繪製,那麼其繪製時機是很難和 VSync 時鐘保持一致的,所以 JavaScript 中又引入了 window.requestAnimationFrame,用來和 VSync 的時鐘週期同步,它的回撥任務會在每一幀的開始執行。

三、巨集任務和微任務

1)巨集任務

  頁面中的大部分任務都是在主執行緒上執行的,這些任務包括了:

  (1)渲染事件(如解析 DOM、計算佈局、繪製);

  (2)使用者互動事件(如滑鼠點選、滾動頁面、放大縮小等);

  (3)JavaScript 指令碼執行事件;

  (4)網路請求完成、檔案讀寫完成事件。

  為了協調這些任務有條不紊地在主執行緒上執行,頁面程序引入了訊息佇列和事件迴圈機制,渲染程序內部會維護多個訊息佇列,比如延遲執行佇列和普通的訊息佇列。然後主執行緒採用一個 for 迴圈,不斷地從這些任務佇列中取出任務並執行任務。把這些訊息佇列中的任務稱為巨集任務。

  頁面的渲染事件、各種 IO 的完成事件、執行 JavaScript 指令碼的事件、使用者互動的事件等都隨時有可能被新增到訊息佇列中,而且新增事件是由系統操作的,JavaScript 程式碼不能準確掌控任務要新增到佇列中的位置,控制不了任務在訊息佇列中的位置,所以很難控制開始執行任務的時間。

<!DOCTYPE html>
<html>
    <body>
        <div id='demo'>
            <ol>
                <li>test</li>
            </ol>
        </div>
    </body>
    <script type="text/javascript">
        function timerCallback2(){
          console.log(2)
        }
        function timerCallback(){
            console.log(1)
            setTimeout(timerCallback2,0)
        }
        setTimeout(timerCallback,0)
    </script>
</html>

  在這段程式碼中,目的是想通過 setTimeout 來設定兩個回撥任務,並讓它們按照前後順序來執行,中間也不要再插入其他的任務。

  但實際情況是不能控制的,比如在呼叫 setTimeout 來設定回撥任務的間隙,訊息佇列中就有可能被插入很多系統級的任務。

  

  所以說巨集任務的時間粒度比較大,執行的時間間隔是不能精確控制的,對一些高實時性的需求就不太符合了,比如監聽 DOM 變化的需求。

2)微任務

  微任務就是一個需要非同步執行的函式,執行時機是在主函式執行結束之後、當前巨集任務結束之前。也就是說每個巨集任務都關聯了一個微任務佇列。

  當 JavaScript 執行一段指令碼的時候,V8 會為其建立一個全域性執行上下文,在建立全域性執行上下文的同時,V8 引擎也會在內部建立一個微任務佇列。

  在現代瀏覽器裡面,產生微任務有兩種方式。

  (1)第一種方式是使用 MutationObserver 監控某個 DOM 節點,然後再通過 JavaScript 來修改這個節點,或者為這個節點新增、刪除部分子節點,當 DOM 節點發生變化時,就會產生 DOM 變化記錄的微任務。

  (2)第二種方式是使用 Promise,當呼叫 Promise.resolve() 或者 Promise.reject() 的時候,也會產生微任務。

  通常情況下,在當前巨集任務中的 JavaScript 快執行完成時,也就在 JavaScript 引擎準備退出全域性執行上下文並清空呼叫棧的時候,JavaScript 引擎會檢查全域性執行上下文中的微任務佇列,然後按照順序執行佇列中的微任務。

  WHATWG 把執行微任務的時間點稱為檢查點。

  在執行微任務過程中產生的新的微任務並不會推遲到下個巨集任務中執行,而是在當前的巨集任務中繼續執行。

  

  在 JavaScript 指令碼的後續執行過程中,分別通過 Promise 和 removeChild 建立了兩個微任務,並被新增到微任務列表中。接著 JavaScript 執行結束,準備退出全域性執行上下文,這時候就到了檢查點了,JavaScript 引擎會檢查微任務列表,發現微任務列表中有微任務,那麼接下來,依次執行這兩個微任務。等微任務佇列清空之後,就退出全域性執行上下文。

  從上面分析可以得出如下幾個結論:

  (1)微任務和巨集任務是繫結的,每個巨集任務在執行時,會建立自己的微任務佇列。

  (2)微任務的執行時長會影響到當前巨集任務的時長。

  (3)在一個巨集任務中,分別建立一個用於回撥的巨集任務和微任務,無論什麼情況下,微任務都早於巨集任務執行。

3)監聽 DOM 變化方法

  MutationObserver 是用來監聽 DOM 變化的一套方法。MutationObserver API 可以用來監視 DOM 的變化,包括屬性的變化、節點的增減、內容的變化等。

  相比較 Mutation Event,MutationObserver 的改進如下:

  (1)首先,MutationObserver 將響應函式改成非同步呼叫,可以不用在每次 DOM 變化都觸發非同步呼叫,而是等多次 DOM 變化後,一次觸發非同步呼叫,並且還會使用一個數據結構來記錄這期間所有的 DOM 變化。

  (2)在每次 DOM 節點發生變化的時候,渲染引擎將變化記錄封裝成微任務,並將微任務新增進當前的微任務佇列中。這樣當執行到檢查點的時候,V8 引擎就會按照順序執行微任務了。

四、Promise

  如果你想要學習一門新技術,最好的方式是先了解這門技術是如何誕生的,以及它所解決的問題是什麼。瞭解了這些後,你才能抓住這門技術的本質。

  如果嵌套了太多的回撥函式就很容易使得自己陷入了回撥地獄,並且程式碼看起來會很亂,原因如下。

  (1)第一是巢狀呼叫,下面的任務依賴上個任務的請求結果,並在上個任務的回撥函式內部執行新的業務邏輯,這樣當巢狀層次多了之後,程式碼的可讀性就變得非常差了。

  (2)第二是任務的不確定性,執行每個任務都有兩種可能的結果(成功或者失敗),這種對每個任務都要進行一次額外的錯誤處理的方式,明顯增加了程式碼的混亂程度。

  原因分析出來後,那麼問題的解決思路就很清晰了:

  (1)第一是消滅巢狀呼叫;

  (2)第二是合併多個任務的錯誤處理。

1)消滅巢狀

  Promise 主要通過下面兩步解決巢狀回撥問題的。

  (1)首先,Promise 實現了回撥函式的延時繫結。

  回撥函式的延時繫結在程式碼上體現就是先建立 Promise 物件 x1,通過 Promise 的建構函式 executor 來執行業務邏輯;建立好 Promise 物件 x1 之後,再使用 x1.then 來設定回撥函式。

  (2)其次,需要將回調函式 onResolve 的返回值穿透到最外層。

  因為根據 onResolve 函式的傳入值來決定建立什麼型別的 Promise 任務,建立好的 Promise 物件需要返回到最外層,這樣就可以擺脫巢狀迴圈了。

  

2)合併錯誤處理

  無論哪個物件裡面丟擲異常,都可以通過最後一個物件 catch 來捕獲異常,通過這種方式可以將所有 Promise 物件的錯誤合併到一個函式來處理,這樣就解決了每個任務都需要單獨處理異常的問題。

  之所以可以使用最後一個物件來捕獲所有異常,是因為 Promise 物件的錯誤具有“冒泡”性質,會一直向後傳遞,直到被 onReject 函式處理或 catch 語句捕獲為止。

3)Promise 與微任務

  Promise 之所以要使用微任務是由 Promise 回撥函式延遲繫結技術導致的。

  下面用一個自定義的 Bromise 來實現Promise。

function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
    //模擬實現resolve和then,暫不支援rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    function resolve(value) {
        //setTimeout(()=>{
            onResolve_(value)
        //},0)
    }
    executor(resolve, null);
}

function executor(resolve, reject) {
    resolve(100)
}
//將Promise改成自定義的Bromsie
let demo = new Bromise(executor)
function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

  執行這段程式碼,發現執行出錯,輸出的內容是:

Uncaught TypeError: onResolve_ is not a function

  之所以出現這個錯誤,是由於 Bromise 的延遲繫結導致的,在呼叫到 onResolve_ 函式的時候,Bromise.then 還沒有執行,所以執行上述程式碼的時候,當然會報錯。

  要讓 resolve 中的 onResolve_ 函式延後執行,可以在 resolve 函式裡面加上一個定時器,也就是取消程式碼中的註釋。

  但是採用定時器的效率並不是太高,好在有微任務,所以 Promise 又把這個定時器改造成了微任務。

五、async/await

  使用 promise.then 仍然是相當複雜,雖然整個請求流程已經線性化了,但是程式碼裡面包含了大量的 then 函式,使得程式碼依然不是太容易閱讀。

  基於這個原因,ES7 引入了 async/await,這是 JavaScript 非同步程式設計的一個重大改進,提供了在不阻塞主執行緒的情況下用同步程式碼實現非同步訪問資源的能力,並且使得程式碼邏輯更加清晰。

1)生成器

  生成器(Generator)函式是一個帶星號函式,而且可以暫停和恢復執行。

function* genDemo() {
    console.log("開始執行第一段")
    yield 'generator 2'

    console.log("開始執行第二段")
    yield 'generator 2'

    console.log("開始執行第三段")
    yield 'generator 2'

    console.log("執行結束")
    return 'generator 2'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

  下面來看看生成器函式的具體使用方式:

  (1)在生成器函式內部執行一段程式碼,如果遇到 yield 關鍵字,那麼 JavaScript 引擎將返回關鍵字後面的內容給外部,並暫停該函式的執行。

  (2)外部函式可以通過 next 方法恢復函式的執行。

2)協程

  要搞懂函式為何能暫停和恢復,那首先要了解協程的概念。協程是一種比執行緒更加輕量級的存在。

  可以把協程看成是跑線上程上的任務,一個執行緒上可以存在多個協程,但是線上程上同時只能執行一個協程。

  如果從 A 協程啟動 B 協程,就把 A 協程稱為 B 協程的父協程。

  協程不是被作業系統核心所管理,而完全是由程式所控制(也就是在使用者態執行)。這樣帶來的好處就是效能得到了很大的提升,不會像執行緒切換那樣消耗資源。

  結合上面那段程式碼的執行過程,畫出了下面的“協程執行流程圖”。

  

  (1)通過呼叫生成器函式 genDemo 來建立一個協程 gen,建立之後,gen 協程並沒有立即執行。

  (2)要讓 gen 協程執行,需要通過呼叫 gen.next。

  (3)當協程正在執行的時候,可以通過 yield 關鍵字來暫停 gen 協程的執行,並返回主要資訊給父協程。

  (4)如果協程在執行期間,遇到了 return 關鍵字,那麼 JavaScript 引擎會結束當前協程,並將 return 後面的內容返回給父協程。

  為了直觀理解父協程和 gen 協程是如何切換呼叫棧的,可以參考下圖:

  

  在 JavaScript 中,生成器就是協程的一種實現方式。

//foo函式
function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}

//執行foo函式的程式碼
let gen = foo()
function getGenPromise(gen) {
    return gen.next().value
}
getGenPromise(gen).then((response) => {
    console.log('response1')
    console.log(response)
    return getGenPromise(gen)
}).then((response) => {
    console.log('response2')
    console.log(response)
})

  foo 函式是一個生成器函式,在 foo 函式裡面實現了用同步程式碼形式來實現非同步操作。

  不過通常,把執行生成器的程式碼封裝成一個函式,並把這個執行生成器程式碼的函式稱為執行器(可參考著名的 co 框架)。

3)async/await

  async/await 技術背後的祕密就是 Promise 和生成器應用,往低層說就是微任務和協程應用。

  根據 MDN 定義,async 是一個通過非同步執行並隱式返回 Promise 作為結果的函式。

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

  先站在協程的視角來看看這段程式碼的整體執行流程圖:

  

  當執行到await 100時,會預設建立一個 Promise 物件。

let promise_ = new Promise((resolve,reject){
  resolve(100)
})

  然後 JavaScript 引擎會暫停當前協程的執行,將主執行緒的控制權轉交給父協程執行,同時會將 promise_ 物件返回給父協程。

  接下來繼續執行父協程的流程,打印出 3。隨後父協程將執行結束,在結束之前,會進入微任務的檢查點,然後執行微任務佇列。

  最後觸發 promise_.then 中的回撥函式,將主執行緒的控制權交給 foo 函式的協程,並同時將 value 值傳給該協程,並繼續執行後續列印語句。

六、渲染流水線

1)含有 CSS

  先結合下面程式碼來看看最簡單的渲染流程:

<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
</body>
</html>

  

  請求 HTML 資料和構建 DOM 中間有一段空閒時間,這個空閒時間有可能成為頁面渲染的瓶頸。

  在 DOM 構建結束之後、theme.css 檔案還未下載完成的這段時間內,渲染流水線無事可做,因為下一步是合成佈局樹,而合成佈局樹需要 CSSOM 和 DOM,所以這裡需要等待 CSS 載入結束並解析成 CSSOM。

  CSSOM 體現在 DOM 中就是document.styleSheets,和 DOM 一樣,CSSOM 也具有兩個作用。

  (1)第一個是提供給 JavaScript 操作樣式表的能力。

  (2)第二個是為佈局樹的合成提供基礎的樣式資訊。

2)含有JavaScript和CSS

  這段程式碼是我在開頭程式碼的基礎之上做了一點小修改,在 body 標籤內部加了一個簡單的 JavaScript。

<html>
<head>
    <link href="theme.css" rel="stylesheet">
</head>
<body>
    <div>geekbang com</div>
    <script>
        console.log('time.geekbang.org')
    </script>
    <div>geekbang com</div>
</body>
</html>

  

  在執行 JavaScript 指令碼之前,如果頁面中包含了外部 CSS 檔案的引用,或者通過 style 標籤內建了 CSS 內容,那麼渲染引擎還需要將這些內容轉換為 CSSOM,因為 JavaScript 有修改 CSSOM 的能力,所以在執行 JavaScript 之前,還需要依賴 CSSOM。也就是說 CSS 在部分情況下也會阻塞 DOM 的生成。

3)白屏

  從發起 URL 請求開始,到首次顯示頁面的內容,在視覺上經歷的三個階段。

  (1)第一個階段,等請求發出去之後,到提交資料階段,這時頁面展示出來的還是之前頁面的內容。

  (2)第二個階段,提交資料之後渲染程序會建立一個空白頁面,通常把這段時間稱為解析白屏,並等待 CSS 檔案和 JavaScript 檔案的載入完成,生成 CSSOM 和 DOM,然後合成佈局樹,最後還要經過一系列的步驟準備首次渲染。

  (3)第三個階段,等首次渲染完成之後,就開始進入完整頁面的生成階段了,然後頁面會一點點被繪製出來。

  要想縮短白屏時長,可以有以下策略:

  (1)通過內聯 JavaScript、內聯 CSS 來移除這兩種型別的檔案下載,這樣獲取到 HTML 檔案之後就可以直接開始渲染流程了。

  (2)還可以儘量減少檔案大小,比如通過 webpack 等工具移除一些不必要的註釋,並壓縮 JavaScript 檔案。

  (3)將一些不需要在解析 HTML 階段使用的 JavaScript 標記上 sync 或者 defer。

  (4)對於大的 CSS 檔案,可以通過媒體查詢屬性,將其拆分為多個不同用途的 CSS 檔案。

七、頁面效能

  頁面優化,其實就是要讓頁面更快地顯示和響應。

  通常一個頁面有三個階段:載入階段、互動階段和關閉階段。

  (1)載入階段,是指從發出請求到渲染出完整頁面的過程,影響到這個階段的主要因素有網路和 JavaScript 指令碼。

  (2)互動階段,主要是從頁面載入完成到使用者互動的整合過程,影響到這個階段的主要因素是 JavaScript 指令碼。

  (3)關閉階段,主要是使用者發出關閉指令後頁面所做的一些清理操作。

1)載入階段

  把這些能阻塞網頁首次渲染的資源稱為關鍵資源,例如JavaScript、首次請求的 HTML 資原始檔、CSS 檔案。

  基於關鍵資源,繼續細化出來三個影響頁面首次渲染的核心因素。

  (1)第一個是關鍵資源個數。

  (2)第二個是關鍵資源大小。

  (3)第三個是請求關鍵資源需要多少個 RTT。

  RTT(Round Trip Time) 就是這裡的往返時延。它是網路中一個重要的效能指標,表示從傳送端傳送資料開始,到傳送端收到來自接收端的確認,總共經歷的時長。

  總的優化原則就是減少關鍵資源個數,降低關鍵資源大小,降低關鍵資源的 RTT 次數。

  (1)將 JavaScript 和 CSS 改成內聯的形式。如果 JavaScript 程式碼沒有 DOM 或者 CSSOM 的操作,則可以改成 async 或者 defer 屬性;同樣對於 CSS,如果不是在構建頁面之前載入的,則可以新增媒體取消阻止顯現的標誌。

  (2)壓縮 CSS 和 JavaScript 資源,移除 HTML、CSS、JavaScript 檔案中一些註釋內容,也可以通過取消 CSS 或者 JavaScript 中關鍵資源的方式。

  (3)通過減少關鍵資源的個數和減少關鍵資源的大小搭配來實現。除此之外,還可以使用 CDN 來減少每次 RTT 時長。

2)互動階段

  談互動階段的優化,其實就是在談渲染程序渲染幀的速度,因為在互動階段,幀的渲染速度決定了互動的流暢度。

  先來看看互動階段的渲染流水線(如下圖)。

  

  大部分情況下,生成一個新的幀都是由 JavaScript 通過修改 DOM 或者 CSSOM 來觸發的。還有另外一部分幀是由 CSS 來觸發的。

  一個大的原則就是讓單個幀的生成速度變快。所以,下面就來分析下在互動階段渲染流水線中有哪些因素影響了幀的生成速度以及如何去優化。

  (1)減少 JavaScript 指令碼執行時間,不要一次霸佔太久主執行緒。

  一種策略是將一次執行的函式分解為多個任務,另一種是把一些和 DOM 操作無關且耗時的任務放到 Web Workers 中去執行。

  (2)避免強制同步佈局。

  通過 DOM 介面執行新增元素或者刪除元素等操作後,是需要重新計算樣式和佈局的,不過正常情況下這些操作都是在另外的任務中非同步完成的,這樣做是為了避免當前的任務佔用太長的主執行緒時間。

  

  執行 JavaScript 新增元素是在一個任務中執行的,重新計算樣式佈局是在另外一個任務中執行。

  所謂強制同步佈局,是指 JavaScript 強制將計算樣式和佈局操作提前到當前的任務中。

function foo() {
    let main_div = document.getElementById("mian_div")
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);
    //由於要獲取到offsetHeight,
    //但是此時的offsetHeight還是老的資料,
    //所以需要立即執行佈局操作
    console.log(main_div.offsetHeight)
}

  

  為了避免強制同步佈局,可以調整策略,在修改 DOM 之前查詢相關值。

function foo() {
    let main_div = document.getElementById("mian_div")
    //為了避免強制同步佈局,在修改DOM之前查詢相關值
    console.log(main_div.offsetHeight)
    let new_node = document.createElement("li")
    let textnode = document.createTextNode("time.geekbang")
    new_node.appendChild(textnode);
    document.getElementById("mian_div").appendChild(new_node);   
}

  (3)避免佈局抖動。

  所謂佈局抖動,是指在一次 JavaScript 執行過程中,多次執行強制佈局和抖動操作。

function foo() {
    let time_li = document.getElementById("time_li")
    for (let i = 0; i < 100; i++) {
        let main_div = document.getElementById("mian_div")
        let new_node = document.createElement("li")
        let textnode = document.createTextNode("time.geekbang")
        new_node.appendChild(textnode);
        new_node.offsetHeight = time_li.offsetHeight;
        document.getElementById("mian_div").appendChild(new_node);
    }
}

  

  (4)合理利用 CSS 合成動畫。

  合成動畫是直接在合成執行緒上執行的,這和在主執行緒上執行的佈局、繪製等操作不同,如果主執行緒被 JavaScript 或者一些佈局任務佔用,CSS 動畫依然能繼續執行。

  另外,如果能提前知道對某個元素執行動畫操作,那就最好將其標記為 will-change,這是告訴渲染引擎需要將該元素單獨生成一個圖層。

  (5)避免頻繁的垃圾回收。

  要儘量避免產生那些臨時垃圾資料。儘可能優化儲存結構,儘可能避免小顆粒物件的產生。

3)虛擬DOM

  虛擬 DOM 解決的事情。

  (1)將頁面改變的內容應用到虛擬 DOM 上,而不是直接應用到 DOM 上。

  (2)變化被應用到虛擬 DOM 上時,虛擬 DOM 並不急著去渲染頁面,而僅僅是調整虛擬 DOM 的內部狀態,這樣操作虛擬 DOM 的代價就變得非常輕了。

  (3)在虛擬 DOM 收集到足夠的改變時,再把這些變化一次性應用到真實的 DOM 上。

  把虛擬 DOM 看成是 DOM 的一個 buffer,和圖形顯示一樣,它會在完成一次完整的操作之後,再把結果應用到 DOM 上,這樣就能減少一些不必要的更新,同時還能保證 DOM 的穩定輸出。

  

八、WebComponent

  WebComponent能提供給開發者元件化開發的能力。

  對內高內聚,對外低耦合。對內各個元素彼此緊密結合、相互依賴,對外和其他元件的聯絡最少且介面簡單。

  WebComponent提供了對區域性檢視的封裝能力,可以讓 DOM、CSSOM 和 JavaScript 執行在區域性環境中,這樣就使得區域性的 CSS 和 DOM 不會影響到全域性。

1)使用

  WebComponent 是一套技術的組合,具體涉及到了 Custom elements(自定義元素)、Shadow DOM(影子 DOM)和HTML templates(HTML 模板)。

<!DOCTYPE html>
<html>
<body>
    <!--
            一:定義模板
            二:定義內部CSS樣式
            三:定義JavaScript行為
    -->
    <template id="geekbang-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk
            }


            div {
                width: 200px;
                background-color: bisque;
                border: 3px solid chocolate;
                border-radius: 10px;
            }
        </style>
        <div>
            <p>time.geekbang.org</p>
            <p>time1.geekbang.org</p>
        </div>
        <script>
            function foo() {
                console.log('inner log')
            }
        </script>
    </template>
    <script>
        class GeekBang extends HTMLElement {
            constructor() {
                super()
                //獲取元件模板
                const content = document.querySelector('#geekbang-t').content
                //建立影子DOM節點
                const shadowDOM = this.attachShadow({ mode: 'open' })
                //將模板新增到影子DOM上
                shadowDOM.appendChild(content.cloneNode(true))
            }
        }
        customElements.define('geek-bang', GeekBang)
    </script>

    <geek-bang></geek-bang>
    <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
    </div>
    <geek-bang></geek-bang>
</body>
</html>

  要使用 WebComponent,通常要實現下面三個步驟。

  (1)首先,使用 template 屬性來建立模板。

  (2)其次,需要建立一個 GeekBang 的類。查詢模板內容;建立影子 DOM;再將模板新增到影子 DOM 上。

  (3)最後,可以像正常使用 HTML 元素一樣使用該元素。

2)影子 DOM

  WebComponent的核心就是影子 DOM。

  (1)影子 DOM 中的元素對於整個網頁是不可見的;

  (2)影子 DOM 的 CSS 不會影響到整個網頁的 CSSOM,影子 DOM 內部的 CSS 只對內部的元素起作用。

  

  從圖中可以看出,使用了兩次 geek-bang 屬性,那麼就會生成兩個影子 DOM,並且每個影子 DOM 都有一個 shadow root 的根節點。

  可以將要展示的樣式或者元素新增到影子 DOM 的根節點上,每個影子 DOM 都可以看成是一個獨立的 DOM,它有自己的樣式、自己的屬性,內部樣式不會影響到外部樣式,外部樣式也不會影響到內部樣式。

九、安全沙箱

  在渲染程序和作業系統之間建一道牆,即便渲染程序由於存在漏洞被黑客攻擊,但由於這道牆,黑客就獲取不到渲染程序之外的任何操作許可權。將渲染程序和作業系統隔離的這道牆就是安全沙箱。

  瀏覽器中的安全沙箱是利用作業系統提供的安全技術,讓渲染程序在執行過程中無法訪問或者修改作業系統中的資料,在渲染程序需要訪問系統資源的時候,得通過瀏覽器核心來實現,然後將訪問的結果通過 IPC 轉發給渲染程序。

  瞭解了被安全沙箱保護的程序會有一系列的受限操作之後,接下來就可以分析渲染程序和瀏覽器核心各自都有哪些職責,如下圖:

  

1)持久儲存

  檔案內容的讀寫都是在瀏覽器核心中完成的:

  (1)儲存 Cookie 資料的讀寫。通常瀏覽器核心會維護一個存放所有 Cookie 的 Cookie 資料庫,然後當渲染程序通過 JavaScript 來讀取 Cookie 時,渲染程序會通過 IPC 將讀取 Cookie 的資訊傳送給瀏覽器核心,瀏覽器核心讀取 Cookie 之後再將內容返回給渲染程序。

  (2)一些快取檔案的讀寫也是由瀏覽器核心實現的,比如網路檔案快取的讀取。

2)網路訪問

  同樣有了安全沙箱的保護,在渲染程序內部也是不能直接訪問網路的,如果要訪問網路,則需要通過瀏覽器核心。

  不過瀏覽器核心在處理 URL 請求之前,會檢查渲染程序是否有許可權請求該 URL,比如檢查 XMLHttpRequest 或者 Fetch 是否是跨站點請求,或者檢測 HTTPS 的站點中是否包含了 HTTP 的請求。

3)使用者互動

  由於渲染程序不能直接訪問視窗控制代碼,所以渲染程序需要完成以下兩點大的改變。

  (1)第一點,渲染程序需要渲染出點陣圖。為了向用戶顯示渲染程序渲染出來的點陣圖,渲染程序需要將生成好的點陣圖傳送到瀏覽器核心,然後瀏覽器核心將點陣圖複製到螢幕上。

  (2)第二點,作業系統沒有將使用者輸入事件直接傳遞給渲染程序,而是將這些事件傳遞給瀏覽器核心。然後瀏覽器核心再根據當前瀏覽器介面的狀態來判斷如何排程這些事件,如果當前焦點位於瀏覽器位址列中,則輸入事件會在瀏覽器核心內部處理;如果當前焦點在頁面的區域內,則瀏覽器核心會將輸入事件轉發給渲染程序。

  之所以這樣設計,就是為了限制渲染程序有監控到使用者輸入事件的能力,所以所有的鍵盤滑鼠事件都是由瀏覽器核心來接收的,然後瀏覽器核心再通過 IPC 將這些事件傳送給渲染程序。

十、Chrome效能工具

1)Audits

  效能指標的分數是由六項指標決定的,它們分別是:

  (1)首次繪製 (First Paint)。

  如果 FP 時間過久,那就是頁面的 HTML 檔案可能由於網路原因導致載入時間過久,可利用網路面板做效能分析。

  (2)首次有效繪製 (First Meaningfull Paint),由於 FMP 計算複雜,所以現在不建議使用該指標了;

  (3)首屏時間 (Speed Index),即 LCP;

  如果 FMP 和 LCP 消耗時間過久,那麼有可能是載入關鍵資源花的時間過久,也有可能是 JavaScript 執行過程中所花的時間過久,所以可以針對具體的情況來具體分析。

  (4)首次 CPU 空閒時間 (First CPU Idle),也稱為 First Interactive,對大部分使用者輸入做出響應即可;

  要縮短首次 CPU 空閒時長,就需要儘可能快地載入完關鍵資源,儘可能快地渲染出來首屏內容。

  (5)完全可互動時間 (Time to Interactive),簡稱 TTI,頁面的內容已經完全顯示出來了,所有的 JavaScript 事件已經註冊完成,頁面能夠對使用者的互動做出快速響應,通常滿足響應速度在 50 毫秒以內。

  如果要解決 TTI 時間過久的問題,可以推遲執行一些和生成頁面無關的 JavaScript 工作。

  (6)最大估計輸入延時 (Max Potential First Input Delay),估計 Web 頁面在載入最繁忙的階段,視窗中響應使用者輸入所需的時間。

  為了改善該指標,可以使用 Web Worker 來執行一些計算,從而釋放主執行緒。另一個有用的措施是重構 CSS 選擇器,以確保它們執行較少的計算。

  

  在渲染程序確認要渲染當前的請求後,渲染程序會建立一個空白頁面,把建立空白頁面的這個時間點稱為 First Paint,簡稱 FP。

  上圖中,bundle.js 是關鍵資源,因此需要完成載入之後,渲染程序才能執行該指令碼,然後指令碼會修改 DOM,引發重繪和重排等一系列操作,當頁面中繪製了第一個畫素時,把這個時間點稱為 First Content Paint,簡稱 FCP。

  接下來繼續執行 JavaScript 指令碼,當首屏內容完全繪製完成時,把這個時間點稱為 Largest Content Paint,簡稱 LCP。

  接下來 JavaScript 指令碼執行結束,渲染程序判斷該頁面的 DOM 生成完畢,於是觸發 DOMContentLoad 事件。等所有資源都載入結束之後,再觸發 onload 事件。

2)Performance

  Performance 可以記錄站點在執行過程中的效能資料,有了這些效能資料,就可以回放整個頁面的執行過程,這樣就方便定位和診斷每個時間段內頁面的執行情況

  下圖區域 1,設定該區域中的“Network”來限制網路載入速度,設定“CPU”來限制 CPU 的運算速度。區域 2 和區域 3有兩個按鈕,黑色按鈕是用來記錄互動階段效能資料的,帶箭頭的圓圈形按鈕用來記錄載入階段的效能資料。

  

  無論採用哪種方式錄製,最終所生成的報告頁都是一樣的。

  

  (1)概覽面板

  引入了時間線,Performance 就會將幾個關鍵指標,諸如頁面幀速 (FPS)、CPU 資源消耗、網路請求流量、V8 記憶體使用量 (堆記憶體) 等,按照時間順序做成圖表的形式展現出來。

  除了以上指標以外,概覽面板還展示載入過程中的幾個關鍵時間節點,如 FP、LCP、DOMContentLoaded、Onload 等事件產生的時間點。這些關鍵時間點體現在了幾條不同顏色的豎線上。

  (2)效能面板

  記錄了非常多的效能指標項,比如 Main 指標記錄渲染主執行緒的任務執行過程,Compositor 指標記錄了合成執行緒的任務執行過程,GPU 指標記錄了 GPU 程序主執行緒的任務執行過程。

  通過概覽面板來定位問題的時間節點,然後再使用效能面板分析該時間節點內的效能資料。比如概覽面板中的 FPS 圖表中出現了紅色塊,那麼點選該紅色塊,效能面板就定位到該紅色塊的時間節點內。

  

  (3)解讀效能面板的各項指標

  先看最為重要的 Main 指標,它記錄了渲染程序的主執行緒的任務執行記錄。

  

  光柵化執行緒池 (Raster),用來讓 GPU 執行光柵化的任務。因為光柵化執行緒池和 GPU 程序中的任務執行也會影響到頁面的效能,所以效能面板也添加了這兩個指標,分別是 Raster 指標和 GPU 指標。

  渲染程序中除了有主執行緒、合成執行緒、光柵化執行緒池之外,還維護了一個 IO 執行緒。

  除此之外,效能面板還添加了其他一些比較重要的效能指標。

  a、第一個是 Network 指標,網路記錄展示了頁面中的每個網路請求所消耗的時長,並以瀑布流的形式展現。

  b、第二個是 Timings 指標,用來記錄一些關鍵的時間節點在何時產生的資料資訊,諸如 FP、FCP、LCP 等。

  c、第三個是 Frames 指標,幀記錄就是用來記錄渲染程序生成所有幀資訊,包括了渲染出每幀的時長、每幀的圖層構造等資訊。

  d、第四個是 Interactions 指標,用來記錄使用者互動操作,比如點選滑鼠、輸入文字等互動資訊。

  (4)詳情面板

  通過上面的圖形只能得到一個大致的資訊,如果想要檢視這些記錄的詳細資訊,就需要引入詳情面板了。