瀏覽器渲染頁面的過程
從耗時的角度,瀏覽器請求、加載、渲染一個頁面,時間花在下面五件事情上:
DNS 查詢
TCP 連接
HTTP 請求即響應(yīng)
服務(wù)器響應(yīng)
客戶端渲染
本文討論第五個部分,即瀏覽器對內(nèi)容的渲染,這一部分(渲染樹構(gòu)建、布局及繪制),又可以分為下面五個步驟:
處理 HTML 標(biāo)記并構(gòu)建 DOM 樹。
處理 CSS 標(biāo)記并構(gòu)建 CSSOM 樹。
將 DOM 與 CSSOM 合并成一個渲染樹。
根據(jù)渲染樹來布局,以計算每個節(jié)點(diǎn)的幾何信息。
將各個節(jié)點(diǎn)繪制到屏幕上。
需要明白,這五個步驟并不一定一次性順序完成。如果 DOM 或 CSSOM 被修改,以上過程需要重復(fù)執(zhí)行,這樣才能計算出哪些像素需要在屏幕上進(jìn)行重新渲染。實(shí)際頁面中,CSS 與 JavaScript 往往會多次修改 DOM 和 CSSOM,下面就來看看它們的影響方式。
阻塞渲染:CSS 與 JavaScript
談?wù)撡Y源的阻塞時,我們要清楚,現(xiàn)代瀏覽器總是并行加載資源。例如,當(dāng) HTML 解析器(HTML Parser)被腳本阻塞時,解析器雖然會停止構(gòu)建 DOM,但仍會識別該腳本后面的資源,并進(jìn)行預(yù)加載。
同時,由于下面兩點(diǎn):
默認(rèn)情況下,CSS 被視為阻塞渲染的資源,這意味著瀏覽器將不會渲染任何已處理的內(nèi)容,直至 CSSOM 構(gòu)建完畢。
JavaScript 不僅可以讀取和修改 DOM 屬性,還可以讀取和修改 CSSOM 屬性。
存在阻塞的 CSS 資源時,瀏覽器會延遲 JavaScript 的執(zhí)行和 DOM 構(gòu)建。另外:
當(dāng)瀏覽器遇到一個 script 標(biāo)記時,DOM 構(gòu)建將暫停,直至腳本完成執(zhí)行。
JavaScript 可以查詢和修改 DOM 與 CSSOM。
CSSOM 構(gòu)建時,JavaScript 執(zhí)行將暫停,直至 CSSOM 就緒。
所以,script 標(biāo)簽的位置很重要。實(shí)際使用時,可以遵循下面兩個原則:
CSS 優(yōu)先:引入順序上,CSS 資源先于 JavaScript 資源。
JavaScript 應(yīng)盡量少影響 DOM 的構(gòu)建。
瀏覽器的發(fā)展日益加快(目前的 Chrome 官方穩(wěn)定版是 61),具體的渲染策略會不斷進(jìn)化,但了解這些原理后,就能想通它進(jìn)化的邏輯。下面來看看 CSS 與 JavaScript 具體會怎樣阻塞資源。
CSS
<style> p { color: red; }</style>
<link rel="stylesheet" href="index.css">
這樣的 link 標(biāo)簽(無論是否 inline)會被視為阻塞渲染的資源,瀏覽器會優(yōu)先處理這些 CSS 資源,直至 CSSOM 構(gòu)建完畢。
渲染樹(Render-Tree)的關(guān)鍵渲染路徑中,要求同時具有 DOM 和 CSSOM,之后才會構(gòu)建渲染樹。即,HTML 和 CSS 都是阻塞渲染的資源。HTML 顯然是必需的,因?yàn)榘ㄎ覀兿M@示的文本在內(nèi)的內(nèi)容,都在 DOM 中存放,那么可以從 CSS 上想辦法。
最容易想到的當(dāng)然是精簡 CSS 并盡快提供它。除此之外,還可以用媒體類型(media type)和媒體查詢(media query)來解除對渲染的阻塞。
<link href="index.css" rel="stylesheet">
<link href="print.css" rel="stylesheet"media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 30em) and (orientation: landscape)">
第一個資源會加載并阻塞。
第二個資源設(shè)置了媒體類型,會加載但不會阻塞,print 聲明只在打印網(wǎng)頁時使用。
第三個資源提供了媒體查詢,會在符合條件時阻塞渲染。
JavaScript
JavaScript 的情況比 CSS 要更復(fù)雜一些。觀察下面的代碼:
<p>Do not go gentle into that good night,</p>
<script>console.log("inline")</script>
<p>Old age should burn and rave at close of day;</p>
<script src="app.js"></script>
<p>Rage, rage against the dying of the light.</p>
<p>Do not go gentle into that good night,</p>
<script src="app.js"></script>
<p>Old age should burn and rave at close of day;</p>
<script>console.log("inline")</script>
<p>Rage, rage against the dying of the light.</p>
這樣的 script 標(biāo)簽會阻塞 HTML 解析,無論是不是 inline-script。上面的 P 標(biāo)簽會從上到下解析,這個過程會被兩段 JavaScript 分別打算一次(加載、執(zhí)行)。
所以實(shí)際工程中,我們常常將資源放到文檔底部。
改變阻塞模式:defer 與 async
為什么要將 script 加載的 defer 與 async 方式放到后面呢?因?yàn)檫@兩種方式是的出現(xiàn),全是由于前面講的那些阻塞條件的存在。換句話說,defer 與 async 方式可以改變之前的那些阻塞情形。
首先,注意 async 與 defer 屬性對于 inline-script 都是無效的,所以下面這個示例中三個 script 標(biāo)簽的代碼會從上到下依次執(zhí)行。
<!-- 按照從上到下的順序輸出 1 2 3 -->
<script async>
? console.log("1");
</script>
<script defer>
? console.log("2");
</script>
<script>
? console.log("3");
</script>
故,下面兩節(jié)討論的內(nèi)容都是針對設(shè)置了 src 屬性的 script 標(biāo)簽。
defer
<script src="app1.js" defer></script>
<script src="app2.js" defer></script>
<script src="app3.js" defer></script>
defer 屬性表示延遲執(zhí)行引入的 JavaScript,即這段 JavaScript 加載時 HTML 并未停止解析,這兩個過程是并行的。整個 document 解析完畢且 defer-script 也加載完成之后(這兩件事情的順序無關(guān)),會執(zhí)行所有由 defer-script 加載的 JavaScript 代碼,然后觸發(fā) DOMContentLoaded 事件。
defer 不會改變 script 中代碼的執(zhí)行順序,示例代碼會按照 1、2、3 的順序執(zhí)行。所以,defer 與相比普通 script,有兩點(diǎn)區(qū)別:載入 JavaScript 文件時不阻塞 HTML 的解析,執(zhí)行階段被放到 HTML 標(biāo)簽解析完成之后。
async
<script src="app.js" async></script>
<script src="ad.js" async></script>
<script src="statistics.js" async></script>
async 屬性表示異步執(zhí)行引入的 JavaScript,與 defer 的區(qū)別在于,如果已經(jīng)加載好,就會開始執(zhí)行——無論此刻是 HTML 解析階段還是 DOMContentLoaded 觸發(fā)之后。需要注意的是,這種方式加載的 JavaScript 依然會阻塞 load 事件。換句話說,async-script 可能在 DOMContentLoaded 觸發(fā)之前或之后執(zhí)行,但一定在 load 觸發(fā)之前執(zhí)行。
從上一段也能推出,多個 async-script 的執(zhí)行順序是不確定的。值得注意的是,向 document 動態(tài)添加 script 標(biāo)簽時,async 屬性默認(rèn)是 true,下一節(jié)會繼續(xù)這個話題。
document.createElement
使用 document.createElement 創(chuàng)建的 script 默認(rèn)是異步的,示例如下。
console.log(document.createElement("script").async); // true
所以,通過動態(tài)添加 script 標(biāo)簽引入 JavaScript 文件默認(rèn)是不會阻塞頁面的。如果想同步執(zhí)行,需要將 async 屬性人為設(shè)置為 false。
如果使用 document.createElement 創(chuàng)建 link 標(biāo)簽會怎樣呢?
const style = document.createElement("link");
style.rel = "stylesheet";
style.href = "index.css";
document.head.appendChild(style); // 阻塞?
其實(shí)這只能通過試驗(yàn)確定,已知的是,Chrome 中已經(jīng)不會阻塞渲染,F(xiàn)irefox、IE 在以前是阻塞的,現(xiàn)在會怎樣我沒有試驗(yàn)。
document.write 與 innerHTML
通過 document.write 添加的 link 或 script 標(biāo)簽都相當(dāng)于添加在 document 中的標(biāo)簽,因?yàn)樗僮鞯氖?document stream(所以對于 loaded 狀態(tài)的頁面使用 document.write 會自動調(diào)用 document.open,這會覆蓋原有文檔內(nèi)容)。即正常情況下, link 會阻塞渲染,script 會同步執(zhí)行。不過這是不推薦的方式,Chrome 已經(jīng)會顯示警告,提示未來有可能禁止這樣引入。如果給這種方式引入的 script 添加 async 屬性,Chrome 會檢查是否同源,對于非同源的 async-script 是不允許這么引入的。
如果使用 innerHTML 引入 script 標(biāo)簽,其中的 JavaScript 不會執(zhí)行。當(dāng)然,可以通過 eval() 來手工處理,不過不推薦。如果引入 link 標(biāo)簽,我試驗(yàn)過在 Chrome 中是可以起作用的。另外,outerHTML、insertAdjacentHTML() 應(yīng)該也是相同的行為,我并沒有試驗(yàn)。這三者應(yīng)該用于文本的操作,即只使用它們添加 text 或普通 HTML Element。