上一篇教程里我们已经讨论过什么是模块,为什么要使用模块以及多种实现模块化的方式。

这次,我们会聊一聊什么是模块打包,为什么要打包模块,模块打包的方式工具,还有它当前在 Web 开发中的运用。

什么是模块打包?

粗俗一点来讲,模块打包就是把一小坨一小坨的代码粘成一大坨。

实际操作起来的时候当然还需要关注一些细节。

为什么要打包模块?

一般来讲,我们用模块化组织代码的时候,都会把模块划分在不同的文件和文件夹里,也可能会包含一些诸如 React 和 Underscore 一类的第三方库。

而后,所有的这些模块都需要通过<script>标签引入到你的 HTML 文件中,然后用户在访问你网页的时候它才能正常显示和工作。每个独立的<script>标签都意味着,它们要被浏览器分别一个个地加载。

这就有可能导致页面载入时间过长。

为了解决这个问题,我们就需要进行模块打包,把所有的模块合并到一个或几个文件中,以此来减少 HTTP 请求数。这也可以被称作是从开发到上线前的构建环节。

还有一种提升加载速度的做法叫做代码压缩(混淆)。其实就是去除代码中不必要的空格、注释、换行符一类的字符,来保证在不影响代码正常工作的情况下压缩其体积。

更小的文件体积也就意味着更短的加载时间。要是你仔细对比过带有 .min 后缀的例如  jquery.min.js 和 jquery.js 的话,应该会发现压缩版的文件相较之下要小很多。

v2-60a0bef5aeb7549f5849bbcf32c0d80e_hd

Gulp 和 Grunt 一类的构建工具可以很方便地解决上述的需求,在开发的时候通过模块来组织代码,上线时再合并压缩提供给浏览器。

打包模块的方法有哪些?

如果你的代码是通过之前介绍过的模块模式来组织的,合并和压缩它们其实就只是把一些原生的 JS 代码合在一起而已。

但如果你使用的是一些浏览器原生不支持的模块系统(例如 CommonJS 或 AMD,以及 ES6 模块的支持现在也不完整),你就需要使用一些专门的构建工具来把它们转换成浏览器支持的代码。这类工具就是我们最近经常听说的 Browserify, RequireJS, Webpack 等等模块化构建、模块化加载工具了。

为了实现模块化构建或载入的功能,这类工具提供许多诸如在你改动源代码后自动重新构建(文件监听)等一系列的功能。

下面我们就一起来看一些实际的例子吧:

打包 CommonJS

在上一篇教程中我们了解到, CommonJS 是同步载入模块的,这对浏览器来说不是很理想。其实下面介绍的模块化构建工具 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 首先会通过抽象语法树(AST)来解析代码中的每一个 require 语句,在分析完所有模块的依赖和结构之后,就会把所有的代码合并到一个文件中。然后你在 HTML 文件里引入一个 bundle.js 就够啦。

多个文件和多个依赖也只需要再稍微配置一下就能正常工作了。

之后你也可以使用一些例如 Minify-JS 的工具来压缩代码。

打包 AMD

假若你使用的是 AMD,你会需要一些例如 RequireJS 或 Curl 的 AMD 加载器。模块化加载工具可以在你的应用中按需加载模块代码。

需要再次提醒一下,AMD 和 CommonJS 的最主要区别是 AMD 是异步加载模块的。这也就意味着你不是必须把所有的代码打包到一个文件里,模块加载不影响后续语句执行,逐步加载的的模块也不会导致页面阻塞无法响应。

不过在实际应用中,为了避免用户过多的请求对服务器造成压力。大多数的开发者还是选择用 RequireJS optimizer, r.js 一类的构建工具来合并和压缩 AMD 的模块。

总的来说,AMD 和 CommonJS 在构建中最大的区别是,在开发过程中,采用 AMD 的应用直到正式上线发布之前都不需要构建。

要是你对 CommonJS vs. AMD 的讨论感兴趣,可以看这一篇 AMD is Not the Answer

Webpack

Webpack 是新推出的构建工具里最受欢迎的。它兼容 CommonJS, AMD, ES6 各类规范。

也许你会质疑,我们已经有这么多诸如 Browserify 或 RequireJS 的工具了,为什么还需要 Webpack 呢?究其原因之一,Webpack 提供许多例如 code splitting(代码分割) 的有用功能,它可以把你的代码分割成一个个的 chunk 然后按需加载优化性能。

举个例子,要是你的 Web 应用中的一些代码只在很少的情况下才会被用到,把它们全都打包到一个文件里是很低效的做法。所以我们就需要 code splitting 这样的功能来实现按需加载。而不是把那些很少人才会用到的代码一股脑儿全都下载到客户端去。

code splitting 只是 Webpack 提供的众多强大功能之一。当然,网上也为这些模块化构建工具吵得不可开交。你要是感兴趣的话也可以在下面这些地方观摩一下:

ES6 模块

看他们吵够了的话,接下来我就要介绍一下  ES6模块了。假如你采用 ES6 模块,在不远的将来对那些构建工具的需求可能会小一些。首先我们还是看看 ES6 模块是怎么加载的吧。

ES6 模块和 CommonJS, AMD 一类规范最主要的区别是,当你载入一个模块时,载入的操作实际实在编译时执行的——也就是在代码执行之前。所以去掉那些不必要的 exports 导出语句可以优化我们应用的性能。

有一个经常会被问到的问题:去除 exports 和冗余代码消除(UglifyJS 一类工具执行后的效果)之间有什么区别?

答案是这个要具体情况具体分析,感兴趣的话可以上 GitHub 看这个 Repo:Rollup’s wiki

让 ES6 模块与冗余代码消除(Dead code elimination)不同的是一种叫做 tree shaking 的技术。Tree shaking 其实恰好是冗余代码消除的反向操作。它只加载你需要调用的代码,而不是删掉不会被执行的代码。我们还是用一个具体的例子说明吧:

假设我们有如下一个使用 ES6 语法,名为 utils.js 的函数:

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 中:

//main.js
import * as Utils from './utils.js';

之后我们再调用一下 each 函数:

//main.js
import * as Utils from './utils.js';

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

通过 "tree shaken" 之后的 main.js 看起来就像下面这样:

//treeshake.js 
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 方法的话:

//main.js
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 = [];
  //注意在filter中调用了each,所以两个方法都会被引入
  each(collection, function(item) {
    if (test(item)) {
      filtered.push(item);
    }
  });
  return filtered;
};

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

很神奇不是么?

你也可以自己在 Rollup.js 的实时预览编辑器里做做试验:live demo and editor

v2-c15de2bbc52eff93e1cceac5598b8f8a_hd

构建 ES6 模块

现在我们已经了解到 ES6 模块载入的与众不同了,但我们还没有聊到底该怎么构建 ES6 模块。

因为浏览器对 ES6 模块的原生支持还不够完善,所以现阶段还需要我们做一些补充工作。

让 ES6 模块在浏览器中顺利运行的常用方法有以下几种:

1.使用语法编译器(Babel 或 Traceur)来把ES6语法的代码编译成 ES5 或者 CommonJS, AMD, UMD 等其他形式。然后再通过 Browserify 或 Webpack 一类的构建工具来进行构建。

2.使用 Rollup.js,这其实和上面差不多,只是 Rollup 还会捎带的利用“tree shaking”技术来优化你的代码。在构建 ES6 模块时 Rollup 优于 Browserify 或 Webpack 的也正是这一点,它打包出来的文件体积会更小。Rollup 也可以把你的代码转换成包括 ES6, CommonJS, AMD, UMD, IIFE 在内的各种格式。其中 IIFE 和 UMD 可以直接在浏览器里运行,AMD, CommonJS, ES6 等还需要你通过 Browserify, Webpack, RequireJS 一类的工具才能在浏览器中使用。

小心踩坑

这里有一些坑还需要和大家说明一下。转换语法优雅的 ES6 代码以便在浏览器里运行并不是一件令人舒爽的事情。

问题在于,什么时候我们才能免去这些多余的工作。

令人感动的答案是:“差不多快了。”

ECMAScript 目前包含一个名为 ECMAScript 6 module loader API 的解决方案。简单来讲,这个解决方案允许你动态加载模块并缓存。还是来举例说明:

myModule.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">
  // loads the 'myModule' export from 'mymodule.js'
  import { hello } from 'mymodule';
  new Hello(); // 'Hello, I am a module!'
</script>

更加详细的介绍也可以在 GitHub上查看:es6-module-loader

如果你现在就想测试这个解决方案的话,我在这里也安利一下 SystemJS. SystemJS 支持在浏览器端和Node动态加载之前介绍过所有格式的模块(ES6 modules, AMD, CommonJS 等),通过把已加载的模块还存在"module registry"里来避免重复加载。它也同样支持转换 ES6 的代码至其他格式。

我们已经有了原生 ES6 模块,还需要那些乱七八糟的玩意儿么?

越来越多的人使用 ES6 模块产生了一些有趣的影响:

HTTP/2 出现之后,模块化构建工具是不是都该被淘汰了?

在 HTTP/1 中,一次 TCP 连接只允许一个请求,所以我们需要通过减少载入的文件数来优化性能。而 HTTP/2 改变了这一切,请求和响应可以并行,一次连接也允许多个请求。

每次请求的消耗也会远远小于 HTTP/1,所以载入一堆模块就不再是一个影响性能的问题了。所以许多人认为打包模块完全就是多余的了。这听起来很合理,但我们也需要具体情况具体分析。

其中有一条,模块化构建解决了一些 HTTP/2 解决不了的问题。例如去除冗余的代码以压缩体积。要是你开发的是一个对性能要求很高的网站,模块化构建从长远上考虑会给你带来更多好处。当然,要是你不那么在意性能问题,以后完全就可以省却这些烦人的步骤了。

总之,我们离所有的网站都采用 HTTP/2 传输还有相当一段时间。短期内模块化构建还是很有必要的。

要是你对 HTTP/2 的其他特性也感兴趣,可以查阅这里:HTTP/2

CommonJS , AMD, UMD 这类标准会过时么?

一旦 ES6 成为了模块化的标准,我们还需要这些非原生的东西么?

这点还值得商榷。

在 JavaScript 中采用统一标准,通过 import 和 export 来使用模块,省略所有繁杂的多余步骤确实很爽。不过到底要多久 ES6 才能成为真正的模块化标准呢?

反正不会很快。

并且开发者也有各自的偏好,“唯一的解决方案”永远也不会存在。

总结

我希望这两篇文章对于你理解 JS 模块有所帮助,要是你忘了之前聊过的内容,可以现在点开再看看:

JavaScript 模块化入门 Ⅰ:理解模块

有任何问题和建议也欢迎在评论区讨论。

原文链接:JavaScript Modules Part 2: Module Bundling,作者:Preethi Kasireddy