在 2017年,保證頁面快速載入的手段涵蓋了方方面面,從壓縮和資源優化,到快取,CDN,程式碼分割以及 tree shaking 等。 然而,即便你不熟悉上面的這些概念,或者你感到無從下手,你仍然可以通過幾個關鍵字以及精細的程式碼結構使得你的頁面獲得巨大的效能提升。
新的 Web 標準 使你能夠更快地載入關鍵資源,這個月晚些時候,Firefox 就會支援這個特性。同時在 Firefox Nightly 版本或者 開發者版本 上已經可以使用這些功能。與此同時,這也是回顧基本原理,深入瞭解 DOM 解析相關效能的一個好時機。
理解瀏覽器的內部機制是每個 web 開發者最強有力的工具。我們看看瀏覽器是如何解釋程式碼以及如何使用推測解析(speculative parsing)來幫助頁面快速載入的。我們會分析 defer
和 async
是如何生效的以及如何利用新的關鍵字 preload
。
構建模組
HTML 描述了一個頁面的結構。為了理解 HTML,瀏覽器首先會將HTML轉換成其能夠理解的一種格式 – 文件物件模型(Document Object Model) 或者簡稱為 DOM。 瀏覽器引擎有這麼一段特殊的程式碼叫做解析器,用來將資料從一種格式轉換成另外一種格式。一個 HTML 解析器就能將資料從 HTML 轉換到 DOM。
在 HTML 當中,巢狀(nesting)定義了不同標籤的父子關係。在 DOM 當中,物件被關聯在樹(一種資料結構)中用於捕獲這些關係。每一個 HTML 標籤都對應著樹種的某個節點(DOM節點)。
瀏覽器一個位元一個位元地構建 DOM。一旦第一個程式碼塊載入到瀏覽器當中,它就開始解析 HTML,新增節點到樹中。
DOM 扮演著兩種角色:它既是 HTML 文件的物件表示,也充當著外界(比如JavaScript)和頁面互動的介面。 當你呼叫 document.getElementById()
,返回的元素是一個 DOM 節點。每個 DOM 節點都有很多函式可以用來訪問和改變它,使用者可以看到相應的變化。
頁面上的 CSS 樣式被對映到 CSSOM 上 – CSS 物件模型(CSS Object Model)。它就像 DOM,但是隻針對於 CSS 而不是 HTML。不像 DOM,它不能增量地構建。因為 CSS 規則會相互覆蓋,所以瀏覽器引擎要進行復雜的計算來確定 CSS 程式碼如何應用到 DOM 上。
關於
標籤的歷史
當瀏覽器構建 DOM 的時候,如果在 HTML 中遇到了一個 標籤,它必須立即執行。如果指令碼是來自於外部的,那麼它必須首先下載指令碼。
在過去,為了執行一個指令碼,HTML 的解析必須暫停。只有在 JavaScript 引擎執行完程式碼之後它才會重新開始解析。
那位為什麼解析必須要暫停呢?那是因為指令碼可以改變 HTML以及它的產物 —— DOM。 指令碼可以通過 document.createElement()
方法新增節點來改變 DOM 結構。為了改變 HTML,指令碼可以使用臭名昭著的document.write()
方法來新增內容。它之所以臭名昭著是因為它能以進一步影響 HTML 解析的方式來改變 HTML。比如,該方法可以插入一個開啟的註釋標籤來使得剩餘的 HTML 都變得不合法。
指令碼還可以查詢關於 DOM 的一些東西,如果是在 DOM 還在在構建的時候,它可能會返回意外的結果。
document.write()
是一個遺留的方法,它能夠以預料之外的方式破壞你的頁面,你應該避免使用它。處於這些原因,瀏覽器開發出了一些複雜的方法來應對指令碼阻塞導致的效能問題,稍後我會解釋。
那麼 CSS 會阻塞頁面嗎 ?
JavaScript 阻塞頁面解析是因為它可以修改文件。CSS 不能修改文件,所以看起來它沒有理由去阻塞頁面解析,對嗎?
那麼,如果指令碼需要樣式資訊,但樣式還沒有被解析呢?瀏覽器並不知道指令碼要怎麼執行——它可能會需要類似 DOM 節點的background-color
屬性,而這個屬性又依賴於樣式表,或者它期望能夠直接訪問 CSSOM。
正因為如此,CSS 可能會阻塞解析,取決於外部樣式表和指令碼在文件中的順序。如果在文件中外部樣式表放置在指令碼之前,DOM 物件和 CSSOM 物件的構建可以互相干擾。 當解析器獲取到一個 script 標籤,DOM 將無法繼續構建直到 JavaScript 執行完畢,而 JavaScript 在 CSS 下載完,解析完,並且 CSSOM 可以使用的時候,才能執行。
另外一件要注意的事是,即使 CSS 不阻塞 DOM 的構建,它也會阻塞 DOM 的渲染。直到 DOM 和 CSSOM 準備好之前,瀏覽器什麼都不會顯示。這是因為頁面沒有 CSS 通常無法使用。如果一個瀏覽器給你顯示了一個沒有 CSS 的凌亂的頁面,而幾分鐘之後又突然變成了一個有樣式的頁面,變換的內容和突然視覺變化使得使用者體驗變得非常糟糕。
具體可以參考由 Milica (@micikato) 在 CodePen 上製作的例子 —— Flash of Unstyled Content。
這種糟糕的使用者體驗有一個名字 — Flash of Unstyled Content 或是 FOUC
為了避免這個問題,你應該儘快地呈現 CSS。記得流行的“樣式放頂部,指令碼放底部”的最佳實踐嗎?你現在知道它是怎麼來的了!
回到未來 – 預解析(speculative parsing)
每當解析器遇到一個指令碼就暫停意味著每個你載入的指令碼都會推遲發現連結到 HTML 的其他資源。
如果你有幾個類似的指令碼和圖片要載入,例如:
1 2 3 4 5 |
<script src="slider.js"></script> <script src="animate.js"></script> <script src="cookie.js"></script> <img src="slide1.png"> <img src="slide2.png"> |
這個過程過去是這樣的:
這個狀況在 2008 年左右改變了,當時 IE 引入了一個概念叫做 “先行下載”。 這是一種在同步的腳步執行的時候保持檔案的下載的一種方法。Firefox,Chrome 和 Safari 隨後效仿,如今大多數的瀏覽器都使用了這個技術,它們有著不同的名稱。Chrome 和 Safari 稱它為 “預掃描器” 而 Firefox 稱它為預解析器。
它的概念是:雖然在執行指令碼時構建 DOM 是不安全的,但是你仍然可以解析 HTML 來檢視其它需要檢索的資源。找到的檔案會被新增到一個列表裡並開始在後臺並行地下載。當指令碼執行完畢之後,這些檔案很可能已經下載完成了。
上面例子的瀑布圖現在看起來是這樣的:
以這種方式觸發的下載請求稱之為 “預測”,因為很有可能指令碼還是會改變 HTML 結構(還記得document.write
嗎?),導致了預測的浪費。雖然這是有可能的,但是卻不常見,所以這就是為什麼預解析仍然能夠帶來很大的效能提升。
而且其他瀏覽器只會對連結的資源進行這樣的預載入。在 Firefox 中,HTML 解析器對 DOM 樹的構建也是演算法預測的。有利的一面是,當推測成功的時候,就沒有必要重新解析檔案的一部分了。缺點是,如果推測失敗了,就需要更多的工作。
關於(預)載入
這種資源載入的方式帶來了顯著地效能提升,你不需要做任何事情就可以使用這種優勢。然而,作為一個 web 開發者,瞭解預解析是如何工作的能幫你最大程度地利用它。
可以預載入的東西在瀏覽器之間有所不同,但所有的主要的瀏覽器都會預載入:
- 指令碼
- 外部 CSS
- 來自
img
標籤的圖片
Firefox 也會預載入 video 元素的 poster
屬性,而 Chrome 和 Safari 會預載入 @import
規則的內聯樣式。
瀏覽器能夠並行下載的檔案的數量是有限制的。這個限制在不同瀏覽器之間是不同的,並且取決於不同的因素,比如:你是否從同一個伺服器或是不同的伺服器下載所有的檔案,又或者是你使用的是 HTTP/1.1 或是 HTTP/2 協議。為了更快地渲染頁面,瀏覽器對每個要下載的檔案都設定優先順序來優化下載。為了弄清這些的優先順序,他們遵守基於資源型別、標記位置以及頁面渲染的進度的複雜方案。
在進行預解析時,瀏覽不會執行內聯的 JavaScript 程式碼塊。這意味著它不會發現任何的指令碼注入資源,這些資源會排到抓取佇列的最後面。
1 2 3 4 |
var script = document.createElement('script'); script.src = "//somehost.com/widget.js"; document.getElementsByTagName('head')[0].appendChild(script); |
你應該儘可能使瀏覽器能更輕鬆訪問到重要的資源。你可以把他們放到 HTML 標籤當中或者將要載入的指令碼內聯到文件的前面。然而,有時候需要一些不重要的資源晚一點被載入。這種情況,你通過 JavaScript 來載入他們來避免預解析。
你也可以看看這個 MDN 指南,裡面講述瞭如何針對預解析優化你的頁面。
defer 和 async
不過,同步的指令碼阻塞解析器仍舊是個問題。並不是所有的指令碼對使用者體驗都是同等的重要,例如那些用於監測和分析的指令碼。解決方法呢?就是去儘可能地非同步載入這些不那麼重要的指令碼。
defer
和async
屬性 提供給開發者一個方式來告訴瀏覽器哪些指令碼是需要非同步載入的。
這兩個屬性都告訴瀏覽器,它可以 “在後臺” 載入指令碼的同時繼續解析 HTML,並在指令碼載入完之後再執行。這樣,指令碼下載就不會阻塞 DOM 構建和頁面渲染了。結果就是,使用者可以在所有的指令碼載入完成之前就能看到頁面。
defer
和 async
之間的不同是他們開始執行指令碼的時機的不同。
defer
比 async
要先引入瀏覽器。它的執行在解析完全完成之後才開始,它處在DOMContentLoaded
事件之前。 它保證指令碼會按照它在 HTML 中出現的順序執行,並且不會阻塞解析。
async
指令碼在它們完成下載完成後的第一時間執行,它處在 window 的load
事件之前。 這意味著有可能(並且很有可能)設定了 async 的指令碼不會按照它們在 HTML 中出現的順序執行。這也意味著他們可能會中斷 DOM 的構建。
無論它們在何處被指定,設定async
的指令碼的載入有著較低的優先順序。他們通常在所有其他指令碼載入之後才載入,而不阻塞 DOM 構建。然而,如果一個指定async
的指令碼很快就完成了下載,那麼它的執行會阻塞 DOM 構建以及所有在之後才完成下載的同步腳。
注: async 和 defer 屬性只對外部指令碼起作用,如果沒有 src
屬性它們會被忽略。
preload
如果你想要延遲處理一些指令碼,那麼async
和 defer
非常棒。那網頁上那些對使用者體驗至關重要的東西呢?預解析器很方便,但是它們只會預載入少數型別的資源並遵循其邏輯。通常的目的都是首先交付 CSS,因為它會阻塞渲染。同步的指令碼總是比非同步的指令碼擁有更高的優先順序。視口中可見的影像會比那些底下的圖片先下載完。還有字型,視訊,SVG… 總而言之 — 這個過程很複雜。
作為作者,你知道哪些資源對你的頁面渲染來說是最重要的。它們其中一些經常深藏在 CSS 或者是指令碼當中,甚至瀏覽器需要花上很長一段時間才會發現他們。對於那些重要的資源,你現在可以使用 來告訴瀏覽器你需要儘快地載入它們。
你只需要寫上:
1 |
<link rel="preload" href="very_important.js" as="script"> |
你幾乎可以連結到任何東西上,as
屬性告訴瀏覽器要下載的是什麼。一些可能的值是:
script
style
image
font
audio
video
你可以在MDN上檢視剩餘的內容型別。
字型可能是隱藏在CSS中最重要的東西。它們對頁面上文字的渲染非常地關鍵,但是它們知道瀏覽器確認它們會被使用之前都不會被載入。 這個檢查只發生在 CSS 已經被解析,應用,並且瀏覽器已經將 CSS 規則匹配到對應的 DOM 節點上時。這個過程在頁面載入的過程中發生的相當晚,並且常常導致文字渲染中不必要的延遲。你可以通過使用 preload 屬性來避免。
有一點要注意,要預載入字型你還必須設定crossorigin 屬性,即使字型在同一個域名下:
1 |
<link rel="preload" href="font.woff" as="font" crossorigin> |
preload 特性目前只有有限的支援度,因為其他瀏覽器還在推出它的過程中。你可以在這裡檢視進度。
結論
瀏覽器是自 90 年代以來一直在進化的極其複雜的野獸。我們已經討論了一些遺留問題以及 Web 開發中的一些最新標準。根據這些指南書寫你的程式碼能夠幫助你選擇最好的策略來提供更加流暢的瀏覽器體驗。
如果你想了解更多關於瀏覽器的工作原理,你可以檢視其他的文章: