前端模块化详解

模块化的目的是使代码可以重用,模块化是一种处理复杂系统,将其分解为更好的可管理模块的方式。简单来说就是解耦,简化开发,一个模块就是实现特定功能的文件,可以更方便地使用别人的代码,想要什么功能,就加载什么模块。

早期,JavaScript 一直没有模块体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼接起来。

其他语言都有这项功能,比如 Ruby 的 require、Python 的 import,甚至就连 CSS 都有 @import,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。

为了实现模块化,达到代码复用的目的,在编写模块时候,需要遵循相同的规范。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD/CMD 两种。前者用于服务器,后者用于浏览器。

CommonJS

概述

Node.js 是 CommonJS 规范的主要实践者,它有四个重要的环境变量为模块化的实现提供支持:moduleexportsrequireglobal。实际使用时,用module.exports定义当前模块对外输出的接口(不推荐直接用 exports),用require加载模块。

注意: CommonJS 并不是 Node 发明的,Node 只是按照该规范做了一套实现。

npm 生态让 node 有了自己的模块仓库,各种类库的不断支持让我们也有了更多选择。CommonJS 一开始就提供了对 npm module 的支持,在路径查找的时候内部配置了对 node_modules 文件夹的查找支持。

模块化方案

  • 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

    1
    2
    3
    4
    5
    // example.js
    var x = 5;
    var addX = function (value) {
    return value + x;
    };

    上面代码中,变量x和函数addX,是当前文件example.js私有的,其他文件不可见。

    如果想在多个文件分享变量,必须定义为global对象的属性。

    1
    global.warning = true;

    上面代码的warning变量,可以被所有文件读取。当然,这样写法是不推荐的。

  • 每个模块内部,module 变量代表当前模块,它是一个对象。

    CommonJS 规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。

    1
    2
    3
    4
    5
    6
    var x = 5;
    var addX = function (value) {
    return value + x;
    };
    module.exports.x = x;
    module.exports.addX = addX;

    上面代码通过module.exports输出变量x和函数addX

    require方法用于加载模块。

    1
    2
    3
    4
    var example = require('./example.js');

    console.log(example.x); // 5
    console.log(example.addX(1)); // 6
  • module 的 exports 属性(即 module.exports)是对外的接口;加载某个模块,其实是加载该模块的 module.exports 属性如果文件中没有 exports 属性,那么外部引用不到任何东西。

    1
    2
    3
    4
    5
    6
    var EventEmitter = require('events').EventEmitter;
    module.exports = new EventEmitter();

    setTimeout(function() {
    module.exports.emit('ready');
    }, 1000);

    上面模块会在加载后1秒后,发出ready事件。其他文件监听该事件,可以写成下面这样。

    1
    2
    3
    4
    var a = require('./a');
    a.on('ready', function() {
    console.log('module a is ready');
    });

    为了方便,Node为每个模块提供一个exports变量,指向module.exports。这等同在每个模块头部,有一行这样的命令。

    1
    var exports = module.exports;

    造成的结果是,在对外输出模块接口时,可以向exports对象添加方法。

    1
    2
    3
    4
    5
    6
    7
    exports.area = function (r) {
    return Math.PI * r * r;
    };

    exports.circumference = function (r) {
    return 2 * Math.PI * r;
    };

    注意:,不能直接将exports变量指向一个值,因为这样等于切断了exportsmodule.exports的联系。

    1
    exports = function(x) {console.log(x)};

    上面这样的写法是无效的,因为exports不再指向module.exports了。

    下面的写法也是无效的。

    1
    2
    3
    4
    5
    exports.hello = function() {
    return 'hello';
    };

    module.exports = 'Hello world';

    上面代码中,hello函数是无法对外输出的,因为module.exports被重新赋值了。

    这意味着,如果一个模块的对外接口,就是一个单一的值,不能使用exports输出,只能使用module.exports输出。

    1
    module.exports = function (x){ console.log(x);};

    如果你觉得,exportsmodule.exports之间的区别很难分清,一个简单的处理方法,就是放弃使用exports,只使用module.exports

  • 使用 require 关键字加载对应的文件,也就是模块。

    require 命令的基本功能是,读入并执行一个 JavaScript 文件,然后返回该模块的 exports 对象,如果没有发现该模块,会报错。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // example.js
    var invisible = function () {
    console.log("invisible");
    }

    exports.message = "hi";

    exports.say = function () {
    console.log(message);
    }

    运行下面的命令,可以输出exports对象。

    1
    2
    3
    4
    5
    6
    var example = require('./example.js');
    example
    // {
    // message: "hi",
    // say: [Function]
    // }

    如果模块输出的是一个函数,那就不能定义在exports对象上面,而要定义在module.exports变量上面。

    1
    2
    3
    4
    5
    6
    // example2.js
    module.exports = function () {
    console.log("hello world")
    }

    require('./example2.js')()

    上面代码中,require命令调用自身,等于是执行module.exports,因此会输出 hello world。

Node 的 module 对象

Node内部提供一个Module构建函数。所有模块都是Module的实例。

1
2
3
4
5
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
// ...

每个模块内部,都有一个module对象,代表当前模块。它有以下属性:

  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.exports 表示模块对外输出的值。

下面是一个示例文件,最后一行输出module变量。

1
2
3
4
// example.js
var jquery = require('jquery');
exports.$ = jquery;
console.log(module);

执行这个文件,命令行会输出如下信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{ id: '.',
exports: { '$': [Function] },
parent: null,
filename: '/path/to/example.js',
loaded: false,
children:
[ { id: '/path/to/node_modules/jquery/dist/jquery.js',
exports: [Function],
parent: [Circular],
filename: '/path/to/node_modules/jquery/dist/jquery.js',
loaded: true,
children: [],
paths: [Object] } ],
paths:
[ '/home/user/deleted/node_modules',
'/home/user/node_modules',
'/home/node_modules',
'/node_modules' ]
}

如果在命令行下调用某个模块,比如node something.js,那么module.parent就是null。如果是在脚本之中调用,比如require('./something.js'),那么module.parent就是调用它的模块。利用这一点,可以判断当前模块是否为入口脚本。

1
2
3
4
5
6
7
8
9
if (!module.parent) {
// ran with `node something.js`
app.listen(8088, function() {
console.log('app listening on port 8088');
})
} else {
// used with `require('/.something.js')`
module.exports = app;
}

CommonJS 模块的特点

CommonJS模块的特点如下:

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

CommonJS 浏览器支持

npm 的模块都是 JavaScript 语言写的,但浏览器用不了,因为不支持 CommonJS 格式。

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

1
2
3
4
- module
- exports
- require
- global

所以就需要辅助工具来替我们完成 commonJS 代码向浏览器代码的转换。社区成熟的解析类库有 browserify,能够完美解析 commonJS。

CommonJS 格式转换工具

利用 node 开发工作工具,提高前端的工作效率,社区里解析 CommonJS 的、构建工程工具有很多, 具有代表性的有:

  • grunt
  • gulp
  • browserify
  • webpack

前端模块化因此更进一步。

grunt

gulp

browserify

Browserify 是目前最常用的 CommonJS 格式转换的工具。

Browserify 是一个编译工具,通过它可以在浏览器环境下像 nodejs 一样使用遵循 CommonJS 规范的模块化编程。浏览器没有定义 require 方法,但是 Node.js 定义了。使用 Browserify,你可以按照在 Node 中使用 require 的方式编写代码。

webpack

AMD

概述

基于 CommonJS 规范的 Node.js 出来以后,服务端的模块概念已经形成,很自然地,大家就想要客户端模块。而且最好两者能够兼容,一个模块不用修改,在服务器和浏览器都可以运行。

但是,由于一个重大的局限,使得 CommonJS 规范不适用于浏览器环境。

CommonJS 用同步的方式加载模块。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。

但是在浏览器端,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于”假死”状态。

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

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

模块化方案

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

1
require([module], callback);

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

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

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

AMD 规范中定义了两个重要的 API :

1
2
3
4
5
//定义声明模块,参数id 模块id标识(可选),参数二是一个数组(可选),依赖其他模块,最后是回调函数
define(id?,[]?,callback)

// 加载模块,参数一,是数组,指定加载的模块,参数二回调函数,模块加载完成后执行
require([module],callback)

AMD 规范 JavaScript 实现

目前,主要有两个 JavaScript 库实现了 AMD 规范: require.js curl.js

requireJS 的具体使用方法可以参考阮一峰的博客Javascript模块化编程(三):require.js的用法

CMD

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

SeaJS 使用案例

1
2
3
4
5
6
7
8
9
10
11
12
// 定义模块  myModule.js
define(function(require, exports, module) {
var $ = require('jquery.js')
$('div').addClass('active');
exports.data = 1;
});

// 加载模块
seajs.use(['myModule.js'], function(my){
var star= my.data;
console.log(star); //1
});

AMD 与 CMD 的区别

AMD 是依赖关系前置,在定义模块的时候就要声明其依赖的模块,CMD 是按需加载依赖就近,只有在用到某个模块的时候再去加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CMD
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
// 此处略去 100 行
var b = require('./b') // 依赖可以就近书写
b.doSomething()
// ...
})

// AMD 默认推荐的是
define(['./a', './b'], function(a, b) { // 依赖必须一开始就写好
a.doSomething()
// 此处略去 100 行
b.doSomething()
...
})

ES6 模块化

现在,ES6 原生支持模块化了。

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

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

1
2
3
4
5
6
7
8
// 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命令输入。

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

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

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

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

不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。目前,通过各种工具库,其实已经做到了这一点。

将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。

不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

这里就不对 ES6 模块化的语法做详细说明了,可参考ES6 模块化

补充:UMD

UMD(Universal Module Definition - 通用模块定义) 是 AMD 和 CommonJS 的糅合。

AMD 以浏览器第一原则发展异步加载模块。

CommonJS 模块以服务器第一原则发展,选择同步加载,它的模块无需包装。

UMD 先判断是否支持 Node.js 的模块(exports)是否存在,存在则使用 Node.js 模块模式;再判断是否支持 AMD(define是否存在),存在则使用 AMD 方式加载模块。

1
2
3
4
5
6
7
8
9
10
11
(function (window, factory) {
if (typeof exports === 'object') {
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
define(factory);
} else {
window.eventUtil = factory();
}
})(this, function () {
// module ...
});

参考链接

-------------本文结束感谢您的阅读-------------
0%