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规范
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规范
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.js和cujo.js。
3. CMD规范
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时代
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 * from …
export { name1, name2, …, nameN } from …
export { 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也有模块化的解决方案。
最后给出一张结论图:
参考
MDN
若干文章