JavaScript 中的模块化
封面图说明:© Michael J. Kochniss | mjk-photo.de | instagram.com/mjk_photo
在早期,JavaScript 程序主要用来实现一些页面上的动画或者简单的交互,所以程序不会太复杂,页面也不会有太多的 JavaScript 代码,前端在 JavaScript 程序中还没有模块化开发的需要。但是随着应用的复杂度增加,为增强代码的可维护性,提升应用的加载性能,前端开发对 JavaScript 模块化开发的需求愈发强烈。本文将按照 JavaScript 模块化开发的演进历程逐一介绍在社区被采用过的不同模块化方案。
什么是模块化
模块化是一种将系统分离成独立功能部分的方法,可将系统分割成独立的功能部分,严格定义模块接口、模块间具有透明性。
简单来说就是我们在开发应用的时候,通常会根据不同的功能分割成不同的模块来开发,开发完成后再按照特定的方式组合成一个整体,最终完成整个应用系统功能的开发。
JavaScript 的模块化开发,能帮助我们解决在开发过程中遇到的全局变量污染问题,函数命名冲突问题,以及依赖关系不好处理的问题,还有代码复用性问题。
无模块化开发
最原始的 JavaScript 程序开发就是没有模块化的概念的,在一个页面中,不管有多少功能,要写多少代码,我们都把代码写到一个或者几个JS 文件中,然后页面通过 script 标签引入相关 JS 文件。当页面功能比较复杂的时候,或者需要不同开发者协同进行同一功能开发的时候,就难免会遇到全局变量污染和命名冲突的问题,再者就是功能非常复杂的时候,写的代码量也会比较多,从外部引入到页面的 JS 文件数量也越来越多,如果没有管理和组织好代码以及代码的依赖关系,后期维护将变得非常被动。
立即执行函数(IIFE)
为防止 JavaScript 代码中的全局变量污染和命名冲突的问题,开发人员在 JavaScript 中引入了自执行函数,也就是大家经常都能在面试中都会被问到的闭包。什么是闭包呢?一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
通过闭包可以形成一个独立的作用域,其里面声明的变量仅在该作用域内,在闭包外部是不能访问和操作的,从而达到了私有变量的目的,也就解决了前文提到的全局变量污染和命名冲突的问题,但是闭包内部可以通过向外暴露一些方法,以让闭包外部能访问到闭包内的变量。另外,如果闭包内部想要访问全局变量或者其他模块里面的变量,这时可以通过立即执行函数的参数传递到闭包内部。
( function( global, factory ) {
if ( typeof module === "object" && typeof module.exports === "object" ) {
module.exports = global.document ?
factory( global, true ) :
function( w ) {
if ( !w.document ) {
throw new Error( "jQuery requires a window with a document" );
}
return factory( w );
};
} else {
factory( global );
}
} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) {
} );
看上面一段代码,是我们熟悉的 jQuery 源码中的一部分,这个闭包函数有两个行参,第一个参数是当 typeof window !== "undefined" 的时候将 window 作为实参传递进去,否则将 this 作为实参传递进去,第二个参数则是一个匿名函数,可见通过闭包来隔离变量的使用是非常广泛的。
命名空间
除了使用立即执行函数可以解决变量污染的问题,还可以使用命名空间的方式来解决,简单来说,就是我们可以通过创建一个简单的对象字面量来保存所有的相关变量和函数,利用对象字面量模拟命名空间的作用。
var NAMESPACE = {
person: function(name) {
this.name = name;
this.getName = function() {
return this.name;
}
}
};
CommonJS
CommonJS规范加载模块是同步的,也就是说,加载完成才执行后面的操作。Node.js主要用于服务器编程,模块都是存在本地硬盘中,加载比较快,所以Node.js采用CommonJS规范。
CommonJS规范分为三部分:module(模块标识)、require(模块引用)、exports(模块定义)。 module变量在每个模块内部,就代表当前模块; exports属性是对外的接口,用于导出当前模块的方法或变量;require()用来加载外部模块,读取并执行js文件,返回该模块的exports对象。
CommonJS 有以下几个特点:
- 所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,再次加载时,会直接读取缓存结果。如果想让模块再次运行,则必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
定义模块:
// config.js
var api = 'https://github.com/trending?since=weekly&_pjax=%23js-pjax-container';
var config = {
api: api,
};
module.exports = config;
引用模块:
// utils.js
var config = require('./config');
var utils = {
request() {
console.log(config.api);
}
};
module.exports = utils;
AMD 模块化方案
随着前端业务的发展,代码也变得越来越复杂,通过简单的立即执行函数或者命名空间的方式来组织代码已经不太适用了。前端需要有一种更清晰、简单的方式处理最开始遇到的全局变量命名冲突的问题以及 JS 代码间的依赖关系。社区中不同的 JS 模块化规范陆续出现,其中使用比较流行的是 AMD 和 CMD,先来介绍一下 AMD 模块化方案。
AMD是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。它是一个在浏览器端模块化开发的规范,由于不是JavaScript原生支持,使用AMD规范进行页面开发需要用到对应的库函数(RequireJS库),实际上AMD 是 RequireJS 在推广过程中对模块定义的规范化的产出。
AMD采用require()语句加载模块,它要求两个参数:
require([module], callback);
第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数callback,则是加载成功之后的回调函数。
定义模块:
// a.js
define(function (){
return {
a:'hello world'
}
});
引用模块:
// b.js
require(['./a.js'], function (moduleA){
console.log(moduleA.a); // 打印出:hello world
});
CMD 模块化方案
CMD是 ”Common Module Definition”,称为 通用模块加载规范 。一般也是用在浏览器端。浏览器端异步加载库 Sea.js 实现的就是CMD规范。它与AMD很类似,不同点在于:AMD推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。
定义模块:
define(function(require, exports, module){
//引入依赖模块(同步)
var module2 = require('./module2')
//引入依赖模块(异步)
require.async('./module3', function (m3) {
})
//暴露模块
exports.xxx = value
})
引用模块:
define(function (require) {
var m1 = require('./module1')
var m4 = require('./module4')
m1.foo()
m4.bar()
})
ES6 Module
在ES6没有引入模块化概念之前,为了解决js模块化开发的问题,社区提出了CommonJS,AMD,CMD模块化开发方案;ES6模块化汲取CommonJS和AMD的优点,语法简洁,支持异步加载,旨在为浏览器和服务器提供通用的模块化解决方案,但从长远来看,未来无论是在web端还是基于node的服务端或者桌面端,模块化开发规范都将统一使用 ES6 Module。
ES6中模块的定义:ES6新增了两个关键字:export和import。export用于把模块里的内容暴露出来,import用于引入模块提供的功能。
// test.js
let name = '前端农民工';
function getName() {
return name;
}
function setName(n) {
name = n;
}
export {
name,
getName,
setName,
}
ES6中模块的加载:使用 import 语法加载模块,如下:
import{ getName } from './test'
getName();
注意:可以使用export default命令,为模块指定默认输出,一个模块只能有一个默认输出,所以export default只能使用一次。
ES6模块运行机制:ES6模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),变量不会被缓存,而是成为一个指向被加载模块的引用。等脚本执行时,根据只读引用,到被加载的那个模块中去取值。
水平有限,文中难免有不足之处,欢迎大家关注我的微信公众号。