一、模块化的发展
js文件
最早的时候,通过文件划分的形式实现模块化,将功能状态数据各自单独放到不同的 JS 文件中。 每个文件作为独立模块,引入到页面,一个script标签对应一个模块,然后调用模块化的成员。
js
<script src="module1.js"></script>
<script src="module2.js"></script>
弊端:模块与模块之间没有依赖关系,维护困难,无私有空间。项目变大时,上述问题更明显。
解决:命名空间
命名空间
每个模块暴露全局对象,模块的内容挂载到对象中。
js
window.moduleA = {
method1: function() {
console.log('moduleA#method1')
}
}
弊端:这种方式未解决模块与模块之间没有依赖关系的问题。
立即执行函数
使用立即执行函数为模块提供私有空间,通过参数的形式作为依赖声明。
js
// module-a.js
(function ($) {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
window.moduleA = {
method1: method1
}
})(jQuery)
评价:上述方式用script标签在页面引入模块,模块加载不受代码控制。时间一久维护起来麻烦。
解决:页面用引入一个JS入口文件,其余用到的模块通过代码控制。按需加载。以及制定模块化的规范。如今流行的模块规范有ES Modules,以及在ES6之前CommonJS、AMD浏览器和服务器通用的模块解决方案。
二、CommonJS、AMD、ES Modules
CommonJS
主要用于服务器端开发 (如 Node.js)。CommonJS规范通过 module.exports 导出模块,通过 require 函数加载模块。CommonJS 模块是同步 加载的,它是为了解决 JavaScript 的作用域问题而定义的模块形式,可以使每个模块它自身的命名空间中执行。
CommonJS模块规范主要分为引用、定义、标识模块三部分:
js
module.exports = function( value ){
return value * 2;
}
/**
* 模块引用
*
* 使用require()方法来引入一个模块;
* 这里引入 模块:moduleA,并复制给变量multiplyBy2;
*
*/
var multiplyBy2 = require('./moduleA');
var result = multiplyBy2(4);
/**
* 模块标识
*
* 指传给require()的参数 可以是小驼峰命名的模块名或是路径
*
* require("模块名"):当前目录下的node_modules目录中的模块
* require("路径"):指定目录的指定模块
*
*/
/**
* 模块定义
*
* module对象:module对象指的模块自身
* export属性:module对象的属性,为外部提供接口
*
*/
module.exports = function( value ){
return value * 2;
}
特点:
- CommonJS是同步加载模块,模块加载的顺序,按照其在代码中出现的顺序。
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
注意:浏览器不兼容CommonJS的根本原因,在于缺少四个Node.js环境的变量。module、exports、require、global
。所以只要能够提供这四个变量,浏览器就能加载 CommonJS 模块。
js
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 之中,这样就实现了模块的加载。
AMD
AMD(异步模块定义)是为浏览器环境设计的,因为 CommonJS 模块系统是同步加载的,当前浏览器环境还没有准备好同步加载模块的条件。
AMD 定义了一套 JavaScript 模块依赖异步加载标准,来解决同步加载的问题。它主要用于浏览器环境 ,在加载依赖模块时使用异步 方式。目前,主要有两个Javascript库实现了AMD规范:require.js和curl.js。AMD使用define函数定义模块,使用 require 函数加载模块。
使用require.js的第一步,是先去官方网站下载最新版本。下载后,假定把它放在js子目录下面,就可以加载了。 <script src="js/require.js"></script>
加载这个文件,也可能造成网页失去响应。解决办法有两个,一个是把它放在网页底部加载,另一个是写成下面这样:
<script src="js/require.js" defer async="true" ></script>
async属性表明这个文件需要异步加载,避免网页失去响应。IE不支持这个属性,只支持defer,所以把defer也写上。
加载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有点像c语言的main()函数。主模块依赖于其他模块,这时就要使用AMD规范定义的的require()函数。
js
// main.js
require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
// some code here
});
require()函数接受两个参数。第一个参数是一个数组,表示所依赖的模块,上例就是['moduleA', 'moduleB', 'moduleC'],即主模块依赖这三个模块;第二个参数是一个回调函数,当前面指定的模块都加载成功后,它将被调用。加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块。
require()异步加载moduleA,moduleB和moduleC,浏览器不会失去响应;它指定的回调函数,只有前面的模块都加载成功后,才会运行,解决了依赖性的问题。
假定主模块依赖jquery、underscore和backbone这三个模块,main.js就可以这样写:
js
require(['jquery', 'underscore', 'backbone'], function ($, _, Backbone){
// some code here
});
require.js会先加载jQuery、underscore和backbone,然后再运行回调函数。主模块的代码就写在回调函数中。
ES Modules
浏览器加载 ES6 模块,也使用<script>
标签,但是要加入type="module"
属性。
js
<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>
插入一个模块foo.js
,由于type
属性设为module
,所以浏览器知道这是一个 ES6 模块。 浏览器对于带有type="module"
的<script>
,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>
标签的defer
属性。
<script>
标签的async
属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。
ES6 模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致。
js
<script type="module">
import utils from "./utils.js";
// other code
</script>
特点:
- 模块之中,可以使用
import
命令加载其他模块(.js
后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export
命令输出对外接口。 - 模块脚本自动采用严格模式,不管有没有声明
use strict
。 - 模块之中,顶层的
this
关键字返回undefined
,而不是指向window
。也就是说,在模块顶层使用this
关键字,是无意义的。
利用顶层的this
等于undefined
这个语法点,可以侦测当前代码是否在 ES6 模块之中。
js
import utils from 'https://example.com/js/utils.js';
const x = 1;
console.log(x === window.x); //false
console.log(this === undefined); // true
- 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。
- 同一个模块如果加载多次,将只执行一次。
- 由于 ES6 输入的模块变量,只是一个"符号连接",所以这个变量是只读的,对它进行重新赋值会报错。
js
// lib.js
export let obj = {};
// main.js
import { obj } from './lib';
obj.prop = 123; // OK
obj = {}; // TypeError
export
通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。
js
// mod.js
function C() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.show = function () {
console.log(this.sum);
};
}
export let c = new C();
// x.js
import {c} from './mod';
c.add();
// y.js
import {c} from './mod';
c.show();
// main.js
import './x';
import './y';
现在执行main.js
,输出的是1
。
css
$ babel-node main.js
1
三、常见面试题
ES6 模块与 CommonJS 模块的差异
- 三个重大差异
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
- CommonJS 模块的
require()
是同步加载模块,ES6 模块的import
命令是异步加载,有一个独立的模块依赖的解析阶段。
解释:
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
CommonJS 模块输出的是值的拷贝,一旦输出一个值,模块内部的变化就影响不到这个值。
js
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// main.js
var mod = require('./lib');
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
lib.js
模块加载以后,它的内部变化就影响不到输出的mod.counter
了。这是因为mod.counter
是一个原始类型的值,会被缓存。
js
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
get counter() {
return counter
},
incCounter: incCounter,
};
//结果
$ node main.js
3
4
ES6 的import
有点像 Unix 系统的"符号连接",原始值变了,import
加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
js
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
CommonJS 加载的是一个对象(即module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
其他区别:
- ESModule在编译期间会将所有import提升到顶部,CommonJs不会提升require。
- CommonJS中顶层的this指向这个模块本身,而ESModule中顶层this指向undefined。
- 两者导入导出语法不同,CommonJS通过module.exports(或exports导出)require导入;ESModule则是export导出,import导入。
- CommonJS 模块的重要特性是加载时执行,即脚本代码在
require
的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。
js
//a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
//b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
a.js
脚本先输出一个done
变量,然后加载另一个脚本文件b.js
。注意,此时a.js
代码就停在这里,等待b.js
执行完毕,再往下执行。b.js
执行到第二行,就会去加载a.js
,这时,就发生了"循环加载"。系统会去a.js
模块对应对象的exports
属性取值,可是因为a.js
还没有执行完,从exports
属性只能取回已经执行的部分,而不是最后的值。上述代码结果是
js
$ node main.js
在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true
ES6 处理"循环加载"与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
js
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
结果:
js
$ node --experimental-modules a.mjs
b.mjs
ReferenceError: foo is not defined
首先,执行a.mjs
以后,引擎发现它加载了b.mjs
,因此会优先执行b.mjs
,然后再执行a.mjs
。接着,执行b.mjs
的时候,已知它从a.mjs
输入了foo
接口,这时不会去执行a.mjs
,而是认为这个接口已经存在了,继续往下执行。执行到第三行console.log(foo)
的时候,才发现这个接口根本没定义,因此报错。
解决这个问题的方法,就是让b.mjs
运行的时候,foo
已经有定义了。这可以通过将foo
写成函数来解决。
js
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};
//结果
$ node --experimental-modules a.mjs
b.mjs
foo
a.mjs
bar
参考:
前端模块化:CommonJS,AMD,CMD,ES6
CommonJS和AMD规范
js模块化编程之彻底弄懂CommonJS和AMD/CMD!
ECMAScript 6入门