天天看點

浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

作者:FEHub
浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

前言

在面試的時候,經常會遇到一道經典的面試題:

如何優化網頁加載速度?

正常的回答中總會有一條:

把 css 檔案放在頁面頂部,把 js 檔案放在頁面底部。

那麼,為什麼要把 js 檔案放在頁面的最底部呢?

我們先來看下這段代碼:

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
        console.log("Howdy ~");
    </script>
    <script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <script src="https://unpkg.com/[email protected]/dist/vue-router.global.js"></script>
  </head>
  <body>
    Hello  ~
  </body>
</html>           

他的執行順序是:

  • 在控制台列印:Howdy ~
  • 請求并執行 vue.global.js
  • 請求并執行 vue-router.global.js
  • 在頁面中展示:Hello ~
  • 觸發 DOMContentLoaded 事件
浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

script 加載邏輯

浏覽器的解析規則是:如果遇到 script 标簽,則暫停建構 DOM,轉而開始執行 script 标簽,如果是外部 script,那麼浏覽器還需要一直等待其「下載下傳」并「執行」後,再繼續解析後面的 HTML。

如果請求并執行「vue.global.js」需要 3 秒,「vue-router.global.js」需要 2 秒,那麼頁面中的 Hello ~,則至少需要 5 秒以上才會展示出來。

可以看到,script 标簽會阻塞浏覽器解析 HTML,如果把 script 都放在 head 中,在網絡不佳的情況下,就會導緻頁面長期處于白屏狀态。

在很久以前,一般都是将這些外聯腳本,放在 body 标簽的最後面,確定先解析展示 body 中的内容,然後再一個個請求執行這些外聯腳本。

那有沒有其他更優雅的解決方案呢?

答案是肯定的,現在 script 标簽新增了 2 個屬性:defer 和 async,就是為了解決此類問題,提升頁面性能的。

<script defer>

先看一下 MDN 上的解釋:

這個布爾屬性被設定用來通知浏覽器該腳本将在文檔完成解析後,觸發 DOMContentLoaded 事件前執行。

有 defer 屬性的腳本會阻止 DOMContentLoaded 事件,直到腳本被加載并且解析完成。

文檔是直接總結了他的特性,我們先看看下面的代碼,展開說說細節,加深一下了解。

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
      console.log("Howdy ~");
    </script>
    <script defer src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <script defer src="https://unpkg.com/[email protected]/dist/vue-router.global.js"></script>
  </head>
  <body>
    Hello  ~
  </body>
</html>           

他的執行順序是:

  • 在控制台列印:Howdy ~
  • 在頁面中展示:Hello ~
  • 請求并執行 vue.global.js
  • 請求并執行 vue-router.global.js
  • 觸發 DOMContentLoaded 事件
浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

script defer 加載邏輯

如果在 script 标簽上設定了 defer 屬性,那麼在浏覽器解析到這裡時,會默默的在背景開始下載下傳此腳本,并繼續解析後面的 HTML,并不會阻塞解析操作。

等到 HTML 解析完成之後,浏覽器會立即執行背景下載下傳的腳本,腳本執行完成之後,才會觸發 DOMContentLoaded 事件。

看起來還是蠻好了解的吧?咱們再來讨論 2 個小細節:

Q1: 如果 HTML 解析完成之後,設定了 defer 屬性的腳本還沒下載下傳完成,會怎樣?

A1: 浏覽器會等腳本下載下傳完成之後,再執行此腳本,執行完成之後,再觸發 DOMContentLoaded 事件。

Q2: 如果有多個設定了 defer 屬性的腳本,那浏覽器會如何處理?

A2: 浏覽器會并行的在背景下載下傳這些腳本,等 HTML 解析完成,并且所有腳本下載下傳完成之後,再按照他們在 HTML 中出現的相對順序執行,等所有腳本執行完成之後,再觸發 DOMContentLoaded 事件。

最佳實踐:

建議所有的外聯腳本都預設設定此屬性,因為他不會阻塞 HTML 解析,可以并行下載下傳 JavaScript 資源,還可以按照他們在 HTML 中的相對順序執行,確定有依賴關系的腳本運作時,不會缺少依賴。

在 SPA 的應用中,可以考慮把所有的 script 标簽加上 defer 屬性,并且放到 body 的最後面。在現代浏覽器中,可以并行下載下傳提升速度,也可以確定在老浏覽器中,不阻塞浏覽器解析 HTML,起到降級的作用。

注意:

  • defer 屬性僅适用于外部腳本,如果 script 腳本沒有 src,則會忽略 defer 特性。
  • defer 屬性對子產品腳本(script type='module')無效,因為子產品腳本就是以 defer 的形式加載的。

<script async>

按照慣例,先看一下 MDN 上的解釋:

對于普通腳本,如果存在 async 屬性,那麼普通腳本會被并行請求,并盡快解析和執行。

對于子產品腳本,如果存在 async 屬性,那麼腳本及其所有依賴都會在延緩隊列中執行,是以它們會被并行請求,并盡快解析和執行。

該屬性能夠消除解析阻塞的 Javascript。

解析阻塞的 Javascript 會導緻浏覽器必須加載并且執行腳本,之後才能繼續解析。

感覺這段描述的已經蠻清晰了,不過咱們還是先看看下面的代碼,展開說說細節,加深一下了解。

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
      console.log("Howdy ~");
    </script>
    <script async src="https://google-analytics.com/analytics.js"></script>
    <script async src="https://ads.google.cn/ad.js"></script>
  </head>
  <body>
    Hello  ~
  </body>
</html>           

他的執行順序是:

  • 在控制台列印:Howdy ~
  • 并行請求 analytics.js 和 ad.js
  • 在頁面中展示:Hello ~
  • 根據網絡的實際情況,以下幾項會無序執行 執行 analytics.js(下載下傳完後,立即執行)執行 ad.js(下載下傳完後,立即執行)觸發 DOMContentLoaded 事件(可能在在上面 2 個腳本之前,之間,之後觸發)
浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

script async 加載邏輯

浏覽器在解析到帶有 async 屬性的 script 标簽時,也不會阻塞頁面,同樣是在背景默默下載下傳此腳本。當他下載下傳完後,浏覽器會暫停解析 HTML,立馬執行此腳本。

看起來還是蠻好了解的吧?咱們再來讨論 2 個小細節:

Q1:如果設定了 async 屬性的 script 下載下傳完之後,浏覽器還沒解析完 HTML,會怎樣?

A1:浏覽器會暫停解析 HTML,立馬執行此腳本,等執行完之後,再繼續解析 HTML。

Q2:如果有多個 async 屬性的 script 标簽,那等他們下載下傳完成之後,會按照代碼順序執行嗎?

A2:不會。執行順序是:誰先下載下傳完成,誰先執行。async 的特點是「完全獨立」,不依賴其他内容。

最佳實踐:

當我們的項目,需要內建其他獨立的第三方庫時,可以使用此屬性,他們不依賴我們,我們也不依賴于他們。 通過設定此屬性,讓浏覽器異步下載下傳并執行他,是個不錯的優化方案。

注意:

  • async 特性僅适用于外部腳本,如果 script 腳本沒有 src,則會忽略 async 特性。

總結

defer

  • 不阻塞浏覽器解析 HTML,等解析完 HTML 之後,才會執行 script。
  • 會并行下載下傳 JavaScript 資源。
  • 會按照 HTML 中的相對順序執行腳本。
  • 會在腳本下載下傳并執行完成之後,才會觸發 DOMContentLoaded 事件。
  • 在腳本執行過程中,一定可以擷取到 HTML 中已有的元素。
  • defer 屬性對子產品腳本無效。
  • 适用于:所有外部腳本(通過 src 引用的 script)。

async

  • 不阻塞浏覽器解析 HTML,但是 script 下載下傳完成後,會立即中斷浏覽器解析 HTML,并執行此 script。
  • 會并行下載下傳 JavaScript 資源。
  • 互相獨立,誰先下載下傳完,誰先執行,沒有固定的先後順序,不可控。
  • 由于沒有确定的執行時機,是以在腳本裡面可能會擷取不到 HTML 中已有的元素。
  • DOMContentLoaded 事件和 script 腳本無相關性,無法确定他們的先後順序。
  • 适用于:獨立的第三方腳本。

另外:async 和 defer 之間最大的差別在于它們的執行時機。

One More Thing

你有沒有想過,如果一個 script 标簽同時設定 defer 和 async,浏覽器會如何處理?

先說結論:從表現形式上來說,async 的優先級比 defer 高,也就是如果同時存在這 2 個屬性,那麼浏覽器将會以 async 的特性去加載此腳本。

這主要分 2 種情況: 如果是「普通腳本」,浏覽器會優先判斷async屬性是否存在,如果存在,則以async特性去加載此腳本,如果不存在,再去判斷是否存在defer屬性。

如果是「子產品腳本」,浏覽器會判斷async屬性是否存在:

  • 如果存在,浏覽器會并行下載下傳此子產品和他的所有依賴子產品,等全部下載下傳完成之後,會立刻執行此腳本。
  • 如果不存在,浏覽器也會并行下載下傳此子產品和他的所有依賴子產品,然後等浏覽器解析完 HTML 之後,再執行此腳本。
  • 另外需要注意的是:在子產品腳本上設定 defer 屬性是無效的。

一圖勝千言

最後,用一張圖概括一下這兩個屬性的加載模式吧:

浏覽器是如何通過 defer 與 async 屬性,優化頁面加載速度的?

defer 和 async 的加載模式

思考題

  • 為什麼浏覽器在解析到普通的 script 标簽時,必須先執行他?
  • 普通的 script 标簽會阻塞浏覽器解析 HTML,這會導緻什麼問題?
本文首發于:https://github.com/mrlmx/blogs/issues/4 ,如果喜歡,記得去點個贊哦~ ❤️

參考

  • https://javascript.info/script-async-defer
  • https://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html
  • https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script
  • https://html.spec.whatwg.org/multipage/scripting.html

繼續閱讀