JavaScript 模块(2):模块打包

516 查看

在第一部分中,我讲解了模块是什么、为何要使用模块和将程序整合为模块的各种方式。而在第二部分,我将会详细讲解模块“打包”:为什么要打包模块,以不同的方式进行打包和模块在 web 开发上的未来。

什么是模块打包?

总体上看,模块打包只是简单地将一组模块(和它们所依赖的模块)以正确的顺序整合为单一文件(或文件组)。我们也知道:对于 web 开发,细节才是可怕的地方。 :)。

究竟为什么需要打包模块?

当你将程序分为各个模块时,通常会将这些模块放到不同文件或文件夹下。当然,你所使用的库(如 Underscore 或 React)也是模块。

因此,每个文件都必须以一个 <script> 标签引入到主 HTML 文件中。然后当用户访问你的主页时,浏览器就会加载这些文件。分离的 <script> 标签就意味着浏览器必须单独地加载每个文件(一个接一个)。

…这对页面加载的时间无疑是个噩耗。

为了解决该问题,我们需要打包或“拼接”所有文件,从而生成一个大文件(或几个文件,视情况而定)以减少请求数量。当你听到开发者讨论“构建步骤”或“构建处理”时,这大概就是他们所讨论的内容了。

另一个加快打包操作的普遍做法是:“压缩”打包的代码。压缩就是从源代码中移除不必要的字符(如空格、注释和换行符等),这样能减少内容的整体大小且不会改变代码的功能。

更少的数据就意味着浏览器处理的时间更短,而且另一方面来说也减少了下载文件的时间。如果你曾看到文件拥有扩展名“min”(如 underscore-min.js),你应该会注意到压缩版本会比 完整版 小很多(当然,毫无可读性)。

构建工具(如 Gulp 和 Grunt)能为开发者直接执行拼接(concatenation)和压缩(minification)操作,并在确保打包生成利于浏览器执行的代码的同时,也会导出一份开发者可读的代码。

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

当使用标准的模块模式(module pattern,在文章的前一节中所讨论的)定义模块时,拼接和压缩文件都能很好运行。你实际所做的是将各个原生 JavaScript 代码混合在一起。

然而,如果你使用的是非原生的模块系统,如 CommonJS 或 AMD(甚至是原生的 ES6 模块格式,因为浏览器仍不支持该语法),浏览器就不能解析识别了。此时你需要使用特定工具将模块转为顺序正确且对浏览器友好的代码。这些工具可以是 Browserify、RequireJS、Webpack 或其它“模块打包工具”或“模块加载器”。

除了打包和(或)加载模块,模块打包工具也提供了很多额外功能,如自动重编译(当你对代码作出修改或为了调试而生成 source maps 时)。

下面是一些常见的模块打包方法:

打包 CommonJS

第一部分中可知,CommonJS 是同步加载模块的,但这对于浏览器来说并不切合实际。我在第一部分中提到了一种解决方案 —— 其中一种是模块打包工具 Browserify。Browserify 是一种将 CommonJS 模块编译成浏览器能执行的代码的工具。

文件导入一个用于计算 number 数组平均数的模块:

因此,main.js 文件有一个依赖项(myDependency)。当使用以下命令时,Browserify 会递归打包所有由 main.js 文件开始引入的模块,到一个名为 bundle.js 的文件:

Browserify 要实现以上功能,它要解析 抽象语法树(AST) 的每个 require 调用,以遍历项目的整个依赖图。一旦它解决了依赖的构造关系,就能将模块以正确的顺序打包进一个单独文件内。然后,在 html 里插入一个用于引入 “bundle.js”<script> 标签,从而确保你的源代码在一个 HTTP 请求中完成下载。

同样地,如果多个文件拥有多个依赖,你只需简单地告诉 Browserify 你的入口文件(entry file),然后休息一会等待它完成即可。

最终产品:打包文件需要通过 Minify-JS 之类的工具压缩打包后的代码。

打包 AMD

如果你使用的是 AMD,你需要使用 AMD 加载器,如 RequireJS 或 Curl。一个模块加载器(与打包工具不同)会动态加载程序需要运行的模块。

再次提醒,AMD 与 CommonJS 的主要区别是:AMD 以异步的方式加载模块。也就是说, 对于 AMD,你实际上不需要将模块打包到一个文件的这个构建步骤,因为它是以异步方式加载模块——也就意味着当用户第一次访问网页时,浏览器会循序渐进地下载程序实际需要执行的文件,而不是一次性下载所有文件。

然而,在实际生产环境中,随着用户操作,大容量的请求开销并不会产生多大意义。但大多数开发者为了优化性能,仍然使用构建工具(如 RequireJS 优化工具和 r.js)打包和压缩它们的 AMD 模块。

总的来说,AMD 与 CommonJS 之间的打包差异是:在开发期间,AMD 应用无须任何构建步骤即可运行。当然,在代码上线前,要使用优化工具(如 r.js)进行优化。

想了解更多关于 CommonJS vs AMD 的有趣讨论,可看看 Tom Dale’s blog 的这篇文章 : )。

Webpack

就打包工具而言,Webpack 是这方面的新生儿。它与你所使用的具体模块系统无关,也就是说它允许开发者使用 CommonJS、AMD 或 ES6。

你可能会疑惑:我们已经有其它打包工具(如 Browserify 和 RequireJS)完成相应工作并做得相当好了,为什么还需要 Webpack。没错,Webpack 提供了一些有用的功能,如“代码分割(code splitting)”——一种将代码库分割为“块(chunks)”的方式,从而能实现按需加载。

例如,如果 web 应用的某段代码块在某种环境下才被用到时,却直接将整个代码库放进一个庞大的打包文件,显然不那么高效。因此,你可使用“代码分割”,将其提取出来成为“打包块(bundled chunks)”,然后按需加载。对于大多数用户只需应用程序的核心部分这种情况,就避免了前期负荷过重的问题。

代码分割只是 Webpack 提供的众多引人注目的功能之一,网上有很多关于 “Webpack 与 Browserify 谁更好”的激烈讨论。下面列出了一些围绕该问题的、能理清思路的讨论:

  • https://gist.github.com/substack/68f8d502be42d5cd4942
  • http://mattdesl.svbtle.com/browserify-vs-webpack
  • http://blog.namangoel.com/browserify-vs-webpack-js-drama

ES6 模块

跟得上吧?很好!因为接下来要讲 ES6 模块,某种意义上它在未来能削弱对打包工具的需求。(你马上会明白我的意思。)首先,让我们知道 ES6 模块如何被加载。

当前的 JS 模块规范(CommonJS、AMD)与 ES6 模块之间最重要的区别是:设计 ES6 模块时考虑到了静态分析。其意思是:当你导入模块时,该导入在编译时(换言之,在脚本开始执行前。)已执行。这允许我们在运行程序前移除那些不被其它模块使用的导出模块(exports)。移除不被使用的模块能节省空间,且有效地减少浏览器的压力。

一个常被提起的问题是:使用 UglifyJS 之类的工具压缩代码后(即消除冗余代码)会有何不同?答案是:“视情况而定”。

(注意:消除冗余代码是一个优化步骤,它能移除无用的代码和变量——即移除打包程序不需要执行的冗余代码)。

有时 UglifyJS 与 ES6 模块的消除冗余代码的工作完全相同,有时则不是。如果你想了解相关知识,可看看 Rollup’s wiki 的案例。

导致 ES6 模块不同的原因是它以不同方式去完成消除冗余代码的效果,我们称该方式为“tree shaking”。Tree shaking 本质与消除冗余代码相反。它仅包含打包文件需要运行的代码,而不是排除打包文件不需要的代码。让我们看看 tree shaking 的一个案例:

假设有一个带有多个函数的 utils.js 文件,每个函数都用 ES6 的语法导出: