【译】JavaScript 模块 2:模块打包

265 阅读15分钟

⚠️(译者注)
本文写于2016年,模块打包部分比较侧重于Rollup.js,目前(2020年)前端最流行的打包工具应该是webpack。

在本文的Part I, 我谈论了什么是模块,为什么开发者要使用他们,以及将他们合并到你的项目中的多种方式。

在本文中,我将探究“打包”模块的确切含义:我们为什么要打包模块,以及这么做的不同方式,还有模块在web开发中的未来。

什么是模块打包?

从高层次上说,模块打包是将一组模块(以及他们的依赖)以正确的顺序捆绑在一起,存放到一个简单的文件(或者是一组文件)中的过程。

与web开发的所有的方面一样,魔鬼总在细节处。:)

到底为什么要打包模块?

当你将你的项目拆分成模块,你通常是将分这些模块组织到不同的文件和文件夹中。你还可能也会有一组现在使用的库的依赖,例如Underscore或者是React。

结果,这些文件都必须要在你主体的HTML文件中以<script>标签形式包含进去,当用户访问你的主页的时候这些将会被浏览器加载。每个文件都有一个独立的<script>意味着浏览器必须独立的加载每一个文件:一个...接着...一个。

...这对于页面的加载时间来说是个坏消息。

为了处理这个问题,我们将所有的文件打包,或者是“拼接”到一个大的文件中(或视情况而定为几个文件),以减少请求的数量。当你听到开发者谈论“构建步骤”或者是“构建过程”,这就是他们所谈论的内容。

另一个常见的加速打包操作的方法是“压缩”打包的代码。压缩是从源代码中移除不必要的字符的过程(例如:空白,注释,新的行字符等等),目的是不改变的代码功能的情况下减少内容的整体大小。

更少的数据意味着更少的浏览器处理时间,同时这也会减少下载文件的时间。如果你见过有“min”扩展名的文件,像是“underscore-min.js”,你很可能已经注意到压缩过的版本相比于完整版本体积更小(不可读)。

任务执行者像是Gulp和Grunt使得合并和压缩对于开发者来说更加直接,确保了暴露给开发者的代码是人类可读的代码,而打包给浏览器的代码是机器优化过的代码。

打包模块的不同方式是什么?

当你使用一种标准模块模式(前面文章中讨论过的)来定义模块时,合并和压缩文件非常有用。所有你真正需要做的是将一组普通的Javascript代码混合到一起。

然而,如果你坚持使用浏览器无法直接解释的非原生模块系统,例如CommonJS 或是AMD(或者甚至是原生的ES6模块形式),你将需要使用一个专门的工具来将你的模块转化成有序的浏览器友好的代码。这就是Browserify,RequireJS, Webpack, 和其他的“模块打包器”或者是“模块加载器”发挥作用的地方。

除了打包和加载模块外,模块打包器提供了一系列的额外的功能例如在你修改或是为调试生成source maps的时候会自动重新编译代码。

让我们来看一些常见的模块打包的方法:

打包CommonJS

正如你从 Part 1中了解到的,CommonJS同步的加载模块,这很好,除了对于浏览器来说不太实用以外。我提到过有一个解决方法 -- 他们中的一个是一个叫做Browserify的模块加载器。Browserify是一个为浏览器编译CommonJS模块的工具。

例如,假设你这个main.js的文件,该文件导入一个模块来计算一组数的平均值:

var myDependency = require(‘myDependency’);

var myGrades = [93, 95, 88, 0, 91];

var myAverageGrade = myDependency.average(myGrades);

因此,当前情况下,我们有一个依赖(myDependency)。使用下面的命令,Browserify将会递归的将所有在main.js文件用到的模块打包到一个叫做bundle.js的文件中。

browserify main.js -o bundle.js

Browserify是通过解析每一个require调用的AST,来搜寻出你的项目的完整依赖图谱。一旦确定了依赖的依存关系结构,便将他们按正确的顺序一起打包到一个单独的文件中。那个时候,所有你需要做的就是将你的bundle.js文件以一个简单的<script>标签插入到你的html文件中,以确保你所有的源代码都在一个HTTP请求中下载。Bang!捆绑完了。

类似的,如果你有多个具有多个依赖项的文件,你只需要告诉Browserify你的入口文件是什么,然后坐着等待它实施它的魔法。

最终产品:打包好的文件,然后准备使用类似于Minify-JS这样的工具来压缩打包好的代码。

打包AMD

如果你在使用AMD,你将会想要使用一个AMD加载器,例如RequireJS 或者是Curl。一个模块加载器(相比于打包器)动态的加载项目需要执行的模块。

提醒下,AMD和CommonJS的主要的不同点是它是异步的加载模块。从这个意义上说,对于AMD ,从技术上讲,你不需要打包模块到一个文件中的构建步骤,因为你是异步的加载模块的, -- 这意味着你将逐步地下载那些执行项目时必需的那些文件,而不是当用户首次访问页面时将所有的文件都一次性下载下来。

然而,事实上,随着时间的推移,每次用户操作的高容量请求的开销在生产中没有多大意义。大多数的web开发者还在为了最佳的性能,使用构建工具来打包和压缩他们的AMD模块,使用的工具例如,RequireJS 优化器,r.js

总体而言,AMD和CommonJS在打包方面的区别在于:在开发中,AMD应用程序可以跳过构建步骤。至少,直到你实时发布代码为止,这时诸如r.js 之类的优化器才能介入其中。

有关CommonJS 和AMD的有趣讨论,请查阅Tom Dale的博客 :)

Webpack

就打包工具而言,Webpack在这方面是个新手(译者注:本文写于2016年2月,2020年时Webpack在打包领域已非常成熟)。它设计与你使用的模块系统无关,允许开发人员酌情使用CommonJS,AMD 或者是ES6。

你也许想知道,在我们已经有了其他的打包工具,比如Browserify和RequireJS,他们已经能完成工作,并且做得很好时,为什么我们还需要Webpack。好吧,其中一点是,Webpack提供了一些非常有用的功能,例如“code splitting”-- 一种将你的代码库拆分成“chunks”的方式,可以按需加载。

例如,如果你的web应用程序仅在某些情况下需要使用代码块,则将整个代码库放入单个bundle文件中可能不高效。在这种场景中,你可以使用代码分割将可以按需加载的代码提取到打包好的chunks中,从而在当大多数用户只关注核心功能的情况下,可以避免前期有大的超负荷的资源的问题。

代码拆分只是Webpack提供的众多强大功能的中的一个,关于Webpack还是Browserify更好,网上有非常大的争议。这里有一些我找到的关于这个问题的较高水平的讨论:

ES6模块

已经回来?太棒了!因为接下来我想谈论下ES6的模块,它在某些方面,在未来可以减少打包器的需求。(你会立刻明白我的意思。)首先,让我们来了解下ES6模块是如何加载的。

当前的JS模块形式(CommonJS,AMD)和ES6模块最大的不同点在于ES6模块在设计思想上是设计成静态分析的。这意味着在导入模块时,模块在编译时被解析 -- 即在脚本开始执行之前。 这使我们可以在运行程序之前删除其他模块未使用的导出。删除未使用的导出可以节省大量空间,从而减轻浏览器的压力。

一个常见的问题随之而来:这与使用UglifyJS之类工具来压缩你的代码时做的死代码的清理有什么不同呢?答案始终是,“视情况而定。”

(注意:死代码清理是一个优化步骤,它会删除不用的代码和变量 -- 将其视为删除已打包项目运行时不需要的多余部分,是在打包完成以后)

有时,死代码清理在UglifyJS 和ES6模块中做的事情是完全一样的,而有时却不同。如果你想一探究竟的话,在Rollup的维基上有一个很酷的例子。

让ES6模块与众不同的是消除死代码的不同方法,叫做“tree shaking”。Tree shaking本质上是死代码清理的逆转。它只会包含你的bundle需要运行的代码,而不是排除你的bundle不需要的代码。让我们来看一个tree shaking的例子 :

假设我们有一个utils.js的文件,有着下面的这些函数,每一个函数我们用ES6语法导出:

export function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 }

export function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
}

export function map(collection, iterator) {
  var mapped = [];
  each(collection, function(value, key, collection) {
    mapped.push(iterator(value));
  });
  return mapped;
}

export function reduce(collection, iterator, accumulator) {
    var startingValueMissing = accumulator === undefined;

    each(collection, function(item) {
      if(startingValueMissing) {
        accumulator = item;
        startingValueMissing = false;
      } else {
        accumulator = iterator(accumulator, item);
      }
    });

	return accumulator;
}

然后,假设我们不知道我们的项目中需要使用哪些工具函数,因此我们直接在main.js中将所有的模块导入,像是这样:

import * as Utils from ‘./utils.js’;

最后,我们只使用了 each 函数:

import * as Utils from ‘./utils.js’;

Utils.each([1, 2, 3], function(x) { console.log(x) });

一旦模块已经被载入以后,我们的main.js文件的“tree shaken”的版本将会是像这样:


function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

each([1, 2, 3], function(x) { console.log(x) });

注意到只导出的我们使用的each,是如何实现的?

同时,如果我们决定使用这个filter函数,而不是each函数,我们最后使用的代码看起来的样子将是这样:

import * as Utils from ‘./utils.js’;

Utils.filter([1, 2, 3], function(x) { return x === 2 });

Tree shaken的版本看起来是这样:

function each(collection, iterator) {
  if (Array.isArray(collection)) {
    for (var i = 0; i < collection.length; i++) {
      iterator(collection[i], i, collection);
    }
  } else {
    for (var key in collection) {
      iterator(collection[key], key, collection);
    }
  }
 };

function filter(collection, test) {
  var filtered = [];
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
};

filter([1, 2, 3], function(x) { return x === 2 });

注意到这次eachfilter都被包含了。这是因为filter中使用了each,因此我们需要将这两个都导出以便模块能够正常工作。

相当圆滑,哈?

我建议你在Rollup 的实例和编辑器中试验和探索下tree shaking。

构建ES6模块

好的,所以我们已经知道ES6的模块跟其他模块的加载方式是不同的,但我们还没有探究使用ES6模块的构建步骤。

不幸的是,ES6模块还需要一些额外的工作,因为还没有原生的使用方法提供给浏览器用于加载ES6模块。

以下是构建/转化ES6模块,使其能够在浏览器中工作的几个选项,其中第一个方法是当今最常见的方法:

  1. 使用一个转换器(比如 Babel 或是 Traceur )来将你的ES6代码转换为ES5的代码,以CommonJS,AMD或是UMD的形式。然后通过模块打包器比如Browserify或是Webpack传输已编译的代码,以创建一个或多个打包好的文件。
  2. 使用Rollup.js, 这个跟选项1非常类似,除了Rollup会在打包之前,依靠ES6模块的强大功能来对ES6的代码和依赖进行静态分析。它使用"tree shaking"在的bundle文件中含入最少的必要内容。总体而言,使用Rolllup.js比Browserify或是Webpack,最主要的好处在于,当你使用ES6模块时,tree shaking能够让你的bundles更小。需要注意的是,Rollup提供了几种格式来将你的代码打包,包括ES6,CommonJS,AMD,UMD,或是IIFE(Immediately invoked function expression)。IIFE和UMD能够在你的浏览器中直接工作,但如果你选择打包成AMD,CommonJS或者是ES6,则需要找到其他的方式来将代码转换为浏览器能理解的格式(例如:通过使用Browserify, Webpack, RequireJS等等)。

跳过限制

作为web开发者,我们不得不跳过非常多的限制。将我们漂亮的ES6模块转化为浏览器能够解释的代码并不总是一件容易的事情。

问题是,什么时候ES6模块能够不需要这些桥梁就能在浏览器运行?

答案是,谢天谢地,“不久之后”。

ECMAScript最近明确了一个解决方案,叫做ECMAScript 6 模块加载API。简而言之,这是一个基于romise程序化API,支持动态加载你的模块,并对其缓存,以便后续导入时不会重新加载该模块的新版本。

它看想起来像是这样:

myModules.js

export class myModule {
  constructor() {
    console.log('Hello, I am a module');
  }

  hello() {
    console.log('hello!');
  }

  goodbye() {
    console.log('goodbye!');
  }
}

main.js

System.import(‘myModule’).then(function(myModule) {
  new myModule.hello();
});

// ‘Hello!, I am a module!’

相应地,你可以通过直接在script 标签中指定"type=module"来定义模块,像是这样:

<script type="module">
  // 加载从'mymodule.js'中导出的'myModule' 
  import { hello } from 'mymodule';

  new Hello(); // 'Hello, I am a module!'
</script>

如果你还没有查阅模块加载的API的polyfill,我强烈建议你至少 看一眼

此外,如果你想试用这个方法,查阅 SystemJS,它是在 ES6模块加载polyfill之上构建的。SystemJS在浏览器和Node中动态加载任一的模块格式(ES6模块,AMD,CommonJS以及或者全局脚本)。它会对所有已加载过的模块保持追踪,记录在一个“模块仓库”中,来避免对之前已加载过的模块的重复加载。更不用说他还能自动转译ES6模块(如果你简单的设置了一个选项),并且还能从其他类型加载任何模块类型!非常简洁。

既然我们已经有了原生的ES6模块了,我们现在还需要打包器吗?

ES6模块的日益普及具有了一些有趣的结果:

HTTP/2能让模块加载器过时吗?

使用HTTP/1,每个TCP连接只被允许一个请求。这就是为什么加载多个资源时需要多个请求。使用HTTP/2,一切都会改变。HTTP/2是完全多路复用的,意味着可以并行中发生多个请求和响应。因此,我们可以用一次简单的连接同时完成多个的请求。

既然每个HTTP请求的花费都比HTTP/1的要低得多,长远来看,加载一堆模块将不再是一个巨大的性能问题。有些人会觉得这意味这模块打包不再需要了。这很有可能,但这要视情况而定。

首先,模块打包提供了HTTP/2无法解决的优势,比如删除未使用的导出以节省空间。如果你正在构建一个与性能息息相关的网站,长远来看打包可能会带给你更多的优势。也就是说,如果你的性能要求不是非常的严苛,你可以完全跳过构建步骤,以最低的成本节省时间。

总体而言,我们距离大多数网站都通过HTTP/2运行他们的代码还有相当长的一段路要走。我倾向于预测构建过程 至少 会在短期内保持下去。

PS: HTTP/2也有其他的差异,如果你感兴趣,这里有一个很好的资源

CommonJS, AMD, 和UMD会过时吗?

一旦ES6模块变成了模块标准,我们还需要其他的非原生的模块形式吗?

我对此表示怀疑。

通过遵循一种标准化的方法来导入和导出JavaScript中的模块,而无需中间步骤,web开发将从中受益匪浅。达到ES6成为模块标准需要多长时间?

可能会是,相当长的时间 :)

另外,有很多人喜欢选择“风味”,因此“一个标准的方法”可能永远不会成为现实。

总结

我希望这两部分组成的文章能够有助于理清开发人员在谈论模块和模块打包时使用的一些行业术语。如果你发现上述的任何内容困惑的,请继续查阅part I

和往常一样,留言跟我谈论,随时提问!

打包愉快 :)