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

  • 2019 年 12 月 16 日
  • 筆記

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

兩年前,我寫了一篇有關module/nomodule技術的文章(譯者註:原文地址https://philipwalton.com/articles/deploying-es2015-code-in-production-today/,文末有此文的譯文鏈接),這項技術允許你在編寫ES2015+程式碼時,使用打包器和轉換器生成兩個版本的程式碼庫,一個具有現代語法的版本(通過 <scripttype="module">載入)和一個使用ES5語法的版本(通過 <scriptnomodule>載入)。該技術允許你向支援模組(譯者註:指ECMA制定的標準的export/import模組語法及其載入機制,又稱為ES Module、ESM、ES6 Module、ES2015 Module,下文中將出現很多"模組"一詞,都是這個含義)的瀏覽器發送更少的程式碼,現在大多數Web框架和CLI都支援它。

但是那時候,儘管能夠在生產中部署現代JavaScript,大多數瀏覽器也都支援模組,我仍然建議打包你的程式碼。

為什麼?主要是因為我覺得在瀏覽器中載入模組很慢。儘管像HTTP/2這樣的新協議理論上有效地支援載入大量小文件,但當時的所有性能研究都認為使用打包器更有效。

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

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

對模組的誤解

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

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

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

模組數量

date-fns

729

lodash-es

643

rxjs

226

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

如果你仔細考慮我所引用研究給出的建議,它沒有說載入模組比普通載入腳本慢,也沒有說你不應該使用模組。它只是說,如果你將數百個未經過壓縮的模組文件部署到生產環境中,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,https://github.com/rollup/rollup-plugin-commonjs),它可以將CommonJS源程式碼轉換為 ES2015。如果一開始你的依賴項採用ES2015模組管理肯定會更好(https://rollupjs.org/guide/en/#why-are-es-modules-better-than-commonjs-modules),但是有一些依賴關係不是這樣管理的並不會阻止你部署模組。

在本文的剩餘部分,我將向你展示如何打包到模組(包括使用動態導入和程式碼拆分的粒度),解釋為什麼它通常比原始腳本更高效,並展示如何處理不支援模組的瀏覽器。

最優打包策略

打包生產程式碼一直是需要權衡利弊。一方面,希望程式碼儘快載入和執行。另一方面,又不希望載入用戶實際用不到的程式碼。

同時,還希望程式碼儘可能地被快取。打包的一個大問題是,即使只是一行程式碼有修改也會使整個打包後的包快取失效。如果直接使用ES2015模組部署應用程式(就像它們在源程式碼中一樣),那麼你可以自由地進行小的更改,同時讓應用程式的大部分程式碼仍然保留在快取中。但就像我已經指出的那樣,這也意味著你的程式碼需要更長時間才能被新用戶的瀏覽器載入完成。

因此,找到最優打包粒度的挑戰是在載入性能和長期快取之間取得適當的平衡。

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

在我看來,你應該儘可能細粒度地拆分程式碼,直到開始顯著地影響載入性能為止。雖然我強烈建議你自己動手進行分析,但是查閱上文引用的研究可以得出一個大致的結論。當載入少於100個模組時,沒有明顯的性能差異。針對HTTP/2性能的研究發現,載入少於50個文件時沒有明顯的差異(儘管他們只測試了1、6、50和1000,所以100個文件可能就可以了)。

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

包級別的程式碼拆分

如上所述,打包技術的一些最新進展使得高性能模組部署成為可能。我提到的增強是指Rollup的兩個新功能:通過動態 import()時自動程式碼拆分(在v1.0.0中添加,https://rollupjs.org/guide/en/#code-splitting)和通過 manualChunks選項進行可編程的手動程式碼拆分(在v1.11.0中添加,https://rollupjs.org/guide/en/#manualchunks)。

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

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

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模組(通過 <scripttype="module">以及隨後 import語句引用的模組時),你將希望使用 modulepreload(https://developers.google.com/web/updates/2017/12/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鉤子(https://rollupjs.org/guide/en/#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應用(https://github.com/philipwalton/rollup-native-modules-boilerplate/blob/78c687bf757374b5e685508e3afc9560a86a3c96/rollup.config.js#L57-L84)生成modulepreload列表(https://github.com/philipwalton/blog/blob/90e914731c77296dccf2ed315599326c6014a080/tasks/javascript.js#L18-L43)的。

注意:雖然對於模組腳本來說,modulepreload絕對比原始的preload更好,但它對瀏覽器的支援更差(目前只支援chrome)。如果你的流量中有相當一部分是非chrome流量,那麼使用classic preload是有意義的。 與使用modulepreload不同,使用preload時需要注意的一點是,預載入腳本不會放在瀏覽器的模組映射中,這意味著可能會不止一次地處理預載入的請求(例如,如果模組在瀏覽器完成預載入之前導入文件)。

為什麼要部署原生模組?

如果你已經在使用像webpack這樣的打包器,並且已經在使用細粒度程式碼拆分和預載入這些文件(與我在這裡描述的類似),那麼你可能想知道是否值得改變策略,使用原生模組。下面是我認為你應該考慮它的幾個原因,以及為什麼打包到原生模組比使用帶有模組載入程式碼的原始腳本要好。

更小的程式碼總量

當使用原生模組時,現代瀏覽器不必為用戶載入任何不必要的模組載入或依賴關係管理程式碼。例如,如果使用原生模組,則根本不需要webpack運行時和清單(https://webpack.js.org/concepts/manifest/)。

更好的預載入

正如我在前一節中提到的,使用 modulepreload允許你載入程式碼並在主執行緒之外解析/編譯程式碼。在其他條件相同的情況下,這意味著頁面的交互速度更快,並且主執行緒在用戶交互期間不太可能被阻塞。

因此,無論你如何細粒度地對應用程式進行程式碼拆分,使用import語句和 modulepreload載入模組要比通過原始script標籤和常規preload載入更有效(特別是如果這些標籤是動態生成的,並在運行時添加到DOM中)。

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

更面向未來

許多最令人興奮的新瀏覽器特性都是構建在模組之上的,而不是原始的腳本。這意味著,如果你想使用這些特性中的任何一個,你的程式碼需要作為原生模組部署,而不是轉換為ES5並通過原始的script標籤載入(我在嘗試使用實驗性KV存儲API時曾提到過這個問題)。

以下是一些僅限模組才有的最令人興奮的新功能:

  • 內置模組(https://github.com/tc39/proposal-javascript-standard-library/)
  • HTML模組(https://github.com/w3c/webcomponents/blob/gh-pages/proposals/html-modules-explainer.md)
  • CSS模組(https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/master/CSSModules/v1Explainer.md)
  • JSON模組(https://github.com/whatwg/html/pull/4407)
  • 導入地圖(https://github.com/WICG/import-maps)
  • workers、service workers和window之間共享模組(https://html.spec.whatwg.org/multipage/workers.html#module-worker-example)

支援舊版瀏覽器

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

對於支援模組但不支援動態導入的瀏覽器,可以使用上面提到的 dynamic-import-polyfill。由於polyfill非常小,並且在可用時將使用瀏覽器的原生動態 import(),因此添加這個polyfill幾乎沒有大小或性能成本。

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

一個實際的例子

由於談論跨瀏覽器兼容性總是比實際實現它要容易,所以我構建了一個演示應用程式(https://rollup-native-modules-boilerplate.glitch.me/),它使用了我在這裡闡述的所有技術。

這個演示程式可以在不支援動態 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()的瀏覽器
  • 使用 <scriptnomodule>支援根本不支援模組的瀏覽器

如果你已經在構建設置中使用了Rollup,我希望你嘗試這裡介紹的技術,並在生產環境中部署原生模組(帶有程式碼拆分和動態導入)。如果你這樣做了,請告訴我進展如何,因為我既想聽你的問題,也想聽你的成功故事!

模組是JavaScript的明確未來,我希望我們所有的工具和依賴都能儘快包含模組。希望本文能在這個方向上起到一點推動作用。

譯者評:1.作者上一篇文章的譯文:https://jdc.jd.com/archives/4911 2.另外一篇講JavaScript原生模組的文章:https://www.jianshu.com/p/9aae3884b05b