模块是 JavaScript 的未来
?本文将主要介绍
在生产环境中部署原生 JavaScript 模块的方法,
以提高网站的负载性能和运行时性能
。
两年
前我写了一篇文章介绍了一种技术——现在通常被称为 module/nomodule 模式——这种技术让你可以编写 ES2015+ 版本的 JavaScript 代码,然后使用打包器和转换工具生成两个版本的代码库,一个版本使用现代语法(通过
加载),另一个使用 ES5 语法(通过
加载)。这项技术使你可以向支持模块的浏览器发送少得多的代码,现在大多数 Web 框架和 CLI 都支持它。
但在那时候,虽然我们能在生产环境中部署现代 JavaScript 代码,而且大多数浏览器都支持模块,我仍然建议你打包自己的代码。
为什么?主要是因为我觉得在浏览器中加载模块很慢。尽管像 HTTP/2 这样的新协议理论上可以快速加载大量小文件,但当时所有的性能研究都认为使用打包器(bundler)效率更高:
https://v8.dev/features/modules#bundle
其实那些研究并没有反映完整的情况。它们研究的模块测试案例是使用未经优化和解压的源文件部署到生产环境来做测试。它没有对比优化过的模块包与优化过的经典脚本是什么情况。
不过当时并没有真正优化过的模块部署方法。但是现在,随着打包器技术的一些突破,我们可以将生产代码部署为 ES2015 模块——包括静态和动态导入——并且比原有的非模块选项性能更出色。实际上本网站已经在生产中使用原生模块有好几个月时间了。
我同很多人交流过,他们都不愿意在大规模生产环境应用程序中用模块,考虑一下都不行。许多人都引用了我刚才提到的研究,该研究建议不要在生产环境中使用模块,除非是为了:
... 小型网络应用程序,总共少于 100 个模块,并且具有相对较浅的依赖树(即最大深度小于 5)。
如果你查看过 node_modules 目录,你可能知道即使是小型应用程序也很容易拥有超过 100 个模块依赖项。我们再来看看 npm 上一些比较流行的实用程序包中有多少个模块:
包
|
模块数量
|
date-fns
|
729
|
lodash-es
|
643
|
rxjs
|
226
|
但这就是围绕模块的主要误解所在。人们认为,在生产中使用模块时你可以选择:(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 版本。
自动拆分代码:
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')) {
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":
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, "import1", function() { return import1; });
var _dep_1__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( "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-18、Firefox 60-66、Safari 11、Chrome 61-63)。
还好有一个很小(~400 字节)、性能很好的 polyfill 可用于动态导入。
将 polyfill 添加到你的网站很简单。你所要做的就是导入它并在应用程序的主入口点初始化它(在任何地方调用 import() 之前):
import dynamicImportPolyfill from 'dynamic-import-polyfill';
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 模块(通过
,然后是 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 加载的模块通常会加载得更快,并且在实例化时不太可能导致主线程阻塞。
Rollup 的 bundle 对象中的所有入口块都包含其静态依赖关系图中的完整导入列表,因此很容易获得 Rollup 的 generateBundle hook 中需要预加载的文件列表。
虽然 npm 上有一些 modulepreload 插件,但为图中的每个入口点生成一个 modulepreload 列表只需要几行代码,所以我更喜欢手动创建它,如下所示: