天天看点

JS模块化:CommonJS,AMD与CMD# 兼容

模块化历史

一、原始写法

function m1(){
	//...
}

function m2(){
	//...
}
           

二、对象写法

var module1 = new Object({
	_count : 0,
    
	m1 : function (){
		//...
	},

	m2 : function (){
		//...
	}

});
           

这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。

三、立即执行函数写法

var module1 = (function(){
	var _count = 0;

	var m1 = function(){
		//...
	};

	var m2 = function(){
		//...
	};

	return {
		m1 : m1,
		m2 : m2
	};
})();
           

Javascript模块的基本写法。

四、放大模式

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

上面的代码为

module

模块添加了一个新方法

m3()

,然后返回新的

module

模块。

五、宽放大模式(Loose augmentation)

var module1 = ( function (mod){

	//...

	return mod;
})(window.module1 || {});
           

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

六、输入全局变量

var module = (function ($, YAHOO) {

	//...

})(jQuery, YAHOO);
           

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

module

模块需要使用

jQuery

库和

YUI

库,就把这两个库(其实是两个模块)当作参数输入

module

。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

CommonJS规范

NodeJS是CommonJS规范的实现,webpack 也是以CommonJS的形式来书写。

简单应用

var math = require('math');
math.add(2,3); // 5
           

原理

浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量.

module

exports

require

global

只要能够提供这四个变量,浏览器就能加载 CommonJS 模块。

var module = {
  exports: {}
};

(function(module, exports) {
  exports.multiply = function (n) { return n * 1000 };
}(module, module.exports))

var f = module.exports.multiply;
f(5) // 5000 
           

上面代码向一个立即执行函数提供 module 和 exports 两个外部变量,模块就放在这个立即执行函数里面。模块的输出值放在 module.exports 之中,这样就实现了模块的加载。

2、Browserify 的实现

知道了原理,就能做出工具了。Browserify是目前最常用的

CommonJS

格式转换的工具。

请看一个例子,

main.js

模块加载

foo.js

模块。

// foo.js
module.exports = function(x) {
  console.log(x);
};

// main.js
var foo = require("./foo");
foo("Hi");
           

使用下面的命令,就能将main.js转为浏览器可用的格式。

$ browserify main.js > compiled.js
           

Browserify到底做了什么?安装一下browser-unpack,就能看清楚了。

$ npm install browser-unpack -g
           

然后,将前面生成的compile.js解包。

$ browser-unpack < compiled.js

[
  {
    "id":1,
    "source":"module.exports = function(x) {\n  console.log(x);\n};",
    "deps":{}
  },
  {
    "id":2,
    "source":"var foo = require(\"./foo\");\nfoo(\"Hi\");",
    "deps":{"./foo":1},
    "entry":true
  }
]
           

可以看到,

browerify

将所有模块放入一个数组,

id

属性是模块的编号,

source

属性是模块的源码,

deps

属性是模块的依赖。

因为

main.js

里面加载了

foo.js

,所以

deps

属性就指定

./foo

对应1号模块。执行的时候,浏览器遇到

require('./foo')

语句,就自动执行1号模块的

source

属性,并将执行后的

module.exports

属性值输出。

3、Tiny Browser Require

虽然

Browserify

很强大,但不能在浏览器里操作,有时就很不方便。

我根据

mocha

的内部实现,做了一个纯浏览器的

CommonJS

模块加载器tiny-browser-require。完全不需要命令行,直接放进浏览器即可,所有代码只有30多行。

它的逻辑非常简单,就是把模块读入数组,加载路径就是模块的id。

function require(p){
  var path = require.resolve(p);
  var mod = require.modules[path];
  if (!mod) throw new Error('failed to require "' + p + '"');
  if (!mod.exports) {
    mod.exports = {};
    mod.call(mod.exports, mod, mod.exports, require.relative(path));
  }
  return mod.exports;
}

require.modules = {};

require.resolve = function (path){
  var orig = path;
  var reg = path + '.js';
  var index = path + '/index.js';
  return require.modules[reg] && reg
    || require.modules[index] && index
    || orig;
};

require.register = function (path, fn){
  require.modules[path] = fn;
};

require.relative = function (parent) {
  return function(p){
    if ('.' != p.charAt(0)) return require(p);
    var path = parent.split('/');
    var segs = p.split('/');
    path.pop();

    for (var i = 0; i < segs.length; i++) {
      var seg = segs[i];
      if ('..' == seg) path.pop();
      else if ('.' != seg) path.push(seg);
    }

    return require(path.join('/'));
  };
};
           

使用的时候,先将上面的代码放入页面。然后,将模块放在如下的立即执行函数里面,就可以调用了。

<script src="require.js" /><script>

require.register("moduleId", function(module, exports, require){
  // Module code goes here
});
var result = require("moduleId");
</script>
           

还是以前面的

main.js

加载

foo.js

为例。

require.register("./foo.js", function(module, exports, require){
  module.exports = function(x) {
    console.log(x);
  };
});

var foo = require("./foo.js");
foo("Hi");
           

注意,这个库只模拟了

require

module

exports

三个变量,如果模块还用到了

global

或者其他

Node

专有变量(比如

process

),就通过立即执行函数提供即可。

加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值,但是引用类型的数据,如对象,数组还是会受影响。

AMD规范

因为CommonJS规范是同步的,如果加载时间很长,整个应用就会停在那里等。这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。

因此,浏览器端的模块,不能采用"同步加载"(synchronous),只能采用"异步加载"(asynchronous)。这就是AMD规范诞生的背景。

CommonJS是主要为了JS在后端的表现制定的,他是不适合前端的,AMD(异步模块定义)出现了,它就主要为前端JS的表现制定规范。

AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

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

require([module], callback);
           

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

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

math.add()与math模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD比较适合浏览器环境。目前,主要有两个Javascript库实现了AMD规范:require.js和curl.js。

详细概括:下面以RequireJS为例说明AMD规范

require.js的诞生,就是为了解决这两个问题:

一、为什么要用require.js?

(1)实现js文件的异步加载,避免网页失去响应;

(2)管理模块之间的依赖性,便于代码的编写和维护。

二、require.js的加载

<script src="js/require.js" defer async="true" ></script>
           

加载require.js以后,下一步就要加载我们自己的代码了。假定我们自己的代码文件是main.js,也放在js目录下面。那么,只需要写成下面这样就行了:

<script src="js/require.js" data-main="js/main"></script>
           

data-main属性的作用是,指定网页程序的主模块。在上例中,就是js目录下面的main.js,这个文件会第一个被require.js加载。由于require.js默认的文件后缀名是js,所以可以把main.js简写成main。

三、主模块的写法

// main.js

require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
	// some code here
});
           

require.js会先加载jQuery、underscore和backbone,然后再运行回调函数。主模块的代码就写在回调函数中。

四、模块的加载

上一节最后的示例中,主模块的依赖模块是[‘jquery’, ‘underscore’, ‘backbone’]。默认情况下,require.js假定这三个模块与main.js在同一个目录,文件名分别为jquery.js,underscore.js和backbone.js,然后自动加载。

使用require.config()方法,我们可以对模块的加载行为进行自定义。require.config()就写在主模块(main.js)的头部。参数就是一个对象,这个对象的paths属性指定各个模块的加载路径。

require.config({
	paths: {
		"jquery": "lib/jquery.min",
		"underscore": "lib/underscore.min",
		"backbone": "lib/backbone.min"
	}
});

require.config({
	baseUrl: "js/lib",
	paths: {
		"jquery": "jquery.min",
		"underscore": "underscore.min",
		"backbone": "backbone.min"
	}
});

require.config({
	paths: {
		"jquery": "https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min"
	}
});
           

require.js要求,每个模块是一个单独的js文件。这样的话,如果加载多个模块,就会发出多次HTTP请求,会影响网页的加载速度。因此,require.js提供了一个优化工具,当模块部署完毕以后,可以用这个工具将多个模块合并在一个文件中,减少HTTP请求数。

五、AMD模块的写法

require.js加载的模块,采用AMD规范。也就是说,模块必须按照AMD的规定来写。

具体来说,就是模块必须采用特定的define()函数来定义。如果一个模块不依赖其他模块,那么可以直接定义在define()函数之中。

简单示例

define(function(){
	var exports = {};
	exports.method = function(){...};
	return exports;
});
           

假定现在有一个math.js文件,它定义了一个math模块。那么,math.js就要这样写:

// math.js
define(function (){
	var add = function (x,y){
		return x+y;
	};
	return {
		add: add
	};
});
           

加载方法如下:

// main.js
require(['math'], function (math){
	alert(math.add(1,1));
});
           

如果这个模块还依赖其他模块,那么define()函数的第一个参数,必须是一个数组,指明该模块的依赖性。

define(['myLib'], function(myLib){
	function foo(){
		myLib.doSomething();
	}
	return {
		foo : foo
	};
});
           

当require()函数加载上面这个模块的时候,就会先加载myLib.js文件。

六、加载非规范的模块

这样的模块在用require()加载之前,要先用require.config()方法,定义它们的一些特征。

举例来说,underscore和backbone这两个库,都没有采用AMD规范编写。如果要加载它们的话,必须先定义它们的特征。

require.config({
	shim: {
		'underscore':{
			exports: '_'
		},
		'backbone': {
			deps: ['underscore', 'jquery'],
			exports: 'Backbone'
		}
	}
});
           

require.config()接受一个配置对象,这个对象除了有前面说过的paths属性之外,还有一个shim属性,专门用来配置不兼容的模块。具体来说,每个模块要定义(1)exports值(输出的变量名),表明这个模块外部调用时的名称;(2)deps数组,表明该模块的依赖性。

七、require.js插件

require.js还提供一系列插件,实现一些特定的功能。

domready插件,可以让回调函数在页面DOM结构加载完成后再运行。

require(['domready!'], function (doc){
	// called once the DOM is ready
});
           

text和image插件,则是允许require.js加载文本和图片文件。

define(
	['text!review.txt','image!cat.jpg'],
	function(review,cat){
		console.log(review);
		document.body.appendChild(cat);
	}
);
           

类似的插件还有json和mdown,用于加载json文件和markdown文件。

这有AMD的WIKI中文版,讲了很多蛮详细的东西,用到的时候可以查看:AMD的WIKI中文版

CMD规范

官网地址

define(function(require,exports,module){...});
           

SeaJS与 RequireJS 的异同

相同之处

RequireJS 和 Sea.js 都是模块加载器,倡导模块化开发理念,核心价值是让 JavaScript 的模块化开发变得简单自然。

不同之处

  • 定位有差异。RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。Sea.js 则专注于 Web 浏览器端,同时通过 Node 扩展的方式可以很方便跑在 Node 环境中。
  • 遵循的规范不同。RequireJS 遵循 AMD(异步模块定义)规范,Sea.js 遵循 CMD (通用模块定义)规范。规范的不同,导致了两者 API 不同。Sea.js 更贴近 CommonJS Modules/1.1 和 Node Modules 规范。
  • 推广理念有差异。RequireJS 在尝试让第三方类库修改自身来支持 RequireJS,目前只有少数社区采纳。Sea.js 不强推,采用自主封装的方式来“海纳百川”,目前已有较成熟的封装策略。
  • 对开发调试的支持有差异。Sea.js 非常关注代码的开发调试,有 nocache、debug 等用于调试的插件。RequireJS 无这方面的明显支持。
  • 插件机制不同。RequireJS 采取的是在源码中预留接口的形式,插件类型比较单一。Sea.js 采取的是通用事件机制,插件类型更丰富。
  • SeaJS只会在真正需要使用(依赖)模块时才执行该模块,执行模块的顺序也是严格按照模块在代码中出现(require)的顺序;而RequireJS会先尽早地执行(依赖)模块, 相当于所有的require都被提前了, 而且模块执行的顺序也不一定100%就是先mod1再mod2. 注意我这里说的是执行(真正运行define中的代码)模块,而非加载(load文件)模块.模块的加载都是并行的, 没有区别, 区别在于执行模块的时机,或者说是解析.RequireJS的做法是并行加载所有依赖的模块, 并完成解析后, 再开始执行其他代码, 因此执行结果只会"停顿"1次, 完成整个过程是会比SeaJS要快.而SeaJS一样是并行加载所有依赖的模块, 但不会立即执行模块, 等到真正需要(require)的时候才开始解析, 这里耗费了时间, 因为这个特例中的模块巨大, 因此造成"停顿"2次的现象, 这就是我所说的SeaJS中的"懒执行".

总之,如果说 RequireJS 是 Prototype 类库的话,则 Sea.js 致力于成为 jQuery 类库。

ES6 Modules

ES6正式提出了内置的模块化语法,我们在浏览器端无需额外引入requirejs来进行模块化。

ES6中的模块有以下特点:

  • 模块自动运行在严格模式下
  • 在模块的顶级作用域创建的变量,不会被自动添加到共享的全局作用域,它们只会在模块顶级作用域的内部存在;
  • 模块顶级作用域的 this 值为 undefined
  • 对于需要让模块外部代码访问的内容,模块必须导出它们

定义模块

使用export关键字将任意变量、函数或者类公开给其他模块。

//导出变量
export var color = "red";
export let name = "cz";
export const age = 25;

//导出函数
export function add(num1,num2){
    return num1+num2;
}

//导出类
export class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }
}

function multiply(num1, num2) {
    return num1 * num2;
}

//导出对象,即导出引用
export {multiply}
           

重命名模块

重命名想导出的变量、函数或类的名称

function sum(num1, num2) {
    return num1 + num2;
}

export {sum as add}
           

这里将本地的sum函数重命名为add导出,因此在使用此模块的时候必须使用add这个名称。

导出默认值

模块的默认值是使用 default 关键字所指定的单个变量、函数或类,而你在每个模块中只能设置一个默认导出。

export default function(num1, num2) {
    return num1 + num2;
}
           

此模块将一个函数作为默认值进行了导出, default 关键字标明了这是一个默认导出。此函数并不需要有名称,因为它就代表这个模块自身。对比最前面使用export导出的函数,并不是匿名函数而是必须有一个名称用于加载模块的时候使用,但是默认导出则无需一个名字,因为模块名就代表了这个导出值。

也可以使用重命名语法来导出默认值。

function sum(num1, num2) {
    return num1 + num2;
}

export { sum as default };
           

加载模块

在模块中使用import关键字来导入其他模块。

import 语句有两个部分,一是需要导入的标识符,二是需导入的标识符的来源模块。此处是导入语句的基本形式:

import { identifier1,identifier2 } from "./example.js"
           
  • 大括号中指定了从给定模块导入的标识符
  • from指明了需要导入的模块。模块由一个表示模块路径的字符串来指定。

当从模块导入了一个绑定时,你不能在当前文件中再定义另一个同名变量(包括导入另一个同名绑定),也不能在对应的 import 语句之前使用此标识符,更不能修改它的值。

导入单个绑定

如果一个模块只导出了一个函数(或变量或类),或者导出了多个接口但是只选择导入其中的一个,那么就可以写成下面单个导入的模式:

import {sum} from './example.js'
           

导入多个绑定

从一个模块中导入多个绑定:

import {sum,multiply} from './example.js'
           

完全导入一个模块

还有一种情况,就是将整个模块当做单一对象导入,该模块的所有导出都会作为对象的属性存在:

import * as example from './example.js'
example.sum(1,2);
example.multiply(2,3);
           

在此代码中, example.js 中所有导出的绑定都被加载到一个名为 example 的对象中,具名导出( sum() 函数、 multiple() 函数)都成为 example 的可用属性。

这种导入格式被称为命名空间导入,这是因为该 example 对象并不存在于 example.js 文件中,而是作为一个命名空间对象被创建使用,其中包含了 example.js 的所有导出成员。

然而要记住,无论你对同一个模块使用了多少次 import 语句,该模块都只会被执行一次。

在导出模块的代码执行之后,已被实例化的模块就被保留在内存中,并随时都能被其他 import 所引用.

import { sum } from "./example.js";
import { multiply } from "./example.js";
import { magicNumber } from "./example.js";
           

尽管此处的模块使用了三个 import 语句,但 example.js 只会被执行一次。若同一个应用中的其他模块打算从 example.js 导入绑定,则那些模块都会使用这段代码中所用的同一个模块实例。

重命名导入

与导出相同,我们同样可以重命名导入的绑定:

import { sum as add} from './example.js'
           

导入默认值

如果一个模块导出了默认值,那么可以这样导入默认值:

import sum from "./example.js";
           

这个导入语句从 example.js 模块导入了其默认值。注意此处并未使用花括号,与之前在非默认的导入中看到的不同。本地名称 sum 被用于代表目标模块所默认导出的函数,因此无需使用花括号。

如果一个模块既导出了默认值、又导出了一个或更多非默认的绑定的模块:

export let color = "red";

export default function(num1, num2) {
    return num1 + num2;
}
           

可以像下面这样使用一条import语句来导入它的所有导出绑定:

import sum,{color} from "./example.js"
           

逗号将默认的本地名称与非默认的名称分隔开,后者仍旧被花括号所包裹。

要记住在 import 语句中默认名称必须位于非默认名称之前。

导入的再导出

有时想在当前的模块中将已导入的内容再导出去,可以像下面这样写:

import {sum} from './example.js'
……
export {sum}
           

但是有一种更简洁的方法:

export {sum} from './example.js'
           

同样可以重命名:

export { sum as add } from "./example.js";
           

也可以使用完全导出:

export * from "./example.js";
           

限制

export 与 import 都有一个重要的限制,那就是它们必须被用在其他语句或表达式的外部,而不能使用在if等代码块内部。原因之一是模块语法需要让 JS 能静态判断需要导出什么,正因为此,你只能在模块的顶级作用域使用 export与import。

# 兼容

AMD规范允许输出的模块兼容CommonJS规范,这时define方法需要写成下面这样:

define(function(require,exports,module){
    var someModule = require("someModule");
    var anotherModule = require("anotherModule");
    ……
    exports.asplode = function(){

    }
})
           

参考资料

  • SeaJS与RequireJS的异同
  • CommonJS、requirejs、ES6的对比
  • SeaJS与RequireJS最大的区别
  • 阮一峰教程