做者 | Philip Walton譯者 | 王強編輯 | 張之棟、Yonie模塊是 JavaScript 的將來?本文將主要介紹在生產環境中部署原生 JavaScript 模塊的方法,以提升網站的負載性能和運行時性能。javascript
兩年前我寫了一篇文章介紹了一種技術——如今一般被稱爲 module/nomodule 模式——這種技術讓你能夠編寫 ES2015+ 版本的 JavaScript 代碼,而後使用打包器和轉換工具生成兩個版本的代碼庫,一個版本使用現代語法(經過<script type="module">
加載),另外一個使用 ES5 語法(經過<script nomodule>
加載)。這項技術使你能夠向支持模塊的瀏覽器發送少得多的代碼,如今大多數 Web 框架和 CLI 都支持它。html
但在那時候,雖然咱們能在生產環境中部署現代 JavaScript 代碼,並且大多數瀏覽器都支持模塊,我仍然建議你打包本身的代碼。java
爲何?主要是由於我以爲在瀏覽器中加載模塊很慢。儘管像 HTTP/2 這樣的新協議理論上能夠快速加載大量小文件,但當時全部的性能研究都認爲使用打包器(bundler)效率更高: https://v8.dev/features/modules#bundlenode
其實那些研究並無反映完整的狀況。它們研究的模塊測試案例是使用未經優化和解壓的源文件部署到生產環境來作測試。它沒有對比優化過的模塊包與優化過的經典腳本是什麼狀況。react
不過當時並無真正優化過的模塊部署方法。可是如今,隨着打包器技術的一些突破,咱們能夠將生產代碼部署爲 ES2015 模塊——包括靜態和動態導入——而且比原有的非模塊選項性能更出色。實際上本網站已經在生產中使用原生模塊有好幾個月時間了。webpack
對模塊的誤解我同不少人交流過,他們都不肯意在大規模生產環境應用程序中用模塊,考慮一下都不行。許多人都引用了我剛纔提到的研究,該研究建議不要在生產環境中使用模塊,除非是爲了:git
若是你查看過 node_modules 目錄,你可能知道即便是小型應用程序也很容易擁有超過 100 個模塊依賴項。咱們再來看看 npm 上一些比較流行的實用程序包中有多少個模塊:... 小型網絡應用程序,總共少於 100 個模塊,而且具備相對較淺的依賴樹(即最大深度小於 5)。es6
包 | 模塊數量 |
---|---|
date-fns | 729 |
lodash-es | 643 |
rxjs | 226 |
但這就是圍繞模塊的主要誤解所在。人們認爲,在生產中使用模塊時你能夠選擇:(1)按原樣部署全部源代碼(包括 node_modules 目錄),或者(2)根本不使用模塊。github
但若是仔細觀察我引用的研究建議,並非說加載模塊比加載常規腳本要慢,而且它並無說你根本不該該使用模塊;它只是說若是你將數百個未通過管理的模塊文件部署到生產環境中,那麼 Chrome 的加載速度會比加載單個壓縮包慢不少。因此給出的建議實際上是繼續使用打包器、編譯器和壓縮器。web
實際上呢?這些都不影響你在生產環境中使用模塊!
其實咱們都應該打包成模塊的格式,由於瀏覽器已經知道如何加載模塊(不會加載模塊的瀏覽器還能使用 nomodule 回退)。若是你檢查一下大多數流行的打包器生成的輸出代碼,你會發現不少模版,其目的僅僅是動態加載其餘代碼並管理依賴項;但若是咱們只使用帶有 import 和 export 語句的模塊,那就用不着這些了!
所幸如今起碼有一個流行的打包器(Rollup)支持模塊做爲輸出格式,意味着你既能夠打包代碼也能生產環境中部署模塊(不須要加載器模版)。並且因爲 Rollup 的 tree-shaking 很棒(據我所知在全部打包器裏是最好的),使用 Rollup 打包到模塊生成的代碼體積是目前全部可用選項中最小的。
更新:Parcel 計劃在下一版本中添加 模塊支持。Webpack 目前不支持模塊輸出格式,但這裏有一些問題正在討論(#293三、#889五、#8896)。
另外一個誤解是除非你全部的依賴項都用模塊,你才能使用模塊;不幸的是(在我看來很是不幸)大多數 npm 包仍然做爲 CommonJS 發佈(有些甚至是用 ES2015 來寫,以後轉換爲 CommonJS 發佈到 npm 上)!
還好 Rollup 還有一個插件(rollup-plugin-commonjs)能夠輸入 CommonJS 源代碼並將其轉換爲 ES2015。雖然說你的依賴項一開始就採用 ES2015 模塊格式確定會更好,但某些依賴項不用模塊並不會阻礙你部署模塊。
在後文中,我將展現如何打包到模塊(包括使用動態導入和粒度代碼拆分),解釋爲何它一般比經典腳本性能更出色,並展現如何處理瀏覽器不支持模塊的狀況。
最優打包策略打包生產代碼的過程都是在作各類權衡。一方面你但願代碼儘快加載和執行,但另外一方面,你不但願加載用戶實際不會使用的代碼。
你還但願代碼儘量多地緩存起來。打包有個大問題,即便只是一行代碼所作的任何更改也會使整個包無效。若是你使用數千個小模塊部署應用程序(就像它們在源代碼中同樣),那麼你能夠自由地作出小規模的更改,同時將應用程序的大部分代碼繼續保留在緩存中——但如前所述,這可能也意味着有新訪問者時你的代碼須要更長時間才能加載。
所以,挑戰在於找到正確的打包粒度——在負載性能和長期可緩存性之間取得適當的平衡。
默認狀況下,大多數包會在動態導入時進行代碼拆分,但我認爲只對動態導入作代碼拆分還不夠精細,特別是當網站有不少回頭客時更是如此(此時緩存是很重要的)。
在我看來,你應該儘量細地拆分代碼,直到它開始顯著影響負載性能。雖然我建議你本身來作具體的分析,但做爲大體的參考,上面提到的研究發現加載少於 100 個模塊時沒有明顯的性能差別;另外一項關於 HTTP/2 的研究發現加載少於 50 個文件時沒有明顯的性能差別(儘管它們只測試了 一、六、50 和 1000 個文件的狀況)。
那麼該如何儘可能拆分代碼,同時還不能作過頭呢?除了經過動態導入進行代碼拆分以外,我還建議經過 npm package 進行代碼拆分——每一個導入的 node 模塊都根據其包名稱放入一個塊裏。
包級別的代碼拆分如前所述,打包技術的一些最新進展大幅提高了模塊部署的性能。這裏提到的進展指的是 Rollup 的兩項新的功能:經過動態 import()自動拆分代碼,和經過 manualChunks 選項手動拆分代碼。前者在 1.0.0 版本引入,後者則是 1.11.0 版本。
自動拆分代碼: https://rollupjs.org/guide/en/#code-splitting
手動拆分代碼: https://rollupjs.org/guide/en/#manualchunks
有了這兩個功能,如今咱們很容易就能配置在包級別拆分代碼的構建。
下面是一個示例配置,它使用 manualChunks 選項將每一個導入的 node 模塊放入一個與其包名匹配的塊中(技術上講就是它在 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`.
// 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 塊中模塊的惟一文件哈希值)。
在該文件的末尾,你會看到像這樣的導出語句(注意它只包含添加到塊的模塊的 export 語句,而不是全部 lodash 模塊):export {cloneDeep, debounce, find};而後,若是有任何其餘塊中的代碼使用那些 lodash 模塊(可能只是 debounce() 方法),那麼該塊將在頂部有一個 import 語句,以下所示:
import {debounce} from './npm.lodash.XXXX.mjs';
但願這個例子能讓你搞清楚該如何使用 Rollup 手動拆分代碼。並且就我的而言,我認爲使用 import 和 export 語句的代碼拆分比使用非標準、特定於打包器實現的代碼拆分更容易閱讀和理解。
例如,咱們很難跟蹤下面這個文件中發生的事情(這其實是個人一個老項目的輸出,那個項目使用了 webpack 的代碼拆分),而且在支持模塊的瀏覽器中這些代碼基本都用不着:(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["import1"],{若是你有幾百個 npm 依賴項怎麼辦?
/***/ "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 依賴項,實際上無法作到一個一個拆分開來。若是你就是這種狀況,我建議設法將一些依賴項分組爲通用塊。通常來講,可能在相近的時間進行代碼更改的包應該分在一個組裏(例如 react 和 react-dom),由於它們必須一塊兒失效(例如我後面展現的示例應用程序將全部 React 依賴項分組到同一個塊: https://github.com/philipwalton/rollup-native-modules-boilerplate/blob/da5e616c24d554dd8ffe562a7436709106be9eed/rollup.config.js#L159-L162)。
動態導入使用原生 import 語句拆分代碼拆分和加載模塊的一個缺點是,你(做爲開發人員)須要處理瀏覽器不支持模塊的狀況。
若是你想使用動態 import() 來延遲加載代碼,那麼你還必須處理一些瀏覽器支持模塊但不支持動態 import() 的狀況(Edge 16-1八、Firefox 60-6六、Safari 十一、Chrome 61-63)。
還好有一個很小(~400 字節)、性能很好的 polyfill 可用於動態導入。
將 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,但這是能夠配置的: https://github.com/GoogleChromeLabs/dynamic-import-polyfill#configuration-options
須要重命名 import() 語句是由於 import 是 JavaScript 中的關鍵字。這意味着不能使用相同的名稱 polyfill 原生 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 表現更好,由於前者不只會下載文件,還會在主線程外當即解析和編譯文件。傳統的 preload 不能這樣作,由於它在預加載時不知道文件是用做模塊腳本仍是經典腳本。
這意味着經過 modulepreload 加載的模塊一般會加載得更快,而且在實例化時不太可能致使主線程阻塞。
生成 modulepreload 列表Rollup 的 bundle 對象中的全部入口塊都包含其靜態依賴關係圖中的完整導入列表,所以很容易得到 Rollup 的 generateBundle hook 中須要預加載的文件列表。
雖然 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);
}
}
例如,這裏是我爲本網站及個人演示應用程序生成 modulepreload 列表的方法: https://github.com/philipwalton/blog/blob/90e914731c77296dccf2ed315599326c6014a080/tasks/javascript.js#L18-L43https://github.com/philipwalton/blog/blob/90e914731c77296dccf2ed315599326c6014a080/tasks/javascript.js#L18-L43
爲什麼部署原生模塊?注意:雖然 modulepreload 確定比模塊腳本的經典 preload 更好,但它的瀏覽器支持也確實不足(目前僅限 Chrome)。若是你的流量中有至關大一部分是非 Chrome 流量,那麼就應該繼續使用經典的 preload。
但使用 preload 時的一個注意事項是,與 modulepreload 不一樣,preload 的腳本不會被放入瀏覽器的模塊映射中,這意味着 preload 的請求可能被屢次處理(例如若是模塊在瀏覽器預加載文件以前就導入文件的狀況)。
若是你正在使用像 webpack 這樣的打包器,而且已經在對文件使用粒度代碼拆分和預加載策略(像我前面提到的那樣),你可能想知道切換到原生模塊是否值得。下面列舉幾條緣由,談一談爲何原生模塊比使用經典腳本和本身的模塊加載代碼更好。
總代碼足跡更小使用原生模塊時,現代瀏覽器的用戶沒必要加載非必要的模塊或依賴項管理代碼。例如,若是使用原生模塊,則根本不須要 webpack 運行時和清單: https://webpack.js.org/concepts/manifest/
更好的預加載如上一節所述,使用 modulepreload 能夠在加載代碼時在主線程外解析 / 編譯它。其餘條件不變的前提下,這意味着你的頁面將更快地得到交互,而且在用戶交互期間主線程不容易阻塞。
所以,不管你對應用程序進行代碼拆分的粒度如何,使用 import 語句和 modulepreload 加載塊都比使用經典腳本標記和常規預加載更加高效(特別是若是這些標記是在運行時動態生成並添加到 DOM 中的話)。
換句話說,對於同一個代碼庫,由 20 個 module 塊組成的 Rollup 包的加載速度比使用 webpack 打包到 20 個經典腳本塊更快(不是由於它是 webpack,而是由於它不是原生模塊)。
更適應將來發展許多使人興奮的瀏覽器新功能都是基於模塊而非經典腳本的。這意味着若是你想要使用這些功能,你的代碼就須要部署爲原生模塊,而不是轉換爲 ES5 並經過經典腳本標記加載(我在嘗試使用實驗性 KV 存儲 API,時遇到過這個問題)。
如下是一些僅限模塊使用的使人興奮的新功能:在 Worker、服務 Worker 和窗口之間共享模塊:
https://html.spec.whatwg.org/multipage/workers.html#module-worker-example
在全球範圍內,超過 83%的瀏覽器原生支持 JavaScript 模塊(包括動態導入),所以對於大多數用戶這項技術能夠直接使用。
對於支持模塊但不支持動態導入的瀏覽器來講,你可使用我在上面提到的 dynamic-import-polyfill。因爲 polyfill 很是小而且在可用時將使用瀏覽器的原生動態 import(),所以添加這個 polyfill 幾乎沒有體積或性能成本。
對於根本不支持模塊的瀏覽器來講,你可使用我在以前的文章中提到的 module/nomodule 技術: https://philipwalton.com/articles/deploying-es2015-code-in-production-today/
一個示例談到跨瀏覽器兼容性時作起來總比提及來更難,因此我構建了一個演示應用程序,使用了我在本文中提到的全部技術: https://rollup-native-modules-boilerplate.glitch.me/
該演示適用於不支持動態 import() 的瀏覽器(如 Edge 18 和 Firefox ESR),它也適用於不支持模塊的瀏覽器(如 Internet Explorer 11)。
爲了代表這個策略不只適用於簡單的用例,我在演示中包含了許多複雜的 JavaScript 應用程序所需的功能:module/nomodule 回退。
它的代碼託管在 GitHub 上(所以你能夠自行 fork repo 並構建),演示程序 託管在 Glitch 上,你能夠試用乃至重組這些功能。
GitHub: https://github.com/philipwalton/rollup-native-modules-boilerplate
Glitch: https://glitch.com/edit/#!/rollup-native-modules-boilerplate
最重要的是示例中使用的 Rollup 配置,由於它定義了模塊的生成方式。
總結但願這篇文章能讓你相信,咱們不只能夠在生產環境中部署原生 JavaScript 模塊,並且這樣作實際上能夠提升網站的負載性能和運行時性能。
下面總結一下所需的步驟:使用<script nomodule>
來處理根本不支持模塊的瀏覽器狀況。
若是你已經在構建環境中使用了 Rollup,推薦你試試本文提到的這些技術並在生產環境中部署原生模塊(包括代碼拆分和動態導入)。歡迎向我提出問題並分享你的成功案例: https://twitter.com/philwalton
模塊是 JavaScript 的將來,我但願全部的工具和依賴項儘快擁抱模塊。但願這篇文章能夠起到一點推進做用。
英文原文:
https://philipwalton.com/articles/using-native-javascript-modules-in-production-today/