iT邦幫忙

2021 iThome 鐵人賽

DAY 9
3

經過昨天的內容,讀者們應該對於網頁的渲染流程有大致的理解了。

我們再小小複習一下,大致上網頁的渲染流程為:

  • 讀取 HTML 後生成 DOM Tree
  • 讀取 HTML 中的 CSS Link Tag 生成 CSSOM Tree
  • DOM Tree 與 CSSOM Tree 共同生成 Render Tree
  • 根據 Render Tree 生成 Layout Tree,負責各元素大小與位置的計算
  • 最後 Paint 畫面

從收到 HTML、CSS 和 JavaScript,再對程式碼進行必需的處理,到最後轉變為顯示像素的過程中還有許多中間步驟。將效能最佳化其實就是瞭解這些步驟中所有的活動,再經過最佳化,這就是所謂的關鍵渲染路徑 Critical Render Path

Non-Blocking Script

當然,現今的 web app 不太可能只靠 HTML 跟 CSS 就完成,還是得靠 JavaScript 來修改網頁的內容、樣式、與使用者互動的行為。JavaScript 可以查詢及修改 DOM 和 CSSOM,在 CSSOM 執行完畢後,JavaScript 才會執行 。這邊給一個小 tip:如果可以的話,CSS file 盡快引入,JS 在 CSS 後引入,因為 JS 的執行會導致網頁載入的暫停。

一般來說我們會在 HTML 中引入 script tag 來載入 JavaScript

<script src="./main.js"></script>

剛剛也提過 JS 的執行會導致網頁載入的暫停,當解析 HTML 時遇到 script tag,會立即載入指定的 JavaScript 並在載入後立即執行它,執行完後才會繼續解析 HTML 的工作。

不過其實 script tag 還有 async 跟 defer 這兩種方式,也就是今天要介紹的 Non-Blocking Script。

async script

async 會非同步去請求外部腳本,回應後停止解析 HTML,馬上執行腳本內容,如果有多個 script 則沒辦法保證執行的先後順序。由於腳本執行時沒辦法確保 DOM 已經全部渲染(下圖中 JS exec 時 HTML 還沒完全 Parse 完),因此常用於載入第三方函式庫等不需要動到 DOM 結構的狀況。

async 最經典的例子大概是載入 Google Analytics 等網站分析工具了,因為它的載入並不是很緊急,也沒有與其他模組互相依賴,不需要注意 script 執行順序的問題,當然,它也不會去動到 DOM 結構。

還有一種方法是透過 JavaScript 動態塞入 script tag,這種動態塞入的 script tag 預設就會是以 async 的方式載入,不過可以透過設定屬性來將非同步載入關閉

const script = document.createElement('script');
script.src = "/itironman/kylemo.js";
document.body.append(script);

// 關閉 async
script.async = fasle;

defer script

defer 也會非同步請求外部腳本,但是載入的腳本會等待瀏覽器解析完 HTML 才執行(實際上的執行時間,會在 DOMContentLoaded 執行之前),類似於把 JS 放在頁尾的情況。與 async 不同,defer 會保證執行的先後順序是依照 script tag 的順序(由上至下)。由於非同步載入、不打斷渲染流程及確保執行順序的特色,基本上如果是不是那麼緊急的 script,都可以加上 defer,當然 script tag 擺放順序自己需要留意一下。


在適當的時機選用不同的載入方式,是有機會提升網頁的效能的,對於 Critical Render Path 或資源載入方式有興趣的讀者,可以更進一步閱讀 Google Developer 的文章或 MDN script tag 的 document

Resource Hint

剛剛介紹的 Non-Blocking Script 原理類似於「由我們告訴瀏覽器」腳本的載入與執行時機,接下來要介紹的 Resource Hint 也有異曲同工之妙,可以「由我們提供一些資訊給瀏覽器」,讓瀏覽器依照我們的提示預先下載一些網頁上會用到重要資源,以達到效能優化的效果。

Resource Hint 不是什麼開源的第三方套件,而是瀏覽器本身提供的效能優化指令。雖然現在的瀏覽器已經很強大了,不過礙於一些流程限制,瀏覽器有些時候也許沒辦法得到想要的資訊,這時候瀏覽器希望可以透過我們來親自告訴它它想要的資訊,讓它更方便做一些優化,這就是 resource hint 這些指令存在的意義。

還記得 Day6 圖片最佳化有提到 srcset 與 sizes 這兩個屬性嗎?它們「廣義」來說其實也屬於 Resource Hint(根據 caniuse 分類)。因為渲染流程的限制,瀏覽器在解析 HTML 時因為還沒有讀取 CSS,沒有辦法得知圖片的大小,因此希望我們可以透過屬性提前告知它,讓它能依據我們給予的提示做一些優化。

用「嚴格」一點的定義來看待的話, Resource Hint 指的是帶有 rel attribute 的 link tag,也就是今天要介紹的五種方式。

今天主要要介紹 5 種 resource hint:

  • preload
  • prefetch
  • preconnect
  • dns-prefetch
  • prerender

(其實嚴格來說 preload 不算是 Resource Hint,因為它有自己獨立的 W3C spec,是一個 deprecated 的 subresource prefetching feature 的替代版本,不過因為概念雷同,這邊我還是將它列進去,待會會詳細說明它與其他 Resource Hint 的差別。)

雖然光看名稱應該會讓人覺得一頭霧水,不過其實它們都有一個共同的目標,或者說共同的效能優化方式

對不久的將來會用到的資源預先處理,這裡的處理有可能是載入資源,或是建立連線,因此在真的要使用到該資源時可以省去不少時間。

而我們今天也有一個共同的目標,就是一起好好了解它們 ?,在開始之前,我想先為各位建立一個 mindset:Resource Hint 雖然強大,但是使用它們其實都是在增加效能與浪費網路資源這兩者之間做取捨。

舉一個生活化的例子,今天你跟朋友約好要去野餐,你預先想到了你們可能會需要喝飲料解解渴,於是你在出發前特別到全聯買了兩大袋的飲料,朋友看到你都說你太聰明了!居然想到要先買好飲料。結果事實是大家根本就不想喝飲料,於是乎野餐結束後你還是提著那兩大袋飲料回家了,心裡想著:「哎!一整天都搬這兩袋,超重的,沒喝完也不好意思跟其他人收錢,今天虧大了!」

錯用 Resource Hint 也是一樣的道理,預先載入了一堆根本不會用到的資源,對效能只是負擔而已,因此 Resource Hint 並不是萬靈丹,越強大的東西越需要謹慎思考如何使用,何時要使用。

(既然是 Browser 提供的功能,免不了會有瀏覽器支援度的問題,讀者可以參考這裡查看個瀏覽器對各個功能的支援度。)

preload & prefetch

preload 與 prefetch 是兩個較常被搞混的技巧,兩者的作用都是在提早取得將來會用到的資源,然而兩者的差別在於:

Preload 用來取得「當前頁面」的重要資源,例如影片點擊播放前的縮圖(跑不出縮圖的影片,不知道影片主要在講什麼,你敢點嗎XDD)。可以想像成告訴瀏覽器:「越快幫我下載這些資源越好!」

Prefetch 告訴瀏覽器「這些資源我待會會用到,先幫我下載吧!」不過與 preload 不同的是 prefetch 抓取的資源不限於當前頁面使用,也就是可以跨越 navigation,例如你很確定使用者會點擊下一頁,就可以使用 prefetch 預先抓取下一頁的資源,至於什麼時候要下載,則交由瀏覽器自行決定。

至於剛剛提到嚴格來說 preload 不算是 Resource Hint,除了它擁有自己的 spec 以外,它與其他四者最大的不同在於另外四個 Resource Hint 是真的「Hint」,它們建議瀏覽器可以先去載入哪些資源或是做哪些事,對瀏覽器而言,這些 hint 的 priority 是比較低的,當瀏覽器有 idle time 再去做就好。而 preload 比較像是強制的告訴瀏覽器:「我現在就要這個!馬上幫我下載,不然我哭給你看!」逼不得已下瀏覽器得把 preload 要的資源視為 high priority,相較於其他 "hint",preload 更像是強制性的 "command"。


(不跟我玩,我就哭給你看!)

as Attribute

瀏覽器對於資源的載入順序是有規則的,是以檔案類型來決定下載的優先順序,以 chrome 舉例來說

High priority : style | font | XHR (sync) 

Medium priority : 位於可視區域的圖片 | Preload without as attribute | XHR (async) 

Low priority : favicon、script async | defer | block、不在可視區域的圖片、媒體檔、SVG 等

preload 與 prefetch 也是以 as 屬性來分辨檔案類型

<link rel="preload" as="font" type="font/woff2" href="myfont.woff2">

可以看到使用 preload 後瀏覽器將資源的載入優先順序提高了。


上面這張表為 chrome 資源載入順序對照表。
Reference

從瀏覽器 devtool 的 network tab 也可以看到每個資源的 priority。


preconnect

preconnect 相當於告訴瀏覽器:「這個網頁將會在不久的將來下載某個 domain 的資源,請先幫我建立好連線。」

要理解 preconnect 能夠達成的事,得了解瀏覽器在實際傳輸資源前,實際上經過哪些步驟:

  • 向 DNS 請求解析網域名,拿到 IP 地址 (DNS Resolution)
  • TCP Handshake
  • (HTTPS connection) SSL Negotiation
  • 建立連線完成,等待拿到資料的第一個byte

上面的四個步驟中,每一步都會需要一個 RTT (Round Trip Time) 的來回時間。所以在實際傳輸資料之前,已經花了3個 RTT 的時間。如果在網路狀況很差的狀況下,會讓獲取資源的速度大大降低。

利用 preconnect 提早建立好與特定 domain 之間的連線,省去了一次完整的 (DNS Lookup + TCP Handshake + SSL Negotiation) ,共三個 Round Trip Time 的時間。

Preconnect Use Cases :

  • 通常只會對確定短時間內就會用到的 domain 做 preconnect,因為如果 10 秒內沒有使用的話瀏覽器會自動把連線 close 掉。
  • CDN:如果網站中有很多資源要從 CDN 拿取,可以 preconnect CDN 的域名,這在不能預先知道有哪些資源要抓取的情況,是蠻適合的 use case。各位還記得在 Day6 圖片優化的章節提到的 Image CDN 嗎?它也是個蠻適合使用 preconnect 的情境喔!
  • Streaming 串流媒體 (待會看下方 lite-youtube-embed 的例子)
  • Dynamic URL request

dns-prefetch

跟 preconnect 類似,差別在於只提示瀏覽器預先處理第一步 DNS lookup 而已。也就是說

dns-prefetch= DNS look up

preconnect = DNS look up + TCP Handshake +  SSL Negotiation

讓我猜猜你在想什麼,「看起來 preconnect 做了比較多優化,有點不太清楚 dns-prefetch 存在的意義跟使用的時機?」

其實基本上適合使用 preconnect 的情境都可以套用在 dns-prefetch 上,畢竟 dns-predetch 算是 preconnect 的子集。還記得一開始建立的 mindset 嗎?因為 preconnect 做了更多的事,相較之下它也會耗費更多的 bandwidth,再加上前面提過的瀏覽器只會保持 preconnect 的 connection 10 秒,超過 10 秒都沒有跟連線目標發送請求,瀏覽器會自動關閉連線,那麼 preconnect 就等於是白做了,還浪費了一些網路資源。

Only For Cross-Origin Domains

根據 MDN doc,只針對 cross-origin domain 做 dns-prefetch 是一個 best practice,因為同源的 IP Address 早就被解析過了。

preconnect Pair With dns-prefetch

<link rel="preconnect" href="https://fonts.googleapis.com/" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com/">

根據 MDN doc,如果確定資源適合做 preconnect,建議一起使用 preconnect 與 dns-prefetch,原因在於瀏覽器支援度,preconnet 的支援度比 dns-prefetch 還差,多虧 HTML fault-tolerant 的特色,如果瀏覽器看到不支援的 hint,會忽略它而不會報錯,將 preconnect 與 dns-prefetch 一起使用的狀況下可以確保就算瀏覽器沒有支援 preconnect,也能最小限度先做 DNS Resolution。

最後總結一下 preconnect 與 dns-prefetch 的使用時機

如果頁面需要與許多第三方網域建立 connection,將它們都做 preconnect 反而會適得其反。 preconnect hint 最好僅用於最關鍵的 connection。對於其他資源,只需使用 即可節省第一步 DNS Resolution 的時間。


prerender

prerender 與 prefetch 都是針對非當前頁面的資源載入,不過 prerender 比 prefetch 更進一步。不僅僅會下載對應的資源,還會對資源進行解析。解析過程中,如果需要其他的資源,還會直接下載或執行這些資源,基本上就是盡可能預先渲染下個頁面,這樣一來當用戶在從當前頁面跳轉到目標頁面時,瀏覽器可以快速的響應。適合用在用戶很高機率會轉到另一個頁面的狀況下使用。

舉個例子

<link rel="prerender" href="next-page.html">

瀏覽器會先去抓取 next-page.html,除了抓取之外,還會直接解析這個 HTML 檔,如果這個 HTML 有引入其他 script,瀏覽器會直接執行這些 script,就像是預先渲染完這個頁面一樣。

既然 prerender 不只抓取資源,甚至會去執行它,在使用上就得格外小心。使用 prerender 的資源應該要是非常確定使用者在不久後一定會存取的頁面,不然反而浪費了更多的 Network Bandwidth。

不過 prerender 的瀏覽器支援度相比 prefetch 就沒有那麼好了,至少目前為止是這樣,因此以相容性的觀點來看,prefetch 目前應該會是比 prerender 更好的選擇。


Resource Hint Case Demo - lite-youtube-embed

今天介紹了那麼多 Resource Hint 的技巧,不實際 demo 一下證明對效能真的有幫助好像有點說不過去,讓我們直接來看個例子吧!

lite-youtube-embed 是一個號稱渲染速度比原生 iframe 快 224 倍的 YouTube 影片播放元件,它能達成這樣的效能提升其實做的事並不複雜,主要有兩件事:

  • 看過 YouTube 的人都知道影片會有一個預覽縮圖(thumbnail),以使用者的角度來看,如果一個影片的預覽圖一直跑不出來,你應該也不敢亂點進去看那個影片吧?這個套件利用 preload resource hint,要瀏覽器盡快下載 YouTube 影片的 thumbnail,使得使用者可以儘早看到預覽圖,提升使用者體驗。

  • 當使用者鼠標移到元件範圍時(hover),對 YouTube domain 進行 preconnect,當使用者真的點下播放鍵時才真正載入 iframe,不過因為有對 youtube domain 先做 preconnect,因此省去 3 個 Round Trip Time 的時間,因此可以更快速開始播放影片。

// pseudo code

// preload 影片的 thumbnail 縮圖
<link rel='preload' href={thumbnail URL} as='image' />

{當滑鼠 hover 到影片上 && (
  <>
    {/* The iframe document and most of its subresources come right off youtube.com */}
    <link rel='preconnect' href={youtube domain} />
    {/* The botguard script is fetched off from google.com */}
    <link rel='preconnect' href='https://www.google.com' />
  </>
)}

啊...說好要 demo,怎麼直接放別人的成果咧?

其實我看到這個套件的當下,覺得這些優化的想法太棒了,簡單又高效,所以試著用相同的概念實作了一個 React Component 版本的 lite-youtube-embed,叫做 react-lite-yt-embed,有興趣的讀者可以隨意看看,除了原作者使用的 resource hint 以外,我還有做一些額外優化例如支援 WebP 與 lazy load,如果覺得對你有幫助,可以不吝嗇的給我一顆 star ?

從這張 gif 可以看到除了影片縮圖是透過 preload 載入以外,當滑鼠 hover 到圖片上時還會先去做 preconnect,當使用者點下播放鍵後,因為已經提前 preconnect,可以減少一些 round trip time,加快影片載入速度。

有興趣的讀者也可以到 codesandbox 範例玩玩。

本日小結

隨著瀏覽器不斷進化,它也提供越來越多功能給我們使用,不管是 Non-Blocking Script 還是 Resource Hint,只要使用時機恰當,都很有可能靠瀏覽器幫我們完成效能的優化,不過它們也有可能是一把雙面刃,如果不當使用,則只是在浪費網路資源,反而造成效能的耗損。

使用 Resource Hint 也很容易讓 code 變的難以維護(想想如果都單純把 hint 加到 HTML 裡,萬一資源有變動,要改動真的十分麻煩),比較合理的方式可能是使用 JavaScript 動態產生這些 Resource Hint,不只可以把相關的 hint 寫在同一個 file,維護上也變得更加容易。(額外補充,Resource Hint 都是 body-ok 的 link,要放在 HTML 的 body 也是可以 work 的)。

最後也真心感謝還繼續支持著系列文的小夥伴,我知道有幾天的篇幅還蠻長的(未來的天數應該也是差不多這個長度 XDD),可能會讓閱讀的人稍微辛苦一點,但這是我給自己的一個小目標,想在系列文把想分享的知識毫無保留的寫出來,相信也真的可以讓讀者有所收穫,我們明天見囉~

References

https://shubo.io/preload-prefetch-preconnect/
https://cythilya.github.io/2018/07/31/preload-vs-prefetch/
https://medium.com/reloading/preload-prefetch-and-priorities-in-chrome-776165961bbf
https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/


上一篇
Day08 X 瀏覽器架構演進史 & 渲染機制
下一篇
Day10 X 實作一個簡單的 Virtualized List 吧!
系列文
今晚,我想來點 Web 前端效能優化大補帖!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言