JavaScript 模块浅谈

简介

最近几年,我们可以选择的 Javascript 组件的生态系统一直在稳步增长。虽然陡增的选择范围是极好的,但当组件混合匹配使用时就会出现很尴尬的局面。开发新手们会很快发现不是所有组件都能彼此 "和平相处"。

为了解决这个问题,两种竞争关系的模块规范 AMD 和 CommonJS 问世了,它们允许开发者遵照一种约定的沙箱化和模块化的方式来写代码,这样就能避免 "污染生态系统"。

模块化演进

本节主要来源于阮一峰老师的 模块的写法,有改动。

原始写法

模块就是实现特定功能的一组方法。

只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。

function m1(){
  //...
}

function m2(){
  //...
}
1
2
3
4
5
6
7

上面的函数 m1() 和 m2(),组成一个模块。使用的时候,直接调用就行了。

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

对象写法

为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。

var module1 = new Object({
  _count: 0,
  m1: function (){
    //...
  },
  m2: function (){
    //...
  }
});
1
2
3
4
5
6
7
8
9

上面的函数 m1() 和 m2(),都封装在 module1 对象里。使用的时候,就是调用这个对象的属性。

module1.m1();
1

但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。

module1._count = 5;
1

立即执行函数写法

使用 "立即执行函数"(Immediately-Invoked Function ExpressionIIFE),可以达到不暴露私有成员的目的。

var module1 = (function(){
  var _count = 0;
  var m1 = function(){
    //...
  };
  var m2 = function(){
    //...
  };
  return {
    m1 : m1,
    m2 : m2
  };
})();
1
2
3
4
5
6
7
8
9
10
11
12
13

使用上面的写法,向外部代码暴露必要的方法,外部代码无法读取内部的 _count 变量。

console.info(module1._count); //undefined
1

module1 就是 Javascript 模块的基本写法。下面,再对这种写法进行加工。

放大模式

如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用"放大模式"(augmentation)。

var module1 = (function (mod){
  mod.m3 = function () {
    //...
  };
  return mod;
})(module1);
1
2
3
4
5
6

上面的代码为 module1 模块添加了一个新方法 m3(),然后返回新的 module1 模块。

宽放大模式

在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上一节的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用"宽放大模式"(Loose augmentation)。

var module1 = ( function (mod){
  //...
  return mod;
})(window.module1 || {});
1
2
3
4

与 "放大模式" 相比,"宽放大模式" 就是 "立即执行函数" 的参数可以是空对象。

输入全局变量

独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。

为了在模块内部调用全局变量,必须显式地将其他变量输入模块。

var module1 = (function ($, YAHOO) {
  //...
})(jQuery, YAHOO);
1
2
3

上面的 module1 模块需要使用 jQuery 库和 YUI 库,就把这两个库(其实是两个模块)当作参数输入 module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。

AMD

随着 RequireJS 成为最流行的实现方式,AMDAsynchromous Module Definition - 异步模块定义)在前端界已经被广泛认同。

这里异步指的是不堵塞浏览器其他任务(dom构建,css渲染等),而加载内部是同步的(加载完模块后立即执行回调)。

模块定义格式如下:

define(id?, dependencies?, factory);
1
  • id:模块的名字,如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字;
  • dependencies:模块的依赖,已被模块定义的模块标识的数组字面量。依赖参数是可选的,如果忽略此参数,它应该默认为 ["require", "exports", "module"]。然而,如果工厂方法的长度属性小于3,加载器会选择以函数的长度属性指定的参数个数调用工厂方法。
  • factory:模块的工厂函数,模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。

下面是只依赖 jquery 的模块 math 的代码:

// math.js
define(['jquery'], function ($) {
  let add = (x, y) => x + y; // 方法
  return add; // 暴露公共方法
});
1
2
3
4
5

AMD 也采用 require 命令加载模块,但是不同于 CommonJS,它要求两个参数。

加载格式如下:

require([module], callback);
1

第一个参数 [module],是一个数组,里面的成员是要加载的模块,callback是加载完成后的回调函数。

如加载上面的 math.js :

// main.js
require(['math'], function(math) {
  alert(math.add(1, 1));
})
1
2
3
4

当 require() 函数加载 math 模块的时候,就会先加载 dependenceModule 模块。当有多个依赖时,就将所有的依赖都写在 define() 函数第一个参数数组中,所以说 AMD 是依赖前置的。

还有稍微复杂点的例子,下面的代码依赖了多个组件并且暴露多个方法:

// foo.js
define(['jquery', 'underscore'], ($, _) => {
  // 方法
  function a(){}; // 私有方法,因为没有被返回(见下面)
  function b(){}; // 公共方法,因为被返回了
  function c(){}; // 公共方法,因为被返回了
  // 暴露公共方法
  return { b: b, c: c }
});
1
2
3
4
5
6
7
8
9

定义的第一个部分是一个依赖数组,第二个部分是回调函数,只有当依赖的组件可用时(像RequireJS这样的脚本加载器会负责这一部分,包括找到文件路径)回调函数才被执行。

注意,依赖组件和变量的顺序是一一对应的(例如,jquery->$, underscore->_)。

同时注意,我们可以用任意的变量名来表示依赖组件。假如我们把$改成$$,在函数体里面的所有对jQuery的引用都由$变成了$$。

还要注意,最重要的是你不能在回调函数外面引用变量$和_,因为它相对其它代码是独立的。这正是模块化的目的所在!

require.js 的实现

require.js 是实现 AMD 标准的库,主要为了解决以下两个问题:

(1)实现js文件的异步加载,避免网页失去响应;

(2)管理模块之间的依赖性,便于代码的编写和维护。

引入 require.js

<script src="js/require.js" defer async="true"  data-main="js/main"></script>
1

async属性表明这个文件需要异步加载,避免网页失去响应。IE不支持这个属性,只支持defer,所以把defer也写上。

data-main属性的作用是,指定网页程序的主模块。在上例中,就是js目录下面的main.js,这个文件会第一个被require.js加载。由于require.js默认的文件后缀名是js,所以可以把main.js简写成main。

编写入口文件 main.js

main.js 被称为"主模块",意思是整个网页的入口代码。它有点像C语言的 main() 函数,所有代码都从这儿开始运行。

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

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

模块配置

上面的示例中,主模块的依赖模块是['jquery', 'underscore', 'backbone']。默认情况下,require.js假定这三个模块与main.js在同一个目录,文件名分别为jquery.js,underscore.js和backbone.js,然后自动加载。

使用 require.config() 方法,可以对模块的加载行为进行自定义。require.config() 就写在主模块(main.js)的头部。参数就是一个对象,这个对象的paths属性指定各个模块的加载路径。

require.config({
  paths: {
    "jquery": "lib/jquery.min",
    "underscore": "lib/underscore.min",
    "backbone": "lib/backbone.min"
  }
});
1
2
3
4
5
6
7

以上代码,将从 main.js 所在路径查找,分别查找 lib 目录下的 jquery.min.jsunderscore.min.jsbackbone.min.js,并将其命名为 jqueryunderscorebackbone

也可直接提取出基目录(baseUrl)

require.config({
  baseUrl: "lib",
  paths: {
    "jquery": "jquery.min",
    "underscore": "underscore.min",
    "backbone": "backbone.min"
  }
});
1
2
3
4
5
6
7
8

如果路径在服务器中,也可直接使用绝对路径:

require.config({
  paths: {
    "jquery": "https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min"
  }
});
1
2
3
4
5

加载非规范的模块

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

回答是可以的。

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

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

require.config({
  shim: {
    'underscore':{
      exports: '_'
    },
    'backbone': {
      deps: ['underscore', 'jquery'],
      exports: 'Backbone'
    }
  }
});
1
2
3
4
5
6
7
8
9
10
11

require.config()接受一个配置对象,这个对象除了有前面说过的paths属性之外,还有一个shim属性,专门用来配置不兼容的模块。具体来说,每个模块要定义

(1)exports值(输出的变量名),表明这个模块外部调用时的名称;

(2)deps数组,表明该模块的依赖性。

比如,jQuery的插件可以这样定义:

shim: {
  'jquery.scroll': {
    deps: ['jquery'],
    exports: 'jQuery.fn.scroll'
  }
}
1
2
3
4
5
6

require.js 插件

require.js还提供一系列插件,实现一些特定的功能。

比如,domready 插件,可以让回调函数在页面 DOM 结构加载完成后再运行。

require(['domready!'], function (doc){
  // called once the DOM is ready
});
1
2
3

text 和 image 插件,则是允许 require.js 加载文本和图片文件。

define(
  [
  'text!review.txt',
  'image!cat.jpg'
  ],

  function(review, cat){
    console.log(review);
    document.body.appendChild(cat);
  }
);
1
2
3
4
5
6
7
8
9
10
11

类似的插件还有 json 和 mdown,用于加载 json 文件和 markdown 文件。

相关网站:

GitHub: require.jsrequire.js APIdownload

相关插件:

textrequire-cssrequirejs-lessrequire-lessrequire-handlebars-plugin

相关模板

volovolojs - create-templateexample-multipageexample-multipage-shim

CMD

CMDCommon Module Definition - 公共模块定义)是 SeaJS 在推广过程中对模块定义的规范化产出,对于模块的依赖,CMD 推崇依赖就近,延迟执行

模块定义格式如下:

define(factory)
1
  • factory 为函数时,表示是模块的构造方法。执行该构造方法,可以得到模块向外提供的接口。factory 方法在执行时,默认会传入三个参数:require、exports 和 module.
define((require, exports, module) => {
  module.exports = {
    fun1: () => {
      let $ = require('jquery');
      return $('#test');
    } 
  };
});
1
2
3
4
5
6
7
8

如上代码,只有当真正执行到 fun1 方法时,才会去执行 jquery。

同时 CMD 也是延自 CommonJS Modules/2.0 规范。这个规范实际上是为了 Seajs 的推广然后搞出来的。那么看看SeaJS是怎么回事儿吧,基本就知道这个规范了。

同样 Seajs 也是预加载依赖 js 跟 AMD 的规范在预加载这一点上是相同的,明显不同的地方是调用,和声明依赖的地方。AMD 和 CMD 都是用 define 和 require,但是 CMD 标准倾向于在使用过程中提出依赖,就是不管代码写到哪突然发现需要依赖另一个模块,那就在当前代码用 require 引入就可以了,规范会帮你搞定预加载,你随便写就可以了。但是 AMD 标准让你必须提前在头部依赖参数部分写好(没有写好? 倒回去写好咯)。这就是最明显的区别。

sea.js 通过 sea.use() 来加载模块。

seajs.use(id, callback?)
1

CommonJS

CommonJS 规范指的是 CommonJS Modules/1.0 规范,CommonJS是一个更偏向于服务器端的规范,由于 Node.js 被广泛认知。因为有 Browserify,它也一直被前端界广泛认同。

就像前面的格式一样,下面是用 CommonJS 规范的写法:

// file1.js
module.exports = {
  a: 1
};

// file2.js
let f1 = require('./file1');
let v = f1.a + 2;
module.exports = {
  v: v
};
1
2
3
4
5
6
7
8
9
10
11

还有更复杂的例子,下面的代码依赖了多个组件并且暴露多个方法:

// 文件名: foo.js
let $ = require('jquery');
let _ = require('underscore');
 
// methods
function a(){}; // 私有方法,因为它没在module.exports中 (见下面)
function b(){}; // 公共方法,因为它在module.exports中定义了
function c(){}; // 公共方法,因为它在module.exports中定义了

// 暴露公共方法
module.exports = {
  b: b,
  c: c
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

CommonJS 加载模块是同步的,所以只有加载完成才能执行后面的操作。像 Node.js 主要用于服务器的编程,加载的模块文件一般都已经存在本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以 CommonJS 规范比较适用。

但如果是浏览器环境,要从服务器加载模块,这是就必须采用异步模式。所以就有了 AMD CMD 解决方案。

UMD

既然 CommonJs 和 AMD 风格一样流行,似乎缺少一个统一的规范。所以人们产生了这样的需求,希望有支持两种风格的 "通用" 模式,于是通用模块规范 UMDUniversal Module Definition - 通用模块定义)诞生了。

UMD 兼容了 AMD 和 CommonJS。AMD 是浏览器优先,异步加载;CommonJS 是服务器优先,同步加载。同时还支持老式的 "全局" 变量规范 (在浏览器环境中将变量暴露到 window 下)。

既然要通用,怎么办呢?

那就先判断是否支持 AMD(define是否存在),存在则使用 AMD 的方式加载;再判断是否支持 node.js 的模块,存在就使用 node.js;如果都不支持,就将变量暴露到全局 (浏览器环境全局的 this 就是 window),这就是所谓的 UMD。

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS 之类的
    module.exports = factory(require('jquery'));
  } else {
    // 浏览器全局变量(root 接收全局的 this,即 window)
    root.returnExports = factory(root.jQuery);
  }
}(this, function ($) {
  // 方法
  function myFunc(){};

  // 暴露公共方法
  return myFunc;
}));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

保持跟上面例子一样的模式,下面是更复杂的例子,它依赖了多个组件并且暴露多个方法:

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery', 'underscore'], factory);
  } else if (typeof exports === 'object') {
    // Node, CommonJS之类的
    module.exports = factory(require('jquery'), require('underscore'));
  } else {
    // 浏览器全局变量(root 即 window)
    root.returnExports = factory(root.jQuery, root._);
  }
}(this, function ($, _) {
  // 方法
  function a(){}; // 私有方法,因为它没被返回 (见下面)
  function b(){}; // 公共方法,因为被返回了
  function c(){}; // 公共方法,因为被返回了

  // 暴露公共方法
  return {
    b: b,
    c: c
  }
}));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

ES Module

es6 通过 importexport 实现模块的输入输出。其中 import 命令用于输入其他模块提供的功能,export 命令用于规定模块的对外接口。

export

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果希望外部文件能够读取该模块的变量,就需要在这个模块内使用export关键字导出变量。如:

导出变量

// profile.js
export let a = 1;
export let b = 2;
export let c = 3;
1
2
3
4

下面的写法是等价的,这种方式更加清晰(在底部一眼能看出导出了哪些变量):

let a = 1;
let b = 2;
let c = 3;
export {a, b, c}
1
2
3
4

export命令除了输出变量,还可以导出函数或类。

导出函数

export function foo(){}
1
function foo(){}
function bar(){}

export {foo, bar as bar2}
1
2
3
4

其中上面的 as 表示给导出的变量重命名。

要注意的是,export 导出的变量只能位于文件的顶层,如果处于块级作用域内,会报错。如:

function foo() {
  export 'bar'; // SyntaxError
}
1
2
3

导出类

export default class {}
1

export 语句输出的值是动态绑定,绑定其所在的模块。

// foo.js
export let foo = 'foo';

setTimeout(function() {
  foo = 'foo2';
}, 500);
1
2
3
4
5
6
// main.js
import * as m from './foo';

console.log(m.foo); // foo
setTimeout(() => console.log(m.foo), 500); // foo2
1
2
3
4
5

import

import命令可以导入其他模块通过 export 导出的部分。

// abc.js
let a = 1;
let b = 2;
let c = 3;
export {a, b, c}
1
2
3
4
5
//main.js
import {a, b, c} from './abc';
console.log(a, b, c);
1
2
3

如果想为导入的变量重新取一个名字,使用as关键字(也可以在导出中使用)。

import {a as aa, b, c};
console.log(aa, b, c);
1
2

如果想在一个模块中先输入后输出一个模块,import 语句可以和 export 语句写在一起。

import {a, b, c} form './abc';
export {a, b, c}

// 使用连写, 可读性不好,不建议
export {a, b, c} from './abc';
1
2
3
4
5

模块的整体加载

使用 * 关键字

// abc.js
export let a = 1;
export let b = 2;
export let c = 3;
1
2
3
4
// main.js
import * as abc from './abc';
console.log(abc.a, abc.b, abc.c);
1
2
3

export default

在export输出内容时,如果同时输出多个变量,需要使用大括号{},同时导入也需要大括号。使用export defalut输出时,不需要大括号,而输入(import)export default输出的变量时,不需要大括号。

// abc.js
let a = 1, b = 2, c = 3;
export {a, b};
export default c;
1
2
3
4
import {a, b} from './abc';
import c from './abc'; // 不需要大括号
console.log(a, b, c) // 1 2 3
1
2
3

本质上,export default 输出的是一个叫做 default 的变量或方法,输入这个 default 变量时不需要大括号。

// abc.js
export {a as default};

// main.js
import a from './abc'; // 这样也是可以的

// 这样也是可以的
import {default as aa} from './abc';
console.log(aa);
1
2
3
4
5
6
7
8
9

参考资料

AMD、CMD、UMD 模块的写法

认识AMD、CMD、UMD、CommonJS

javascript模块化之CommonJS、AMD、CMD、UMD、ES6

Module 的语法 - 阮一峰 ECMAScript 6 入门

Javascript模块化编程(一):模块的写法 - 阮一峰的网络日志

Javascript模块化编程(二):AMD规范 - 阮一峰的网络日志

Javascript模块化编程(三):require.js的用法 - 阮一峰的网络日志

MIT Licensed | Copyright © 2018-present 滇ICP备16006294号

Design by Quanzaiyu | Power by VuePress | Hosted by Coding Pages