做者 | 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/