模块化
一、CommonJS
CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为 ServerJS,后来为了
体现它的广泛性,修改为 CommonJS,平时我们也会简称为 CJS。
1. 模块导出
1.1 exports 导出
exports
是一个对象,我们可以在这个对象中添加需要导出的多个属性。
// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']
exports.name = name;
exports.age = age;
exports.songs = songs;
我们可以在另外一个 js 文件中导入:
// main.js
var bar = require('./bar');
上面的代码表示 bar.js
文件里的 name、age、songs
被导出,然后被导入到 main.js
文件中的 bar
变量。实际上是 bar
变量等于 exports
这个对象,也就是 require
通过 node 文件导入查找方式,最终找到了 bar.js
文件中的 exports
这个对象,并将该对象赋值给 bar
。
通过上面的解释我们可以知道,bar
和 exports
是指向同一个对象的,所以,我们修改 exports
对象属性的值,相应的,bar
对象属性的值也要跟着改变。看下面代码:
// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']
exports.name = name;
exports.age = age;
exports.songs = songs;
setTimeout(() => {
exports.name = '张学友'
}, 1000)
// main.js
var bar = require('./bar');
console.log(bar.name);
setTimeout(() => {
console.log(bar.name);
}, 2000)
输出结果:
'xzy'
'张学友'
1.2 module.exports 导出
我们在使用 Node 时,有时会使用 exports
导出,有时又会使用 module.exports
导出,那么这两者有什么区别吗?
区别就是每个模块(js 文件)导出的对象实际上是 module.exports
所指的对象,也就是 require
导入的是 module.exports
对象,而不是 exports
对象, exports
仅仅只是默认指向了 module.exports
对象而已。
exports = module.exports;
所以,如果我们手动修改了 exports
的指向,那么导出 exports.xxx = xxx
是无效的。看下面代码:
// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']
exports = {}; // 修改 exports 的指向
exports.name = name;
exports.age = age;
exports.songs = songs;
// main.js
var bar = require('./bar');
console.log(bar);
// {}
上面代码可以看出,模块导出的对象不是 exports
。
接下来,再看另外一组代码:
// bar.js
var name = 'zxy';
var age = 58;
var songs = ['慢慢', '遥远的她']
module.exports = {
name: '张学友'
}
exports.name = name;
exports.age = age;
exports.songs = songs;
// main.js
var bar = require('./bar');
console.log(bar);
// { name: '张学友' }
可以看出,即使我们修改了 module.exports
的指向(赋值一个新对象),在 main.js
导入的仍然是 module.exports
所指的那个对象。这就是说明,模块真正导出的是 module.exports
所指对象,而 exports
默认指向了 module.exports
,这样我们修改 exports
对象的属性便会直接修改了 module.exports
所指的对象。此外,exports
会受到我们手动修改其指向而产生非预期的导入结果。
因此,在某些必要的情况下,我们推荐使用 module.exports
来添加我们需要导出的内容。
注意: Node 源码里是先创建 module.exports = {}
,然后再将 exports
指向 module.exports
,并非是先创建 exports = {}
,然后再将 exports
赋值给 module.exports
。
CommonJS 中是没有 module.exports 的概念的,但是为了实现模块的导出,Node 中使用的是 Module
的类,每一个模块都是 Module
的一个实例,也就是 module
;
所以在 Node 中真正用于导出的其实根本不是 exports,而是 module.exports,因为 module 才是导出的真正实现者。
2. 模块导入
Node 的模块导入仅有 require
这一种方法。
require 是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
var mod = require(x); // x 是字符串
这里,我们需要知道 require
的导入查找规则,分为两种:x 是一个路径和 x 不是一个路径。
2.1 x 是一个路径
当 x
是一个路径时,即以 ./ 或 ../ 或 /(根目录)开头的,这里也分两种情况:
-
如果
x
有后缀名,按照后缀名的格式查找对应的文件,如果没有找到,就报错:Cannot find module x -
如果
x
没有后缀名,会按照如下顺序进行查找:-
直接查找文件 x
-
查找 x.js 文件
-
查找 x.json 文件
-
查找 x.node 文件
-
没有找到对应的文件,将 x 作为一个目录,查找 x 直接目录下面的 index 文件
-
查找 x/index.js 文件
-
查找 x/index.json 文件
-
查找 x/index.node 文件
-
如果找不到,报错 Cannot find module x
-
2.2 x 不是一个路径
当 x
不是一个路径,就是一个不含 /
或 \
的字符串,也分两种情况:
- 如果
x
是一个核心模块,直接返回核心模块,并且停止查找 - 如果
x
不是一个核心模块,直接报错:Cannot find module x
3. 模块的加载过程
3.1 模块在被第一次引入时,模块中的 js 代码会被运行一次
当我们在一个模块中导入另外一个模块时,这个被导入的模块的 js 代码会被执行一次。
3.2 模块被多次引入时,会缓存,最终只加载(运行)一次
因为每个模块对象 module 都有一个属性:loaded
,为false表示还没有加载,为true表示已经加载。当被引入一次后, loaded
被置为 ture
,表示已经被加载过一次了,所以之后多次引入不会引起代码的重新运行。
3.3 如果有循环引入,那么加载顺序是什么?
如果出现下图模块的引用关系,那么加载顺序是什么呢?
这是一个图结构,图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search),Node 采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb。
4. CommonJS 规范、AMD 规范 和 CMD规范
4.1 CommonJS 规范 的缺点
CommonJS加载模块是同步的,同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行。这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快。但是如果将它应用于浏览器呢?浏览器加载 js 文件需要先从服务器将文件下载下来,之后在加载运行,这样需要等很久才能渲染出来页面,非常影响用户对页面的第一印象,并且还会影响用户的交互体验。
所以在浏览器中,我们通常不使用CommonJS规范。
当然在webpack中使用CommonJS是另外一回事,因为它会将我们的代码转成浏览器可以直接执行的代码。
在早期为了可以在浏览器中使用模块化,通常会采用 AMD 规范或 CMD 规范,但是目前一方面现代的浏览器已经支持 ES Modules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ES Module 代码的转换。所以,AMD 规范和 CMD 已经使用非常少了。
4.2 AMD 规范
AMD 是 Asynchronous Module Definition(异步模块定义)的缩写,AMD 规范主要是应用于浏览器的一种模块化规范,它采用的是异步加载模块,事实上 AMD 规范还要早于 CommonJS,但是 CommonJS 目前依然在被使用,而 AMD 规范使用的较少了
由于规范只是定义代码应该如何去编写,只有有了具体的实现才能被应用。
所以,AMD 规范实现的比较常用的库是 require.js 和 curl.js 。
4.3 CMD 规范
CMD 是Common Module Definition(通用模块定义)的缩写,CMD 规范也是应用于浏览器的一种模块化规范,它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来,但是目前 CMD 规范使用也是非常少了。
CMD 规范也有自己比较优秀的实现方案:SeaJS。
二、ES Module
JavaScript 没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:CommonJS、AMD、CMD。所以 ES6 推出了 ES Module 模块化。
ES Module和CommonJS的模块化有一些不同之处:
-
一方面它使用了
import
和export
关键字 -
另一方面它采用解析期的静态分析,并且也加入了动态引用的方式
注意:采用 ES Module将自动采用严格模式:use strict
1. 导出方式
1.1 export
// bar.js
export var name = '张学友';
export var age = 18;
在语句声明的前面直接加上export关键字。
1.2 export
将所有需要导出的标识符,放到export后面的 {}中
// bar.js
var name = '张学友';
var age = 18;
export {
name,
age
}
这里的 {}
里面不是 ES6 的对象字面量的增强写法,{}
也不是表示一个对象的,它就是一个单纯的花括号,里面只是存放导出的标识符列表内容,所以:export {name: name}
,是错误的写法。
我们使用最多的是这种方式,还有下面的 export default
。
1.3 导出时给标识符起一个别名
// bar.js
var name = '张学友';
var age = 18;
export {
name as myname,
age as myage
}
在前面的第二种方法中,我们可以为导出的变量添加别名,这样在导入的时候就能减少命名数不足的冲突了。不过,在导入的时候,需要导入对应的别名而不是原来的变量名。
1.4 export default
前面三种方式的导出功能都是有名字的导出。在 export
导出时指定了名字,在 import
导入时需要知道导出的具体名字。因此,我们在导入时必须去查看导出的变量名,这样就会产生一种不便。所以,官方给出来一种默认导出方式 export default
。
默认导出时可以不需要指定名字,在导入时不需要也不能使用 {},并且可以自己来指定名字,同时它也方便我们和现有的CommonJS等规范相互操作。
// bar.js
var name = '张学友';
var age = 18;
export default {
name,
age
}
// export name;
// export function(){}
// index.js
import obj from './bar.js'
console.log(obj);
// {name: "张学友", age: 18}
上面代码中,export default
后面可以跟一个任何类型的值,此时跟在其后面的 {}
是一个普通对象{name, age}
,和前面方式中的{}
不一样。导入时,我们可以自定义导入的变量名,这里的obj
就是 {name, age}
对象了。
注意:在一个模块中,只能有一个默认导出default export
,而前面三种方式是可以有无限多个导出的。
2. 导入方式
import
关键字相当于变量声明关键字,后面跟着的变量名是不能被声明过的。
2.1 import xxx from xxx
// index.js
import { name, age } from './bar.js'
这里需要注意,from
后面跟着的路径必须添加后缀名.js
,因为我们是直接使用在浏览器上的,除非有使用 webpack 等工具。其次,{}
也不是一个对象,里面只是存放导入的标识符列表内容。最后,导入的变量名需要与导出的变量名保持一致。
2.2 导入时给标识符起别名
// index.js
import { name as uname, age as uage } from './bar.js'
console.log(uname, uage);
// '张学友' 18
我们在导入的时候,为导入的变量名起了别名,那么在后面代码中必须使用别名,而不能使用原来的变量名。
我们也可以同时在导出和导入时都起别名,看下面代码:
// bar.js
var name = '张学友';
var age = 18;
export {
name as myname,
age as myage
}
// index.js
import { myname as uname, myage as uage } from './bar.js'
console.log(uname, uage);
// '张学友' 18
2.3 以 * 通配符导入
通过 *
将导出的变量到一个模块功能对象(a module object)上。
// bar.js
var name = '张学友';
var age = 18;
export {
name,
age
}
// index.js
import * as foo from './bar.js'
console.log(foo);
console.log(foo.name, foo.age);
在这种方法下,foo
被定义为一个模块功能对象,这个模块功能对象包含着被导出的所有变量属性:
2.4 以 export default 导出的导入方式
如果导出方式是 export default
,那么导入的时候,导入变量名我们可以自定义。虽然 import
是相当于变量声明关键字,我们可以自定义变量名,但是不能写成解构赋值的形式。看下面代码:
// bar.js
var name = '张学友';
var age = 18;
export default [
name,
age
]
// index.js
import [name, age] from './bar.js'
console.log(name, age);
// 写成结构赋值的形式,代码运行不通过
即使是写成对象形式的结构赋值,也是不可以的。
// bar.js
var name = '张学友';
var age = 18;
export default {
name,
age
}
// index.js
import {name, age} from './bar.js'
console.log(name, age);
// 写成结构赋值的形式,代码运行不通过
3. ES Module 在浏览器和 Node 中的使用
3.1 在浏览器中使用
如果我们想要在浏览器中使用 ES Module,那么我们必须做到下面两步。
第一步,当前项目必须在服务器环境使用。
这是因为受到 CORS 安全策略限制而无法在本地使用,这是 MDN 的官方解释:
如果你尝试用本地文件加载HTML 文件 (i.e. with a
file://
URL), 由于 JavaScript 模块的安全性要求,你会遇到CORS 错误。你需要通过服务器来做你的测试。
所以,我们可以使用 VS Code 中的 Live Server 插件来部署一个 Web 服务器,是我们的 .HTML
文件在线上环境打开。

第二步,必须在 script
标签内添加 type="module"
属性,且 script
标签内部不得写任何代码。
// index.html
<body>
<script src="./index.js" type="module"></script>
</body>
添加了 type="module"
后,index.js
就变成了一个模块,就可以使用 import
关键字了。不然会报如下错误:
Uncaught SyntaxError: Cannot use import statement outside a module
这里只需要引入主要的 js 文件(入口函数)即可,不需要引入与 index.js
有依赖关系的其它 js 文件,比如我在 index.js
文件导入了 bar.js
文件,那么是不需要引入 bar.js
文件的,有与 index.js
存在依赖的文件均是模块文件。
3.2 在 Node 中使用
方式一:在package.json中配置 type: module
方式二:文件以 .mjs 结尾,表示使用的是 ES Module
将所有的 js 文件的后缀名改为 .mjs
3.3 CommonJS 和 ES Module 交互使用
所谓 CommonJS 和 ES Module 交互使用,就是依赖的各模块不仅有 CommonJS 模块,也有 ES Module 模块。比如有以 .js
为后缀名的 ES Module 模块,也有以 .mjs
为后缀名的CommonJS 模块,互为依赖。
不过:
- 通常情况下,CommonJS 不能加载 ES Module
- 以
require
来加载export
- 因为 CommonJS 是同步加载的,但是ES Module必须经过静态分析等,无法在这个时候执行 JavaScript代码
- 但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持
- Node 当中是不支持的
- 以
- 多数情况下,ES Module 可以加载 CommonJS
- 以
import
来加载module.exports
- ES Module在加载CommonJS时,会将其
module.exports
导出的内容作为default
导出方式来使用 - 这个依然需要看具体的实现,比如 webpack 中是支持的、Node 最新版本也是支持的
- 以
4. ES Module 和 CommonsJS 的加载过程的区别
4.1 CommonsJS 的加载过程
-
CommonJS模块加载 js文件的过程是在运行时加载的,并且是同步的
- 运行时加载意味着 JS 引擎是在执行 js 代码的过程中加载模块的
- 同步的就意味着一个文件没有加载结束之前,后面的代码都不会执行
-
CommonJS 通过 module.exports 导出的是一个引用对象
- 不仅可以在导入的地方修改属性,也可以在导出的地方修改属性
4.2 ES Module加载过程
-
与 CommonJS 一样,模块在被第一次引入时,模块中的 js 代码会且仅被运行一次
-
ES Module 加载 js 文件的过程是编译(解析)时加载的,并且是异步的
- 编译时(解析)时加载,意味着
import
不能和运行时相关的代码放在一起使用 - 异步的就意味着 JS 引擎在遇到
import
时会去获取这个 js 文件,但是这个获取的过程是异步的,并不会阻塞主线程继续执行。如果我们后面有普通的script标签以及对应的代码,那么ES Module对应的 js 文件和代码不会阻塞它们的执行
- 编译时(解析)时加载,意味着
-
ES Module 通过 export 导出的是变量本身的引用(包括基本变量和复杂变量)
- 由于是在解析阶段加载模块的,所以导出的只能是变量的引用,而不是变量的值,所以才会有任何变量(基本变量和复杂变量)的引用。
- 上面的引用会被放在模块环境记录(module environment record)中,模块环境记录会和变量进行绑定(binding),并且这个绑定是实时的
- 由于是在导出阶段将变量的引用绑定在模块环境记录,所以我们可以在导出文件中修改导出变量的值,但不能在导入文件中修改导出变量的值(可以理解为
const
定义的常量。当然,可以修改导出的是一个对象里面的属性值)
导出引用被绑定在模块环境记录中,并且是实时的:
// bar.js var name = '张学友'; var obj = { songs: '李香兰' } export { name, obj } setTimeout(() => { name = '刘德华' }, 1000)
// index.js import { name, obj } from './bar.js' console.log(name, obj); // 张学友 {songs: "李香兰"} setTimeout(() => { console.log(name, obj); // 刘德华 {songs: "李香兰"} }, 2000)
只能在导出文件中修改导出变量的值,不能在导入文件修改导出变量的值
// bar.js var name = '张学友'; var obj = { songs: '李香兰' } export { name, obj } setTimeout(() => { console.log(obj); // 张学友 {songs: "遥远的她"} }, 1000);
// index.js import { name, obj } from './bar.js' console.log(name, obj); // 张学友 {songs: "李香兰"} try { name = '刘德华' } catch (error) { console.log(error); // TypeError: Assignment to constant variable. } try { obj.songs = '遥远的她' } catch (error) { console.log(error); } console.log(name, obj); // 张学友 {songs: "遥远的她"}
5. 补充
5.1 export和 import 结合使用
export {x} from xxx
这个方式是从 xxx
导入 x
,然后又将其导出。看下面代码:
// foo.js
export { name, age } from './bar.js'
// 等于
import { name, age } from './bar.js'
export { name, age };
那些需要导入name, age
变量的文件,完全可以从bar.js
导入,因此这个方式感觉多此一举,但是这个方式既然存在,那么就有它存在的道理,那么这个方式有什么用呢?
在开发和封装一个功能库时,通常我们将希望暴露的所有接口放到一个文件中,这样方便指定统一的接口规范,也方便程序员调用和阅读。这个时候,我们就可以使用 export和 import 结合使用了。
5.2 import()
我们是不能在一个逻辑代码里面来通过 import
加载模块的,如下面代码会报语法错误:
if(true) {
import {name, age} from './bar.js';
}
这是因为 JS 代码在被解析阶段时就必须知道各个模块之间的依赖关系,而进入逻辑代码里加载模块是已经在 JS 代码执行阶段了,故报语法错误。
所以,如果我们确实想动态地加载模块,就可以使用 import()
了,因为 import()
是一个函数,是在 JS 代码执行阶段执行的。
imports()
是一个异步加载函数,并且返回一个 promise
。
if (true) {
import('./bar.js').then((res) => {
console.log(res);
}).catch((err) => {
console.log(err);
})
}
若加载成功, res
就是一个模块功能对象,与上面 2.3 ”以 * 通配符导入“ 的模块功能对象一样;若加载失败,err
是其加载失败的原因。