現代腳本的加載

原文地址: Modern Script Loading, 文章做者是Preact做者Jason Millerhtml

背景知識

先簡單介紹一下模塊script(Module script), 它指的是現代瀏覽器支持經過<script type=module src=main.js></script>來加載現代的ES6模塊. 現代瀏覽器對ES6現代語法有良好的支持,這意味着咱們能夠給這些現代瀏覽器提供更緊湊的‘現代代碼’,一方面能夠減少打包的體積,減小網絡傳輸的帶寬,另外還能夠提升腳本解析的效率和運行效率.前端

下圖來源於module/nomodule pattern, 對比了模塊script傳統(legacy) script的性能:react

體積對比:webpack

Version Size (minified) Size (minified + gzipped)
ES2015+ (main.mjs) 80K 21K
ES5 (main.es5.js) 175K 43K

解析效率:git

Version Parse/eval time (individual runs) Parse/eval time (avg)
ES2015+ (main.mjs) 184ms, 164ms, 166ms 172ms
ES5 (main.es5.js) 389ms, 351ms, 360ms 367ms

Ok,爲了兼容舊瀏覽器, module/nomodule pattern這篇文章介紹了一種module/nomodule 模式, 簡單說就是同時提供兩個script, 由瀏覽器來決定加載哪一個文件es6

<script type="module" src="main.mjs"></script>



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


看起來很美好是吧? 現實是:中間存在一些瀏覽器,它們能夠識別模塊script可是不認識nomodule屬性, 這就致使了這些瀏覽器會同時加載這兩個文件(下文統一稱爲‘雙重加載’(over-fetching)).github

OK,正式進入正文. 給正確的瀏覽器交付正確代碼是一件棘手的事情。本文會介紹幾種方式, 來解決上述的問題:

給現代瀏覽器伺服'現代的代碼'對性能有很大的幫助。因此你應該針對現代瀏覽器提供包含更緊湊和優化的現代語法的Javascript包,同時又能夠保持對舊瀏覽器的支持web

現有的工具鏈的生態系統基本都是在module/nomodule模式上整合的,它聲明式加載現代和傳統代碼(legacy code),即給瀏覽器提供兩個源代碼,讓它來本身來決定用哪一個:瀏覽器

<script type="module" src="/modern.js"></script>  
<script nomodule src="/legacy.js"></script>

然而現實老是給你當頭一棒,它沒咱們指望的那麼簡單直接。上述基於HTML的加載方式在Edge和Safari中會被同時加載!緩存

怎麼辦?

怎麼辦?咱們想依賴瀏覽器來交付不一樣的編譯目標,可是一些舊瀏覽器並不能優雅地支持這種簡潔的寫法。

首先,Safari 在10.1開始支持JS模塊, 但不支持nomodule屬性。值得慶幸的是,Sam找到了一種方法,能夠經過Safari 10和11中非標準的beforeload事件來模擬 nomodule, 也就是能夠認爲Safari 10.1開始是能夠支持module/nomodule模式

選項1: 動態加載

咱們能夠實現一個小型script加載器來規避這個問題,工做原理相似於LoadCSS。只不過這裏須要依靠瀏覽器的來實現ES模塊和nomodule屬性.

咱們首先嚐試執行一個模塊script進行'石蕊試驗'(litmus test), 而後由這個試驗的結果來決定加載現代代碼仍是傳統代碼:

<script type=module>  
  self.modern = true
</script>

  
<script>  
  addEventListener('load', function() {
    var s = document.createElement('script')
    if (self.modern) {
      s.src = '/modern.js'
      s.type = 'module'
    }
    else {
      s.src = '/legacy.js'
    }
    document.head.appendChild(s)
  })
</script>

然而,這個解決方案必須等待進行‘石蕊試驗’模塊script執行完成, 才能開始注入script。這是由於<script type=module>始終是異步的,因此別無它法(延遲到load事件後)。

另外一種實現方式是檢查瀏覽器是否支持nomodule, 這是方式能夠避免上述的延遲加載問題, 只不過這意味着像Safari 10.1這些支持模塊, 卻不支持nomodule的瀏覽器也會被當作傳統瀏覽器,這也許可能好事(相對於兩個腳本都加載以及有一些bug),代碼以下:

var s = document.createElement('script')  
if ('noModule' in s) {  // 注意這裏的大小寫
  s.type = 'module'
  s.src = '/modern.js'
}
else  
  s.src = '/legacy.js'
}
document.head.appendChild(s)

如今把它們封裝成函數,並確保兩種方式都統一使用異步的方式加載(上文提到模塊script是異步的,而傳統script不是):

<script>  
  $loadjs("/modern.js","/legacy.js")
  function $loadjs(src,fallback,s) {
    s = document.createElement('script')
    if ('noModule' in s) s.type = 'module', s.src = src
    else s.async = true, s.src = fallback   // 統一使用異步方式加載
    document.head.appendChild(s)
  }
</script>

看起來已經很完美了,還有什麼問題呢?咱們還沒考慮預加載(preloading)

這個有點蛋疼, 由於通常瀏覽器只會靜態地掃描HTML,而後查找它能夠預加載的資源。 咱們上面介紹的模塊加載器是徹底動態的,因此瀏覽器在沒有運行咱們的代碼以前,是沒辦法發現咱們要預加載現代仍是傳統的Javascript資源的。

不過有一個解決辦法,就是不完美:就是使用<link rel=modulepreload>來預加載現代版本的包, 舊瀏覽器會忽略這條規則,然而目前只有Chrome支持這麼作:

<link rel="modulepreload" href="/modern.js">  
<script type=module>self.modern=1</script>

其實預加載這種技術是否有效,取決於嵌入你的腳本的HTML文檔的大小

若是你的HTML載荷很小, 好比只是一個啓動屏或者只是簡單啓動客戶端應用,那麼放棄預加載掃描對你的應用性能影響很小。
若是你的應用使用服務器渲染大量有意義的HTML, 並以流(stream)的方式傳輸給瀏覽器,那麼預加載掃描就是你的朋友,但這也未必是最佳方法。

譯註: 現代瀏覽器都支持分塊編碼傳輸,等服務端徹底輸出html可能有一段空閒時間,這時候能夠經過預加載技術,讓瀏覽器預先去請求資源

大概代碼以下:

<link rel="modulepreload" href="/modern.js">  
<script type=module>self.modern=1</script>  
<script>  
  $loadjs("/modern.js","/legacy.js")
  function $loadjs(e,d,c){c=document.createElement("script"),self.modern?(c.src=e,c.type="module"):c.src=d,document.head.appendChild(c)}
</script>

還要指出的是,支持JS模塊的瀏覽器通常也支持<link rel = preload>。對於某些網站,相比依靠modulepreload, 使用<link rel=preload as=script crossorigin>可能更有意義。不過性能上面可能欠點,由於傳統的腳本預加載不會像modulepreload同樣隨着時間的推移而去展開解析工做(rel=preload只是下載,不會嘗試去解析腳本)。

選項2: 用戶代理嗅探

我辦法拿出一個簡潔的代碼示例,由於用戶代理檢測不在本文的範圍以內,推薦閱讀這篇Smashing Magazine文章

本質上,這種技術在每一個瀏覽器上都使用<script src=bundle.js>來加載代碼,當bundle.js被請求時,服務器會解析瀏覽器的用戶代理,並選擇返回現代代碼仍是傳統代碼,取決於瀏覽器是否能被識別爲現代瀏覽器.

儘管這種方法比較通用,但它也有一些嚴重的缺點:

  • 由於依賴於服務端實現,因此前端資源不能被靜態部署(例如靜態網站生成器(如github page),Netlify等等)
  • 很難進行有效的緩存. 如今這些JavaScript URL的緩存會因用戶代理而異,這是很是不穩定的, 而不少緩存機制只是將URL做爲緩存鍵,如今這些緩存中間件可能就沒辦法工做了。
  • UA檢測很難,容易出現誤報
  • 用戶代理字符串容易被篡改,並且天天都有新的UA出現

解決這些限制的一種方法就是module/nomodule模式與'用戶代理區分'結合起來,首先這能夠避免單純的module/nomodule模式須要發送多個軟件包問題,儘管這種方法仍然會下降頁面(這時候指HTML,而不是Javascript包)的可緩存性,可是它能夠有效地觸發預加載,由於生成HTML的服務器根據用戶代理知道應該使用modulepreload仍是preload:

function renderPage(request, response) {  
  let html = `<html><head>...`;

  const agent = request.headers.userAgent;
  const isModern = userAgent.isModern(agent);
  if (isModern) {
    html += `
      <link rel=modulepreload href=modern.mjs>
      <script type=module src=modern.mjs></script>
    `;
  } else {
    html += `
      <link rel=preload as=script href=legacy.js>
      <script src=legacy.js></script>
    `;
  }

  response.end(html);
}

對於那些已經在使用服務端渲染的網站來講,用戶代理嗅探是一個比較有效的解決方案

選項 3:不考慮舊版本瀏覽器

注意這裏的‘舊版本瀏覽器’特指那些出現雙重加載的瀏覽器. 對於module/nomodule模式支持比較差(即雙重加載)的主要是一些舊版本的Chrome、Firefox和Safari. 幸運的是這部分瀏覽器的市場範圍一般是比較窄,由於用戶會自動升級到最新的版本。Edge 16-18是例外, 但還有但願: 新版本的Edge會使用基於Chromium的渲染器,能夠不受該問題的影響.

對於某些應用程序來講,接受這一點妥協是徹底合理的:你能夠給90%的瀏覽器中提供現代代碼,讓他們得到更好的體驗,而極少數舊瀏覽器不得不拋棄它們,它們只是付出的額外帶寬(即雙重加載),並不影響功能。值得注意的是,佔據移動端主要市場份額的用戶代理不會有雙重加載問題,因此這些流量不太可能來自於低速或者高昂流量費的手機。

若是你的網站用戶主要使用移動設備或較新版本的瀏覽器,那麼最簡單的module/nomodule模式將適用於你的絕大多數用戶, 其餘用戶就不考慮了,反正也是能夠跑起來的, 優先考慮大多數用戶的體驗。

<script type=module>  
!function(e,t,n){!("noModule"in(t=e.createElement("script")))&&"onbeforeload"in t&&(n=!1,e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove())}(document)
</script>

  
<script src=modern.js type=module></script>

  
  
<script src=legacy.js nomodule async defer></script>

選項 4: 使用條件包

nomodule能夠巧妙地用來條件加載那些現代瀏覽器不須要的代碼, 例如polyfills。經過這種方法,最壞的狀況就是polyfill和bundle都會被加載(例如Safari 10.1),但這畢竟是少數。鑑於目前通行的作法就是在全部瀏覽器中一致同仁地加載polyfills,相比而言, 條件polyfills可讓大部分現代瀏覽器用戶避免加載polyfill代碼。

<script nomodule src="polyfills.js"></script>

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

Angular CLI支持配置這種方式來加載polyfill, 查看Minko Gechev的代碼示例.
瞭解了這種方式以後,我決定在preact-cli中支持自動polyfill注入,你能夠查看這個PR

若是你使用Webpack,這裏有一個html-webpack-plugin插件能夠方便地爲polyfill包添加nomodule屬性.

你應該怎麼作?

答案取決於你的使用場景, 選擇和大家的架構匹配的選項:
若是你的應用只是客戶端渲染, 並且你的HTML不超過一個<script>,選項1比較合適;
若是你的應用使用服務端渲染,並且能夠接受緩存問題,那麼能夠選擇選項2;
若是你開發的是同構應用,預加載的功能可能對你很重要,這時你能夠考慮選項3和4.

就我我的而言,相比考慮桌面端瀏覽器資源下載成本,我更傾向於優化移動設備解析時間. 移動用戶體驗會受到數據解析、流量費用,電池消耗等因素的影響,而桌面用戶每每不須要考慮這些因素。
另外這些優化適用於90%的用戶,好比我工做面對的大部分用戶都是使用現代或移動瀏覽器的。

擴展閱讀

有興趣繼續深刻?能夠從下面的文章開始挖掘:

感謝Phil, Shubhie, Alex, Houssein, Ralph 以及 Addy 的反饋.

相關文章
相關標籤/搜索