【译】在生产环境中使用原生JavaScript模块

原文地址:philipwalton.com/articles/us…
原文做者:PHILIP WALTON
译者:龚亮 ,校对:刘辉
声明:本翻译仅作学习交流使用,转载请注明来源javascript

两年前,我写了一篇有关module/nomodule技术的文章,这项技术容许你在编写ES2015+代码时,使用打包器和转换器生成两个版本的代码库,一个具备现代语法的版本(经过<script type="module">加载)和一个使用ES5语法的版本(经过<script nomodule>加载)。该技术容许你向支持模块(译者注:指ECMA制定的标准的export/import模块语法及其加载机制,又称为ES Module、ESM、ES6 Module、ES2015 Module,下文中将出现不少"模块"一词,都是这个含义)的浏览器发送更少的代码,如今大多数Web框架和CLI都支持它。html

可是那时候,尽管可以在生产中部署现代JavaScript,大多数浏览器也都支持模块,我仍然建议打包你的代码。java

为何?主要是由于我以为在浏览器中加载模块很慢。尽管像HTTP/2这样的新协议理论上有效地支持加载大量小文件,但当时的全部性能研究都认为使用打包器更有效。node

其实当时的研究是不完整的。该研究所使用的模块测试示例由部署到生产环境中未优化和未缩小的源文件组成。它并无将优化后的模块包与优化后的原始脚本进行比较。react

不过,当时并无更好的方法来部署模块(译者注:指遵循ES2015模块规范的文件)。可是如今,打包技术取得了一些最新进展,能够将生产代码部署为ES2015模块(包含静态导入和动态导入),从而得到比非模块(译者注:指除ES2015模块外的传统部署方式)更好的性能。实际上,这个站点(译者注:指原文章所在的网站)已经在生产环境中使用原生模块好几个月了。webpack

对模块的误解

与我交流过的不少人都认为模块(译者注:指遵循ES2015模块规范的部署方式)是大规模生产环境下应用程序的一个选择罢了。他们中的许多人引用了我刚刚提到的研究,并建议不要在生产环境中使用模块,除非:git

...小型web应用程序,总共只有不到100个模块,依赖树相对较浅(即最大深度小于5)。es6

若是你曾经查看过node_modules目录,可能知道即便是小型应用程序也很容易有超过100个模块依赖项。咱们来看看npm上一些流行的工具包有多少个模块依赖项吧:github

模块数量
date-fns 729
lodash-es 643
rxjs 226

人们对模块的主要误解是,在生产环境中使用模块时只有两个选择:(1)按原样部署全部源代码(包括node_modules目录),(2)彻底不使用模块。web

若是你仔细考虑我所引用研究给出的建议,它没有说加载模块比普通加载脚本慢,也没有说你不该该使用模块。它只是说,若是你将数百个未通过压缩的模块文件部署到生产环境中,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),它能够将CommonJS源代码转换为ES2015。若是一开始你的依赖项采用ES2015模块管理确定会更好,可是有一些依赖关系不是这样管理的并不会阻止你部署模块。

在本文的剩余部分,我将向你展现如何打包到模块(包括使用动态导入和代码拆分的粒度),解释为何它一般比原始脚本更高效,并展现如何处理不支持模块的浏览器。

最优打包策略

打包生产代码一直是须要权衡利弊。一方面,但愿代码尽快加载和执行。另外一方面,又不但愿加载用户实际用不到的代码。

同时,还但愿代码尽量地被缓存。打包的一个大问题是,即便只是一行代码有修改也会使整个打包后的包缓存失效。若是直接使用ES2015模块部署应用程序(就像它们在源代码中同样),那么你能够自由地进行小的更改,同时让应用程序的大部分代码仍然保留在缓存中。但就像我已经指出的那样,这也意味着你的代码须要更长时间才能被新用户的浏览器加载完成。

所以,找到最优打包粒度的挑战是在加载性能和长期缓存之间取得适当的平衡。

默认状况下,大多数打包器在动态导入时进行代码拆分,但我认为仅动态导入的代码拆分粒度不够细,特别是对于拥有大量留存用户的站点(缓存很重要)。

在我看来,你应该尽量细粒度地拆分代码,直到开始显著地影响加载性能为止。虽然我强烈建议你本身动手进行分析,可是查阅上文引用的研究能够得出一个大体的结论。当加载少于100个模块时,没有明显的性能差别。针对HTTP/2性能的研究发现,加载少于50个文件时没有明显的差别(尽管他们只测试了一、六、50和1000,因此100个文件可能就能够了)。

那么,最好的代码拆分方法是什么呢?除了经过动态导入作代码拆分外,我还建议以npm包为粒度作代码拆分,node_modules中的模块都合并到以其包名命名的文件中。

包级别的代码拆分

如上所述,打包技术的一些最新进展使得高性能模块部署成为可能。我提到的加强是指Rollup的两个新功能:经过动态import()自动代码拆分(在v1.0.0中添加)和经过manualChunks选项进行可编程的手动代码拆分(在v1.11.0中添加)。

有了这两个功能,如今很容易在包级别进行代码拆分的构建配置。

这是一个使用manualChunks选项配置的例子,每一个位于node_module里的模块将被合并到以包名命名的文件里(固然,这种模块路径里确定包含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`.
      // 返回最后一个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模块(经过<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要严格得多,它不只下载文件,并且在主线程以外当即开始解析和编译文件。传统的预加载没法作到这一点,由于它不知道在预加载时该文件将用做模块脚本仍是原始脚本。

这意味着经过modulepreload加载模块一般会更快,并且在实例化时不太可能致使主线程卡顿。

生成modulepreload列表

Rollup的bundle对象中的每一个入口文件在其静态依赖关系图中包含完整的导入列表,所以在Rollup的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应用生成modulepreload列表的

注意:虽然对于模块脚原本说,modulepreload绝对比原始的preload更好,但它对浏览器的支持更差(目前只支持chrome)。若是你的流量中有至关一部分是非chrome流量,那么使用classic preload是有意义的。

与使用modulepreload不一样,使用preload时须要注意的一点是,预加载脚本不会放在浏览器的模块映射中,这意味着可能会不止一次地处理预加载的请求(例如,若是模块在浏览器完成预加载以前导入文件)。

为何要部署原生模块?

若是你已经在使用像webpack这样的打包器,而且已经在使用细粒度代码拆分和预加载这些文件(与我在这里描述的相似),那么你可能想知道是否值得改变策略,使用原生模块。下面是我认为你应该考虑它的几个缘由,以及为何打包到原生模块比使用带有模块加载代码的原始脚本要好。

更小的代码总量

当使用原生模块时,现代浏览器没必要为用户加载任何没必要要的模块加载或依赖关系管理代码。例如,若是使用原生模块,则根本不须要webpack运行时和清单

更好的预加载

正如我在前一节中提到的,使用modulepreload容许你加载代码并在主线程以外解析/编译代码。在其余条件相同的状况下,这意味着页面的交互速度更快,而且主线程在用户交互期间不太可能被阻塞。

所以,不管你如何细粒度地对应用程序进行代码拆分,使用import语句和modulepreload加载模块要比经过原始script标签和常规preload加载更有效(特别是若是这些标签是动态生成的,并在运行时添加到DOM中)。

换句话说,由Rollup打包出的20个模块文件将比由webpack打包出的20个原始脚本文件加载得更快(不是由于webpack,而是由于它不是原生模块)。

更面向将来

许多最使人兴奋的新浏览器特性都是构建在模块之上的,而不是原始的脚本。这意味着,若是你想使用这些特性中的任何一个,你的代码须要做为原生模块部署,而不是转换为ES5并经过原始的script标签加载(我在尝试使用实验性KV存储API时曾提到过这个问题)。

如下是一些仅限模块才有的最使人兴奋的新功能:

支持旧版浏览器

在全球范围内,超过83%的浏览器原生支持JavaScript模块(包括动态导入),所以对于你的大多数用户来讲,不须要作任何处理就可使用这项技术。

对于支持模块但不支持动态导入的浏览器,可使用上面提到的dynamic-import-polyfill。因为polyfill很是小,而且在可用时将使用浏览器的原生动态import(),所以添加这个polyfill几乎没有大小或性能成本。

对于根本不支持模块的浏览器,可使用我前面提到的module/nomodule技术。

一个实际的例子

因为谈论跨浏览器兼容性老是比实际实现它要容易,因此我构建了一个演示应用程序,它使用了我在这里阐述的全部技术。

A demo app showing how to use native JavaScript modules with legacy browser support

这个演示程序能够在不支持动态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()的浏览器
  • 使用<script nomodule>支持根本不支持模块的浏览器

若是你已经在构建设置中使用了Rollup,我但愿你尝试这里介绍的技术,并在生产环境中部署原生模块(带有代码拆分和动态导入)。若是你这样作了,请告诉我进展如何,由于我既想听你的问题,也想听你的成功故事!

模块是JavaScript的明确将来,我但愿咱们全部的工具和依赖都能尽快包含模块。但愿本文能在这个方向上起到一点推进做用。

译者评:
1.做者上一篇文章的译文:jdc.jd.com/archives/49…
2.另一篇讲JavaScript原生模块的文章:www.jianshu.com/p/9aae3884b…


若是你以为这篇内容对你有价值,请点赞,并关注咱们的官网和咱们的微信公众号(WecTeam):

WecTeam
相关文章
相关标签/搜索