41

[译]JavaScript 模块打包方案

 4 years ago
source link: https://juejin.im/post/5e3000dae51d4557e77d29ea
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

翻译自: JavaScript Modules Part 2: Module Bundling 2016.2 (需科学上网访问)

第一部分介绍了什么是模块,为什么需要模块化,以及模块化的多种方式。在这部分,我将讨论模块打包: 为什么我们要打包模块、打包模块的不同方法,以及未来模块在 web 开发中的发展。

什么是模块打包?

模块打包(bundling)是将一组模块及其依赖按照正确的顺序打包成一个文件或一组文件的过程。

为什么要打包模块

代码模块化通常是分成不同的文件,在 HTML 中这些文件都必须使用 <script> 标签引入,当用户访问页面时,浏览器会加载这些 js 资源。这意味着浏览器需要一个一个的加载这些文件,页面的加载时间会拉得很长。

我们可以将所有文件打包(bundle)成一个大文件或几个文件以减少请求数量,这个打包过程就是我们常说的"构建过程(build)", 而在构建之后通常会压缩代码。

压缩是从源代码中删除不必要字符的过程 (例如空白、注释、新行字符等), 目的是在不改变代码功能的情况下降低文件的整体大小。

数据越少,浏览器的处理时间也越少,可以减少文件的下载时间。压缩版本的扩展名一般有 'min' ,例如 underscore-min.js ,与完整版相比文件大小要小很多。

像 Gulp 和 Grunt 这样的任务执行工具可以将文件进行合并和缩小,开发者编写的是可读的代码,浏览器运行的是优化之后的代码。

有哪些方式可以打包模块?

当你使用一个标准的模块模式 (在这篇文章中讨论) 来定义你的模块时,合并和缩小文件非常有效。我们需要做的就是把一堆普通的 JavaScript 代码合在一起。

但是,如果使用浏览器不支持的模块格式,例如 common js 、AMD (甚至 ES6 模块) ,就需要使用专门的工具将模块转换为浏览器可运行的代码。 Browserify 、 RequireJS 、 Webpack 和其他 “模块打包工具” 或 “模块加载器”则可以做这个事情。

除了打包和加载模块之外,模块打包工具还提供了很多额外功能,比如当更改代码时自动重新编译代码或者为了调试生成源代码映射(source map)。

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

Browserify(CommonJS 模块)

正如你从第 1 部分所知道的,CommonJS 同步加载模块,但它不适用于浏览器,我们可以使用 Browserify 来打包模块。Browserify 是一个为浏览器编译 CommonJS 模块的工具。

这里有一个 main.js 文件,它引入一个模块来计算数字数组的平均值:

var myDependency = require(‘myDependency’);

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

var myAverageGrade = myDependency.average(myGrades);
复制代码

main.js 中有一个依赖模块 myDependency,使用下面的命令,Browserify 从入口模块 main.js 开始,递归地分析代码中 require 函数的调用, 将所有依赖模块打包到 bundle.js 中:

browserify main.js -o bundle.js
复制代码

Browserify 根据代码文件生成的 AST 来找到每个模块 require 的模块名,因而得到每个模块的依赖关系。在知道如何构建依赖关系之后,以正确的顺序将它们打包成一个文件。我们所要做的就是将 bundle.js 通过 <script> 标签插入到 HTML 中,这样所有源代码就可以在一个 HTTP 请求中下载了。

如果有多个文件具有多个依赖项,只需给 Browserify 提供入口文件,同样可以打包成一个文件。

最后,还需要将打包后的代码提供给 Minify-JS 等工具做进一步处理,代码压缩优化之后再提供给浏览器。

r.js(AMD 模块)

如果使用的是 AMD 格式的模块,就需要使用 AMD 的模块加载器,如 RequireJS 或者 Curl。模块加载器 (vs bundler) 可以动态加载程序需要运行的模块。

AMD 与 CommonJS 的主要区别之一是它可以异步加载模块。从技术上讲,实际上不需要将模块打包到一个文件,因为是异步加载模块 - 逐步下载那些执行程序而不是在用户第一次访问页面时立即下载所有文件。

但是并行请求节省的开销在生产中没有多大意义,大多数网络开发人员仍然使用构建工具来打包和缩小他们的 AMD 模块以获得最佳性能,例如使用像 RequiredJS 优化器 r.js 这样的工具。

AMD 和 CommonJS 在模块打包上的区别是: 在开发过程中,AMD 应用程序可以不需要构建。除了像代码实时推送这样的场景可以使用  r.js 这样的优化器来处理。

有关 CommonJS vs. AMD 的有趣讨论,请查看 Tom Dale 博客上的这篇文章 。

Webpack

Webpack 对模块系统没有要求,可以打包所有模块,包括 CommonJS 、 AMD 和 ES6。与 Browserify 和 RequireJS 相比,Webpack 提供了一些有用的功能,比如 “代码拆分”,一种将代码库拆分成按需加载的 “块” 的方法。

例如,一个 web 应用程序中包含只有在特定情况下才需要的代码块,那么将整个代码库放入一个巨大的 bundle 文件中会增大文件体积。这种情况下可以使用代码拆分的方式,将代码提取到可以按需加载的打包块(chunk)中, 当大多数用户只需要使用核心功能时,可以避免加载所有的模块代码。

代码拆分只是 Webpack 提供的许多引人注目的功能之一,网上有很多关于 Webpack 还是 Browserify 更好的讨论。这里只是一些更客观的讨论,这些讨论对解决这个问题很有用:

ES6 模块

ES6 模块,它在某种程度上可以减少未来对模块打包的需求(模块打包是为了减少网络请求)。我们先了解一下 ES6 模块是如何加载的。

当前 JS 模块规范 (CommonJS 、 AMD) 和 ES6 模块之间最重要的区别是 ES6 模块的设计考虑到了静态分析。这意味着,模块的引入在编译阶段就发生了,也就是说,**在程序开始执行之前就删除了其他模块不使用的依赖。**移除未使用的引入模块可以显著减小文件体积,降低浏览器的压力。

这里有一个常见问题: 使用像 UglifyJS 这样的工具包来减少代码和去除'死代码'有什么不同?

去除'死代码'是一个优化步骤,它删除了未使用的代码和变量,可以认为是被打包之后删除了不会运行的多余代码。

在某些情况下,UglifyJS 和 ES6 模块的去除‘死代码’完全相同,而其他情况则不是,例如 Rollup 的 wiki 上有一个很酷的例子。

使 ES6 模块不同的是去除'死代码'的方法,称为 “tree shaking”。tree shaking 本质上是反向的死代码消除。它只包括需要运行的代码,而不是排除不需要的代码。来看一个 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;
}
复制代码

假设我们不知道我们想在程序中使用什么 utils 函数,所以我们在 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 shaking 版本将如下所示:

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 shaking 的版本就像这样:

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.js 的在线演示和编辑中探索 tree shaking。

打包 ES6 模块

我们知道 ES6 模块与其他模块格式加载方式不同,但我们还没谈过ES6 模块的构建步骤。另外,并不是所有浏览器都支持 ES6 模块,所以还需要做一些额外工作。

image.png


来自:developer.mozilla.org/en-US/docs/…

以下是构建和转义 ES6 模块以便在浏览器中工作的几个选项,第一种是目前最常见的方法:

transpiler + bundler

使用转义器(例如 Babel or Traceur) 将 ES6 代码转换成 ES5 代码,无论是 CommonJS 、 AMD 还是 UMD 格式。然后通过一个模块打包工具 (如 Browserify 或 Webpack) 来构建代码,以创建一个或多个打包的文件。

Rollup.js

使用 Rollup.js,它与第一种方案(transpiler + bundler)非常相似,但 Rollup 附带 ES6 模块解析功能,在打包之前静态分析 ES6 代码和依赖项。它使用 “tree shaking” 最低限度的打包需要运行的代码。当使用 ES6 模块时,Rollup.js 与Browserify 或 Webpack 相比的主要好处是 tree shaking 可以使你的包变小。

需要注意的是, Rollup 可以打包多种格式的模块代码,包括 ES6 、 CommonJS、 AMD 、 UMD 或 IIFE。IIFE 和 UMD 的 bundle 可以在浏览器中正常工作,但是 AMD 、 CommonJS 或 ES6 格式的模块, 需要使用其他方法来将代码转换为浏览器理解的格式 (例如,使用 Browserify 、 Webpack 、 RequireJS 等)。

ES6 模块动态加载

作为网络开发人员,我们必须克服很多障碍。将 ES6 模块转换成浏览器可以理解的代码并不容易,但 ES6 模块迟早可以直接在所有浏览器中运行的。

ECMAScript 目前有一个名为 ECMAScript 6 模块加载程序 API 的解决方案,这是一个基于 Promise 编程的 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!’
复制代码

也可以通过直接在脚本标签中指定 “type = module” 来定义模块,如下所示:

<script type="module">
  // loads the 'myModule' export from 'mymodule.js'
  import { hello } from 'mymodule';

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

如果你还没有看过模块加载API polyfill 的仓库,强烈建议你看一看

此外,如果你想测试这种方法,可以查看基于 ES6 模块加载程序 polyfill 构建的 SystemJS。SystemJS 可以在浏览器和 Node 环境动态加载任何模块格式 (ES6 模块、 AMD 、 CommonJS 和/或全局脚本)。它使用 “模块注册表” 来跟踪所有加载的模块,以避免重新加载已加载的模块。它还会自动转换 ES6 模块 (如果设置了一个选项),并且能够加载任何模块类型!相当整洁。

既然语言层面支持了 ES6 模块,还需要打包吗?

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

HTTP/2 会让模块打包过时吗?

使用 HTTP/1,每个 TCP 连接只允许一个请求。这就是为什么加载多个资源需要多个请求的原因。如果使用 HTTP/2,HTTP/2 是完全多路复用的,这意味着多个请求和响应可以并行发生。因此,我们可以通过单个连接同时服务多个请求。

由于每个 HTTP 请求的成本明显低于 HTTP/1,从长远来看,加载一堆模块不会是一个巨大的性能问题。一些人认为这意味着模块打包不再是必要的。这当然是可能的,但还是要看发展情况。

首先,模块打包提供了 HTTP/2 不能提供的好处,比如删除未使用的模块以减小代码体积。如果你的网站对性能要求很高,模块打包可能会给你带来更多好处。如果性能要求不是那么极端,也可以跳过构建步骤以最小的成本节省时间。

总的来说,我们离让大多数网站通过 HTTP/2 提供代码还很远,构建过程至少在短期内是需要的。

另外,HTTP/2 也有一些其他特点,如果你好奇,这里有一个很好的资源


CommonJS、AMD 和 UMD 会过时吗?

一旦 ES6 成为模块标准,我们真的需要其他的模块格式吗?在 JavaScript 中导入和导出模块遵循单一的标准化方法没有中间步骤,这对 Web 开发很友好。那么需要多长时间才能达到 ES6 是模块标准的程度?很有可能,很长一段时间。

此外,有很多人喜欢有多种方式可供选择,所以 “一个标准且统一的方法” 可能永远不会成为现实。

我希望这篇由两部分组成的文章有助于澄清开发人员在谈论模块和模块打包时使用的一些术语。如果你发现上面的任何术语令人困惑,请继续查看第一部分


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK