瞅瞅JavaScript模块标准

模块是每门语言构建复杂系统的必备特性,JavaScript自然也不例外。JavaScript当前流行的模块化标准有CommonJS、AMD、CMD、ES6等等,本文对这些标准做了简单梳理,努力做到应用时不懵逼,不乱用。

模块

现如今几乎每门语言都有自己的模块化解决方案,这是随着软件工程越来越复杂的必然产物。贴几个流行语言的模块化介绍大家感受下:

所有语言的模块化解决方案都是为了实现将复杂的程序拆分成独立的几个模块,每个模块写明自己的依赖、输出自己的能力。模块化让复杂代码变得容易维护、方便复用。

概览

JavaScript标准众多,缕清这几个标准的发展史有助于大家选择采用哪种方案来写代码。

  1. CommonJS应该是最早在民间自发产生的服务端模块化标准,一开始是叫ServerJS,后来改名了。
  2. 服务端JS有了模块化标准之后,浏览器JS表示我也必须有,于是基于CommonJS标准产生了AMD,和CommonJS相比最大的不同就是依赖的异步加载。
  3. CMD是类似AMD的对于浏览器JS模块化标准,源自Sea.js。
  4. ES6则是集大成者,其统一了同步和异步的模块化标准,试图让JS模块化标准从分裂走向统一,并取得了不小的成绩。

标准定制一般都是和实现相辅相成的,那么JS这些有名的模块化标准主要都有哪些实现呢?

CommonJS AMD CMD ES6
Node.js/RingoJS RequireJS/curl.js SeaJS ES6

每个标准都在JS世界的不同领域中得到广泛的应用,对这些标准进行初步的了解是有必要的。

CommonJS

为了方便,直接使用Node.js的模块化实现来说明CommonJS标准。下面给出按照CommonJS标准写的demo,随后其他标准的demo也会实现一样的功能。

// math.js
const { PI } = Math;
exports.area = (r) => PI * r ^ 2;
exports.circumference = (r) => 2 * PI * r;
console.log(module);

// main.js
var area = require('./math').area;
var result = area(3);
console.log(result);

CommonJS模块定义了三个变量,moduleexportsrequire

module

通过console.log(module),我们可以打印出module的结构如下:

Module {
  id: '.',                                                      // 模块Id,一般都是文件的绝对路径
  exports: { area: [Function], circumference: [Function] },     // 模块对外输出的变量
  parent: null,                                                 // 调用该模块的模块,如果直接执行就是null
  filename: '/path/to/demo/math.js',                            // 带绝对路径的文件名
  loaded: false,                                                // 模块是否加载完成
  children: [],                                                 // 模块的依赖
  paths:                                                        // 模块依赖的搜索路径
   [ '/path/to/demo/node_modules',
     '/path/to/node_modules',
     '/path/node_modules',
     '/node_modules' ] }

exports

module对象中是有字段exports的,exports实际上就是module.exports

var exports = module.exports;

因此导出变量有两种方式:

exports.area = (r) => PI * r ^ 2;
exports.circumference = (r) => 2 * PI * r;

// 或者也可以如下
module.exports.area = (r) => PI * r ^ 2;
module.exports.circumference = (r) => 2 * PI * r;

因为exportsmodule.exports的引用,在导出的时候我们就要格外小心了。

exports.area = (r) => PI * r ^ 2;
module.exports = (r) => 2 * PI * r; // 将module.exports对象覆盖,area这个变量就不会被导出。

exports = (r) => 2 * PI * r; // exports就不再是module.exports的引用了,会导致后面的circumference导出无效。
exports.circumference = (r) => 2 * PI * r;

require

require的参数是模块id,require实现的功能就是根据模块id去找到对应的依赖模块。模块id的变数主要在两个方面,一个是后缀名,一个是路径。

首先来说后缀名,一般默认是js的,所以我们在依赖的以后一般不需要添加后缀名。而且找不到的话,Node.js还会尝试添加.json.node后缀去查找。

var area = require('./math').area;

// 和上面是一样的
var area = require('./math.js').area;

再来说路径,绝对路径和相对路径就不多说,比较好理解。

var area = require('/math').area;   // 在指定的绝对路径查找模块
var area = require('./math').area;  // 在相对与当前目录的路径查找模块

还有如果不是以"."、".."或者"/"开头的话,那就会先去核心模块路径找,找不到再按照module.paths指定的路径找。

var area = require('math').area;

AMD

同样的,本节采用RequireJS来说明AMD标准。先上一个例子。

// math.js
define('app/math', function () {
    const { PI } = Math;
    return {
        area: function (r) {
          return PI * r ^2;math.js
        },
        circumference: function (r) {
          return 2 * PI * r;
        }
    };
});

// main1.js
define(['app/math', 'print'], function (math, print) {
    print(math.area(3));
});

// main2.js
define(function (require) {
    var math = require('./math');
    var print = require('print');
    print(math.area(3));
});

define

AMD使用define这个api来定义一个模块,其语法比较简单。

define(id?, dependencies?, factory);

模块id和依赖都是可选参数,只有构造函数是必须的。

id

AMD的模块id和CommonJSmodule对象中的id作用是一样的,用来唯一的指定模块,一般是模块的绝对路径。虽然define函数将这个id暴露给使用者,但一般也是不填的,一些优化工具会自动生成绝对路径作为id参数传给define函数。id的定义也和CommonJS类似,相对路径、绝对路径、js后缀可以省略等等。详细的可以查看AMD模块id的格式

dependencies

factory函数中使用到的依赖需要先在这里指明,比如示例代码,需要指明app/mathprint,然后将他们作为factory的参数传给函数体使用。AMD协议保证在factory函数执行之前,能将所有的依赖都准备好。

除了指明依赖之外,dependencies还有一种写法。这种写法是为了方便复用按照CommonJS规范写的模块,足见AMD规范的良苦用心。

define(function(require, exports, module) {
    var a = require("a");
    exports.foo = function () {
        return a.bar();
    };
});

RequireJS中依赖的查找路径是通过配置文件来指定的baseUrlpathsbundles等,这一点和Node.js是完全不一样的。

AMD这个标准有个比较明显的缺陷就是所有的依赖都必须要先执行,这个从其接口的设计上就能看出来。如果依赖比较多的话,这个事情就比较坑爹了。

factory

这个参数名字比较有意思,叫工厂函数,当某块被依赖的时候,这个工厂函数就会被执行,而且即便被依赖多次,也只会执行一次。在factory中需要导出变量的时候,直接return就可以了,当然也可以使用CommonJS规范的exports。

相比较而言,AMD标准还是比较复杂的。

CMD

CMD虽然没有CommonJSAMD出名,但是SeaJS在国内还是比较出名,这里也捎带提及CMD规范,不多说,来demo代码先。

// math.js
define(function(require, exports, module) {
    const { PI } = Math;
    exports.area = function (r) {
        return PI * r ^2;math.js
    },
    exports.circumference = function (r) {
        return 2 * PI * r;
    }
});

// main1.js
define(function(require, exports, module) {
    var area = require('./math').area;
    var print = require('print');
    print(area(3));
});

上面的示例和AMD的示例虽然比较像,但是实际上CMD的规范和AMD还是不太一样的,有自己的一些特色。

define

模块定义和虽然和AMD一样用的是define函数,但是只支持factory一个参数。

define(factory);

factory和AMD也是类似的,可以是函数,也可以是一个object。

require && require.async

CMD除了有同步的require接口,还有异步接口require.async,这样就解决了我们之前提到的AMD需要先把所有依赖都加载好才能执行factory的弊端。

define(function(require) {
    // 同步接口示例
    var a = require('./a');
    a.doSomething();

    // 异步接口示例
    require.async('./b', function(b) {
        b.doSomething();
    });
})

exports

这个就比较类似CommonJS的exports了,是用来输出API或者对象的。

module

这个也比较类似CommonJS的module对象,不过相比于Node.js的module对象要简单的多,只包括

module.uri                  // 模块完整解析出来的uri
module.dependencies         // 所有的依赖
module.exports              // 导出的能力

从上面的简单描述可以看出,CMD想同时解决AMD和CommonJS能解决的问题,基于AMD和CommonJS的设计做了简化优化,同时设计了异步require的接口等。关于CMD的大量细节可以查看SeaJS官网

ES6

一直以来JavaScript语言本身是没有内置的模块系统,ES6终结了这个局面。虽然ES6的普及还需要好多年,但ES6完全兼容ES5的所有特性。ES6的写法可以通过转换工具转成ES5来执行,是时候好好学习ES6了。

让我们来看看用ES6实现上面的示例是什么样的?

// math.js
const { PI } = Math;
export function area(r) {
    return PI * r ^ 2;
}
export function circumference(r) {
    return 2 * PI * r;
}

// main.js
import { area, circumference } from './math';
console.log(area(3));

export

ES6的模块是严格要求一个模块一个文件,一个文件一个模块的。每个模块可以只导出一个变量,也可以导出多个变量。

一个模块导出多次使用命名的导出(named exports)。

export const sqrt = Math.sqrt;
export function square(x) {
    return x * x;
}

一个模块只导出一次使用默认导出(default exports),非常方便。

export default 'abc';
export default foo();
export default /^xyz$/;
export default 5 * 7;
export default { no: false, yes: true };
export default function () {}

import

ES6的import和之前标准的require是比较不一样的,被导出变量是原有变量的只读视图。这意味着虽然变量被导出了,但是它还是和内部变量保持关联,被导出变量的变化,会导致内部变量也跟着变化。也许这正是ES6重新取了import这个名字而没有使用require的原因。这一点和require是完全不一样的,require变量导出之后就生成了一个新的变量,和原始的内部变量就脱离关系了。有个demo能比较好的说明这个问题。

//------ lib.js ------
export let counter = 3;
export function incCounter() {
    counter++;
}

//------ main.js ------
import { counter, incCounter } from './lib';

// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4

模块是ES6语言的一项重大特性,里面的细节比较多,详细描述怕是篇幅太长了,需要详细了解ES6模块语法的同学请移步ES Modules

总结

本文简单描述了CommonJS、AMD、CMD以及ES6的模块标准,仔细研究各个标准的细节可以一窥JavaScript模块化标准的发展历程。JavaScript语言早期作为网站的一种脚本语言,不需要模块化这种特性,但随着node.js的出现,js的工程越来越复杂,模块化也越来越重要。CommonJS、AMD和CMD是在语言不支持的情况下发展出来的第三方模块化解决方案,ES6正是基于这些解决方案提出了语言内置的模块标准,希望ES6能尽快的推广起来,这样JSer就能轻松许多啦。

参考文献

微信一键关注
微信一键关注

推荐阅读更多精彩内容