JavaScript - 模块化

关于JavaScript中模块化的一些总结。

在发开大型软件项目时,一般需要通过模块化来管理代码。将一段有逻辑联系的代码封装成模块,其内部数据与实现是私有的,将一些公用接口暴露出去,与其他模块进行通信,最后组织成程序。

模块化的作用:

  • 避免命名冲突
  • 按需加载
  • 提高复用程度
  • 提高可维护性

软件工程提倡高内聚,低耦合

目前流行的JS模块化规范有CommonJSAMDCMDES6模块。通常将一个JS文件作为一个模块,向外暴露特定的变量和函数。

CommonJS

NodeCommonJS规范的主要实践者,通过moduleexportsrequire等关键词实现模块加载模块定义

在服务端一般模块文件都存在本地,读取速度比较快,所以使用同步加载方式一般没问题。

CommonJS同步方式加载模块。

模块引用

使用require()方法引入模块,传入的变量为模块标识符。

在Node中有三种模块类型,核心模块文件模块自定义模块

  • 核心模块: Node内置的一些模块,例如httpfspath等,模块标识符为模块名称。在Node的源码编译过程中已经将这些模块编译为二进制代码,所以加载过程很快。
  • 文件模块: 一般为用户自己定义的一些模块,一个文件就是一个模块,模块标识符为文件路径。由于指明了文件加载路径,所以加载速度较快。
  • 自定义模块: 用户使用npm等包管理工具安装的模块,一般保存在node_moudles目录下,模块标识符为模块名称。由于需要按照npm的加载规则匹配模块,所以加载过程较慢。
//main.js

var http = require('http'); //引入核心模块
var math = require('./math'); //引入文件模块
var koa = require('koa'); //引入自定义模块

console.log(math.add(1, 2)); //使用math模块,输出3
模块定义

对于文件模块,我们需要自己定义,一个文件就是一个模块。在CommonJS规范中exports对象是模块的唯一出口,定义导出的变量或方法。

//math.js

exports.add = function (a, b) { //导出add方法
return a + b;
}

exports.subtract = function (a, b) { //导出subtract方法
return a - b;
}

但是Node并不是完全按照CommonJS规范实现的,而是根据自身需要做了一些取舍,所以表现出一些特性。在Node中,我们更推荐使用module.exports来定义模块。

//math.js

function add (a, b) {
return a + b;
}

function subtract (a, b) {
return a - b;
}

module.exports = {
add: add,
subtract: subtract
};
module.exports 和 exports

在Node中,一个模块(文件)就是一个闭包,通过闭包机制实现了命名空间。

(function (exports, require, module, __filename, __dirname) {
//code
})();

每执行一个文件,就会自动创建一个module对象,而module.exports是其中的一个属性。所以module.exports才是真正的模块对象,exports只是对它的一个引用。

因此,使用module.exports可以对该变量重新赋值,而使用exports不能进行重新赋值,只能对exports下的属性进行赋值,如exports.add = add

ES6模块

ES6标准在语法层面上实现了模块化,通过使用importexport实现模块加载模块定义。由于是标准规范,使用在客户端和服务端都可以使用,一般是配合webpack等打包工具进行管理,因为现阶段ES6还需通过babel进行编码。

不同于其他三种模块化方式,ES6模块是在编译过程中加载模块,而不是动态地引入一个对象。

模块定义

使用export命令导出模块,也可以使用export default指定默认输出模块。

//math.js

function add (a, b) {
return a + b;
}

function subtract (a, b) {
return a - b;
}

export {add, subtract} //输出函数
模块引用

使用import命令导入模块,可以使用解构赋值的方式导入。

import {add, subtract} from './math';  //引入add和subtract函数

console.log(add(1, 2)); //使用add函数,输出3
CommonJS 和 ES6模块

摘自 - 前端模块化:CommonJS,AMD,CMD,ES6

CommonJS模块输出的是一个值的拷贝,ES6模块输出的是值的引用
  • CommonJS模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
  • ES6模块的运行机制与CommonJS不一样。JS引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6import有点像Unix系统的符号连接,原始值变了,import加载的值也会跟着变。因此,ES6模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
CommonJS模块是运行时加载,ES6模块是编译时输出接口
  • 运行时加载: CommonJS模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
  • 编译时加载: ES6模块不是对象,而是通过export命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。

CommonJS加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而ES6模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

AMD

require.jsAMD规范的主要实践者,通过define定义模块、require引入模块。

在客户端,一般是远程加载模块,受网络限制,使用异步方式更合理。

AMD异步方式加载模块,推崇依赖前置,提前执行,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

模块引用

首先需要在HTML中使用<script>引入require.js和主模块。

// HTML
<script src="js/require.js" data-main="js/main"></script> //引入require.js 和 main.js

使用require.config()引入模块文件,可以设置路径前缀、设置模块名称。再使用require()使用模块,如下方式设置别名。

//main.js

//模块引入
require.config({
baseUrl: "js/lib", //路径前缀
paths: {
"jquery": "jquery.min", //实际路径为js/lib/jquery.min.js
"underscore": "underscore.min",
"math": "math"
}
});

//模块使用
require(["jquery", "underscore", "math"], function($, _, math){ //使用别名
//code...
$("body").addClass("lock-screen"); //通过$别名使用jQuery
console.log(math.add(1, 2)); //通过math别名使用math模块,输出3
});
模块定义

使用define()方法定义一个模块,模块内容放在回调函数中,并通过return抛出。

// math.js

// 定义math模块
define(function () {
function add(x, y) {
return x + y;
}

function subtract (a, b) {
return a - b;
}

return {
add: add,
subtract: subtract
};
});

// 定义一个依赖jQuery的模块
define(['jquery'],function($){

function setClass() {
$("body").addClass("lock-screen");
}

return {
setClass: setClass
};
})

CMD

sea.jsCMD规范的主要实践者,CMD吸收了CommonJSAMD

CMD异步方式加载模块,推崇依赖就近,延迟执行,在代码中引入模块并运行。

模块引用

首先需要在HTML中使用<script>引入sea.js和主模块。

// HTML
<script src="js/sea.js" data-main="js/main"></script> //引入sea.js 和 main.js

使用define()创建作用域,在回调函数(传入参数reuqire)中通过require()引入模块。

// main.js

define(function (require) {
var math = require('./math');
console.log(math.add(1, 2)); //使用math模块,输出3
})
模块定义
// math.js

define(function (require, exports, module) {
function add(x, y) {
return x + y;
}

function subtract (a, b) {
return a - b;
}

module.exports = {
add: add,
subtract: subtract
}
})

命名空间

JS的全局变量其实是定义在一个window对象下的,例如var history = 1;其实是var window.history = 1;。然而,window对象又有许多的内置属性,如果我们自定义的属性刚好和内置属性重名的话,就会起冲突。

因此,为了避免变量冲突,就有必要引入命名空间来隔离变量作用域,其实是用到闭包自触发函数来模拟的。许多模块化的库都是基于这一原理。

自触发函数

先来看看自触发函数(IIFE),在函数定义后立即触发。因为立即执行,后续一般不会再调用,所以可以使用匿名函数。

// Type 1, 没有匿名,但是立即触发
start();
function start() {
//...
}

//Tpye 2
(function () {
//...
})();

//Type 3
!function () {
//...
}();

Tpye 4
;(function() {
//...
})();
命名空间

结合闭包立即执行函数,我们就可以很好的封装一个模块了。闭包用来限定作用域(命名空间),立即执行函数避免全局变量名字冲突。

jQuery就是用于以上两种机制,暴露出了一个$,来操作内部方法。

以下例子,封住了user模块,解决变量冲突。

(function(window) {
var name;

function getName() {
return name;
}

function setName(name) {
name = name;
}

window.user = { getName, setName };
})(window);

user.setName('zhaoo');
console.log(user.getName()); // zhaoo

此外,ES6中的letconst可以产生一个块级作用域,其实也是用到了该原理。用babel编译后是如下ES5代码,是不是一目了然。

// ES6
{
let a = "abc";
};
console.log(a); // Error, a is not defind
// ES5
(function(){
var a = "abc";
})();
console.log(a); // Error, a is not defind

命名空间模式也存在许多不足,一般在ES5年代比较多见。现在一般都是用模块化的方式直接引入文件模块了。

参考文献

前端模块化:CommonJS,AMD,CMD,ES6

查看评论