JavaScript模块化编程

扒一扒JavaScript模块化的历史,彻底搞清楚JavaScript中的require、import和export。

一、什么是模块化

  在做一些较为复杂的项目时,我们经常会引入一些模块,这些模块里封装了很多现成的“轮子”,在开发时直接使用会比从头开发轻松很多。

  所谓模块,就是实现特定功能的一组方法,而模块化是将模块的代码创造自己的作用域,只向外部暴露公开的方法和变量,而这些方法之间高度解耦。

  我们要学习模块化这种思想,因为如果不用模块化编写代码,会有以下问题:
  ● 代码杂乱无章,没有条理性,不便于维护,不便于复用
  ● 很多代码重复、逻辑重复
  ● 全局变量污染
  ● 不方便保护私有数据(闭包)

  但是,JavaScript本身不是一种模块化编程语言,在ES6以前,它是不支持“类”(class)的,所以也就没有“模块”(module)了。

  那么在那个时代开发者们怎么实现模块化的功能呢?下面来扒一扒JavaScript模块化的历史。

二、命名空间时代

  我们知道,在ES6之前,JS是没有块作用域的,私有变量和方法的隔离主要靠函数作用域,公开变量和方法的隔离主要靠对象的属性引用。

1. 封装函数

  在原始社会(最初的时候),模块就是实现特定功能的一组方法
  只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。
  比如写一个 utils.js 工具函数文件。

function add(x, y) {
    if(typeof x !== "number" || typeof y !== "number") return;
    return x + y;
}

function square(x) {
    if(typeof x !== "number") return;
    return x * x;
}

  上面的函数 add(x, y)square(x) 就组成一个模块。使用的时候,直接调用就行了:

<script src="./utils.js"></script>
<script>
    add(2, 3);
    square(4);
</script>

  这种做法的缺点很明显:
  ● “污染”了全局变量。
    ৹ 此时的公开函数其实是挂载到了全局对象window下,无法保证不与其他模块发生变量名冲突。
  ● 模块成员之间看不出直接关系。

2. 对象写法

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

var mathUtils = new Object({
  _count: 0,
  add: function(x, y) {
    return x + y
  },
  square: function(x) {
    return x * x
  }
});

  上面的函数 add(x, y)square(x) ,都封装在 mathUtils 对象里。使用的时候,就是调用这个对象的属性:

mathUtils.add();
mathUtils.square();

  这样的写法缺点也很明显:
  ● 会暴露所有模块成员,内部状态可以被外部改写。
    ৹ 比如外部代码可以直接改变内部_count的值:mathUtils._count = 1

3. 立即执行函数写法

  使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),创建闭包来封装私有变量,可以达到不暴露私有成员的目的。

// 定义
var mathUtils = (function() {
  var _count = 0
  return {
    inc: function() {
      _count += 1
    },
    dec: function() {
      _count += -1
    }
  }
})()

// 调用
mathUtils.inc()
mathUtils.dec()

  使用上面的写法,外部代码无法读取内部的 _count 变量。

console.info(mathUtils._count); // undefined

  那如果模块需要引入其他依赖(比如jQuery)呢?可以这么改写代码:

var mathUtils = (function($) {
  var $body = $("body")
  var _count = 0
  var add = function(x, y) {
    return x + y
  };
  var square = function(x) {
    return x * x
  };

  return {
    add: add,
    square: square
  };
})(jQuery)

  以上封装模块的方式叫作:模块模式。mathUtils 就是一个module,这就是JavaScript模块的基本写法。

  事实上在 jQuery 时代,大量使用了模块模式。详情可见我之前博客:《仿jQuery作JS库的封装(系列)》

  这样的缺点是:
  ● jQuery 的插件必须在 jquery.js 文件之后,文件的加载顺序被严格限制住,依赖越多,依赖关系越混乱,越容易出错。
  相信在使用boostrap,layui之类库的时候,大家都踩过坑。

<script src="jquery.js"></script>
<script src="underscore.js"></script>
<script src="utils.js"></script>
<script src="base.js"></script>
<script src="main.js"></script>

三、主流模块规范时代

  在ES6以前,还没有提出一套官方的规范,从社区和框架推广程度而言,目前通行的JavaScript模块规范有两种:CommonJS 和 AMD。

1. CommonJS规范

Node.js
  Nodejs 的出现,让 JavaScript 能够运行在服务端环境中。在当时,前端的复杂程度有限,没有模块也是可以的,但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。此时迫切需要建立一个标准来实现统一的模块系统,也就是后来的 CommonJS
  也是基于此,随后在浏览器端(前端),requirejs 和 seajs 之类的工具包也出现了,可以说在对应规范下,require统治了ES6之前的所有模块化编程。

  CommonJS 规定每个模块内部,module 代表当前模块,这个模块是一个对象,有 id,filename,loaded,parent,children,exports 等属性,module.exports 属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取 module.exports 变量。
  CommonJS 有一个全局性方法 require(),用于加载模块。

  假定有一个工具类模块 utils.js,我们要在 base.js 中使用它里面的方法:

// 直接赋值给 module.exports 变量
// utils.js
module.exports = function() {
  console.log("I'm utils.js module")
}

// base.js
var util = require("./utils.js")
util() // I'm utils.js module


// 或者挂载到 module.exports 对象下
// utils.js
module.exports.say = function() {
  console.log("I'm utils.js module")
}

// base.js
var util = require("./utils.js")
util.say() // I'm utils.js module

  正是由于 CommonJS 使用的 require 方式的推动,才有了后面的AMD、CMD 也采用的 require 方式来引用模块的风格。

  扯一个题外话,对node不感兴趣的话可以直接跳过这一段,直接看下一节:AMD规范
  在 CommonJS 中,暴露模块使用 module.exports 和 exports ,很多人不明白暴露对象为什么会有两个,现在我们来看下它们的区别。

  为了方便,Node 为每个模块提供一个 exports 自由变量,指向 module.exports。这等同在每个模块头部,有一行这样的命令。

var exports = module.exports

  exports 和 module.exports 共享了同个引用地址,如果直接对 exports 赋值会导致两者不再指向同一个内存地址,但最终不会对 module.exports 起效。

// module.exports 可以直接赋值
module.exports = 'Hello world'  

// exports 不能直接赋值
exports = 'Hello world'

  CommonJS总结:
  CommonJS 规范加载模块是同步的,用于服务端,由于 CommonJS 会在启动时把内置模块加载到内存中,也会把加载过的模块放在内存中。所以在 Node 环境中用同步加载的方式不会有很大问题。

  另外,CommonJS模块加载的是输出值的拷贝。也就是说,外部模块输出值变了,当前模块的导入值不会发生变化。

2. AMD规范

AMD & Require.js
  CommonJS 规范的出现,使得 JS 模块化在 NodeJS 环境中得到了施展机会。但 CommonJS 如果应用在浏览器端,同步加载的机制会使得 JS 阻塞 UI 线程,造成页面卡顿。

  怎么理解呢,举个例子:

var math = require('math')
math.add(2, 3)

  对于上述代码,如果在浏览器中运行,必须等第一行代码的math.js加载完成后才会执行第二行代码。也就是说,如果加载时间很长,整个应用就会停在那里等。

  这对服务器端不是一个问题,因为所有的模块都存放在本地硬盘,可以同步加载完成,等待时间就是硬盘的读取时间。但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,在此期间浏览器处于“假死”状态。
  因此,浏览器端的模块,不能采用“同步加载”(synchronous),只能采用“异步加载”(asynchronous)。这就是 AMD 规范诞生的背景。

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

  模块必须采用特定的 define() 函数来定义。

define(id?, dependencies?, factory)

  ● id:字符串,模块名称(可选)
  ● dependencies:是我们要载入的依赖模块(可选),使用相对路径,注意是数组格式
  ● factory:工厂方法,返回一个模块函数

  如果一个模块不依赖其它模块,那么可以直接定义在 define() 函数之中。

// math.js
define(function() {
  var add = function(x, y) {
    return x + y
  }
  return {
    add: add
  }
})

  如果这个模块还依赖其它模块,那么 define() 函数的第一个参数,必须是一个数组,指明该模块的依赖性。
  比如下面这个模块,当require()函数加载该模块的时候,就会先加载 Lib.js 文件。

define(["Lib"], function(Lib) {
  function foo() {
    Lib.doSomething()
  }

  return {
    foo: foo
  }
})

  AMD 也采用 require() 语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback);

  ● [module]:第一个参数[module],是一个数组,里面的成员就是要加载的模块
  ● callback:第二个参数callback,则是加载成功之后的回调函数

  如果将上面的代码改写成AMD形式,就是下面这样:

require(["math"], function(math) {
  math.add(2, 3)
})

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

  AMD 的特点:
  ● 延迟加载
  ● 依赖前置

  利用模块加载后执行回调的机制,目前,主要有两个JavaScript库实现了AMD规范:require.jscujo.js

3. CMD规范

SeaJS
  CMD(Common Module Definition),是 SeaJS 推崇的规范。
  归纳一下它的特点:
  ● CMD 可以认为是 CommonJS 的前端实现
  ● SeaJS 由阿里的(玉帛)编写
  ● SeaJS 在几年前比较火,从去年(可能是2017年)开始已经停止更新

  与AMD不同,CMD 则是依赖就近,用的时候再 require。它写起来是这样的:

define(function(require, exports, module) {
  var clock = require("clock")
  clock.start()
})

  CMD 与 AMD 一样,也是采用特定的 define() 函数来定义,用 require() 语句加载模块。

define(id?, dependencies?, factory)

  ● id:字符串,模块名称(可选)
  ● dependencies:是我们要载入的依赖模块(可选),使用相对路径,注意是数组格式
  ● factory:工厂方法,返回一个模块函数

  如果一个模块不依赖其它模块,那么可以直接定义在 define() 函数之中。

define(function(require, exports, module) {

  // 模块代码

});

  如果这个模块还依赖其它模块:

define("hello", ["jquery"], function(require, exports, module) {

  // 模块代码

});

  * 注意:带 id 和 dependencies 参数的 define 用法不属于 CMD 规范,而属于 Modules/Transport 规范。

  CMD 和 AMD 的区别
  AMD 和 CMD 最大的区别是对依赖模块的执行时机处理不同,而不是加载的时机或者方式不同,二者皆为异步加载模块。

  AMD 依赖前置,js可以方便知道依赖模块是谁,立即加载。

  而 CMD 就近依赖,需要使用把模块变为字符串解析一遍才知道依赖了那些模块,这也是很多人诟病 CMD 的一点,牺牲性能来带来开发的便利性,实际上解析模块用的时间短到可以忽略。

  举个例子:

// main.js
define(function(require, exports, module) {
  console.log("I'm main")
  var mod1 = require("./mod1")
  mod1.say()
  var mod2 = require("./mod2")
  mod2.say()

  return {
    hello: function() {
      console.log("hello main")
    }
  }
})

// mod1.js
define(function() {
  console.log("I'm mod1")
  return {
    say: function() {
      console.log("say: I'm mod1")
    }
  }
})

// mod2.js
define(function() {
  console.log("I'm mod2")
  return {
    say: function() {
      console.log("say: I'm mod2")
    }
  }
})

  以上代码分别用 require.js 和 sea.js 执行,打印结果如下:
  require.js:(先执行所有依赖中的代码)

I'm mod1
I'm mod2
I'm main
say: I'm mod1
say: I'm mod2

  sea.js:(用到依赖时,再执行依赖中的代码)

I'm main

I'm mod1
say: I'm mod1
I'm mod2
say: I'm mod2

4. UMD规范

  UMD(Universal Module Definition)是 AMD 和 CommonJS 的兼容性处理,提出了跨平台的解决方案。

(function(root, factory) {
  if (typeof exports === "object") {
    // CommonJS
    module.exports = factory()
  } else if (typeof define === "function" && define.amd) {
    // AMD
    define(factory)
  } else {
    // 挂载到全局
    root.eventUtil = factory()
  }
})(this, function() {
  function myFunc() {
  }

  return {
    foo: myFunc
  }
})

  应用 UMD 规范的 JS 文件其实就是一个立即执行函数,通过检验 JS 环境是否支持 CommonJS 或 AMD 再进行模块化定义。

四、ES6时代

ECMAScript6 Modules
  ES6 标准发布后,module 成为标准,标准使用是以 export 指令导出接口,以 import 引入模块,但是在我们一贯的 node 模块中,依然采用的是 CommonJS 规范,使用 require 引入模块,使用 module.exports 导出接口。

  下面来看一下 ES6 模块化的写法。

1. export导出模块

  export 语法声明用于导出函数、对象、指定文件(或模块)的原始值,如下代码:
  * 注意:在 node 中使用的是 exports ,不要混淆了。

export { name1, name2,, nameN }
export { variable1 as name1, variable2 as name2,, nameN }
export let name1, name2,, nameN // 也可以使用 var 关键字
export let name1 =, name2 =,, nameN // 也可以使用 var 和 const 关键字
export default expression
export default function () {} // 也可以是 class,function*
export default function name1() {} // 也可以是 class,function*
export { name1 as default,}
export * fromexport { name1, name2,, nameN } fromexport { import1 as name1, import2 as name2,, nameN } from

  ● name1… nameN-导出的“标识符”。导出后,可以通过这个“标识符”在另一个模块中使用import引用
  ● default-设置模块的默认导出。设置后import不通过“标识符”而直接引用默认导入,需要注意的是,这时import命令后面,不使用大括号。
  ● * 继承模块并导出继承模块所有的方法和属性
  ● as-重命名导出“标识符”
  ● from-从已经存在的模块、脚本文件…导出

  export 有两种模块导出方式:命名式导出(名称导出)和 默认导出(定义式导出),命名式导出每个模块可以多个,而默认导出每个模块仅一个。

a. 命名式导出

  模块可以通过 export 前缀关键词声明导出对象,导出对象可以是多个。这些导出对象用名称进行区分,称之为命名式导出。

export { myFunction } // 导出一个已定义的函数
export const foo = Math.sqrt(2) // 导出一个常量
export function multiply(x, y) {
  return x * y
}

  我们可以使用 *from 关键字来实现模块的继承:

export * from 'article'

  模块导出时,可以指定模块的导出成员。导出成员可以认为是类中的公有对象,而非导出成员可以认为是类中的私有对象:

var name = '文渊博客'
var domain = 'https://www.wenyuanblog.com'
export {name, domain}; // 相当于导出{name:name,domain:domain}

  模块导出时,我们可以使用 as 关键字对导出成员进行重命名:

var name = '文渊博客'
var domain = 'https://www.wenyuanblog.com'
export {name as siteName, domain}

  需要注意的是,通过 命名式导出 的模块,在引入时需要进行结构,也就是说,导出必须是 export {a} 的形式。而且大部分风格都建议,模块中最好在末尾用一个export导出所有的接口
  什么意思呢,看一组正反例:

/* 错误的代码 - 导出变量 */
export 1 // export在导出接口的时候,必须与模块内部的变量具有一一对应的关系,直接导出1没有任何意义

var a = 100
export a // 无法完成解构

/* 错误的代码 - 导出function */
function f() {}
export f // 报错

/* 正确的写法 - 导出变量 */
export var x = 1

var a = 1
export {a}

var b = 1
export {b as y}

/* 正确的写法 - 导出function */
export function f() {};

function f() {}
export {f};

b. 默认导出

  使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。但是,用户肯定希望快速上手,未必愿意通过阅读文档,去了解模块有哪些属性和方法。

  为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default命令,为模块指定默认输出。

// export-default.js
export default function () {
  console.log('foo')
}

  上面代码是一个模块文件 export-default.js,它的默认输出是一个函数。
  其他模块加载该模块时,import 命令可以为该匿名函数指定任意名字。

// import-default.js
import customName from './export-default'
customName() // 'foo'

  上面代码的 import 命令,可以用任意名称指向 export-default.js 输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号

  export default 命令用在非匿名函数前,也是可以的。

// export-default.js
export default function foo() {
  console.log('foo')
}

// 或者写成
function foo() {
  console.log('foo')
}
export default foo

  上面代码中,foo函数的函数名foo,在模块外部是无效的。加载的时候,视同匿名函数加载。

c. 命名式导出与默认导出

  默认导出可以理解为另一种形式的命名导出,默认导出可以认为是使用了default名称的命名导出。
  下面两种导出方式是等价的:

const D = 123
export default D
export { D as default }

d. export使用示例

  使用名称导出一个模块时:

// "my-module.js" 模块
export function cube(x) {
  return x * x * x
}
const foo = Math.PI + Math.SQRT2
export { foo }

  在另一个模块(脚本文件)中,我们可以像下面这样引用:

import { cube, foo } from 'my-module'
console.log(cube(3)) // 27
console.log(foo) // 4.555806215962888

  使用默认导出一个模块时:

// "my-module.js"模块
export default function (x) {
  return x * x * x
}

  在另一个模块(脚本文件)中,我们可以像下面这样引用,相对名称导出来说使用更为简单:

// 引用 "my-module.js"模块
import cube from 'my-module'
console.log(cube(3)) // 27

2. import引入模块

  import 语法声明用于从已导出的模块、脚本中导入函数、对象、指定文件(或模块)的原始值,如下代码:

import defaultMember from "module-name"
import * as name from "module-name"
import { member } from "module-name"
import { member as alias } from "module-name"
import { member1 , member2 } from "module-name"
import { member1 , member2 as alias2 , [...] } from "module-name"
import defaultMember, { member [ , [...] ] } from "module-name"
import defaultMember, * as name from "module-name"
import "module-name"

  ● name-从将要导入模块中收到的导出值的名称
  ● member, memberN-从导出模块,导入指定名称的多个成员
  ● defaultMember-从导出模块,导入默认导出成员
  ● alias, aliasN-别名,对指定导入成员进行的重命名
  ● module-name-要导入的模块。是一个文件名
  ● as-重命名导入成员名称(“标识符”)
  ● from-从已经存在的模块、脚本文件等导入

  import 模块导入与 export 模块导出功能相对应,也存在两种模块导入方式:命名式导入(名称导入)和 默认导入(定义式导入)。
  import 的语法跟 require 不同,而且 import 必须放在文件的最开始,且前面不允许有其他逻辑代码,这和其他所有编程语言风格一致。

a. 命名式导入

  我们可以通过指定名称,就是将这些成员插入到当作用域中。导出时,可以导入单个成员或多个成员:
  注意,花括号里面的变量与 export 后面的变量一一对应

import {myMember} from "my-module"
import {foo, bar} from "my-module"

  通过 * 符号,我们可以导入模块中的全部属性和方法。当导入模块全部导出内容时,就是将导出模块(’my-module.js’)所有的导出绑定内容,插入到当前模块(’myModule’)的作用域中:

import * as myModule from "my-module"

  导入模块对象时,也可以使用as对导入成员重命名,以方便在当前模块内使用:

import {reallyReallyLongModuleMemberName as shortName} from "my-module"

  导入多个成员时,同样可以使用别名:

import {reallyReallyLongModuleMemberName as shortName, anotherLongModuleName as short} from "my-module"

  导入一个模块,但不进行任何绑定(仅仅执行my-module模块,但是不输入任何值):

import "my-module"

b. 默认导入

  在模块导出时,可能会存在默认导出。同样的,在导入时可以使用 import 指令导入这些默认值。
  直接导入默认值:

import myDefault from "my-module"

  也可以在命名空间导入和名称导入中,同时使用默认导入:

import myDefault, * as myModule from "my-module" // myModule 做为命名空间使用

  或

import myDefault, {foo, bar} from "my-module" // 指定成员导入

c. import使用示例

// file.js
function getJSON(url, callback) {
  let xhr = new XMLHttpRequest()
  xhr.onload = function () { 
    callback(this.responseText) 
  };
  xhr.open("GET", url, true)
  xhr.send()
}
export function getUsefulContents(url, callback) {
  getJSON(url, data => callback(JSON.parse(data)))
}

// main.js
import { getUsefulContents } from "file"
getUsefulContents("http://itbilu.com", data => {
  doSomethingUseful(data)
})

3. default关键字

// d.js
export default function() {}

  等效于:

function a() {}
export {a as default}

  在import的时候,可以这样用:

import a from './d'
// 等效于,或者说就是下面这种写法的简写,是同一个意思
import {default as a} from './d'

  这个语法糖的好处就是 import 的时候,可以省去花括号{}。

  简单的说,如果 import 的时候,你发现某个变量没有花括号括起来(没有*号),那么你在脑海中应该把它还原成有花括号的as语法。

  所以,下面这种写法你也应该理解了吧:

import $,{each,map} from 'jquery'
// import后面第一个$是{defalut as $}的替代写法。

4. as关键字

  as 简单的说就是取一个别名,export 中可以用,import 中其实也可以用:

// a.js
var a = function() {}
export {a as fun}

// b.js
import {fun as a} from './a'
a()

  上面这段代码,export 的时候,对外提供的接口是 fun,它是 a.js 内部 a 这个函数的别名,但是在模块外面,认不到 a,只能认到 fun。

  import 中的 as 就很简单,就是你在使用模块里面的方法的时候,给这个方法取一个别名,好在当前的文件里面使用。之所以是这样,是因为有的时候不同的两个模块可能通过相同的接口,比如有一个 c.js 也通过了 fun 这个接口:

// c.js
export function fun() {}

  如果在 b.js 中同时使用 a 和 c 这两个模块,就必须想办法解决接口重名的问题,as 就解决了。

5. 总结ES6的模块机制

  CommonJS 和 AMD 规范都只能在运行时确定依赖。而 ES6 在语言层面提出了模块化方案, ES6 module 模块编译时就能确定模块的依赖关系,以及输入和输出的变量。ES6 模块化这种加载称为“编译时加载”或者静态加载。

  运行机制:JS引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

  另外,在 webpack 对 ES Module 打包的时候, ES Module 会编译成 require/exports 来执行。

五、全文总结

  JavaScript的模块化规范经过了命名空间/模块模式、CommonJS、AMD/CMD、ES6 的演进,利用现在常用的 gulp、webpack 打包工具,非常方便我们编写模块化代码。

  掌握这几种模块化规范的区别和联系有助于提高代码的模块化质量,比如,CommonJS 输出的是值拷贝,ES6 Module 在静态代码解析时输出只读接口,AMD 是异步加载,推崇依赖前置;CMD 是依赖就近,延迟执行,在使用到模块时才去加载相应的依赖。

  而在当代的三大框架中,vue 、angular、react已经将模块化的思想植入在里面,都集成了各自的模块化;webpack也有模块化的解决方案。

  最后给出一张结论图:
JavaScript模块化编程


参考
MDN
若干文章


  目录