浅谈 JS 模块化

896 阅读11分钟

你是否对前端模块化的概念还傻傻分不清,特别是js模块化,闭包,CMD,AMD, commonJS,seat.js, requirejs和ES6 Modules,这些概念你都清晰了吗?如果没有的话,那啃一啃这篇文章对你或许有帮助。

众所周知。js由于历史原因并没有模块的概念,Javascript不是一种模块化编程语言,它不支持"类"(class),更遑论"模块"(module)了。(正在制定中的ECMAScript标准第六版,将正式支持"类"和"模块",但还需要很长时间才能投入实用)。 自从ajax带来了web2.0概念后,js代码已经和以前大不相同了,2009年HTML5兴起后,前端代码的行数已经呈现井喷式发展,随着代码量的增加,模块的缺失的缺点日益凸显,Javascript模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。这方面Javascript社区做了很多努力和探索,在现有的运行环境中,实现"模块"的效果。

模块化的概念

这一概念并不是js独有,而是借鉴了其他语言的设计思想,下面是百度百科对模块的定义:

模块,又称构件,是能够单独命名并独立地完成一定功能的程序语句的集合(即程序代码和数据结构的集合体)

提炼关键字:独立、集合、完成一定功能。 上面的提炼,再从其他语言的实现中借鉴下,总结起来,我们期待的模块有如下特性:

  • 独立性——能够独立完成一个功能,不受外部环境的影响
  • 完整性——完成一个特定功能
  • 集合性——一组语句的集合
  • 依赖性——可以依赖已经存在的模块
  • 被依赖——可以被其他模块依赖
  • 其实我们想要的就是一个独立的模块,并能引用依赖,及被依赖。

C语言的库和头文件(include),java的包(import)。这在其他语言中都是原生支持的特性,在js中却是没有的。为了弥补这种缺陷,Javascript开发者们做了很多尝试,以期达到一目的。

js模块化发展

模块化是一个语言膨胀的必经之路,它能够帮助开发者拆分和组织代码。

Module模式

函数

仅从定义来看,那么一个函数即可成为一个模块(独立,集合,完成一个功能),那我们就先从最原始的探索开始,也许不经意间,我们早已在使用模块了。

//最简单的函数,可以称作一个模块
function add(x, y) {
	return x + y;
}

这种做法的缺点很明显:"污染"了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。

闭包

在模块化规范形成之前,JS开发者使用Module设计模式来解决JS全局作用域的污染问题。Module模式最初被定义为一种在传统软件工程中为类提供私有和公有封装的方法。在JavaScript中,Module模式使用匿名函数自调用 (闭包)来封装,通过自定义暴露行为来区分私有成员和公有成员。

let myModule = (function (window) {
    let moduleName = 'module'  // private
    // public
    function setModuleName(name) {
      moduleName = name
    }
    // public
    function getModuleName() {
      return moduleName
    }
    return { setModuleName, getModuleName }  // 暴露行为
})(window)

上面例子是Module模式的一种写法,它通过闭包的特性打开了一个新的作用域,缓解了全局作用域命名冲突和安全性的问题。 但这种实现其实并不完美,仍然需要手动维护依赖的顺序。典型的场景就是上面的jquery必须先于我们的代码引入,不然会报引用错误,这显然不是我们想要的。 开发者并不能够用它来组织和拆分代码,于是乎便出现了以此为基石的模块化规范。

模块化规范

有了模块,我们就可以更方便地使用别人的代码,想要什么功能,就加载什么模块。但是,这样做有一个前提,那就是大家必须以同样的方式编写模块,否则你有你的写法,我有我的写法,岂不是乱了套!考虑到Javascript模块现在还没有官方规范,这一点就更重要了。目前,通行的Javascript模块规范共有两种:CommonJS和AMD。

1、 CommonJS

2009年,美国程序员Ryan Dahl创造了node.js项目,将javascript语言用于服务器端编程。这标志"Javascript模块化编程"正式诞生。在这之前js 只被用于浏览器编程的环境中,因为网页编程的复杂度有限,没有模块化也不是什么大不了的事,但是在服务端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。

CommonJS主要用在Node开发上,每个文件就是一个模块,每个文件都有自己的一个作用域。通过module.exports暴露public成员。例如:

let x = 1;
function add() {
  x += 1;
  return x;
}
module.exports.x = x;
module.exports.add = add;

此外,CommonJS通过require()引入模块依赖,require函数可以引入Node的内置模块、自定义模块和npm等第三方模块。

// 文件名:main.js
let xm = require('./x.js');
console.log(xm.x);  // 1
console.log(xm.add());  // 2
console.log(xm.x);   // 1

我们说,Module模式是模块化规范的基石,CommonJS也是对Module模式的一种封装。我们完全可以用Module模式来实现上面的代码效果:

let xModule = (function (){
  let x = 1;
  function add() {
    x += 1;
    return x;
  }
  return { x, add };
})();
let xm = xModule;
console.log(xm.x);  // 1
console.log(xm.add());  // 2
console.log(xm.x);   // 1

通过Module模式模拟的CommonJS原理,我们就可以很好的解释CommonJS的特性了。因为CommonJS需要通过赋值的方式来获取匿名函数自调用的返回值,所以require函数在加载模块是同步的。这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。CommonJS模块的加载机制局限了CommonJS在客户端上的使用,因为通过HTTP同步加载CommonJS模块是非常耗时的。

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

2、 CMD

CMD是(Common Module Definition)公共模块定义 的缩写。CMD可能是在commonjs之后抽象出来的一套模块化语法定义和使用的标准。

CMD规范参照commonjs中的方式,定义模块的方式如下:

define(function(require, exports, module) {
  //  同步加载模块
  var a = require('./a');
  a.doSomething();
  // 异步加载一个模块,在加载完成时,执行回调
  require.async(['./b'], function(b) {
    b.doSomething();
  });
  // 对外暴露成员
  exports.doSomething = function() {};
});
// 使用模块
seajs.use('path');

一个文件就是一个模块,文件名就是模块的名字,使用模块的方法也和commonjs中一致,只需require就好了,模块名字可省略后缀。

//使用event.js模块
var ec = require('event');

CMD的典型实现就是seajs,应用的很广泛。

3、 AMD

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

AMD的核心是define函数。调用define函数最常见的方式是传入三个参数——模块名、该模块依赖的模块标识符数组、以及将会返回该模块定义的工厂函数。

// 定义calculator(计算器)模块。此模块依赖其他模块
define('calculator', ['adder'], function(adder) {
    // 返回具有add方法的匿名对象。——译注
    return {
        add: function(n1, n2) {
            /*
             * 实际调用的是adder(加法器)模块的add方法。
             * 而且adder模块已在前一参数['adder']中指明了。——译注
             */
            return adder.add(n1, n2);
        }
    };
});

AMD也采用require()语句加载模块,但是不同于CommonJS,它要求两个参数: require([module], callback) 第一个参数[module],是一个数组,里面的成员就是要加载的模块;第二个参数callback,则是加载成功之后的回调函数。

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

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

4、 require.js

require.js是AMD异步模块编程思想的实现,或者说 AMD 是 RequireJS 在推广过程中对模块定义的规范化产出。

require.js的诞生,就是为了解决这两个问题:

  • (1)实现js文件的异步加载,避免网页失去响应;
  • (2)管理模块之间的依赖性,便于代码的编写和维护。

4.1、 require.js 加载

使用require.js的第一步,是先去官方网站下载最新版本。下载后,假定把它放在js子目录下面,就可以加载了。

<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。

4.2、 主模块编写

假定主模块依赖jquery、underscore和backbone这三个模块,main.js就可以这样写:

require(['jquery', 'underscore', 'backbone'], function ($, _, Backbone){
  // some code here
});

require.js会先加载jQuery、underscore和backbone,然后再运行回调函数。主模块的代码就写在回调函数中。

4.3、模块的加载

使用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"
    }
  });

4.4 加载非规范性模块

理论上,require.js加载的模块,必须是按照AMD规范、用define()函数定义的模块。但是实际上,虽然已经有一部分流行的函数库(比如jQuery)符合AMD规范,更多的库并不符合。那么,require.js是否能够加载非规范的模块呢?

这样的模块在用require()加载之前,要先用require.config()方法,定义它们的一些特征。

require.config()接受一个配置对象,这个对象除了有前面说过的paths属性之外,还有一个shim属性,专门用来配置不兼容的模块。具体来说,每个模块要定义(1)exports值(输出的变量名),表明这个模块外部调用时的名称;(2)deps数组,表明该模块的依赖性。

举例来说,underscore和backbone这两个库,都没有采用AMD规范编写。如果要加载它们的话,必须先定义它们的特征:

    require.config({
    shim: {
      'underscore':{
        exports: '_'
      },
      'backbone': {
        deps: ['underscore', 'jquery'],
        exports: 'Backbone'
      }
    }
  });

ES6 module

ES6的模块化已经不是规范了,而是JS语言的特性。随着ES6的推出,AMD和CMD也随之成为了历史。ES6模块与模块化规范相比,有两大特点:

模块化规范输出的是一个值的拷贝,ES6 模块输出的是值的引用。 模块化规范是运行时加载,ES6 模块是编译时输出接口。 模块化规范输出的是一个对象,该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,ES6 module 是一个多对象输出,多对象加载的模型。从原理上来说,模块化规范是匿名函数自调用的封装,而ES6 module则是用匿名函数自调用去调用输出的成员。

参考资料