JavaScript 的未来是模块化?
模块是 JavaScript 的未来?本文将主要介绍在生产环境中部署原生 JavaScript 模块的方法,以提高网站的负载性能和运行时性能。
两年前我写了一篇文章介绍了一种技术——现在通常被称为module/nomodule 模式——这种技术让你可以编写ES2015+ 版本的JavaScript 代码,然后使用打包器和转换工具生成两个版本的代码库,一个版本使用现代语法(通过 <script type="module">加载),另一个使用 ES5 语法(通过<script nomodule>加载)。这项技
术使你可以向支持模块的浏览器发送少得多的代码,现在大多数 Web 框架和 CLI 都支持它。
但在那时候,虽然我们能在生产环境中部署现代 JavaScript 代码,而且大多数浏览器都支持模块,我仍然建议你打包自己的代码。
为什么?主要是因为我觉得在浏览器中加载模块很慢。尽管像 HTTP/2 这样的新协议理论上可以快速加载大量小文件,但当时所有的性能研究都认为使用打包器(bundler)效率更高。
其实那些研究并没有反映完整的情况。它们研究的模块测试案例是使用未经优化和解压的源文件部署到生产环境来做测试。它没有对比优化过的模块包与优化过的经典脚本是什么情况。
不过当时并没有真正优化过的模块部署方法。但是现在,随着打包器技术的一些突破,我们可以将生产代码部署为 ES2015 模块——包括静态和动态导入——并且比原有的非模块选项性能更出色。实际上本网站已经在生产中使用原生模块有好几个月时间了。
对模块的误解
我同很多人交流过,他们都不愿意在大规模生产环境应用程序中用模块,考虑一下都不行。许多人都引用了我刚才提到的研究,该研究建议不要在生产环境中使用模块,除非是为了:
…小型网络应用程序,总共少于 100 个模块,并且具有相对较浅的依赖树(即最大深度小于 5)。
如果你查看过 node_modules 目录,你可能知道即使是小型应用程序也很容易拥有超过 100 个模块依赖项。我们再来看看 npm 上一些比较流行的实用程序包中有多少个模块:
但这就是围绕模块的主要误解所在。人们认为,在生产中使用模块时你可以选择:
(1)按原样部署所有源代码(包括 node_modules 目录),或者(2)根本不使用模块。
但如果仔细观察我引用的研究建议,并不是说加载模块比加载常规脚本要慢,并且它并没有说你根本不应该使用模块;它只是说如果你将数百个未经过管理的模块文件部署到生产环境中,那么 Chrome 的加载速度会比加载单个压缩包慢很多。所以给出的建议其实是继续使用打包器、编译器和压缩器。
实际上呢?这些都不影响你在生产环境中使用模块!
其实我们都应该打包成模块的格式,因为浏览器已经知道如何加载模块(不会加载模块的浏览器还能使用 nomodule 回退)。如果你检查一下大多数流行的打包器生成的输出代码,你会发现很多模版,其目的仅仅是动态加载其他代码并管理依赖项;但如果我们只使用带有 import 和 export 语句的模块,那就用不着这些了!
所幸现在起码有一个流行的打包器( Rollup )支持模块作为输出格式,意味着你既可以打包代码也能生产环境中部署模块(不需要加载器模版)。而且由于 Rollup 的 tree-shaking 很棒(据我所知在所有打包器里是最好的),使用 Rollup 打包到模块生成的代码体积是目前所有可用选项中最小的。
更新:Parcel 计划在下一版本中添加模块支持。Webpack 目前不支持模块输出格式,但这里有一些问题正在讨论(#2933 、#8895 、#8896 )。
另一个误解是除非你所有的依赖项都用模块,你才能使用模块;不幸的是(在我看来非常不幸)大多数 npm 包仍然作为 CommonJS 发布(有些甚至是用 ES2015 来写,之后转换为 CommonJS 发布到 npm 上)!
还好 Rollup 还有一个插件( rollup-plugin-commonjs )可以输入 CommonJS 源代码并将其转换为 ES2015。虽说你的依赖项一开始就采用 ES2015 模块格式肯定会更好,但某些依赖项不用模块并不会阻碍你部署模块。
在后文中,我将展示如何打包到模块(包括使用动态导入和粒度代码拆分),解释为什么它通常比经典脚本性能更出色,并展示如何处理浏览器不支持模块的情况。
最优打包策略
打包生产代码的过程都是在做各种权衡。一方面你希望代码尽快加载和执行,但另一方面,你不希望加载用户实际不会使用的代码。
你还希望代码尽可能多地缓存起来。打包有个大问题,即使只是一行代码所做的任何更改也会使整个包无效。如果你使用数千个小模块部署应用程序(就像它们在源代码中一样),那么你可以自由地做出小规模的更改,同时将应用程序的大部分代码继续保留在缓存中——但如前所述,这可能也意味着有新访问者时你的代码需要更长时间才能加载。
因此,挑战在于找到正确的打包粒度——在负载性能和长期可缓存性之间取得适当的平衡。
默认情况下,大多数包会在动态导入时进行代码拆分,但我认为只对动态导入做代码拆分还不够精细,特别是当网站有很多回头客时更是如此(此时缓存是很重要的)。
在我看来,你应该尽可能细地拆分代码,直到它开始显著影响负载性能。虽然我建议你自己来做具体的分析,但作为大致的参考,上面提到的研究发现加载少于 100 个模块时没有明显的性能差异;另一项关于 HTTP/2 的研究发现加载少于 50 个文件时没有明显的性能差异(尽管它们只测试了 1、6、50 和 1000 个文件的情况)。
那么该如何尽量拆分代码,同时还不能做过头呢?除了通过动态导入进行代码拆分之外,我还建议通过 npm package 进行代码拆分——每个导入的 node 模块都根据其包名称放入一个块里。
包级别的代码拆分
如前所述,打包技术的一些最新进展大幅提升了模块部署的性能。这里提到的进展指的是 Rollup 的两项新的功能:通过动态 import()自动拆分代码,和通过manualChunks 选项手动拆分代码。前者在1.0.0 版本引入,后者则是1.11.0 版本。
有了这两个功能,现在我们很容易就能配置在包级别拆分代码的构建。
下面是一个示例配置,它使用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"],{
/***/ "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 依赖项,实际上没法做到一个一个拆分开来。如果你就是这种情况,我建议设法将一些依赖项分组为通用块。一般来说,可能在相近的时间进行代码更改的包应该分在一个组里(例如 react 和 react-dom),因为它们必须一起失效(例如我后面展示的示例应用程序将所有 React 依赖项分组到同一个块)。
动态导入
使用原生import 语句拆分代码拆分和加载模块的一个缺点是,你(作为开发人员)需要处理浏览器不支持模块的情况。
如果你想使用动态import() 来延迟加载代码,那么你还必须处理一些浏览器支持模块但不支持动态import() 的情况(Edge 16-18、Firefox 60-66、Safari 11、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__,但这是可以配置的。
需要重命名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 列表的方法。
注意:虽然 modulepreload 肯定比模块脚本的经典 preload 更好,但它的浏览器支持也确实不足(目前仅限 Chrome)。如果你的流量中有相当大一部分是非 Chrome 流量,那么就应该继续使用经典的 preload。
但使用 preload 时的一个注意事项是,与 modulepreload 不同,preload 的脚本不会被放入浏览器的模块映射中,这意味着 preload 的请求可能被多次处理(例如如果模块在浏览器预加载文件之前就导入文件的情况)。
为何部署原生模块?
如果你正在使用像 webpack 这样的打包器,并且已经在对文件使用粒度代码拆分和预加载策略(像我前面提到的那样),你可能想知道切换到原生模块是否值得。下面列举几条原因,谈一谈为什么原生模块比使用经典脚本和自己的模块加载代码更好。
总代码足迹更小
使用原生模块时,现代浏览器的用户不必加载非必要的模块或依赖项管理代码。例如,如果使用原生模块,则根本不需要 webpack 运行时和清单。
更好的预加载
如上一节所述,使用 modulepreload 可以在加载代码时在主线程外解析 / 编译它。其他条件不变的前提下,这意味着你的页面将更快地获得交互,并且在用户交互
期间主线程不容易阻塞。
因此,无论你对应用程序进行代码拆分的粒度如何,使用 import 语句和 modulepreload 加载块都比使用经典脚本标记和常规预加载更加高效(特别是如果这些标记是在运行时动态生成并添加到 DOM 中的话)。
换句话说,对于同一个代码库,由 20 个 module 块组成的 Rollup 包的加载速度比使用 webpack 打包到 20 个经典脚本块更快(不是因为它是 webpack,而是因为它不是原生模块)。
更适应未来发展
许多令人兴奋的浏览器新功能都是基于模块而非经典脚本的。这意味着如果你想要使用这些功能,你的代码就需要部署为原生模块,而不是转换为 ES5 并通过经典脚本标记加载(我在尝试使用实验性 KV 存储 API ,时遇到过这个问题)。
以下是一些仅限模块使用的令人兴奋的新功能:
- 内置模块。
- HTML 模块。
- CSS 模块。
- JSON 模块。
- 导入映射。
- 在 Worker、服务 Worker 和窗口之间共享模块。
支持旧版浏览器
在全球范围内,超过83%的浏览器原生支持JavaScript 模块(包括动态导入),因此对于大多数用户这项技术可以直接使用。
对于支持模块但不支持动态导入的浏览器来说,你可以使用我在上面提到的 dynamic-import-polyfill 。由于 polyfill 非常小并且在可用时将使用浏览器的原生动态 import(),因此添加这个 polyfill 几乎没有体积或性能成本。
对于根本不支持模块的浏览器来说,你可以使用我在之前的文章中提到的 module/nomodule 技术。
一个示例
谈到跨浏览器兼容性时做起来总比说起来更难,所以我构建了一个演示应用程序,使用了我在本文中提到的所有技术。
该演示适用于不支持动态 import() 的浏览器(如 Edge 18 和 Firefox ESR),它也适用于不支持模块的浏览器(如 Internet Explorer 11)。
为了表明这个策略不仅适用于简单的用例,我在演示中包含了许多复杂的 JavaScript 应用程序所需的功能:
- Babel 变换(包括 JSX)。
- CommonJS 依赖(例如 react、react-dom)。
- CSS 依赖项。
- 资产哈希。
- 代码拆分。
- 动态导入(带有 polyfill 回退)。
- module/nomodule 回退。
它的代码托管在 GitHub 上(因此你可以自行 fork repo 并构建),演示程序托管在Glitch 上,你可以试用乃至重组这些功能。
最重要的是示例中使用的 Rollup 配置,因为它定义了模块的生成方式。
总结
希望这篇文章能让你相信,我们不仅可以在生产环境中部署原生 JavaScript 模块,而且这样做实际上可以提高网站的负载性能和运行时性能。
下面总结一下所需的步骤:
使用打包器,确保你的输出格式为 ES2015 模块。
尽量拆分代码(如果可能的话一直拆分到 node 包)。
预加载静态依赖关系图中的所有模块(通过 modulepreload)。
使用 polyfill 处理不支持动态 import() 的浏览器情况。
使用<script nomodule>来处理根本不支持模块的浏览器情况。
如果你已经在构建环境中使用了 Rollup,推荐你试试本文提到的这些技术并在生产环境中部署原生模块(包括代码拆分和动态导入)。欢迎向我提出问题并分享你的成功案例!
模块是JavaScript 的未来,我希望所有的工具和依赖项尽快拥抱模块。希望这篇文章可以起到一点推动作用。
作者:Philip Walton
译者:王强
来源:InfoQ