上一篇,我們介紹了DOM,CSSOM和渲染樹(shù)是怎么回事,如果有不知道的,最好先回顧一下這篇文章Web渲染性能,DOM,CSSOM和渲染,看完這個(gè)你就全懂了(一), 接下來(lái)將繼續(xù)我們的渲染歷程。
渲染順序
理解這個(gè)過(guò)程對(duì)開(kāi)發(fā)設(shè)計(jì)人員來(lái)說(shuō)很關(guān)鍵,它會(huì)幫助我們?cè)O(shè)計(jì)站點(diǎn)的時(shí)候充分考慮到用戶(hù)體驗(yàn)和性能問(wèn)題。頁(yè)面加載后,瀏覽器會(huì)構(gòu)造DOM,CSSOM和渲染樹(shù),當(dāng)這些都創(chuàng)建好之后,就會(huì)開(kāi)始在屏幕上繪制每一個(gè)元素。
布局操作
首先瀏覽器給每個(gè)渲染樹(shù)節(jié)點(diǎn)創(chuàng)建布局(layout)信息, 它包含了節(jié)點(diǎn)的將來(lái)顯示的位置數(shù)據(jù)(像素點(diǎn)位置)。這個(gè)過(guò)程叫布局(Layout),也可以叫回流(reflow), 因?yàn)樗部赡馨l(fā)生在窗口的大小改變,滾動(dòng)等事件或DOM元素操作中。
注:我們應(yīng)該盡量避免頁(yè)面產(chǎn)生多次布局操作,因?yàn)檫@是代價(jià)昂貴的操作。
顯示操作
到現(xiàn)在為止,我們已經(jīng)有了一棵渲染樹(shù),也就是節(jié)點(diǎn)位置列表,包含了需要顯示的所有信息。
因?yàn)殇秩緲?shù)上的節(jié)點(diǎn)可以重疊顯示,它們的CSS屬性,決定了它們的外觀,位置,如何變化(動(dòng)畫(huà))。我了更好的控制渲染,瀏覽器引入了層的概念。建立層,瀏覽器可以高效地在頁(yè)面生命周期內(nèi)執(zhí)行顯示操作。層也可以幫助元素以堆棧的形式顯示(Z軸方向)。
在每一層,瀏覽器將會(huì)顯示元素的每一個(gè)像素,比如邊框,背景,顏色,陰影,文字等。這個(gè)過(guò)程也叫柵格化。為了改進(jìn)性能,瀏覽器必須使用不同的線程去做這種柵格化操作。
這個(gè)層跟Photoshop中的層類(lèi)似,你可以通過(guò)Chrome開(kāi)發(fā)者工具看到頁(yè)面中的不同層。打開(kāi)開(kāi)發(fā)者工具->更多工具->選擇層,你將會(huì)看到更多細(xì)節(jié)。
注:柵格化通常是CPU完成的,但現(xiàn)在我們有新技術(shù)可以使用GPU來(lái)做這個(gè)操作,從而提高性能
合成操作
直到現(xiàn)在,我們還沒(méi)有在顯示器上畫(huà)出任何一個(gè)像素。我們有的只是一些不同的層(位圖Bitmap Image),這些層將會(huì)以特定的順序被顯示出來(lái)。在合成操作中,它們將被發(fā)送給GPU,最終顯示在顯示屏上。
一次性發(fā)送所有層顯然是低效的。因?yàn)槿绻谢亓骰蛘咧乩L操作,這個(gè)每次都會(huì)發(fā)生。所以一個(gè)層會(huì)被打碎成很多個(gè)可顯示的小塊(Tiles)。你可以在開(kāi)發(fā)者工具的渲染面板中看到這些小塊。
綜合上面的信息,我們可以看到瀏覽器的事件順序,我們把這個(gè)執(zhí)行順序叫做關(guān)鍵渲染路徑,如下圖。
關(guān)鍵渲染路徑(Critical Rendering Path)
瀏覽器引擎
創(chuàng)建DOM樹(shù),CSSOM樹(shù)和處理渲染邏輯的工作,是被一個(gè)叫做瀏覽器引擎的軟件完成的。它內(nèi)置于瀏覽器中。這個(gè)引擎包含了渲染所需要的所有東西,能夠把從HTML字符串文檔最終轉(zhuǎn)化成屏幕上的像素點(diǎn)。
如果你聽(tīng)人們討論WebKit, 他們就是在說(shuō)瀏覽器引擎。WebKit是APPLE Safari瀏覽器的默認(rèn)引擎。Google Chrome使用的是Blink, 微軟最新的Edge瀏覽器也使用了跟Chrome一樣的引擎。還有一些其他公司的,比如Firefox…
瀏覽器中的渲染過(guò)程
我們都知道,JavaScript語(yǔ)言是遵循ECMAScript標(biāo)準(zhǔn)的, 因此每個(gè)JavaScript引擎比如V8,Chakra,Spider Monkey等,都被必須遵守這個(gè)標(biāo)準(zhǔn)。
有了這個(gè)標(biāo)準(zhǔn),運(yùn)行JavaScript就能給我們一致的體驗(yàn),無(wú)論是在瀏覽器中,還是Node.js中,Deno等環(huán)境中運(yùn)行相同的JS代碼,都能給我們相同的結(jié)果。。這很棒,并且會(huì)提高我們的產(chǎn)品質(zhì)量。
然而,這種情況在瀏覽器渲染中就不存在了,盡管HTML,CSS,JavaScript,這些語(yǔ)言的標(biāo)準(zhǔn)被一些機(jī)構(gòu)控制,但瀏覽器把它們組合到一起,如何渲染出來(lái),這個(gè)過(guò)程沒(méi)有標(biāo)準(zhǔn)化。各個(gè)公司就各個(gè)公司的辦法。比如Chrome就跟Safari的做法不一樣。
因此很難預(yù)測(cè)在一個(gè)特殊瀏覽器中的渲染順序和機(jī)制。盡管如此,HTML規(guī)范也做了些努力來(lái)標(biāo)準(zhǔn)化渲染操作。但是瀏覽器怎么遵守,遵守多少完全取決于他們自己。
除了這些不一致性,有些共通的東西是在所有瀏覽器中是一樣的。 讓我們來(lái)理解一下通常瀏覽器渲染事件的過(guò)程是怎么樣的。
解析和外部資源
解析是讀取HTML構(gòu)造DOM樹(shù)的過(guò)程。所以這個(gè)過(guò)程也叫DOM解析,做這個(gè)工作的程序叫DOM Parser。
大多數(shù)瀏覽器提供了DOMParser API來(lái)構(gòu)造一個(gè)DOM樹(shù)。你可以試著構(gòu)造一個(gè)DOMParser的實(shí)例,然后使用parseFromString方法,看看可以構(gòu)造出一個(gè)什么DOM樹(shù)。
當(dāng)瀏覽器請(qǐng)求一個(gè)頁(yè)面,服務(wù)器返回了HTML文本(Content-Type設(shè)成text/html), 瀏覽器可以在只接收到整個(gè)文檔中的開(kāi)始幾行或幾個(gè)字符就開(kāi)始解析操作。所以瀏覽器可以增量地構(gòu)造DOM樹(shù),一次一個(gè)節(jié)點(diǎn)地從頭到尾解析。
在上面這個(gè)例子中,我們?cè)L問(wèn)incremental.html文件,設(shè)置網(wǎng)速只有10kbps,這樣它會(huì)花很長(zhǎng)時(shí)間來(lái)下載這個(gè)包含了1000個(gè)H1元素文件。從下圖可以看到,瀏覽器從最初的收到的一些字節(jié)就開(kāi)始構(gòu)造DOM樹(shù),并把他們顯示出來(lái)。剩下的東西還在后臺(tái)下載中,就這樣邊下載邊解析。
上面是該請(qǐng)求的性能圖表, 你會(huì)看到這些事件發(fā)生的時(shí)間。當(dāng)他們發(fā)生的越早,用時(shí)越短,說(shuō)明用戶(hù)體驗(yàn)越好。
FP表示首次渲染,表示瀏覽器開(kāi)始在顯示器上顯示東西了??赡芫褪呛?jiǎn)單到顯示Body中背景的第一個(gè)像素。
FCP表示首次內(nèi)容渲染,說(shuō)明瀏覽器已經(jīng)渲染了圖片或文字的第一個(gè)像素
LCP表示最大內(nèi)容渲染,說(shuō)明瀏覽器渲染了最大的一塊文字或圖片
L表示onload事件,是由瀏覽器的window對(duì)象發(fā)出的。類(lèi)似的DCL由document對(duì)象發(fā)出,它冒泡至window,這樣你就可以在window對(duì)象上監(jiān)聽(tīng)它。這些事件有些復(fù)雜,我們接下來(lái)討論它。
只要瀏覽器解析時(shí)碰到外部文件,它就會(huì)開(kāi)始后臺(tái)下載那個(gè)文件(非主線程)。比如JavaScript , CSS , image 或者其他任何外部資源都會(huì)這樣。
最重要的就是要記住,解析通常發(fā)生在主線程。如果主線程解析JavaScript很忙,DOM解析操作就會(huì)停止工作,直到主線程再次空閑。之所以重要,因?yàn)橹挥衧cript標(biāo)簽(JavaScript文件)會(huì)阻塞解析,而其他請(qǐng)求如image,stylesheet, pdf, video等外部文件不會(huì)阻塞解析。
解析阻塞腳本
當(dāng)瀏覽器碰到script元素,如果是一段內(nèi)嵌腳本,瀏覽器停止HTML解析,立即執(zhí)行該腳本,然后繼續(xù)解析HTML。所有內(nèi)嵌JavaScript都會(huì)阻塞HTML解析。
如果script是外部腳本文件,瀏覽器會(huì)停止主線程工作(停止DOM解析),去下載js文件并等待其完成下載。當(dāng)js下載后,瀏覽器會(huì)先執(zhí)行下載的文件,然后繼續(xù)主線程的DOM解析工作。如果瀏覽器發(fā)現(xiàn)另外一個(gè)script標(biāo)簽,它也會(huì)做同樣的操作。為什么瀏覽器要停止當(dāng)前的DOM解析工作呢?
我們知道,瀏覽器從JavaScript運(yùn)行時(shí)暴露了DOM API,意味著我們可以用JavaScript訪問(wèn)或操作DOM元素。這就是那些動(dòng)態(tài)的web框架可以工作的原理。比如React, Vue, Angular…但如果瀏覽器同時(shí)運(yùn)行DOM解析和執(zhí)行JS,那就會(huì)產(chǎn)生競(jìng)爭(zhēng)關(guān)系,因?yàn)閭z線程都可能改變DOM,最終導(dǎo)致DOM樹(shù)不準(zhǔn)確,所以DOM解析和JS執(zhí)行都必須在主線程上。
盡管如此,當(dāng)下載JS文件的時(shí)候,停止DOM解析在大多數(shù)情況下是完全沒(méi)有必要的。所以HTML5增加了async屬性給script標(biāo)簽。當(dāng)瀏覽器碰到帶async的script標(biāo)簽,它下載JS文件的時(shí)候不會(huì)停止DOM解析,一旦下載結(jié)束,就會(huì)阻塞DOM解析并且立即執(zhí)行JavaScript代碼。
我們還有一個(gè)更好用的defer屬性,它跟async類(lèi)似,下載的時(shí)候不會(huì)阻塞DOM解析。不一樣的是,當(dāng)defer文件下載完畢后,不會(huì)立即執(zhí)行,會(huì)等到DOM樹(shù)完全構(gòu)造完成后才會(huì)執(zhí)行。
在上面的例子中, parser-blocking.html文件,在30元素后,有一個(gè)阻塞解析的script, 這就是為什么瀏覽器開(kāi)始顯示了30個(gè)元素,然后停止了DOM解析,開(kāi)始下載JavaScript文件。第二個(gè)script文件有defer屬性,所以它會(huì)在DOM樹(shù)完全建立后才執(zhí)行。
如果看性能面板,瀏覽器一開(kāi)始構(gòu)造DOM樹(shù),有了一些HTML內(nèi)容時(shí),F(xiàn)P和FCP就發(fā)生了。我們就看到一下東西呈現(xiàn)出來(lái)了。
LCP發(fā)生于5秒后,因?yàn)樾枰幚鞪avaScript,所以DOM解析就會(huì)被阻塞5秒(JS文件的下載時(shí)間),并且只有30個(gè)文本元素被顯示。但是這些東西還不足以成為最大的渲染內(nèi)容(根據(jù)Google標(biāo)準(zhǔn))。一旦JS文件下載并執(zhí)行完成, DOM解析恢復(fù),這時(shí)最大內(nèi)容會(huì)被顯示出來(lái),所以LCP就觸發(fā)了。
渲染阻塞-CSS
我們已經(jīng)知道,任何其他外部資源,除了JavaScript文件,都不會(huì)阻塞DOM解析。所以CSS也不會(huì)直接阻塞DOM解析。。。等等。。。不會(huì)直接阻塞!??!什么意思?其實(shí)CSS會(huì)阻塞DOM解析,但我們需要先了解渲染過(guò)程。
瀏覽器引擎會(huì)將HTML文本變成DOM樹(shù),并且它也從stylesheet可以構(gòu)造CSSOM樹(shù)。但是DOM樹(shù)和CSSOM樹(shù)的構(gòu)造都是在主線程上進(jìn)行的,然后它倆合并成渲染樹(shù)。我們已經(jīng)知道DOM樹(shù)是生成是增量的,一邊讀HTML一邊生成節(jié)點(diǎn)添加到樹(shù)上。但這不是CSSOM的構(gòu)造過(guò)程,CSSOM樹(shù)構(gòu)造不是增量型的,是必須在某一特定時(shí)候發(fā)生的。
當(dāng)瀏覽器發(fā)現(xiàn)塊,它會(huì)解析所有的內(nèi)嵌CSS并且更新CSSOM樹(shù),然后繼續(xù)進(jìn)行正常的DOM解析,inline樣式也一樣的處理。
然而,如果碰到外部CSS文件,事情就大不一樣了。不像外部JavaScript文件,外部CSS文件不是解析阻塞資源,所以瀏覽器可以在后臺(tái)繼續(xù)下載,DOM解析仍然會(huì)繼續(xù)。
另外,不像HTML,CSSOM構(gòu)造不是增量型的,它不能邊讀邊被構(gòu)造。原因是一個(gè)文件末尾的CSS規(guī)則,可能會(huì)修改文件最頂部的規(guī)則。所以,如果進(jìn)行增量構(gòu)造,那就會(huì)導(dǎo)致渲染樹(shù)的多次渲染,因此CSSOM節(jié)點(diǎn)隨時(shí)可能會(huì)發(fā)生變化。那將會(huì)極大的降低用戶(hù)體驗(yàn),用戶(hù)就可能看到頁(yè)面效果不停的變化。所以當(dāng)所有CSS規(guī)則被處理后,CSSOM樹(shù)會(huì)被更新,然后渲染樹(shù)也會(huì)被更新,最后才在顯示器上呈現(xiàn)。
CSS確實(shí)是渲染阻塞資源。一旦瀏覽器發(fā)送一個(gè)請(qǐng)求去外部stylesheet,渲染樹(shù)的構(gòu)造就停止了。因此關(guān)鍵渲染路徑(Critical Redering Path)也會(huì)被卡住,什么都顯示不了。盡管如此,DOM樹(shù)的解析仍然繼續(xù)。
想一下瀏覽器可能已經(jīng)使用老的CSSOM樹(shù)就生成了渲染樹(shù),并且顯示了一些東西在顯示器上,然后又碰到了外部CSS文件,怎么辦?這種情況很糟糕,一旦外部CSS文件下載完成,CSSOM樹(shù)要被更新,渲染樹(shù)也要被更新,所有那些已經(jīng)被顯示的元素要被新渲染樹(shù)的內(nèi)容所替代重新顯示,這就會(huì)導(dǎo)致閃爍,是很糟糕的用戶(hù)體驗(yàn)。
所以瀏覽器會(huì)等等stylesheet下載解析完成,這樣CSSOM準(zhǔn)備好,渲染樹(shù)就可以準(zhǔn)備好,渲染關(guān)鍵路徑就不會(huì)被阻塞了。基于上述原因,通常建議所有外部CSS文件要盡早的被加載,最好是放在里。
另一種情況,外部JS文件已經(jīng)被完全下載了,而外部CSS文件仍然在后臺(tái)下載,這是瀏覽器會(huì)執(zhí)行JS文件么?有什么危害么?
我們知道,CSSOM提供了JavaScript API來(lái)跟DOM元素的樣式進(jìn)行交互。比如,你可以讀取更新元素的背景顏色el.style.backgourndColor 屬性。這個(gè)與el關(guān)聯(lián)的style屬性暴露了CSSOM API。當(dāng)stylesheet在后臺(tái)下載的時(shí)候,JavaScript仍然會(huì)被執(zhí)行,因?yàn)槲覀兊闹骶€程沒(méi)有被阻塞。如果我們的JavaScript程序訪問(wèn)DOM元素的CSS屬性,它會(huì)拿到當(dāng)前CSSOM上的值。但是一旦stylesheet下載完成并被解析后,這就會(huì)導(dǎo)致CSSOM被更新,我們剛才JavaScript拿到的值就過(guò)時(shí)了,因此,在下載CSS的時(shí)候去執(zhí)行JS是不安全的,應(yīng)該盡量避免。
根據(jù)HTML5規(guī)范,瀏覽器可以下載一個(gè)JavaScript文件,但不會(huì)立即執(zhí)行,直到所有stylesheets先被處理了。當(dāng)一個(gè)stylesheet阻塞了JavaScript執(zhí)行,這叫做腳本阻塞CSS。
上面這個(gè)例子,script-blocking.html包含了一個(gè)link標(biāo)簽(這個(gè)是CSS),然后跟著一個(gè)script標(biāo)簽(這個(gè)是JavaScript文件),這里JavaScript文件下載特別快,沒(méi)有任何延遲,但CSS文件花費(fèi)了6秒去下載。所以盡管JavaScript文件加載完成,它仍然不會(huì)被瀏覽器立即執(zhí)行。只有當(dāng)CSS文件被處理完,我們才能看到JavaScript輸出的Hello World。
文檔的DOMContentLoaded事件
這個(gè)事件DOMContentLoaded(簡(jiǎn)稱(chēng)DCL)在瀏覽器已經(jīng)完成了所有DOM樹(shù)的構(gòu)建而發(fā)出的。這里有很多因素會(huì)影響這個(gè)事件的發(fā)生。
如果我們的HTML沒(méi)有任何腳本,DOM解析不會(huì)被阻塞,DCL就會(huì)在解析完整個(gè)HTML立即發(fā)生。如果我們有阻塞的腳本,DCL就會(huì)等所有阻塞腳本被下載執(zhí)行后再發(fā)生。
另外,如果我們把CSS考慮進(jìn)來(lái),事情就變得復(fù)雜了。盡管沒(méi)有外部腳本,DCL仍然要等所有stylesheets被加載。因?yàn)镈CL就記錄一個(gè)時(shí)間點(diǎn),說(shuō)明整個(gè)DOM樹(shù)準(zhǔn)備好了,但如果CSSOM沒(méi)有準(zhǔn)備好,DOM樹(shù)并不能被安全的訪問(wèn)。所以大多數(shù)瀏覽器會(huì)等CSSOM也準(zhǔn)備好才發(fā)出這個(gè)事件。
DCL是最重要的網(wǎng)站性能指標(biāo)之一,我們要優(yōu)化它,越小越好。最佳實(shí)踐之一就是使用defer,async標(biāo)簽給script元素。讓這些腳本可以在后臺(tái)下載。第二,我們要優(yōu)化渲染阻塞的stylesheets.
Window的load事件
我們已經(jīng)知道JavaScript可以阻塞DOM樹(shù)的生成,帶其他外部文件不行,比如CSS,圖片,視頻等等。
DOMContentLoaded事件記錄了DOM樹(shù)完成構(gòu)造并且可以安全訪問(wèn)的時(shí)間點(diǎn)。window.onload記錄了當(dāng)外部的css和其他文件被下載完成的時(shí)間點(diǎn)。
在上面的例子中, rendering.html 文件有一個(gè)外部CSS文件在head中,它要花5秒被下載。因?yàn)樗趆ead中,所以FP和FCP是在5秒后發(fā)生的。這個(gè)css文件會(huì)阻塞渲染,看不到任何東西。
在那之后,我們有個(gè)圖片元素,大概需要10秒去下載,所以瀏覽器仍然繼續(xù)在后臺(tái)下載這個(gè)文件,并且繼續(xù)進(jìn)行DOM解析和渲染。
接下來(lái),我們有3個(gè)JavaScript文件,他們將花3秒,6秒,9秒去下載。更重要的是,他們不是async的,這就意味著總共需要18秒順序執(zhí)行下載。而且直到前一個(gè)被下載并執(zhí)行完成后,后面一個(gè)才會(huì)繼續(xù)下載執(zhí)行。盡管如此,我們的現(xiàn)代瀏覽器似乎采用了新的策略,更高效地下載了它們,所以總共用時(shí)9秒左右。因?yàn)樽詈笠粋€(gè)文件下載會(huì)影響DCL, 所以DCL發(fā)生在9.1秒。
這時(shí)仍然有其他外部資源在下載,就是那張圖片,它仍然在后臺(tái)下載,大概花了10秒下載完成了,所以window.load事件在10.2秒被觸發(fā),意味著整個(gè)頁(yè)面加載完成。
以上就是瀏覽器的整個(gè)渲染過(guò)程,希望可以幫助大家理解它的原理,也希望大家可以批評(píng)指正錯(cuò)誤,一起討論學(xué)習(xí)。