历史背景
在早些时候的网页开发过程中,为了团队协作和代码维护方便,一些开发者会将JavaScript代码分别写在不同的文件里面,然后通过多个script标签来加载它们。
<script src="./common.js"></script>
<script src="./tools.js"></script>
<script src="./others.js"></script>
这么写确实没什么毛病,但是有一个非常大的问题,那就是所有JS变量会处在同一全局作用域下,这时候就需要额外注意由于作用域 变量提升所带来的问题了。
<script>
// common.js
var name = '木遁--花树界降临'
setTimeout(() => console.log(name), 2000)
</script>
<script>
// others.js
var name = '木遁--树界降临'
</script>
上面的列子可以很清晰的表现出由于处在同一作用域下面,JS变量提升所引发的问题, common.js文件声明的name变量会被others.js文件中name变量所覆盖,过了2秒后,common.js文件的name变量会变为"木遁--树界降临"。
而且还有另外一个问题比较麻烦,如果引入的文件中代码块之间有依赖的关系,则需要额外关注脚本加载的顺序,如果文件依赖顺序有改动,就得需要在html中手动变更加载标签的顺序。
要解决这样的问题,就需要将这些脚本文件『模块化』
- 任何一个模块都要有自己的 变量作用域,两个模块之间的内部变量不会产生冲突
- 不同模块直接保留相互 导入和导出 的方式方法,模块之间能够相互通信,模块的执行与加载遵循一定的规范,能保证彼此之间的依赖关系。
CommonJS规范
Node.js的大名想必大家都是知晓的,它是一个基于V8引擎,事件驱动I/O的服务端JS运行环境,2009年刚刚面世时,它就实现了一套名为CommonJS的模块化规范。
在CommonJS规范里,每一个js文件就是一个模块(module), 每个模块内部都可以使用 require函数和 module.exports对象来对模块进行导入和导出。
示例
// 示例一
const a = require('./a') // 获取a文件的导出结果
module.exports = a // 导出当前模块内部的a的值
// 示例二
// index.js
require('./a.js)
const num = require('./b.js)
console.log(num)
// a.js
var num = require('./b.js)
setTimeout(() => console.log(num), 1000)
// b.js
var num = new Date().getTime()
module.exports = num
- index.js文件通过
require函数, 分别加载了a.js和b.js两个模块,与此同时输出b.js模块中的结果。 - a.js文件也通过
require函数加载了b.js模块, 在1秒后也输出了加载来模块b的结果。 - b.js文件定义了一个变量num, 然后通过
module.exports将num导出。
这三个模块之间的关系
将示例二中的代码执行node index.js命令, 查看输出结果,可以发现,得到的两行输出结果都是相同的,而且无论执行多少次二者都相同。
接下来,将模块b中的代码再改变一下,如下:
// b.js
let m = new Date().getTime()
setTimeout(() => {
m = new Date().getTime()
console.log(m, '我是模块b,一秒后输出:', m)
}, 1000)
module.exports = m
上面的代码将变量m在一秒后重新赋值,并输出,其它文件保持不动,接着再执行一下node index.js命令看一下:
上面这个示例,输出了三行内容,第一行是index.js输出的,第二行是b.js模块输出,最后一行是由a.js模块输出,第一行内容和第三行内容相同,第二行的内容不同。
上面的列子都是使用非引用类型(基本数据类型)的数据做的示例,下面再将b.js的代码调整一下:
// b.js
let m = {
a: 123
}
setTimeout(() => {
m.a = 456
console.log(m, '我是模块b,一秒后输出:', m)
}, 1800)
module.exports = m
// a.js
const m = require('./b.js')
setTimeout(() => console.log(m, '我是模块a.js'), 1500)
// index.js
const a = require('./a.js')
const b = require('./b.js')
console.log(b, '我是模块index.js')
为了提高辨识度,在每个模块的打印语句中添加说明,此外将b.js模块中m这个变量赋值为一个引用类型, 地球人都知道,在js中引用类型数据的值是保存在
堆内存(Heap)中的对象(Object),按着正常的逻辑来说,当我改变变量m对象中属性a的值时,其它使用变量m的地方的值也会跟着变化,OK,知道了这一点接着执行node index.js命令,看一下输出结果:
可以看到上面运行的结果,模块index.js和模块a.js中加载进来的b.js模块的值并没有变化。
通过上面的几个小例子,可以发现非常完美的解决了文章开头提到的两个问题,下面总结一下:
- 模块直接内部即使有相同的变量名,他们运行时没有冲突。说明它具有处理模块变量作用域的能力。
b.js模块通过module.exports导出了一个内部变量,并且它在a.js和index.js两个模块中能被加载。 说明它具有导入导出模块的方式,同时能够处理基本的依赖关系。- 在不同的模块都加载了
b.js模块,而得到的结果是相同的。说明它保证了模块单例。 也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。 - 从示例代码执行过程可以看出来,CommonJS是一个同步加载模块的模块化规范,每当一个模块
require一个子模块时,都会停止当前模块的解析直到子模块读取解析并加载。
通过上面总结的四点可以看出来,这样的CommonJS模块只能在Node.js环境中运行,直接在其他环境中运行这样的代码模块就会报错。这是因为只有node才会在解析JS的过程中提供一个require方法,这样当解析器执行代码时,发现有模块调用了require函数,就会通过参数找到对应模块的物理路径,通过系统调用从硬盘读取文件内容,解析这段内容最终拿到导出结果并返回。而其他运行环境并不一定会在解析时提供这么一个require方法。
AMD模块化规范
除了上面说的CommonJS规范中JS代码通过Node.js可以运行在服务端,另一能够让JS运行环境就是浏览器了。但是浏览器并没有提供想Node.js里一样的require方法。不过,受到CommonJS模块化规范的启发,WEB端逐渐发展起来了AMD, SystemJS规范等适合浏览器端运行的JS模块化开发规范。
AMD全称Asyncchronous module definition, 意为**异步模块定义**, 不同于CommonJS规范的同步加载,AMD所有模块默认都是异步加载,如果在web端也使用同步加载,那么页面在解析脚本文件的过程中可能导致页面暂停响应。
AMD模块的定义与CommonJS有一点不同,下面将上面的例子稍微调整一下:
// index.js
require(['a', 'b'], function(a, b) {
console.log(b, '我是模块index')
})
// a.js
define(function(require) {
var m = require('b')
setTimeout(() => console.log(m, '我是模块a'), 1500)
})
// b.js
define(function(require) {
var m = new Date().getTime()
return m
})
AMD模块也支持文件级别的模块,模块ID默认为文件名,并且能并行加载多个模块,但是不能按需加载,必须提前加载所需依赖,在这个模块文件中,需要使用define函数来定义一个模块,在回调函数中接受定义组件内容。这个回调函数接受一个require方法,使其能够在组件内部加载其它模块,分别传入模块ID, 就能加载对应文件内的AMD模块。这个回调函数的返回值即是模块导出结果。
require函数接收两个参数,第一个参数写明入口模块的依赖表,第二个参数作为回调参数依次会传入前面依赖的导出值,在上面的例子中在index.js中只需要在回调函数中打印b传入的值即可。
与CommonJS规范不同的是,在Node.js里可以直接通过 node index.js来查看模块输出结果,在AMD中,由于是运行在WEB端,这里需要一个html文件,同时在里面加载这个入口模块。所以再加入一个index.html文件作为浏览器中的启动入口。
如果想要使用AMD规范,还需要添加一个符合AMD规范的加载器脚本在页面中,符合AMD规范实现的库有很多,比如:RequireJS 、curl 、Dojo 、Nodules 等等,这里使用require.js
<!DOCTYPE html>
<html lang="en">
<!-- ...省略部分代码 -->
<!-- 必须加载require.js等符合AMD模块化库之后才可以加载其它模块 这里需要注意书写顺序 -->
<script src="./require.js"></script>
<!-- 只加载入口模块即可 -->
<script src="./index.js"></script>
<!-- ...省略部分代码 -->
</html>
上面例子中模块间的关系如下:
将上面的代码在浏览器中运行,然后再调整代码,这里就不重复的贴代码了,与上面CommonJS规范中使用的示例内容逻辑一致。 下面是运行的结果:
基本数据类型情况
引用数据类型情况
从上面的例子中可以看出,AMD与CommonJS一样,都解决了文章开头说的变量作用域依赖关系之类的问题,只是AMD这种默认异步,在回调函数中定义模块内容,使用起来有些麻烦。此外,AMD的模块也不能运行在Node端,因为内部的define函数、require函数都必须配合在浏览器中加载require.js这类库才能使用。
CMD规范
CMD规范是阿里的玉伯提出来的,实现js库为sea.js。它和requirejs非常类似,即一个js文件就是一个模块,但是CMD的加载方式与AMD稍有区别,是通过按需加载的方式,而不是必须在模块开始就加载所有的依赖。 这里就不展示代码示例了(主要是我懒)。
UMD规范
有些时候我们写的模块需要同时运行在浏览器环境和Node.js中,因此我们需要分别写一份AMD模块和CommonJS模块来运行在各自的环境,如果每次模块内容还有改动 就需要去改动这两个模块,非常麻烦。
基于这个问题,UMD(Universal Module Definition)作为一种 同构(isomorphic)的一种模块化解决方案出现,它能够使我们只需要在一个地方定义模块内容,并同时兼容AMD和CommonJS语法。
写⼀个 UMD 模块也⾮常简单,我们只需要判断⼀下这些模块化规范的特征值,判断出当前究竟在哪种 模块化规范的环境下,然后把模块内容⽤检测出的模块化规范的语法导出即可。
(function(self, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
// 当前环境是 CommonJS 规范环境
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// 当前环境是 AMD 规范环境
define(factory)
} else {
// 什么环境都不是,直接挂在全局对象上
self.umdModule = factory();
}
}(this, function() {
return function() {
return Math.random();
}
}
)
);
上面的代码就是一种定义UMD模块的方式,根据代码逻辑,我们可以知道它首先去检查当前加载的模块规范是什么,如果module.exports在当前环境是一个对象,那么当前肯定是CommonJS,则直接使用module.exports导出结果,如果当前环境中有define函数并且define.amd为真,那么就可以使用AMD的define函数来定义一个模块,如果上面两者都不符合,那么直接将模块内容挂载到全局对象上面,这样也能加载到模块导出的结果。
ESModule规范
在EcmaScript 2015即ES6之后,js在语言标准层面上实现了模块化功能,在使用ESModule时,可以通过import 和 exprot 两个关键字来对模块进行导入导出。
我们继续使用上面的列子,代码调整如下:
// index.js
import './a.js'
import m from './b.js';
console.log(m, '我是模块index')
// a.js
import m from './b.js'
setTimeout(() => console.log(m, '我是模块a'))
// b.js
const m = new Dtae().getTime()
export default m
ESModule 与 CommonJS 和 AMD 最⼤的区别在于,ESModule 是由 JS 解释器实现,⽽后两者是在宿主环
境中运⾏时实现。ESModule 导⼊实际上是在语法层⾯新增了⼀个语句,⽽ AMD 和 CommonJS 加载模块
实际上是调⽤了 require 函数。
import 和 export是新语法,我们没有办法兼容,如果浏览器无法解析就会报语法错误index.js:2 Uncaught SyntaxError: Unexpected identifier
而对于CommonJS和AMD来说,只需要增加一个require函数,就可以保证这两者不报语法错误
ESModule 规范⽀持通过这些⽅式导⼊导出代码,具体使⽤哪种情况得根据如何导出来决定:
示例一:
// index.js
import {a, b} from './other.js'
// other.js
export const a = () => {}
export const b = 12345678
示例二:
import tools from './utils.js
// util.js
const add = (a, b) => a + b
const filterData = (arr, id) => arr.filter((val) => val !== id )
export default {
add,
filterData
}
// 或者
let obj = {
add,
filterData
}
export default obj
示例三:
import * as colors from './theme.js'
对于export和 export default来说,本质上,export default就是输出一个叫做default的变量或方法
有一个地方需要注意的是:import {a, b} from './other.js' 这里的括号并不代表获取的结果是一个对象,不是ES6中对象的结构语法
每个JS的运行环境就是一个解释器,不然它也不会认识JS语法,解释器的作用就是使用ECMAScript的规范去识别JS语法,即处理和执行语言本身的内容,按照语言的逻辑去正确执行
在解释器的上层,每个运行环境都会在解释器的基础上封装一些环境相关的API, 比如Node.js中的global、process对象,浏览器中的window、document对象等等。这些运行
环境的API受到各自规范的影响,例如浏览器端的 W3C 规范,它们规定了 window 对象和 document 对象上的
API 内容,以使得我们能让 document.getElementById 这样的 API 在所有浏览器上运⾏正常。
总结
下面总结下上面所说的知识点
CommonJS规范
- 模块是运行时加载, CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
- 模块输出的是一个值的拷贝, 一旦输出一个值,模块内部的变化就影响不到这个值。
- 模块同步加载, 每当一个模块加载一个子模块时,都会停止当前模块的解析直到子模块读取解析并加载
- 运行在服务端(因为同步,会造成阻塞,服务端模块都存在本地,不存在网络请求等情况)
AMD规范
- 依赖前置, 即当前模块依赖的其他模块,模块依赖必须在真正执行具体的回调函数前解决
- 模块输出的是一个值的拷贝, 一旦输出一个值,模块内部的变化就影响不到这个值。
- 模块异步加载, 允许指定回调函数
- 运行在浏览器端
CMD规范
与AMD规范类似,二者主要有下面的区别
- 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)
- CMD 推崇依赖就近,AMD 推崇依赖前置。
- AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。比如 AMD 里,require 分全局 require 和局部 require,都叫 require。CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹。
ESModule
-
可以运行在服务端和浏览器
-
模块是编译时加载,ES6 模块不是对象,而是通过
export命令显式指定输出的代码,import是采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。 -
模块输出的是值的引用, JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。 因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
-
模块变量是只读的,ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。