前端模块标准之CommonJS、ES6 Module、AMD、UMD介绍

模块之于程序,就像细胞之于生命体,是具有特定功能的组成单元。不同的模块负责不同的工作,他们以某种方式联系在一起,共同保证程序的正常运转。

以下主要介绍目前使用最多的 CommonJS 和 ES6 Module,简单介绍AMD、UMD

CommonJS

CommonJS是由JavaScript社区于2009年提出的包含模块、文件、IO、控制台在内的一系列标准。
在Node.js的实现中采用了CommonJS标准的一部分,并在其基础上进行了一些调整。现在提到CommonJS一般指Node.js中的版本,而非它的原始定义。

博主的姊妹篇:前端模块化之CommonJS和ES6 Module的区别

1. 模块

CommonJS中规定每个文件是一个模块。将一个JavaScript文件直接通过script标签插入页面中与封装成CommonJS模块最大的不同在于,前者的顶层作用域是全局作用域,在进行变量及函数声明时会污染全局环境;而后者会形成一个属于模块自身的作用域,所有的变量及函数只有自己能访问,对外是不可见的。请看下面的例子:

  1. // a.js
  2. var name = 'a'
  3. // index.js
  4. var name = 'index'
  5. require('./a.js')
  6. console.log(name) // index
  7. // 可见每个模块是拥有各自的作用域的,互不干扰

2. 导出

导出是一个模块向外暴露自身的唯一方式。在CommonJS中,通过module.exports可以导出模块中的内容,如:

  1. module.exports = {
  2. name: 'aa',
  3. add: function(a, b) {
  4. return a + b
  5. }
  6. }

CommonJS模块内部会有一个module对象用于存放当前模块的信息,可以理解成在每个模块的最开始定义了以下对象:

  1. var module = {...}
  2. // 模块自身逻辑
  3. module.exports = {...}

module.exports用来指定该模块要对外暴露哪些内容,在上面的代码中我们导出了一个对象,包含name和add两个属性。为了书写方便,CommonJS也支持另一种简化的导出方式—直接使用exports。

  1. exports.name = 'aa'
  2. exports.add = function(a, b) {
  3. return a + b
  4. }

在实现效果上,这段代码和上面的module.exports没有任何不同。其内在机制是将exports指向了module.exports,而module.exports在初始化时是一个空对象。我们可以简单地理解为,CommonJS在每个模块的首部默认添加了以下代码:

  1. var module = {
  2. exports: {}
  3. }
  4. var exports = module.exports

因此,为exports.add赋值相当于在module.exports对象上添加了一个属性。在使用exports时要注意一个问题,即不要直接给exports赋值,否则会导致其失效。如:

  1. exports = {
  2. name: 'aa'
  3. }

上面代码中,由于对exports进行了赋值操作,使其指向了新的对象,module.exports却仍然是原来的空对象,因此name属性并不会被导出。

注:module.exports与exports混用。因为可能会被覆盖。还有实际使用时,为了提高可读性,应该将module.exports及exports语句放在模块的末尾。

3. 导入

在CommonJS中使用require进行模块导入,如:

  1. // 在index.js中导入了a模块,并调用了它的add函数。
  2. // a.js
  3. module.exports = {
  4. add: function (a, b) { return a + b }
  5. }
  6. // index.js
  7. const fn = require('./a.js')
  8. const sum = fn.add(2, 3)
  9. console.log(sum) // 5

当我们require一个模块时会有两种情况:

  • require的模块是第一次被加载。这时会首先执行该模块,然后导出内容。
  • require的模块曾被加载过。这时该模块的代码不会再次执行,而是直接导出上次执行后得到的结果。

我们前面提到,模块会有一个module对象用来存放其信息,这个对象中有一个属性loaded用于记录该模块是否被加载过。它的值默认为false,当模块第一次被加载和执行过后会置为true,后面再次加载时检查到module.loaded为true,则不会再次执行模块代码。

有时我们加载一个模块,不需要获取其导出的内容,只是想要通过执行它而产生某种作用,比如把它的接口挂在全局对象上,此时直接使用require即可。

  1. require('./task.js')

另外,require函数可以接收表达式,借助这个特性我们可以动态地指定模块加载路径。

  1. const moduleNames = ['foo.js', 'bar.js']
  2. moduleNames.forEach(name => {
  3. require('./' + name)
  4. })

ES6 Module

在JavaScript之父Brendan Eich最初设计这门语言时,原本并没有包含模块的概念。基于越来越多的工程需求,为了使用模块化进行开发,JavaScript社区中涌现出了多种模块标准,其中也包括CommonJS。一直到2015年6月,由TC39标准委员会正式发布了ES6(ECMAScript 6.0),从此JavaScript语言才具备了模块这一特性。
看下面的例子,我们将前面的a.js和index.js使用ES6的方式进行了改写。

博主的姊妹篇:前端模块化之CommonJS和ES6 Module的区别

  1. // a.js
  2. export default {
  3. name: 'aa',
  4. add: function (a, b) { return a + b }
  5. }
  6. // index.js
  7. import fn from './a.js'
  8. const sum = fn.add(2, 3)
  9. console.log(sum) // 5

ES6 Module也是将每个文件作为一个模块,每个模块拥有自身的作用域,不同的是导入、导出语句。

  • import和export也作为保留关键字在ES6版本中加入了进来(CommonJS中的module并不属于关键字)

ES6 Module 会自动采用严格模式,

  • 这在ES5(ECMAScript 5.0)中是一个可选项。以前我们可以通过选择是否在文件开始时加上“use strict”来控制严格模式,在ES6 Module中不管开头是否有“usestrict”,都会采用严格模式。如果将原本是CommonJS的模块或任何未开启严格模式的代码改写为ES6 Module要注意这点。

1. 导出

在ES6 Module中使用export命令来导出模块。export有两种形式:

  • 命名导出
  • 默认导出
    一个模块可以有多个命名导出。它有两种不同的写法:
  1. // 写法1
  2. export const name = 'aa'
  3. export const add = function(){}
  4. // 写法2
  5. const name = 'aa'
  6. const add = function(){}
  7. export { name, add }
  8. // 在使用命名导出时,可以通过as关键字对变量重命名。如
  9. export { name, add as getSum }

与命名导出不同,模块的默认导出只能有一个

  1. export defalut { // 不能有2个export defalut
  2. name: 'aa',
  3. add: function(){}
  4. }

我们可以将export default理解为对外输出了一个名为default的变量,因此不需要像命名导出一样进行变量声明,直接导出值即可。

  1. // 导出字符串
  2. export defalut 'aaa'
  3. // 导出 class
  4. export defalut class {...}
  5. // 导出匿名函数
  6. export defalut function(){}

2. 导入

ES6 Module中使用import语法导入模块。首先我们来看如何加载带有命名导出的模块,请看下面的例子:

  1. // a.js
  2. const name = 'aa'
  3. const add = function(a, b){ return a + b}
  4. export { name, add }
  5. // index.js
  6. import { name, add } from './a.js'
  7. add(2, 3)
  8. // 与命名导出类似,我们可以通过as关键字可以对导入的变量重命名
  9. import { name, add as getSum } from './a.js'
  10. getSum(2, 3)
  11. // 在导入多个变量时,我们还可以采用整体导入的方式。如:
  12. import * as cal from './a.js'
  13. cal.getSum(2, 3)

加载带有命名导出的模块时,import后面要跟一对大括号来将导入的变量名包裹起来,并且这些变量名需要与导出的变量名完全一致。导入变量的效果相当于在当前作用域下声明了这些变量(name和add),并且不可对其进行更改,也就是所有导入的变量都是只读的。

其他类型模块

前面我们介绍的主要是CommonJS和ES6 Module,除此之外在开发中还有可能遇到其他类型的模块。有些如AMD、UMD等标准目前使用的场景已经不多,但当遇到这类模块时仍然需要知道如何去处理。

1. 非模块化文件

非模块化文件指的是并不遵循任何一种模块标准的文件。如果你维护的是一个几年前的项目,那么极有可能里面会有非模块化文件,最常见的就是在script标签中引入的jQuery及其各种插件。

如何使用Webpack打包这类文件呢?其实只要直接引入即可,如:

  1. import './jquery.min.js'

这句代码会直接执行jquery.min.js,一般来说jQuery这类库都是将其接口绑定在全局,因此无论是从script标签引入,还是使用Webpack打包的方式引入,其最终效果是一样的。

但假如我们引入的非模块化文件是以隐式全局变量声明的方式暴露其接口的,则会发生问题。如:

  1. // 通过在顶层作用域声明变量的方式暴露接口
  2. var calculator = {
  3. // ...
  4. }

由于Webpack在打包时会为每一个文件包装一层函数作用域来避免全局污染,上面的代码将无法把calculator对象挂在全局,因此这种以隐式全局变量声明需要格外注意。

2. AMD

AMD是英文Asynchronous Module Definition(异步模块定义)的缩写,它是由JavaScript社区提出的专注于支持浏览器端模块化的标准。从名字就可以看出它与CommonJS和ES6 Module最大的区别在于它加载模块的方式是异步的。下面的例子展示了如何定义一个AMD模块。

  1. define('getSum', ['calculator'], function(math) {
  2. return function(a, b) {
  3. console.log('sum' + calculator.add(a, b))
  4. }
  5. })

在AMD中使用define函数来定义模块,它可以接受3个参数。

  • 第1个参数是当前模块的id,相当于模块名;
  • 第2个参数是当前模块的依赖,比如上面我们定义的getSum模块需要引入calculator模块作为依赖;
  • 第3个参数用来描述模块的导出值,可以是函数或对象。如果是函数则导出的是函数的返回值;如果是对象则直接导出对象本身。

和CommonJS类似,AMD也使用require函数来加载模块,只不过采用异步的形式。

  1. require(['getSum'], function(getSum) {
  2. getSum(2, 3)
  3. })

require的第1个参数指定了加载的模块,第2个参数是当加载完成后执行的回调函数。

通过AMD这种形式定义模块的好处在于其模块加载是非阻塞性的,当执行到require函数时并不会停下来去执行被加载的模块,而是继续执行require后面的代码,这使得模块加载操作并不会阻塞浏览器。

尽管AMD的设计理念很好,但缺点是:

  • 与同步加载的模块标准相比其语法要更加冗长。
  • 另外其异步加载的方式并不如同步显得清晰,并且容易造成回调地狱(callback hell)。
    在目前的实际应用中已经用得越来越少,大多数开发者还是会选择CommonJS或ES6 Module的形式。

3. UMD

我们已经介绍了很多的模块形式,CommonJS、ES6 Module、AMD及非模块化文件,在很多时候工程中会用到其中两种形式甚至更多。有时对于一个库或者框架的开发者来说,如果面向的使用群体足够庞大,就需要考虑支持各种模块形式。

严格来说,UMD并不能说是一种模块标准,不如说它是一组模块形式的集合更准确。UMD的全称是Universal Module Definition,也就是通用模块标准,它的目标是使一个模块能运行在各种环境下,不论是CommonJS、AMD,还是非模块化的环境(当时ES6 Module还未被提出)。

看下面的例子:

  1. // calculator
  2. (function(global, main) {
  3. // 根据当前环境采取不同的导出方式
  4. if (typeof define === 'function' && defind.amd) {
  5. // AMD
  6. define(...)
  7. } else if (typeof exports === 'object') {
  8. // CommonJS
  9. module.exports = ...
  10. } else {
  11. global.add = ...
  12. }
  13. })(this, function() {
  14. // 定义模块主体
  15. return {...}
  16. })

可以看出,UMD其实就是根据当前全局对象中的值判断目前处于哪种模块环境。当前环境是AMD,就以AMD的形式导出;当前环境是CommonJS,就以CommonJS的形式导出。

需要注意的问题是,UMD模块一般都最先判断AMD环境,也就是全局下是否有define函数,而通过AMD定义的模块是无法使用CommonJS或ES6 Module的形式正确引入的。

  • 在Webpack中,由于它同时支持AMD及CommonJS,也许工程中的所有模块都是CommonJS,而UMD标准却发现当前有AMD环境,并使用了AMD方式导出,这会使得模块导入时出错。
    • 当需要这样做时,我们可以更改UMD模块中判断的顺序,使其以CommonJS的形式导出即可。

参考《Webpack实战:入门、进阶与调优》(居玉皓)

博主的姊妹篇:前端模块化之CommonJS和ES6 Module的区别