模块化历史
一、原始写法
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.jsrequire(['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最大的区别
- 阮一峰教程