從瀏覽器環境到JavaScript執行流程的一次簡單梳理

我的孫女叫小芳發表於2019-03-03

參考文章

www.ruanyifeng.com/blog/2014/1…

www.zhihu.com/question/64…

www.alloyteam.com/2016/05/jav…

nodejs.org/zh-cn/docs/…

juejin.im/post/59e85e…

juejin.im/post/5be5a0…

juejin.im/post/5a6547…

瀏覽器環境的多程式環境

首先明確一下兩個概念

  • 程式是CUP資源分配的最小單位 (每個程式之間相互獨立,各自擁有一塊執行資源)
  • 執行緒是CPU資源排程的最小單位 (一個程式可以包含多個執行緒,多個執行緒協作完成任務,共享一個程式中的資源)
    現代瀏覽器是一個及其龐大的大型軟體,在某種程度上甚至不亞於一個作業系統,它由多媒體支援、圖形顯示、GPU渲染、程式管理、記憶體管理、沙箱機制、儲存系統、網路管理等大大小小數百個元件組成。如果這麼大的一個軟體是單程式的,那麼其中一個元件出現問題,整個瀏覽器就無法運作,及其影響使用者體驗,所以瀏覽器在實現上是多程式的。

瀏覽器擁有的多個程式

  • 一個 Browser 程式
    • 瀏覽器的主程式,負責瀏覽器介面的顯示與使用者互動。
    • 負責建立和銷燬其他程式
    • 網路資源管理
  • 多個 Renderer 程式
    • 每個tab頁一個程式,瀏覽器有自己的優化策略,如多個空白tab頁的時候會將其合併
    • 每個iframe頁面單獨一個renderer程式
    • 每個renderer程式是一個獨立的沙箱,相互之間隔離不受影響
  • 一個 GPU 程式
  • 多個 NPAPI Render 程式多個 Pepper Plugin 程式
    • 每種型別的外掛對應一個程式,僅當使用該外掛時才建立

瀏覽器多執行緒的渲染程式(Renderer程式)

程式碼寫的怎麼樣,頁面效能如何的直觀感覺是頁面生成的快不快,這個與瀏覽器的渲染程式息息相關。下面先梳理一下

Renderer程式有哪些主要的執行緒

  • GUI渲染執行緒
    • 負責渲染瀏覽器介面,解析HTML,CSS,構建DOM樹和RenderObject樹,佈局和繪製等。
    • 當介面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該執行緒就會執行
    • 注意,GUI渲染執行緒與JS引擎執行緒是互斥的,當JS引擎執行時GUI執行緒會被掛起(相當於被凍結了),GUI更新會被儲存在一個佇列中等到JS引擎空閒時立即被執行。
  • JS引擎執行緒
    • 也稱為JS核心,負責處理Javascript指令碼程式。(例如V8引擎)
    • JS引擎執行緒負責解析Javascript指令碼,執行程式碼。
    • 一個Renderer程式中只有一個JS引擎執行緒
    • GUI渲染執行緒與JS引擎執行緒是互斥的,所以如果JS執行的時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染載入阻塞。
  • 事件觸發執行緒
    • 歸屬於瀏覽器而不是JS引擎,用來控制事件迴圈(點選,滑鼠移動這些都是瀏覽器事件)
    • 事件觸發之後會加入到事件佇列等待執行
  • 定時觸發器執行緒
    • setIntervalsetTimeout所在的執行緒
    • 瀏覽器定時計數器並不是由JavaScript引擎計數的,是交給瀏覽器計時
    • setTimeout(fn,ms) 指定某個任務在主執行緒最早可得的空閒時間執行,ms秒之後將fn函式加入到佇列中
    • W3C在HTML標準中規定,規定要求setTimeout中低於4ms的時間間隔算為4ms。所以setTimeout的延時設定為0也不可能瞬發
  • 非同步http請求執行緒
    • 在XMLHttpRequest連線後是通過瀏覽器新開一個執行緒請求
    • 將檢測到狀態變更時,如果設定有回撥函式,非同步執行緒就產生狀態變更事件,將這個回撥再放入事件佇列中。再由JavaScript引擎執行。

GUI渲染執行緒與JS引擎執行緒互斥

由於JavaScript是可操縱DOM的,如果在修改這些元素屬性同時渲染介面(即JS執行緒和UI執行緒同時執行),那麼渲染執行緒前後獲得的元素資料就可能不一致了。
因此為了防止渲染出現不可預期的結果,瀏覽器設定GUI渲染執行緒與JS引擎為互斥的關係,當JS引擎執行時GUI執行緒會被掛起,GUI更新則會被儲存在一個佇列中等到JS引擎執行緒空閒時立即被執行。

網頁解析的流程

主流程

頁面的解析工作是在 Renderer 程式中進行的,Renderer 程式通過在主執行緒中持有的 Blink 例項邊接收邊解析 HTML 內容。每次從網路緩衝區中讀取 8KB 以內的資料。瀏覽器自上而下逐行解析 HTML 內容,經過詞法分析、語法分析,構建 DOM 樹。當遇到外部 CSS 連結時,主執行緒調使用網路請求板塊非同步獲取資源,不阻塞而繼續構建 DOM 樹。當 CSS 下載完畢後,主執行緒在合適的時機解析 CSS 內容,經過詞法分析、語法分析,構建 CSSOM 樹。瀏覽器結合 DOM 樹和 CSSOM 樹構建 Render 樹,並計算佈局屬性,每個 Node 的幾何屬性和在座標系中的位置,最後進行繪製展現在螢幕上。當遇到外部 JS 連結時,主執行緒調使用網路請求板塊非同步獲取資源,因為 JS 可能會修改 DOM 樹和 CSSOM 樹而造成迴流和重繪,此時 DOM 樹的構建是處於阻塞狀態的。但主執行緒並不會掛起,瀏覽器會用一個輕量級的掃描器去發現後續需要下載的外部資源,提前發起網路請求,而指令碼內部的資源不會識別,比方 document.write。當 JS 下載完畢後,瀏覽器調使用 V8 引擎在 Script Streamer 執行緒中解析、編譯 JS 內容,並在主執行緒中執行。

image.png | left | 747x292

渲染流程

當 DOM 樹構建完畢後,還需經過好幾次轉換,它們有多種中間表示。首先計算佈局、繪圖樣式,轉換為 RenderObject 樹(也叫 Render 樹)。再轉換為 RenderLayer 樹,當 RenderObject 擁有同一個座標系(比方 canvas、absolute)時,它們會合併為一個 RenderLayer,這一步由 CPU 負責合成。接著轉換為 GraphicsLayer 樹,當 RenderLayer 滿足合成層條件(比方 transform,熟知的硬體加速)時,會有自己的 GraphicsLayer,否則與父節點合併,這一步同樣由 CPU 負責合成。最後,每個 GraphicsLayer 都有一個 GraphicsContext 物件,負責將層繪製成點陣圖作為紋理上傳給 GPU,由 GPU 負責合成多個紋理,最終顯示在螢幕上。
另外,為了提升渲染效能效率,瀏覽器會有專使用的 Compositor 執行緒來負責層合成,同時負責解決部分互動事件(比方滾動、觸控),直接響應 UI 升級而不阻塞主執行緒。主執行緒把 RenderLayer 樹同步給 Compositor 執行緒,由它開啟多個 Rasterizer 執行緒,進行光柵化解決,在可視區域以瓦片為單位把頂點資料轉換為片元,最後交付給 GPU 進行最終合成渲染。

JavaScript的事件迴圈

首先要將JavaScript分成同步任務和非同步任務,其次是引入巨集任務(macro-task)和微任務(micro-task)

同步任務和非同步任務

下面用一副導圖來描述一下同步任務與非同步任務的執行

  • 首先會判斷一個任務是同步任務還是非同步任務,同步任務進入主執行緒,非同步的進入Event Table並註冊函式
    • 同步任務:頁面骨架和頁面渲染等這些主要的且資源耗時小的任務
    • 非同步任務:圖片、音樂等載入資源耗時長的或需要等待條件觸發的任務
  • 當資源下載完畢或者指定的事件完成之後Event Table將對應的函式移入Event Queue中等待執行
  • 主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函式,進入主執行緒執行。
  • 上述過程會不斷重複,也就是常說的Event Loop(事件迴圈)。
圖片1.png | center | 738x596

巨集任務和微任務

  • 巨集任務(macrotask):
    • 可以理解為執行的整個程式碼塊就是一個巨集任務(每次執行棧中的程式碼)
    • 每一個巨集任務都是連貫執行,中間不會中斷去執行其他任務
    • 瀏覽器為了能夠使得JS內部task與DOM任務能夠有序的執行,會在一個task執行結束後,在下一個 task 執行開始前,對頁面進行重新渲染 (task->渲染->task->...
    • 主程式碼塊,setTimeout,setInterval等(可以看到,事件佇列中的每一個事件都是一個macrotask)
  • 微任務(microtask):
    • 一個巨集任務執行完畢下一個巨集任務執行之前執行這一個巨集任務中產生的微任務(某一個macrotask執行完後,就會將在它執行期間產生的所有microtask都執行完畢(在渲染前))
    • 所以它的響應速度相比setTimeout(setTimeout是task)會更快,因為無需等渲染
    • Promise,process.nextTick, .then等
圖片2.png | center | 747x164

一道讀程式題

console.log(1); 
setTimeout(function(){
    console.log(2);
},1000);

console.log(3); 

var p = new Promise(function(resolve,reject){ 
    setTimeout(function(){
        console.log(4);
    },500);
    resolve(); 
}).then(function(){
    console.log(5);
});

console.log(6);

p.then(function(){
    console.log(7);
});
console.log(8)

// 1 3 6 8 5 7 4 2
複製程式碼

第一次巨集任務迴圈:
執行的整段程式碼看做一個巨集任務

  • 遇到 console.log(1) 列印1
  • setTimeout()交給定時器處理執行緒處理,1秒延時後將任務加入巨集任務佇列 記為 s1
  • 遇到console.log(3) 列印3
  • 遇到Promise,執行裡面的回撥函式
  • setTimeout()交給定時器處理執行緒處理,0.5秒延時後將任務加入巨集任務佇列 記為s2
  • then中的回撥函式加入這次的微任務佇列 then1
  • 跳出Promise後,執行console.log(6),列印6
  • 執行p.then,把回撥函式加入微任務佇列 then2
  • 執行console.log(8)列印 8
    此時列印的是 1 3 6 8
    巨集任務佇列:s1,s2
    微任務佇列:then1,then2
    第一次巨集任務執行結束之後執行這次產生的微任務,依次執行then1,then2,依次列印 58

第二次巨集任務開始
這裡需要注意的是s1s2觸發計時事件,是在Event Table中註冊,等待計時完畢之後把回撥函式加入Event Queue中,所以 s20.5秒比s11秒要先完成,這樣在Event Queue中先放入s2,再放入s1
第二次巨集任務執行s2,列印4,沒有微任務
第三次巨集任務執行s1,列印2,沒有微任務

一些定時事件、點選事件、網路載入都是交給瀏覽器的API處理的,這些地方會建立巨集任務,而且並不是直接加入到佇列中,而是有一個EventTable來記錄這些事件,等到條件滿足之後再將回撥的函式註冊到Event Queue中,每次巨集任務從 Event Queue中獲取下一個巨集任務

相關文章