AMD、CommonJS和ES6的模块化方式
CommonJS是用于Node.js上的JS模块化规范,而AMD是用于浏览器端的模块化异步加载约定。
如果js代码运行在服务器上,这些代码很有可能要承担并发访问并做一些资源的处理工作,所以CommonJS的瓶颈是磁盘。这种场合下,相同或相似功能的代码通常要被执行多次,因此CommonJS的实现中具备缓存的功能。
而AMD是在用户的浏览器上随网页一起加载的,它的瓶颈是带宽。AMD的实现了异步加载,使得用户的网页可以渐进地载入,这能提供更好的用户体验,适合在浏览器上使用。
本文的CommonJS部分主要参照于《深入浅出Node.js》一书。
CommonJS使用的加载方式可以参考cnblog博客上的介绍,以及CommonJS官网。
有关AMD的介绍,阮一峰写了一些介绍文章,而ES6的加载方式可以在ES6入门中查看。
CommonJS简介
- 一个文件就是一个模块,模块具备单独的作用域,不会污染全局环境;
- 模块加载的结果会被缓存,因为要处理服务器端资源,且要承担并发访问。模块可以多次加载,但是除了第一次加载,后续的调用均从缓存中取得;
- 因为结果缓存的原因,如果你想动态输出变量,需要写成getter( )的形式,以方法闭包的形式传出。
定义和加载模块
一个js文件就是一个具备单独作用域的模块,Node环境会为每个模块提供以下变量:
require( )
:加载模块用的方法。因为一个模块很有可能还要加载其他模块,所以要有这个方法;module
:指当前模块本身,它有个属性.exports
,该属性上的值即是模块对外暴露的方法或变量;exports
:它指向module.exports
,注意不要直接给它赋值,这会切断它的指向。可以在该对象上挂载方法、变量;__dirname
:当前模块的文件夹路径;__filename
:当前模块的包含文件名的路径。
模块在第一次被加载的时候里面的代码会被执行,Node.js模块加载引擎会缓存挂载在module.exports
上的所有变量和函数,因此可以看到很多库的源码都是先定义一堆方法和变量,最后把这些东西放到module.exports
上:
这里是我用的是KOA对静态资源请求处理的中间件,可以看到一个模块也要加载其他模块,所以开头使用require( )
方法引入其他模块,最后module.exports
对外暴露出staticFiles这个方法。
CommonJS对每个模块提供require( )
方法用于加载其他模块。每当使用该方法时,Node根据一系列判断逻辑来决定从何处获取模块文件,找到模块的.js文件后便加载运行该模块,返回并缓存运行后产生的module.exports
对象。之后相同的require( )
将直接从缓存中取出结果返回。
引入整个模块对象:1
const Koa = require('koa');
这里以KOA2框架演示,使用require
加载了koa模块,然后就可以执行new Koa( )
实例化一个Web服务对象并进行其他工作。
只引入模块的一部分方法(按需引入):1
let { stat, exists, readFile } = require('fs');
这里只从fs模块中引出3个方法,在下面的代码中可以使用这三个方法名来调用对应的方法。需要注意的是,这里使用的是ES6的解构赋值。
加载模块的路径
例如有以下代码:1
const mod = require(str);
在执行到这里时,Node会按照以下顺序来尝试加载:
- 如果str是某个已经被缓存的文件模块,那么直接返回之;
- 如果str是某个Node的内置模块名,那么直接返回该内置模块;
- 如果str以
/
或./
或../
开头,会执行以下查找,找到则加载并返回模块:- 将str当作文件路径并依次添加
.js
/.json
/.node
后缀查找; - 将str当做目录,在其下查找
package.json
/index.js
/index.json
/index.node
这几个文件;
- 将str当作文件路径并依次添加
- 如果以上方式均没找到,则依次向父级目录递归,每次向上之后都分别当做文件路径、目录来查找依次;
- 都没找到则会产生error。
这里引用网上的一张判断流程图:
例如某文件位于/a/b.js
,而它使用了代码require('c')
,则查找方式如下:
- /a/b/node_modules/c.js (如果没找到则将.js后缀换成.json和.node)
- /a/b/node_modules/c/ (在这个目录内查找模块定义文件)
- /a/node_modules/c.js (如果没找到则将.js后缀换成.json和.node)
- /a/node_modules/c/ (在这个目录内查找模块定义文件)
- /node_modules/c.js (如果没找到则将.js后缀换成.json和.node)
- /node_modules/c/ (在这个目录内查找模块定义文件)
可以近似理解成,总是从当前目录下的node_modules
文件夹里寻找;若找不到,则当成路径寻找模块定义文件(例如package.json)。对于以上步骤都找完了,那就返回上一级目录继续,由此反复。
Node内部执行模块的方式
Node的模块分为2类,内置模块和文件模块。内置模块是由Node编译出的二进制字节码文件,加载速度最快,而文件模块是动态加载的,速度稍慢。这两类模块都会被缓存。
文件模块分为.js
、.node
、.json
三种后缀,对于每种后缀Node的加载方式也有不同:.js
后缀的是普通的CommonJS文件模块;.node
后缀的是通过C/C++编写的插件类的东西,用的也是特殊的加载方式;.json
则会读取文件并用JSON.parse( )
来解析加载。
可以参考阮一峰的文章,源码实现可以参考《深入浅出Node.js》一书,还有一篇社区的博文。
Node.js加载内置模块的方式这里不再赘述。
当加载.js后缀的模块时,Node.js引擎会使用fs内置模块读取该文件,并将其内容放置于一个封闭的匿名函数中,形如:1
2
3(function(exports,require,module,__filename,__dirname){
/* 加载的模块的代码在这里执行 */
});
运行这一段代码使用的是vm原生模块中的vm.runInThisContext( )
方法,因此这段代码不会污染全局作用域。而前面提到的CommonJS可以使用的几个变量,其实都是这个function的参数,可以看做是把加载的模块的代码复制到function内,所以说模块代码中可以直接使用exports、require等变量和方法。
Node会在构建时计算出__filename
和__dirname
等传入参数的值。
发布包的Package.json
这是Node独有的模块定义文件,它是一个JSON文件,内含一个类。如果你想发布自己的模块,或是自己的模块引用了其他的依赖,想要分享的时候让别人不会缺失这些依赖,那么就要准备一个package.json
文件了。
这个文件中的最外层的JSON中定义了一系列属性,这里列举Node规定的必须的属性:
main
:模块的入口文件名;name
:包名,如果想发布该包则它必须在NPM上是唯一的;version
:版本号,一般是x.y.z这样的;dependencies
:包的依赖项,要指定版本。NPM可以通过这个来帮你自动安装依赖,当你执行npm i
指令的时候就会按照这里面定义的依赖包和版本来自动安装;keywords
:关键字数组,用于搜索;maintainers
:维护者数组,元素是一个包含name
、email
、web
三个属性的对象;contriutors
:贡献者数组,其中第一个成员是包作者本人;bugs
:可以提交bug的地址;lincenses
:许可证,这是一个数组;repositories
:源码托管的地址,这是一个数组;scripts
:这个属性不是必须的,常用于安装、卸载等操作自动执行js文件,例如大部分库的npm start
这一操作能顺利执行,实际上在scripts
字段里必须配置好start
指令对应执行哪一个js文件。
AMD简介
- 需要先引入require.js文件,必须有了这个文件才能运用AMD异步加载其它js文件;
- 想被加载的模块必须按照正确的AMD写法才能被正确引入。如果没有按照AMD的规范来写,则需要一些额外的处理方式。
定义和加载模块
首先,require.js必须被先加载,因此很有可能要把require.js的script标签写在最前面。一般加载完require.js文件后,还要指定一个js文件作为入口,AMD从入口文件开始异步建立依赖关系并完成加载。写法如下:1
<script defer data-main="js/index" src="js/require.js"></script>
这里在引入require.js的同时,指定了js/index.js
文件作为入口、这里data-main
属性即指的是上述的入口文件。require.js一般允许你省略.js后缀。
异步加载的一般写法:1
2
3require(['jquery', 'underscore', 'vue'], function($,_,Vue){
/* 在该函数内可以使用$、_、Vue参数名了 */
})
上述代码会令浏览器加载jquery
、underscore
、vue
三个名称的js文件,并在其中的回调函数中赋予他们引用的变量名,这三个文件加载完成后才会执行回调函数中的内容。注意前面的数组和后面回调函数的参数是一一对应的。
加载的文件如果是标准的AMD文件,会按照它定义的依赖先准备好依赖项;
加载的如果不是AMD文件,也可以在requirejs.config( )
中单独定义依赖,使用shim
参数配置好即可。
如果想要定义AMD模块,例如自己编写一个符合AMD规范的库,那么你需要在自己写的js代码中使用define( )
方法来将模块定义为AMD规范。
下面是直接定义模块的用法:1
2
3define(function(){
/* 模块内容 */
})
如果该函数没有返回值,那么该函数将在被加载的时候执行一次;
如果该函数有返回值,除了被加载的时候执行,它的返回值可以在require调用的时候,传入回调中作为参数。即返回值作为模块暴露出的属性。
如果该模块还依赖于其它模块,那么就要使用下面的用法:1
2
3define(['mod'],function(m){
/* 模块内容,这里可以使用变量m代表mod模块 */
})
此时会先加载模块mod
,你在该模块中可以使用变量m
来访问mod
模块。
非AMD模块适配处理
AMD加载的模块必须符合其标准,即使用define( )
定义依赖项与模块内容。但是实际使用中很多模块并没有用符合AMD格式的方式定义,因此需要我们对这些模块进行适配
这里我们以jQuery来举例,进行AMD适配:1
2
3
4
5
6
7requirejs.config({
shim:{
'jQuery':{
exports:'$'
}
}
})
这里的shim
表示需要适配的模块,exports
表示jQuery使用变量$
作为暴露出的接口
如果需要适配的模块还依赖于其它模块,例如jQuery的datatable插件依赖于jQuery模块,所以也会产生一个依赖关系。因为jQuery和datatable都不是AMD规范的,所以还可以在require中定义适配时的依赖关系:1
2
3
4
5
6
7requirejs.config({
shim:{
'jQuery.datatable':{
deps:['jQuery']
}
}
})
这里没有exports
属性,因为它挂载在jQuery.fn
上面,所以无需使用接口引出。
对于通过shim
来适配AMD规范的模块而言,可以定义一个deps
属性,它是一个表示该模块所依赖的模块的数组。
加载路径和别名
对于大的项目,如果其中的模块需要大量复用,并且模块存放在不同的路径下,那么可以为各个模块指定路径、别名
以下示例为AMD模块指定加载来源和别名:1
2
3
4
5
6
7requirejs.config({
paths:{
'jQ':['https://a-c.fun/js/jquery-3.2.1.min','js/jquery-3.2.1.min'],
'V':['js/vue.min'],
'T':['test']
}
})
这里使你可以使用jQ
作为jQuery的别名而不用每次都写成复杂的jquery-3.2.1.min
,而且它给出了两个加载来源,先从CDN或者网络上获取js文件,如果无法获取才从当前站点路径中获取。
另外可以注意到,加载的多个文件都来源于./js/路径下,因此可以指定一个基目录,所有加载的js文件都来源自该基目录。
指定基目录的方式:1
2
3
4
5
6
7
8
9requirejs.config({
baseUrl:'js',
paths:{
'jQ':['jquery-3.2.1.min'],
'V':['vue.min'],
'T':['../test']
/* 因为指定基目录为js/,而test不在其中,因此test就要往上走一级了 */
}
})
插件应用:加载CSS
require.js原生不支持异步加载css,因此必须使用css插件。
准备好require.js的css加载插件,然后用以下方式:1
2
3
4
5
6
7
8
9
10
11
12requirejs.config({
'map': {
'*': {
'css': 'js/require.css.min'
}
},
'shim':{
'index_style':{
deps:['css!./css/index.css']
}
}
})
使用这种方式,注意deps加载css文件需要加上css!
前缀,表示调用定义名为css
的插件来加载之。
ES6模块化方式
ES6中定义了新的模块定义和加载方式。ES6在设计模块规范的时候,考虑到了以下因素:
- Node服务器端和浏览器端可以使用同一种语法加载和定义,无需修改;
- 出于第1条原因,CommonJS中的
require
、module
、__filename
等一系列变量均无法使用; - ES6模块也默认是严格模式,顶层作用域的this始终为undefined,浏览器和服务端要做到统一;
- 模块应该能令编辑器进行静态优化,例如支持代码提示。
出于这些原因,ES6的模块具备以下特点:
- 模块默认是严格模式,因此顶层this为undefined,也不能使用
arguements
参数; - 模块内也没有
require
、module
、__filename
等属于CommonJS的变量; - 模块在编译的时候就会被加载,效率更高,还能支持静态分析;
- 出于上一条原因,ES6使用的
import
语句无法使用拼接字符串等动态调用方式; - 不同于CommonJS,ES6的模块输出的不是模块接口的拷贝,而是一个引用。ES6模块不会被缓存运行结果;
- 浏览器还是Node端都是异步加载。
定义和加载模块
先来看CommonJS和ES6加载整个模块的语法:1
2
3
4
5//CommonJS的加载方式
let fs = require('fs');
//ES6的加载方式
import fs from 'fs';
如果只想加载模块的部分内容,可以使用下面的语法:1
2
3
4
5//CommonJS的加载方式
let { stat, exists, readFile } = require('fs');
//ES6的加载方式
import { stat, exists, readFile } from 'fs';
可以看出,在ES6中使用import
语句来加载模块。
注意这里面import的两部分均必须是编译时即可确定的,它不能是一个表达式。例如import * from myMod( )
这样需要任何运行获得结果(拼接字符串也算)的用法都是错误的。还有一个动态的import( )函数,可以用于动态引入,可惜支持的浏览器太少了。
加载时也可以指定一个别名:1
import * as FileSystem from 'fs';
这样就使用FileSystem
作为当前文件中加载的fs
模块的名字了
ES6在遇到import的时候,它将执行模块内的代码,因此你可以写成import 'mod';
这样来直接执行mod的代码,但是不引入任何模块。
import语句始终会在其他代码运行前执行,因此你可以把它当做隐含的变量提升,视为它始终被写在js代码文件的最顶部。
需要注意的是,ES6不会缓存模块的输出。它不像CommonJS一样,令模块对外暴露出的方法、变量在第一次运行之后就被缓存。ES6的模块实际上是一个引用,它可以用于获取实时的值。但是多次相同的import
并不会使模块运行多次。模块虽然结果不被缓存,但是实际上还是单例模式运行。
另外,对ES6加载的模块是只读的,任何赋值行为会导致错误。
定义模块使用export
命令。和CommonJS一样,ES6的每个文件就是一个单独的模块,它具备单独的作用域而不会污染全局变量,如果你想将该文件中的某些变量和方法暴露出来作为模块的对外的接口,那么需要使用export
关键字输出它们。
下面是一个输出模块的例子:1
2
3
4
5
6
7
8let firstName = 'Michael';
let lastName = 'Jackson';
function talkWith() { }
export {firstName, lastName, talkWith};
//输出的时候还可以指定别名,如下
//export {firstName as fn, lastName as ln, talkWith as tw};
也可以写下面的形式,但是这种形式显然不如上面的例子:1
2
3export firstName = 'Michael';
export lastName = 'Jackson';
export function talkWith() { };
注意以下写法存在的问题:1
2
3
4
5
6
7
8
9
10//错误的例子:
let m = 123;
function f() {};
export m;
export f;
//你要导出哪一个?导出m还是导出f?
//改成这样就对了:
export {m, f};
我们知道引入其他模块的时候如果使用import * from 'mod'
,如果想要引入其中的某些部分,使用大括号代替*
星号即可。如果调用的用户不想知道模块中的各个类名,那么可以使用export default
命令为模块指定默认输出:1
export default function() {};
上面的例子就是以一个匿名函数作为模块的默认输出,实际上即使不是匿名函数也是可以的,在使用任何名字import
该模块的时候,该函数将作为模块的接口。一个模块只能有一个默认接口。
默认接口实际上是有名字的,其名称为default,可以使用import { deafult as otherName } from 'mod';
来给引入并给默认接口赋予别名
import
和export
可以一同使用,用于转发模块接口。可以写成export * from 'mod';
或是export { func } from 'mod';
,默认接口也可以转发。注意转发并没有将这些模块导入当前模块。
加载方式
ES6目前在Node端和浏览器端都没有被原生支持
在现在(2018-08-08,Node版本是10.8.0)这个时间,Node文档上明确写着加载ES6的模块是需要使用特性声明的
例如my-app.mjs要通过node --experimental-modules my-app.mjs
这种方式来加载
Node要求ES6模块使用.mjs
后缀。require
不能加载.mjs
后缀的文件、.mjs
文件内也不能使用require
。
浏览器端对ES6模块的支持很差,可以在这里查看。
对于支持ES6模块加载的浏览器而言,如果想要加载ES6模块,需要在script标签中添加type="module"
属性。
对于ES6模块,浏览器会进行异步加载,不会阻塞渲染线程,页面准备完毕后才会执行。如果有多个ES6模块,则会按照它们在DOM中的顺序依次执行。
各类型的模块互用
CommonJS环境下加载ES6模块的方式见上文,这里讲ES6模块加载CommonJS。
因为ES6是更新的技术,所以它是兼容CommonJS的。ES6在加载CommonJS模块的时候,会将它的module.exports
属性当做模块的默认导出属性,即将module.exports
当做export default
。
ES6来加载CommonJS的情况下,还是会在第一次加载模块后将运行结果缓存,来模拟原生CommonJS的加载方式。
CommonJS加载ES6模块,不能使用require指令,而是要使用import( )
函数,import( )
返回值是一个对象,这个对象包含了ES6模块中所有的接口,这个对象的.default
属性就是ES6模块的默认接口
CommonJS模块和ES6模块会遇到循环加载的问题。
CommonJS因为模块的输出会被缓存,获取的是值的拷贝,所以在遇到循环加载会直接从缓存中取出要加载的内容。但是如果循环加载的时候模块没有执行完毕,那么未被执行到的语句也不会运作。一般模块对外暴露方法、属性的代码都在最后,即这些类似module.exports
的代码没有被执行到,显然会出错。
例如模块A运行到一半后加载模块B,于是引擎转而去运行模块B的代码了,但是模块B执行到一半又需要加载模块A,于是乎模块B只能访问到模块A前一半的输出,而A后半部分的module.exports
导出的东西B自然就无法访问到了,导致require出一堆undefined。
ES6因为没有循环机制,它的模块接口是只的引用,因此ES6遇到循环加载会报出一个变量未定义的错误。