JavaScript 模块化入门Ⅱ:模块打包构建

JavaScript 模块化入门Ⅱ:模块打包构建

原文链接:JavaScript Modules Part 2: Module Bundling

作者:Preethi Kasireddy

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

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

什么是模块打包?

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

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

为什么要打包模块?

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

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

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

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

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

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


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

构建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 模块化入门Ⅰ:理解模块

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

文章被以下专栏收录