回到目录

[前端知识体系] 模块加载

2018-04-02

0x1 翻翻旧帐

众所周知,JavaScript 是一门在 10 天内被创造出来的语言,JavaScript 之父 Brendan Eich 在当年创造它的时候也无法想象它能发展到如今这种繁荣的境况。所以它的一些缺陷(没有模块的概念)在 HTML5 兴起后前端代码的行数出现井喷式爆发之后日益凸显:

<script src="module1.js"/>
<script src="module2.js"/>
<script src="libraryA.js"/>
<script src="module3.js"/>

这是最原始的 JavaScript 文件加载方式,如果把每一个文件看做是一个模块,那么他们的接口通常是暴露在全局作用域下,也就是定义在 window 对象中,不同模块的接口调用都是一个作用域中,一些复杂的框架,会使用命名空间的概念来组织这些模块的接口,典型的例子如 YUI 库。

这种原始的加载方式暴露了一些显而易见的弊端:

  • 全局作用域下容易造成变量冲突
  • 文件只能按照 <script> 的书写顺序进行加载
  • 开发人员必须主观解决模块和代码库的依赖关系
  • 在大型项目中各种资源难以管理,长期积累的问题导致代码库混乱不堪

实际上模块并非 JavaScript 独有的概念,在软件编程中,模块一直作为非常重要的特性,帮助开发者抽象出可复用,易理解,易组织的代码。比如 C 语言的库和头文件(include),Java 的包(import)。

模块的特点有:

  • 独立性——能够独立完成一个功能,不受外部环境的影响
  • 完整性——完成一个特定功能
  • 集合性——一组语句的集合
  • 依赖性——可以依赖已经存在的模块
  • 被依赖——可以被其他模块依赖

Webpack 的文档里是这么描述模块的:每个模块具有比完整程序更小的接触面,使得校验、调试、测试轻而易举。 精心编写的模块提供了可靠的抽象和封装界限,使得应用程序中每个模块都具有条理清楚的设计和明确的目的。

可惜,JavaScript 不是一种模块化编程语言,它不支持「类」(class),更遑论「模块」(module)了。所以当网页的 JavaScript 代码和文件越来越多时,JavaScript 没有模块的缺陷越来越成为了所有前端心头的一块大石。基于以上背景,社区开启了一系列的「模块化运动」。

0x2 模块化推演

1、原始写法

模块就是实现特定功能的一组方法。只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。

function m1(){
    // do something
}
function m2(){
    // do something
}

上面的函数 m1() 和 m2() 组成一个模块。使用的时候,直接调用就行了。
这种做法的缺点很明显:「污染」了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。

2、对象写法

为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。

var module1 = new Object({
  _count: 0,
  m1: function() {
    // do something
  },
  m2: function() {
    // do something
  }
});

上面的函数 m1() 和 m2(),都封装在 module1 对象里。使用的时候,就是调用这个对象的属性。module1.m1(),但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值:module1._count=5

3、立即执行函数写法

使用「立即执行函数」(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的。

var module1 = (function() {
  var _count = 0;
  var m1 = function() {
    // do something
  }
  var m2 = function() {
    // do something
  }
  return {      
    m1: m1,
    m2: m2
  }
})();

使用上面的写法,外部代码无法读取内部的 _count 变量。

console.info(module1._count);     //undefined

module1 就是 Javascript 模块的基本写法。下面,再对这种写法进行加工。

4、放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用「放大模式」(augmentation)。

var module1 = (function(mod) {
  mod.m3 = function() {
    //...
  };
  return mod;
})(module1);

上面的代码为 module1 模块添加了一个新方法 m3(),然后返回新的 module1 模块。

5、宽放大模式

在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上一节的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用「宽放大模式」。

var module1 = (function(mod) { 
  //... 
  return mod;
})(window.module1 || {});

与「放大模式」相比,「宽放大模式」就是「立即执行函数」的参数可以是空对象。

6、输入全局变量

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

var module1 = (function ($, YAHOO) { 
  //... 
})(jQuery, YAHOO);

上面的 module1 模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入 module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

0x3 模块化标准演变

尽管推演出了 JavaScript「模块化」的最佳实践,但只要没有公认的标准,模块就无法通用起来,因为模块的意义就在于让别人可以方便的加载和使用。考虑到 JavaScript 并没有通用的官方规范,于是乎社区就制定了两种模块化规范:CommonJS 和 AMD(顺便再提提 UMD 和 CMD)。

1、CommonJS

2009年,美国程序员 Ryan Dahl 创造了 Node.js 项目,将 JavaScript 语言用于服务器端编程。这标志「JavaScript 模块化编程」正式诞生(其幕后的模块化标准即 CommonJS)。因为老实说,在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。

Node.js 的模块系统,就是采用 CommonJS 规范实现的。在 CommonJS 中,有一个全局性方法 require(),用于加载模块。假定有一个数学模块 math.js,就可以像下面这样加载。

var math = require('math');

// 然后,就可以调用模块提供的方法:
math.add(2,3); // 5

CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目(不妨想想,该标准为什么针对浏览器环境之外),比如在服务器和桌面环境中。这个项目最开始是由 Mozilla 的工程师 Kevin Dangoor 在 2009 年 1 月创建的,当时的名字是 ServerJS。2009年8月,这个项目改名为 CommonJS,以显示其 API 的更广泛实用性。CommonJS 是一套规范,它的创建和核准是开放的。这个规范已经有很多版本和具体实现。

CommonJS 规范是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。该规范的主要内容是:每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

一个直观的例子:

// moduleA.js
module.exports = function( value ){
    return value * 2;
}

// moduleB.js
var multiplyBy2 = require('./moduleA');
var result = multiplyBy2(4);

CommonJS 是同步加载模块,但其实也有浏览器端的实现,其原理是现将所有模块都定义好并通过 id 索引,这样就可以方便的在浏览器环境中解析了

优点:

  • 模块加载的顺序,按照其在代码中出现的顺序(同步)。
  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。

缺点:

  • 不能非阻塞的并行加载多个模块
  • 同步的模块加载方式不适合在浏览器环境中,同步意味着阻塞加载,浏览器资源是异步加载的

2、AMD

AMD(Asynchronous Module Definition,异步模块定义)是为浏览器环境设计的,因为 CommonJS 模块系统是同步加载的,当前浏览器环境还没有准备好同步加载模块的条件。这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于「假死」状态。

因此,浏览器端的模块,不能采用「同步加载(synchronous)」,只能采用「异步加载(asynchronous)」。这就是 AMD 规范诞生的背景。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD 也采用 require() 语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback);

第一个参数 [module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback,则是加载成功之后的回调函数。如果将前面的代码改写成 AMD 形式,就是下面这样:

require(['math'], function (math) {
    math.add(2, 3);
});

math.add() 与 math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。

优点:

  • 适合在浏览器环境中异步加载模块
  • 可以并行加载多个模块

缺点:

  • 提高了开发成本,代码的阅读和书写比较困难,模块定义方式的语义不顺畅
  • 不符合通用的模块化思维方式,是一种妥协的实现

3、UMD

Universal Module Definition 规范类似于兼容 CommonJS 和 AMD 的语法糖,是模块定义的跨平台解决方案。

UMD 的实现很简单:

  • 先判断是否支持 CommonJS 模块格式(exports 是否存在),存在则使用 CommonJS 模块格式。
  • 再判断是否支持 AMD(define 是否存在),存在则使用 AMD 方式加载模块。
  • 前两个都不存在,则将模块公开到全局(window 或 global)。

各种具体的实现方式,这里举例一 个 jQuery 按照如上方式实现的代码:

// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define([], factory);
    } else if (typeof exports === 'object') {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory();
    } else {
        // Browser globals (root is window)
        root.returnExports = factory();
  }
}(this, function () {
    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

如果要兼容 CMD,只需要稍微改造:

// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {
    if (typeof define === 'function' && (define.amd || define.cmd)) {
        // AMD. Register as an anonymous module.
        define([], factory);
    } else if (typeof exports === 'object') {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory();
    } else {
        // Browser globals (root is window)
        root.returnExports = factory();
  }
}(this, function () {
    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

4、CMD

CMD 即 Common Module Definition 通用模块定义,CMD 规范是国内发展出来的,就像 AMD 有个 RequireJS,CMD 有个 SeaJS,SeaJS 要解决的问题和 RequireJS 一样,只不过在模块定义方式和模块加载(可以说运行、解析)时机上有所不同。

CMD 和 AMD 的异同:

  • 执行模块的机制大不一样:由于 RequireJS 是执行的 AMD 规范,因此所有的依赖模块都是预执行,而 SeaJS对模块的态度是懒执行。
  • 两者都是异步加载模块,但执行时机不一样。AMD 会尽早地执行(依赖)模块, 相当于所有的 require 都被提前了,而 CMD 则是在需要时才执行(依赖)模块。

0x4 正统标准问世——ES6 Module

由上述可知:在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

let { stat, exists, readFile } = require('fs');

// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

上面代码的实质是整体加载 fs 模块(即加载 fs 的所有方法),生成一个对象(_fs),然后再从这个对象上面读取 3 个方法。这种加载称为「运行时加载」,因为只有运行时才能得到这个对象,导致完全没办法在编译时做「静态优化」。

ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

// ES6模块
import { stat, exists, readFile } from 'fs';

上面代码的实质是从fs模块加载 3 个方法,其他方法不加载。这种加载称为「编译时加载」或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

除了静态加载带来的各种好处,ES6 模块还有以下好处。

  • 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
  • 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。

模块功能主要由两个命令构成:export 和 import。export 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。下面是一个 JS 文件,里面使用 export 命令输出变量。

// profile.js
export let firstName = 'Michael';
export let lastName = 'Jackson';
export let year = 1958;

// 另外一种写法:
let firstName = 'Michael';
let lastName = 'Jackson';
let year = 1958;
export {firstName, lastName, year};

// export命令除了输出变量,还可以输出函数或类(class)。
export function multiply(x, y) {
  return x * y;
};

// 通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
function v1() { ... }
function v2() { ... }

export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

使用 export 命令定义了模块的对外接口以后,其他 JS 文件就可以通过 import 命令加载这个模块。

// main.js
import {firstName, lastName, year} from './profile.js';

function setName(element) {
  element.textContent = firstName + ' ' + lastName;
}

// 如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import { lastName as surname } from './profile.js';

// 注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
foo();
import { foo } from 'my_module';

// 由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}
// 上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。

// 最后,import语句会执行所加载的模块,因此可以有下面的写法。
import 'lodash';

// 上面代码仅仅执行lodash模块,但是不输入任何值。如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。
import 'lodash';
import 'lodash';

import { foo } from 'my_module';
import { bar } from 'my_module';
// 等同于
import { foo, bar } from 'my_module';

// 整体加载
import * as circle from './circle';

0x5 模块加载与编译

我们重新回到过去,从 JavaScript 刀耕火种的年代追溯起那个时间的 JavaScript 是如何加载的?

<script src="module1.js"/>
<script src="module2.js"/>
<script src="libraryA.js"/>
<script src="module3.js"/>

显而易见,这种方式是同步而且阻塞的,每个模块都会引发一次请求。为了减少请求,我也可以这么处理(把四个文件合并到一起):

<script src="all.js"/>

但这种方式显然也有同样的问题,虽然请求变小,但单次请求体积变大,同样会堵塞式加载。

而分块传输,按需进行懒加载,在实际用到某些模块的时候再增量更新,才是较为合理的模块加载方案。要实现模块的按需加载,就需要一个对整个代码库中的模块进行静态分析编译打包的过程。

1、构建工具

在开始之前,先了解下目前主流的构建工具:Gulp / Grunt。它们的作用即优化前端工作流程。比如自动刷新页面、combo、压缩 css/js、编译 less/sass 等等。简单来说,就是使用 Gulp/Grunt,然后配置你需要的插件,就可以把以前需要手工做的事情让它帮你做了,这就是前端自动化构建的基础。

Gulp、Grunt 和 Make(常见于c/cpp)、Ant、Maven、Gradle(Java/Android)、Rake、Thor(Ruby)一样,都是是 Task Runner。用来将一些繁琐的task自动化并处理任务的依赖关系。

2、模块编译

按照传统的 <script> 引用的 JS 文件,是不存在「编译」过程的,因为 JavaScript 作为一门解释性语言,能够直接在浏览器环境中执行。这里所谓的「编译」分为两种:

  • SeaJS / RequireJS:是一种「在线编译模块」的方案,相当于在页面上加载一个 CMD/AMD 解释器。这样浏览器就认识了 define、exports、module 这些东西。也就实现了模块化。
  • Browserify / Webpack : 是一个「预编译模块」的方案,相比于上面,这个方案更加智能。以 Webpack 为例:首先它是预编译的,不需要在浏览器中加载解释器(sea.js / require.js)。另外你在本地直接写 JS,不管是 AMD / CMD / ES6 风格的模块化,它都能认识,并且编译成浏览器认识的 JS。

Webpack 作为一个模块编译器,还做了一些 Grunt/Gulp 这类构建工具也会做的事情,导致了很多时候会将他们混淆,但还是要记住 Webpack 最核心的定位。

3、模块打包

在线编译模块是没有打包这个过程的,顶多就是代码的压缩/合并/混淆。而预编译模块的方案中,需要打包输出最终 JavaScript 文件。因为预编译的方案里开发的代码并未被浏览器完全理解,所以需要在预编译后输出为浏览器所认识的代码,在这个过程中,编译和打包可以理解为一个环节,最终的产物往往是类似 bundle.js 这样的聚合产物。

0x6 相关文献