【譯】在生產環境中使用原生JavaScript模塊

原文地址:philipwalton.com/articles/us…
原文做者:PHILIP WALTON
譯者:龔亮 ,校對:劉輝
聲明:本翻譯僅作學習交流使用,轉載請註明來源javascript

兩年前,我寫了一篇有關module/nomodule技術的文章,這項技術容許你在編寫ES2015+代碼時,使用打包器和轉換器生成兩個版本的代碼庫,一個具備現代語法的版本(經過<script type="module">加載)和一個使用ES5語法的版本(經過<script nomodule>加載)。該技術容許你向支持模塊(譯者注:指ECMA制定的標準的export/import模塊語法及其加載機制,又稱爲ES Module、ESM、ES6 Module、ES2015 Module,下文中將出現不少"模塊"一詞,都是這個含義)的瀏覽器發送更少的代碼,如今大多數Web框架和CLI都支持它。html

可是那時候,儘管可以在生產中部署現代JavaScript,大多數瀏覽器也都支持模塊,我仍然建議打包你的代碼。java

爲何?主要是由於我以爲在瀏覽器中加載模塊很慢。儘管像HTTP/2這樣的新協議理論上有效地支持加載大量小文件,但當時的全部性能研究都認爲使用打包器更有效。node

其實當時的研究是不完整的。該研究所使用的模塊測試示例由部署到生產環境中未優化和未縮小的源文件組成。它並無將優化後的模塊包與優化後的原始腳本進行比較。react

不過,當時並無更好的方法來部署模塊(譯者注:指遵循ES2015模塊規範的文件)。可是如今,打包技術取得了一些最新進展,能夠將生產代碼部署爲ES2015模塊(包含靜態導入和動態導入),從而得到比非模塊(譯者注:指除ES2015模塊外的傳統部署方式)更好的性能。實際上,這個站點(譯者注:指原文章所在的網站)已經在生產環境中使用原生模塊好幾個月了。webpack

對模塊的誤解

與我交流過的不少人都認爲模塊(譯者注:指遵循ES2015模塊規範的部署方式)是大規模生產環境下應用程序的一個選擇罷了。他們中的許多人引用了我剛剛提到的研究,並建議不要在生產環境中使用模塊,除非:git

...小型web應用程序,總共只有不到100個模塊,依賴樹相對較淺(即最大深度小於5)。es6

若是你曾經查看過node_modules目錄,可能知道即便是小型應用程序也很容易有超過100個模塊依賴項。咱們來看看npm上一些流行的工具包有多少個模塊依賴項吧:github

模塊數量
date-fns 729
lodash-es 643
rxjs 226

人們對模塊的主要誤解是,在生產環境中使用模塊時只有兩個選擇:(1)按原樣部署全部源代碼(包括node_modules目錄),(2)徹底不使用模塊。web

若是你仔細考慮我所引用研究給出的建議,它沒有說加載模塊比普通加載腳本慢,也沒有說你不該該使用模塊。它只是說,若是你將數百個未通過壓縮的模塊文件部署到生產環境中,Chrome將沒法像加載單個通過壓縮的模塊同樣快速的加載它們。因此建議繼續使用打包器、編譯器和壓縮器(譯者注:原文是minifier,指去除空格註釋等)。

實際狀況是,你能夠在生產環境中使用上面全部技術的同時,也可使用ES2015模塊!

事實上,由於瀏覽器已經知道如何加載模塊(對不支持模塊的瀏覽器能夠作降級處理),因此模塊纔是咱們應該打包出的格式。若是你檢查大多數流行的打包器生成的輸出代碼,你會發現不少樣板代碼(譯者注:指rollup和webpack中的runtime的代碼),其惟一的目的是動態加載其它代碼並管理依賴,但若是咱們只使用帶有importexport語句的模塊,則不須要這些代碼!

幸運的是,今天至少有一個流行的打包器(Rollup)支持模塊做爲輸出格式,這意味着能夠打包代碼並在生產環境中部署模塊(沒有加載器樣板代碼)。因爲Rollup(根據個人經驗,這是最好的打包器)具備出色的tree-shaking,使得Rollup打包出的模塊是目前全部打包器輸出模塊中代碼最少的。

更新: Parcel計劃在下一版本中添加模塊支持。Webpack目前不支持模塊輸出格式,但這裏有一些相關討論#2933#8895#8896

另外一個誤解是,除非你的全部依賴項都使用模塊,不然你不能使用模塊。不幸的是大多數npm包仍然以CommonJS的形式發佈(甚至有些包以ES2015編寫,但在發佈到npm以前轉換爲CommonJS)!

儘管如此,Rollup有一個插件(rollup-plugin-commonjs),它能夠將CommonJS源代碼轉換爲ES2015。若是一開始你的依賴項採用ES2015模塊管理確定會更好,可是有一些依賴關係不是這樣管理的並不會阻止你部署模塊。

在本文的剩餘部分,我將向你展現如何打包到模塊(包括使用動態導入和代碼拆分的粒度),解釋爲何它一般比原始腳本更高效,並展現如何處理不支持模塊的瀏覽器。

最優打包策略

打包生產代碼一直是須要權衡利弊。一方面,但願代碼儘快加載和執行。另外一方面,又不但願加載用戶實際用不到的代碼。

同時,還但願代碼儘量地被緩存。打包的一個大問題是,即便只是一行代碼有修改也會使整個打包後的包緩存失效。若是直接使用ES2015模塊部署應用程序(就像它們在源代碼中同樣),那麼你能夠自由地進行小的更改,同時讓應用程序的大部分代碼仍然保留在緩存中。但就像我已經指出的那樣,這也意味着你的代碼須要更長時間才能被新用戶的瀏覽器加載完成。

所以,找到最優打包粒度的挑戰是在加載性能和長期緩存之間取得適當的平衡。

默認狀況下,大多數打包器在動態導入時進行代碼拆分,但我認爲僅動態導入的代碼拆分粒度不夠細,特別是對於擁有大量留存用戶的站點(緩存很重要)。

在我看來,你應該儘量細粒度地拆分代碼,直到開始顯著地影響加載性能爲止。雖然我強烈建議你本身動手進行分析,可是查閱上文引用的研究能夠得出一個大體的結論。當加載少於100個模塊時,沒有明顯的性能差別。針對HTTP/2性能的研究發現,加載少於50個文件時沒有明顯的差別(儘管他們只測試了一、六、50和1000,因此100個文件可能就能夠了)。

那麼,最好的代碼拆分方法是什麼呢?除了經過動態導入作代碼拆分外,我還建議以npm包爲粒度作代碼拆分,node_modules中的模塊都合併到以其包名命名的文件中。

包級別的代碼拆分

如上所述,打包技術的一些最新進展使得高性能模塊部署成爲可能。我提到的加強是指Rollup的兩個新功能:經過動態import()自動代碼拆分(在v1.0.0中添加)和經過manualChunks選項進行可編程的手動代碼拆分(在v1.11.0中添加)。

有了這兩個功能,如今很容易在包級別進行代碼拆分的構建配置。

這是一個使用manualChunks選項配置的例子,每一個位於node_module裏的模塊將被合併到以包名命名的文件裏(固然,這種模塊路徑裏確定包含node_modules)

export default {
  input: {
    main: 'src/main.mjs',
  },
  output: {
    dir: 'build',
    format: 'esm',
    entryFileNames: '[name].[hash].mjs',
  },
  manualChunks(id) {
    if (id.includes('node_modules')) {
      // Return the directory name following the last `node_modules`.
      // 返回最後一個node_modules後面跟着的目錄名
      // Usually this is the package, but it could also be the scope.
      // 一般都會是一個包名,也有多是一個私有域
      const dirs = id.split(path.sep);
      return dirs[dirs.lastIndexOf('node_modules') + 1];
    }
  },
}
複製代碼

manualChunks選項接收一個函數,該函數將模塊文件路徑做爲唯一的參數,也能夠返回一個文件名,參數中的模塊將被加入到這個文件裏。若是沒有返回任何內容,參數中的模塊將被添加到默認文件中。

考慮從lodash-es包中導入cloneDeep()debounce()find()模塊的一個應用程序。上面的配置將把各個模塊(以及它們導入的任何其它lodash模塊)一塊兒放入一個名爲npm.lodash-es.XXXX.mjs的輸出文件中,(其中XXXX是lodash-es模塊文件的哈希值)。

在該文件的末尾,你會看到這樣的導出語句(注意,它只包含添加到塊中模塊的導出語句,而不是全部lodash模塊):

export {cloneDeep, debounce, find};
複製代碼

但願這個例子能清楚地說明使用Rollup手動拆分代碼的工做原理。就我我的而言,我認爲使用importexport語句的代碼拆分比使用非標準的、特定於打包器實現的代碼拆分更容易閱讀和理解。

例如,跟蹤這個文件中發生了什麼很難(我之前使用webpack對一個項目作代碼拆分後的實際輸出),並且在支持模塊的瀏覽器中其實不須要這些代碼:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["import1"],{

/***/ "tLzr":
/*!*********************************!*\
  !*** ./app/scripts/import-1.js ***!
  \*********************************/
/*! exports provided: import1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "import1", function() { return import1; });
/* harmony import */ var _dep_1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./dep-1 */ "6xPP");

const import1 = "imported: " + _dep_1__WEBPACK_IMPORTED_MODULE_0__["dep1"];

/***/ })

}]);
複製代碼

若是你有數百個npm依賴項怎麼辦?

我在上面說過,我認爲包級別上的代碼拆分是站點代碼拆分的最佳狀態,而又不會太激進。

固然,若是你的應用程序從數百個不一樣的npm包中導入模塊,那麼瀏覽器可能沒法有效地加載全部模塊。

可是,若是你確實有不少npm依賴項,那麼先不要徹底放棄這個策略。請記住,你可能不會在每一個頁面上加載全部的npm依賴項,所以檢查實際加載了多少依賴項很是重要。

儘管如此,確實有一些很是大的應用程序具備如此多的npm依賴關係,以致於它們不能實際地對其中的每個應用程序進行代碼拆分。若是你是這種狀況,我建議你找出一種方法來將一些依賴項分組到公共文件中。通常來講,你能夠將可能在同一時間發生變化的包(例如,Reactreact-dom)分組,由於它們必須一塊兒失效(例如,我稍後展現的示例應用程序將全部React依賴項分組爲同一個文件)。

動態導入

使用原生import語句進行代碼拆分和模塊加載的一個缺點是,須要開發人員對不支持模塊的瀏覽器作兼容處理。

若是你想使用動態import()懶加載代碼,那麼你還必須處理這樣一個事實:有些瀏覽器支持模塊,但不支持動態import()(Edge 16–18, Firefox 60–66, Safari 11, Chrome 61–63)。

幸運的是,一個很小的(~400字節)、很是高性能的polyfill可用於動態import()

向站點添加polyfill很容易。你所要作的是導入它並在應用程序的主入口點初始化它(在調用import()以前):

import dynamicImportPolyfill from 'dynamic-import-polyfill';

// This needs to be done before any dynamic imports are used. And if your
// modules are hosted in a sub-directory, the path must be specified here.
dynamicImportPolyfill.initialize({modulePath: '/modules/'});
複製代碼

最後要作的是告訴Rollup將輸出代碼中的動態import()重命名爲你指定的另外一個名稱(經過output.dynamicImportFunction選項配置)。動態導入polyfill默認使用名稱爲__import__,可是能夠配置它。

須要重命名import()語句的緣由是import是JavaScript中的一個關鍵字。這意味着不可能使用相同的名稱來填充原生import(),由於這樣作會致使語法錯誤。

讓Rollup在構建時重命名它是很好的,這意味着你的源代碼可使用標準版本,而且在未來再也不須要polyfill時,你將沒必要從新更改它。

高效加載JavaScript模塊

當你使用代碼拆分的時候,最好預加載全部立刻要使用的模塊(即主入口模塊導入圖中的全部模塊)。

可是,當你加載實際的JavaScript模塊(經過<script type="module">以及隨後import語句引用的模塊時),你將但願使用modulepreload而不是傳統的preload(僅適用於原始腳本)。

<link rel="modulepreload" href="/modules/main.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-one.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-two.XXXX.mjs">
<link rel="modulepreload" href="/modules/npm.pkg-three.XXXX.mjs">
<!-- ... -->
<script type="module" src="/modules/main.XXXX.mjs"></script>
複製代碼

實際上,對於預加載原生的模塊,modulepreload實際上比傳統的preload要嚴格得多,它不只下載文件,並且在主線程以外當即開始解析和編譯文件。傳統的預加載沒法作到這一點,由於它不知道在預加載時該文件將用做模塊腳本仍是原始腳本。

這意味着經過modulepreload加載模塊一般會更快,並且在實例化時不太可能致使主線程卡頓。

生成modulepreload列表

Rollup的bundle對象中的每一個入口文件在其靜態依賴關係圖中包含完整的導入列表,所以在Rollup的generateBundle鉤子中很容易得到須要預加載哪些文件的列表。

雖然在npm上確實存在一些modulepreload插件,可是爲圖中的每一個入口點生成一個modulepreload列表只須要幾行代碼,因此我更願意像這樣手動建立它:

{
  generateBundle(options, bundle) {
    // A mapping of entry chunk names to their full dependency list.
    const modulepreloadMap = {};

    for (const [fileName, chunkInfo] of Object.entries(bundle)) {
      if (chunkInfo.isEntry || chunkInfo.isDynamicEntry) {
        modulepreloadMap[chunkInfo.name] = [fileName, ...chunkInfo.imports];
      }
    }

    // Do something with the mapping...
    console.log(modulepreloadMap);
  }
}
複製代碼

例如,這裏是我如何爲這個站點以及個人demo應用生成modulepreload列表的

注意:雖然對於模塊腳原本說,modulepreload絕對比原始的preload更好,但它對瀏覽器的支持更差(目前只支持chrome)。若是你的流量中有至關一部分是非chrome流量,那麼使用classic preload是有意義的。

與使用modulepreload不一樣,使用preload時須要注意的一點是,預加載腳本不會放在瀏覽器的模塊映射中,這意味着可能會不止一次地處理預加載的請求(例如,若是模塊在瀏覽器完成預加載以前導入文件)。

爲何要部署原生模塊?

若是你已經在使用像webpack這樣的打包器,而且已經在使用細粒度代碼拆分和預加載這些文件(與我在這裏描述的相似),那麼你可能想知道是否值得改變策略,使用原生模塊。下面是我認爲你應該考慮它的幾個緣由,以及爲何打包到原生模塊比使用帶有模塊加載代碼的原始腳本要好。

更小的代碼總量

當使用原生模塊時,現代瀏覽器沒必要爲用戶加載任何沒必要要的模塊加載或依賴關係管理代碼。例如,若是使用原生模塊,則根本不須要webpack運行時和清單

更好的預加載

正如我在前一節中提到的,使用modulepreload容許你加載代碼並在主線程以外解析/編譯代碼。在其餘條件相同的狀況下,這意味着頁面的交互速度更快,而且主線程在用戶交互期間不太可能被阻塞。

所以,不管你如何細粒度地對應用程序進行代碼拆分,使用import語句和modulepreload加載模塊要比經過原始script標籤和常規preload加載更有效(特別是若是這些標籤是動態生成的,並在運行時添加到DOM中)。

換句話說,由Rollup打包出的20個模塊文件將比由webpack打包出的20個原始腳本文件加載得更快(不是由於webpack,而是由於它不是原生模塊)。

更面向將來

許多最使人興奮的新瀏覽器特性都是構建在模塊之上的,而不是原始的腳本。這意味着,若是你想使用這些特性中的任何一個,你的代碼須要做爲原生模塊部署,而不是轉換爲ES5並經過原始的script標籤加載(我在嘗試使用實驗性KV存儲API時曾提到過這個問題)。

如下是一些僅限模塊纔有的最使人興奮的新功能:

支持舊版瀏覽器

在全球範圍內,超過83%的瀏覽器原生支持JavaScript模塊(包括動態導入),所以對於你的大多數用戶來講,不須要作任何處理就可使用這項技術。

對於支持模塊但不支持動態導入的瀏覽器,可使用上面提到的dynamic-import-polyfill。因爲polyfill很是小,而且在可用時將使用瀏覽器的原生動態import(),所以添加這個polyfill幾乎沒有大小或性能成本。

對於根本不支持模塊的瀏覽器,可使用我前面提到的module/nomodule技術。

一個實際的例子

因爲談論跨瀏覽器兼容性老是比實際實現它要容易,因此我構建了一個演示應用程序,它使用了我在這裏闡述的全部技術。

A demo app showing how to use native JavaScript modules with legacy browser support

這個演示程序能夠在不支持動態import()的瀏覽器中運行(如Edge 18和Firefox ESR),也能夠在不支持模塊的瀏覽器中運行(如Internet Explorer 11)。

爲了說明這個策略不只適用於簡單的用例,我還包含了當今複雜的JavaScript應用程序須要的許多特性:

  • Babel轉換(包括JSX)
  • CommonJS的依賴關係(例如react,react-dom)
  • CSS依賴項
  • Asset hashing
  • 代碼拆分
  • 動態導入(帶有polyfill降級機制)
  • module/nomodule降級機制

代碼託管在GitHub上(所以你能夠派生repo並本身構建它),而演示則託管在Glitch上,所以你能夠從新組合代碼並使用這些特性。

最重要的是查看示例中使用的Rollup配置,由於它定義瞭如何生成最終模塊。

總結

但願這篇文章讓你相信,如今不只能夠在生產環境中部署原生JavaScript模塊,並且這樣作能夠提升站點的加載和運行時性能。

如下是快速完成此工做所需步驟的摘要:

  • 使用打包器,但要確保輸出格式爲ES2015模塊
  • 積極地進行代碼拆分(若是可能的話,一直到node包)
  • 預加載靜態依賴關係圖中的全部模塊(經過modulepreload)
  • 使用polyfill來支持不支持動態import()的瀏覽器
  • 使用<script nomodule>支持根本不支持模塊的瀏覽器

若是你已經在構建設置中使用了Rollup,我但願你嘗試這裏介紹的技術,並在生產環境中部署原生模塊(帶有代碼拆分和動態導入)。若是你這樣作了,請告訴我進展如何,由於我既想聽你的問題,也想聽你的成功故事!

模塊是JavaScript的明確將來,我但願咱們全部的工具和依賴都能儘快包含模塊。但願本文能在這個方向上起到一點推進做用。

譯者評:
1.做者上一篇文章的譯文:jdc.jd.com/archives/49…
2.另一篇講JavaScript原生模塊的文章:www.jianshu.com/p/9aae3884b…


若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam):

WecTeam
相關文章
相關標籤/搜索