窺探現代瀏覽器架構(三)

前言

本文是筆者對Mario Kosaka寫的inside look at modern web browser系列文章的翻譯。這裏的翻譯不是指直譯,而是結合我的的理解將做者想表達的意思表達出來,並且會盡可能補充一些相關的內容來幫助你們更好地理解。javascript

渲染進程裏面發生的事

這篇文章是探究Chrome內部工做原理的四集系列文章中的第三篇。以前咱們分別探討了Chrome的多進程架構以及導航的過程都發生了什麼。在本篇文章中,咱們將要窺探一下渲染進程在渲染頁面的時候具體都發生了什麼事情。css

渲染進程會影響到Web性能的不少方面。頁面渲染的時候發生的東西實在太多了,本篇文章只能做一個大致的介紹。若是你想要了解更多相關的內容,Web Fundamentals的Performance欄目有不少資源能夠查看。html

渲染進程處理頁面內容

渲染進程負責標籤(tab)內發生的全部事情。在渲染進程裏面,主線程(main thread)處理了絕大多數你發送給用戶的代碼。若是你使用了web worker或者service worker,相關的代碼將會由工做線程(worker thread)處理。合成(compositor)以及光柵(raster)線程運行在渲染進程裏面用來高效流暢地渲染出頁面內容。html5

渲染進程的主要任務是將HTML,CSS,以及JavaScript轉變爲咱們能夠進程交互的網頁內容java

渲染進程裏面有:一個主線程(main thread),幾個工做線程(worker threads),一個合成線程(compositor thread)以及一個光柵線程(raster thread)git

解析

構建DOM

前面文章提到,渲染進程在導航結束的時候會收到來自瀏覽器進程提交導航(commit navigation)的消息,在這以後渲染進程就會開始接收HTML數據,同時主線程也會開始解析接收到的文本數據(text string)並把它轉化爲一個DOM(Document Object Model)對象github

DOM對象既是瀏覽器對當前頁面的內部表示,也是Web開發人員經過JavaScript與網頁進行交互的數據結構以及APIweb

如何將HTML文檔解析爲DOM對象是在HTML標準中定義的。不過在你的web開發生涯中,你可能歷來沒有遇到過瀏覽器在解析HTML的時候發生錯誤的情景。這是由於瀏覽器對HTML的錯誤容忍度很大。舉些例子:若是一個段落缺失了閉合p標籤(</p>),這個頁面仍是會被當作爲有效的HTML來處理;Hi! <b>I'm <i>Chrome</b>!</i> (閉合b標籤寫在了閉合i標籤的前面) ,雖然有語法錯誤,不過瀏覽器會把它處理爲Hi! <b>I'm <i>Chrome</i></b><i>!</i>。若是你想知道瀏覽器是如何對這些錯誤進行容錯處理的,能夠參考HTML規範裏面的An introduction to error handling and strange cases in the parser內容。chrome

子資源加載

除了HTML文件,網站一般還會使用到一些諸如圖片,CSS樣式以及JavaScript腳本等子資源。這些文件會從緩存或者網絡上獲取。主線程會按照在構建DOM樹時遇到各個資源的循序一個接着一個地發起網絡請求,但是爲了提高效率,瀏覽器會同時運行「預加載掃描」(preload scanner)程序。若是在HTML文檔裏面存在諸如<img>或者<link>這樣的標籤,預加載掃描程序會在HTML解析器生成的token裏面找到對應要獲取的資源,並把這些要獲取的資源告訴瀏覽器進程裏面的網絡線程。 canvas

主線程會解析HTML內容而且構建出DOM樹

JavaScript會阻塞HTML的解析過程

當HTML解析器碰到script標籤的時候,它會中止HTML文檔的解析從而轉向JavaScript代碼的加載,解析以及執行。爲何要這樣作呢?由於script標籤中的JavaScript可能會使用諸如document.write()這樣的代碼改變文檔流(document)的形狀,從而使整個DOM樹的結構發生根本性的改變(HTML規範裏面的overview of the parsing model部分有很好的示意圖)。由於這個緣由,HTML解析器不得不等JavaScript執行完成以後才能繼續對HTML文檔流的解析工做。若是你想知道JavaScipt的執行過程都發生了什麼,V8團隊有不少關於這個話題的討論以及博客

給瀏覽器一點如何加載資源的提示

Web開發者能夠經過不少方式告訴瀏覽器如何才能更加優雅地加載網頁須要用到的資源。若是你的JavaScript不會使用到諸如document.write()的方式去改變文檔流的內容的話,你能夠爲script標籤添加一個async或者defer屬性來使JavaScript腳本進行異步加載。固然若是能知足到你的需求,你也可使用JavaScript Module。同時<link rel="preload">資源預加載能夠用來告訴瀏覽器這個資源在當前的導航確定會被用到,你想要儘快加載這個資源。更多相關的內容,你可閱讀Resource Prioritization - Getting the Browser to Help You這篇文章。

樣式計算 - Style calculation

擁有了DOM樹咱們還不足以知道頁面的外貌,由於咱們一般會爲頁面的元素設置一些樣式。主線程會解析頁面的CSS從而肯定每一個DOM節點的計算樣式(computed style)。計算樣式是主線程根據CSS樣式選擇器(CSS selectors)計算出的每一個DOM元素應該具有的具體樣式,你能夠打開devtools來查看每一個DOM節點對應的計算樣式。

主線程解析CSS來肯定每一個元素的計算樣式

即便你的頁面沒有設置任何自定義的樣式,每一個DOM節點仍是會有一個計算樣式屬性,這是由於每一個瀏覽器都有本身的默認樣式表。由於這個樣式表的存在,頁面上的h1標籤必定會比h2標籤大,並且不一樣的標籤會有不一樣的magin和padding。若是你想知道Chrome的默認樣式是長什麼樣的,你能夠直接查看代碼

佈局 - Layout

前面這些步驟完成以後,渲染進程就已經知道頁面的具體文檔結構以及每一個節點擁有的樣式信息了,但是這些信息仍是不能最終肯定頁面的樣子。舉個例子,假如你如今想經過電話告訴你的朋友你身邊的一幅畫的內容:「畫布上有一個紅色的大圓圈和一個藍色的正方形」,單憑這些信息你的朋友是很難知道這幅畫具體是什麼樣子的,由於他不知道大圓圈和正方形具體在頁面的什麼位置,是正方形在圓圈前面呢仍是圓圈在正方形的前面。

你站在一幅畫面前經過電話告訴你朋友畫上的內容

渲染網頁也是一樣的道理,只知道網站的文檔流以及每一個節點的樣式是遠遠不足以渲染出頁面內容的,還須要經過佈局(layout)來計算出每一個節點的幾何信息(geometry)。佈局的具體過程是:主線程會遍歷剛剛構建的DOM樹,根據DOM節點的計算樣式計算出一個佈局樹(layout tree)。佈局樹上每一個節點會有它在頁面上的x,y座標以及盒子大小(bounding box sizes)的具體信息。佈局樹長得和先前構建的DOM樹差很少,不一樣的是這顆樹只有那些可見的(visible)節點信息。舉個例子,若是一個節點被設置爲了display:none,這個節點就是不可見的就不會出如今佈局樹上面(visibility:hidden的節點會出如今佈局樹上面,你能夠思考一下這是爲何)。一樣的,若是一個僞元素(pseudo class)節點有諸如p::before{content:"Hi!"}這樣的內容,它會出如今佈局上,而不存在於DOM樹上。

主線程會遍歷每一個DOM tree節點的計算樣式信息來生成一棵佈局樹

即便頁面的佈局十分簡單,佈局這個過程都是很是複雜的。例如頁面就是簡單地從上而下展現一個又一個段落,這個過程就很複雜,由於你須要考慮段落中的字體大小以及段落在哪裏須要進行換行之類的東西,它們都會影響到段落的大小以及形狀,繼而影響到接下來段落的佈局。

瀏覽器得考慮段落是否是要換行

若是考慮到CSS的話將會更加複雜,由於CSS是一個很強大的東西,它可讓元素懸浮(float)到頁面的某一邊,還能夠遮擋住頁面溢出的(overflow)元素,還能夠改變內容的書寫方向,因此單是想一下你就知道佈局這個過程是一個十分艱鉅和複雜的任務。對於Chrome瀏覽器,咱們有一整個負責佈局過程的工程師團隊。若是你想知道他們工做的具體內容,他們在BlinkOn Conference上面的相關討論被錄製了下來,有時間的話你能夠去看一下。

繪畫 - Paint

知道了DOM節點以及它的樣式和佈局其實仍是不足以渲染出頁面來的。爲何呢?舉個例子,假如你如今想對着一幅畫畫一幅同樣的畫,你已經知道了畫布上每一個元素的大小,形狀以及位置,你仍是得思考一下每一個元素的繪畫順序,由於畫布上的元素是會互相遮擋的(z-index)。

一我的拿着畫筆站在畫布前面,在思考着是先畫一個圓仍是先畫一個正方形

舉個例子,若是頁面上的某些元素設置了z-index屬性,繪製元素的順序就會影響到頁面的正確性。

單純按照HTML佈局的順序繪製頁面的元素是錯誤的,由於元素的z-index元素沒有被考慮到

在繪畫這個步驟中,主線程會遍歷以前的到的佈局樹(layout tree)來生成一系列的繪畫記錄(paint records)。繪畫記錄是對繪畫過程的註釋,例如「首先畫背景,而後是文本,最後畫矩形」。若是你曾經在canvas畫布上有使用過JavaScript繪製元素,你可能會覺着這個過程不是很陌生。

主線程遍歷佈局樹來生成繪畫記錄

高成本的渲染流水線(rendering pipeline)更新

關於渲染流水線有一個十分重要的點就是流水線的每一步都要使用到前一步的結果來生成新的數據,這就意味着若是某一步的內容發生了改變的話,這一步後面全部的步驟都要被從新執行以生成新的記錄。舉個例子,若是佈局樹有些東西被改變了,文檔上那些被影響到的部分的繪畫順序是要從新生成的。

DOM+Style,佈局以及繪畫樹

若是你的頁面元素有動畫效果(animating),瀏覽器就不得不在每一個渲染幀的間隔中經過渲染流水線來更新頁面的元素。咱們大多數顯示器的刷新頻率是一秒鐘60次(60fps),若是你在每一個渲染幀的間隔都能經過流水線移動元素,人眼就會看到流暢的動畫效果。但是若是流水線更新時間比較久,動畫存在丟幀的情況的話,頁面看起來就會很「卡頓」。

流水線更新沒有遇上屏幕刷新,動畫就有點卡

即便你的渲染流水線更新是和屏幕的刷新頻率保持一致的,這些更新是運行在主線程上面的,這就意味着它可能被一樣運行在主線程上面的JavaScript代碼阻塞。

某些動畫幀被JavaScript阻塞了

對於這種狀況,你能夠將要被執行的JavaScript操做拆分爲更小的塊而後經過requestAnimationFrame這個API把他們放在每一個動畫幀中執行。想知道更多關於這方面的信息的話,能夠參考Optimize JavaScript Execution。固然你還能夠將JavaScript代碼放在WebWorkers中執行來避免它們阻塞主線程。

在動畫幀上運行一小段JavaScript代碼

合成

如何繪製一個頁面?

到目前爲止,瀏覽器已經知道了關於頁面如下的信息:文檔結構,元素的樣式,元素的幾何信息以及它們的繪畫順序。那麼瀏覽器是如何利用這些信息來繪製出頁面來的呢?將以上這些信息轉化爲顯示器的像素的過程叫作光柵化(rasterizing)

可能一個最簡單的作法就是隻光柵化視口內(viewport)的網頁內容。若是用戶進行了頁面滾動,就移動光柵幀(rastered frame)而且光柵化更多的內容以補上頁面缺失的部分。Chrome的第一個版本其實就是這樣作的。然而,對於現代的瀏覽器來講,它們每每採起一種更加複雜的叫作合成(compositing)的作法。

最簡單的光柵化過程

什麼是合成

合成是一種將頁面分紅若干層,而後分別對它們進行光柵化,最後在一個單獨的線程 - 合成線程(compositor thread)裏面合併成一個頁面的技術。當用戶滾動頁面時,因爲頁面各個層都已經被光柵化了,瀏覽器須要作的只是合成一個新的幀來展現滾動後的效果罷了。頁面的動畫效果實現也是相似,將頁面上的層進行移動並構建出一個新的幀便可。

你能夠經過Layers panel在DevTools查看你的網站是如何被瀏覽器分紅不一樣的層的。

頁面合成過程

頁面分層

爲了肯定哪些元素須要放置在哪一層,主線程須要遍歷渲染樹來建立一棵層次樹(Layer Tree)(在DevTools中這一部分工做叫作「Update Layer Tree」)。若是頁面的某些部分應該被放置在一個單獨的層上面(滑動菜單)但是卻沒有的話,你能夠經過使用will-change CSS屬性來告訴瀏覽器對其分層。

主線程遍歷佈局樹來生成層次樹

你可能會想要給頁面上全部的元素一個單獨的層,然而當頁面的層超過必定的數量後,層的合成操做要比在每一個幀中光柵化頁面的一小部分還要慢,所以衡量你應用的渲染性能是十分重要的一件事情。想要獲取關於這方面的更多信息,能夠參考文章Stick to Compositor-Only Properties and Manage Layer Count

在主線程以外光柵化和合成頁面

一旦頁面的層次樹建立出來而且頁面元素的繪製順序肯定後,主線程就會向合成線程(compositor thread)提交這些信息。而後合成線程就會光柵化頁面的每一層。由於頁面的一層可能有整個網頁那麼大,因此合成線程須要將它們切分爲一塊又一塊的小圖塊(tiles)而後將圖塊發送給一系列光柵線程(raster threads)。光柵線程會柵格化每一個圖塊而且把它們存儲在GPU的內存中。

光柵線程建立圖塊的位圖併發送給GPU

合成線程能夠給不一樣的光柵線程賦予不一樣的優先級(prioritize),進而使那些在視口中的或者視口附近的頁面能夠先被光柵化。爲了響應用戶對頁面的放大和縮小操做,頁面的圖層(layer)會爲不一樣的清晰度配備不一樣的圖塊。

當圖層上面的圖塊都被柵格化後,合成線程會收集圖塊上面叫作繪畫四邊形(draw quads)的信息來構建一個合成幀(compositor frame)。

  • 繪畫四邊形:包含圖塊在內存的位置以及圖層合成後圖塊在頁面的位置之類的信息。
  • 合成幀:表明頁面一個幀的內容的繪製四邊形集合

上面的步驟完成以後,合成線程就會經過IPC向瀏覽器進程(browser process)提交(commit)一個渲染幀。這個時候可能有另一個合成幀被瀏覽器進程的UI線程(UI thread)提交以改變瀏覽器的UI。這些合成幀都會被髮送給GPU從而展現在屏幕上。若是合成線程收到頁面滾動的事件,合成線程會構建另一個合成幀發送給GPU來更新頁面。

合成線程構建出合成幀,合成幀會被髮送給瀏覽器進程而後再發送給GPU

合成的好處在於這個過程沒有涉及到主線程,因此合成線程不須要等待樣式的計算以及JavaScript完成執行。這也就是爲何說只經過合成來構建頁面動畫是構建流暢用戶體驗的最佳實踐的緣由了。若是頁面須要被從新佈局或者繪製的話,主線程必定會參與進來的。

總結

在這篇文章中,咱們探討了從解析HTML文件到合成頁面整個的渲染流水線。但願你讀完後,能夠本身去看一些關於頁面性能優化的文章了。

在接下來也是最後一篇本系列的文章中,咱們將要查看合成線程更多的細節,來了解一下當用戶在頁面移動鼠標(mouse move)以及進行點擊(click)的時候瀏覽器會作些什麼事情。

持續關注個人技術動態

我是進擊的大蔥,關注我和我一塊兒進步成獨當一面的全棧工程師!

文章首發於:窺探現代瀏覽器架構(三)

關注個人我的公衆號獲取個人最新技術推送!

相關文章
相關標籤/搜索