前言
CommonJS 听说过?AMD 听说过,CMD也听说过,还有个ES6 模块化。当项目里使用的时候,我们是否真正知道,你用的到底基于哪个规范的模块化?
Ps: 由于文章太长,为了方便阅读,建议先看右侧文章目录,了解大纲之后逐步阅读。
第一章: 追溯根源,为何前端需要模块化?
为了彻底弄清楚模块化这个东西,我们要从最开始模块化的起源说起。
无模块化的原始时代
最开始 js
只是作为一个脚本语言来使用,做一些简单的表单校验,动画实现等等。 代码都是这样的,直接把代码写进 <script>
标签里,代码量非常少。
1 | <script> |
代码量剧增带来的灾难性问题
后来随着ajax异步请求的出现,前端能做的事情越来越多,代码量飞速增长。 也暴露出了一些问题。
全局变量的灾难
这个非常好理解,就是大家的代码都在一个作用域,不同的人定义的变量可能会重复从而产生覆盖。
1 | name = 'LiuKun'; |
依赖关系管理的灾难
如果 c 依赖了 b,b 依赖了 c,则 script 引入的顺序必须被依赖的放在前面,试想要是有几十个文件,我们都要弄清楚文件依赖关系然后手动,按顺序引入,无疑这是非常痛苦的事情。
1 | <script type="text/javascript" src="a.js"></script> |
早期的解决方式
- 闭包,使
function
内部的变量对全局隐藏,达到了封装的目的,但是最外层模块名还是暴露在全局,要是模快越来越多,依然会存在模块名冲突的问题。
1 | module = function () { |
- 命名空间
1 | app.tools.module.add = function(c){ |
毫无疑问以上两种方法都不够优雅。那么,模块化到底需要解决什么问题提呢?我们先设想一下可能有以下几点:
- 安全的包装一个模块的代码,避免全局污染
- 唯一标识一个模块
- 优雅的将模块api暴露出去
- 方便的使用模块
第二章: 服务端模块化 CommonJS
Nodejs 出现开创了一个新的纪元,使得我们可以使用 javascript 写服务器代码,对于服务端而言必然是需要模块化的。
Nodejs 和 CommonJS 的关系
Nodejs
的模块化能一种成熟的姿态出现离不开CommonJS
的规范的影响- 在服务器端
CommonJS
能以一种寻常的姿态写进各个公司的项目代码中,离不开Nodejs
的优异表现 Nodejs
并非完全按照规范实现,针对模块规范进行了一定的取舍,同时也增加了少许自身特性
CommonJS 规范简介
CommonJS 对模块的定义非常简单,主要分为
模块引用
,模块定义
和模块标识
3 部分
模块引用
1 | var add = require('./add.js'); |
模块定义
1 | module.exports.add = function () { |
可以在一个文件中引入模块并导出另一个模块
1 | var add = require('./add.js'); |
大家可能会疑惑,并没有定义 module,require 这两个属性是怎么来的呢? 其实,一个文件代表一个模块,一个模块除了自己的函数作用域之外,最外层还有一个模块作用域,module 就是代表这个模块,exports 是 module 的属性。require 也在这个模块的上下文中,用来引入外部模块。
模块标识
模块标识就是 require()
函数的参数,规范是这样的:
- 必须是字符串
- 可以是以./ ../开头的相对路径
- 可以是绝对路径
- 可以省略后缀名
CommonJS 的模块规范定义比较简单,意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出将上下游模块无缝衔接,每个模块具有独立的空间,它们互不干扰。
Nodejs 的模块化实现
Node 中一个文件是一个 模块(Module)的实例
Node 中 Module 构造函数:
1 | function Module(id, parent){ |
其中 id 是模块 id,exports 是这个模块要暴露出来的 api,parent 是父级模块,loaded 表示这个模块是否加载完成,因为 CommonJS 是运行时加载,loaded 表示文件是否已经执行完毕返回一个对象。
Node 模块分类
如图所示 Node 模块一般分为两种,核心模块
和 文件模块
。
核心模块,就是
Node
内置的模块,比如http
、path
等。在Node
的源码的编译时,核心模块就一起被编译进了二进制执行文件,部分核心模块(内建模块) 被直接加载进内存中。文件模块,就是外部引入的模块如
node_modules
里通过npm
安装的模块,或者我们项目工程里自己写的一个js
文件或者json
文件。
在Node模块的引入过程中,一般要经过一下三个步骤
- 路径分析
- 文件定位
- 编译执行
核心模块会省略文件定位和编译执行这两步,并且在路径分析中会优先判断,加载速度比一般模块更快。
require 是如何分析路径,文件定位并且编译执行的?
路径分析
前面已经说过,不论核心模块还是文件模块都需要经历路径分析这一步,当我们 require
一个模块的时候,Node
是怎么区分是 核心模块
还是 文件模块
,并且进行查找定位呢?
Node支持如下几种形式的模块标识符,来引入模块:
1 | // 核心模块 |
那么对于这个都是字符串的引入方式, Node
会优先去内存中查找匹配 核心模块
,如果匹配成功便不会再继续查找
- 比如
require
,http
模块的时候,会优先从核心模块
里去成功匹配, 如果核心模块
没有匹配成功,便归类为文件模块
; - 以
.
、..
和/
开头的标识符,require
都会根据当前文件路径将这个相对路径或者绝对路径转化为真实路径,也就是我们平时最常见的一种路径解析; - 非路径形式的文件模块, 如上面的
express
和codemirror/addon/merge/merge.js
,这种模块是一种特殊的文件模块,一般称为自定义模块
。自定义模块
的查找最费时,因为对于自定义模块有一个模块路径,Node
会根据这个模块路径依次递归查找。
模块路径Node
的模块路径是一个数组,模块路径存放在 module.paths
属性上。 我们可以找一个基于 npm
或者 yarn
管理项目,在根目录下创建一个test.js
文件,内容为 console.log(module.paths)
, 如下:
1 | //test.js |
然后在根目录下用 Node
执行:
1 | node test.js |
可以看到我们已经将模块路径打印出来
模块路径的生成规则如下:
- 当前路文件下的 node_modules 目录
- 父目录下的 node_modules 目录
- 父目录的父目录下的 node_modules 目录
- 沿路径向上逐级递归,直到根目录下的 node_modules 目录
对于自定义文件比如 express
,就会根据模块路径依次递归查找,在查找同时并进行文件定位。
文件定位
扩展名分析,我们在使用 require 的时候有时候会省略扩展名,那么 Node 怎么定位到具体的文件呢?这种情况下,Node 会依次按照.js、.json、.node 的次序一次匹配。(.node是C++扩展文件编译之后生成的文件),若扩展名匹配失败,则会将其当成一个包来处理,我这里直接理解为 npm 包。
包处理,对于包 Node 会首先在当前包目录下查找
package.json
(CommonJS包规范)通过JSON.parse()
解析出包描述对象,根据main
属性指定的入口文件名进行下一步定位。 如果文件缺少扩展名,将根据扩展名分析规则定位。 若main
指定文件名错误或者压根没有package.json
,Node 会将包目录下的 index 当做默认文件名。 再依次匹配 index.js、index.json、index.node。 若以上步骤都没有定位成功将进入下一个模块路径,父目录下的node_modules
目录下查找,直到查找到根目录下的node_modules
,若都没有定位到,将抛出查找失败的异常。
模块编译
- .js 文件, 通过
fs
模块同步读取文件后编译执行 - .node 文件, 用 C/C++ 编写的扩展文件,通过
dlopen()
方法加载最后编译生成的文件。 - .json, 通过
fs
模块同步读取文件后,用JSON.parse()
解析返回结果。 - 其余扩展名文件。它们都是被当做
.js
文件载入。
每一个编译成功的文件都会将其文件路径作为索引缓存在
Module._cache
对象上,以提高二次引入的性能。
我们还知道 Node 的每个模块中都有 __filename
、 __dirname
这两个变量,是怎么来的的呢?
- 其实JavaScript模块在编译过程中,Node 对获取的 JavaScript 文件内容进行了头部和尾部的包装。在头部添加了
(function (exports, require, module,__filename, __dirname){
,而在尾部添加了})
; 。
因此一个JS模块经过编译之后会被包装成下面的样子:
1 | (function(exports, require, module, __filename, __dirname){ |
第三章:前端模块化
前面我们所说的CommonJS规范,都是基于node来说的,所以前面说的CommonJS都是针对服务端的实现。
前端模块化和服务端模块化有什么区别?
- 服务端加载一个模块,直接就从硬盘或者内存中读取了,消耗时间可以忽略不计
- 浏览器需要从服务端下载这个文件,所以说如果用
CommonJS
的require
方式加载模块,需要等代码模块下载完毕,并运行之后才能得到所需要的API
。
为什么CommonJS不适用于前端模块?
如果我们在某个代码模块里使用 CommonJS
的方法 require
了一个模块,而这个模块需要通过 http
请求从服务器去取,如果网速很慢,而 CommonJS
又是同步的,所以将阻塞后面代码的执行,从而阻塞浏览器渲染页面,使得页面出现假死状态。
因此后面 AMD规范
随着 RequireJS
的推广被提出,异步模块加载,不阻塞后面代码执行的模块引入方式,就是解决了前端模块异步模块加载的问题。
AMD(Asynchronous Module Definition) & RequireJS
AMD 异步模块加载规范
与 CommonJS
的主要区别就是异步模块加载,就是模块加载过程中即使 require
的模块还没有获取到,也不会影响后面代码的执行。
RequireJS AMD
规范的实现。其实也可以说 AMD
是 RequireJS
在推广过程中对模块定义的规范化产出。
模块定义
- 独立模块的定义,不依赖其它模块的模块定义
1 | //独立模块定义 |
- 非独立模块,依赖其他模块的模块定义
1 | define(['math', 'graph'], function(math, graph){ |
模块引用
1 | require(['a', 'b'], function(a, b){ |
CommonJS 和 AMD 的对比
- CommonJS 一般用于服务端,AMD 一般用于浏览器客户端
- CommonJS 和 AMD 都是运行时加载
什么是运行时加载?
我觉得要从两个点上去理解:
- CommonJS 和 AMD 模块都只能在运行时确定模块之间的依赖关系
- require 一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的
1 | //CommonJS 模块 |
上面代码实质是整体加载 path 模块,即加载了 path 所有方法,生成一个对象,然后再从这个对象上面读取3个方法。这种加载就称为 运行时加载
。
再看下面一个AMD的例子:
1 | //a.js |
运行 b.js 时得到结果:
1 | //a.js执行 |
可以看到当运行 b.js
时,因为 b.js
require a.js
模块的时候后 a.js
模块会先执行。验证了前面所说的”require一个模块的时候,模块会先被执行”。
CMD(Common Module Definition) & SeaJS
CMD 通用模块规范,由国内的玉伯提出。
SeaJS CMD的实现,其实也可以说 CMD
是 SeaJS
在推广过程中对模块定义的规范化产出。
与 AMD规范
的主要区别在于定义模块和依赖引入的部分。AMD
需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中:
1 | define(['dep1', 'dep2'], function(dep1, dep2){ |
与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义:
1 | define(factory); |
在依赖示例部分,CMD
支持动态引入,require
、exports
和 module
通过形参传递给模块,在需要依赖模块时,随时调用 require( )
引入即可,示例如下:
1 | define(function(require, exports, module){ |
也就是说与 AMD 相比,CMD 推崇依赖就近, AMD 推崇依赖前置。
ES6 模块
如前面所述,
CommonJS
和AMD
都是运行时加载。ES6
在语言规格层面上实现了模块功能,是编译时加载,完全可以取代现有的CommonJS
和AMD
规范,可以成为浏览器和服务器通用的模块解决方案。这里关于ES6模块
我们项目里使用非常多,所以详细讲解。
export
- 导出一个变量
1 | export const name = 'pengpeng'; |
- 导出一个函数
1 | export function foo(x, y){} |
- 常用导出方式(推荐)
1 | const name = 'dingman'; |
- As用法
1 | const s = 1; |
可以利用as将模块输出多次。
import
- 一般用法
1 | import { name, age } from './person.js'; |
- As 用法
1 | import { name as personName } from './person.js'; |
import 命令具有提升效果,会提升到整个模块的头部,首先执行,如下也不会报错:
1 | getName(); |
- 整体模块加载 *
1 | //逐一加载 |
export default
其实 export default
,在项目里用的非常多,一般一个 Vue组件
或者 React组件
我们都是使用 export default
命令,需要注意的是使用 export default
命令时,import
是不需要加 {}
的。而不使用 export default
时,import
是必须加 {}
,示例如下:
1 | //person.js |
export default
其实是导出一个叫做 default
的变量,所以其后面不能跟变量声明语句。
1 | //错误 |
值得注意的是我们可以同时使用 export
和 export default
1 | //person.js |
前面一直提到,CommonJS是运行时加载,ES6时编译时加载,那么两个有什么本质的区别呢?
ES6 模块与 CommonJS 模块加载区别
ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。所以说ES6是编译时加载,不同于 CommonJS
的运行时加载(实际加载的是一整个对象),ES6模块不是对象,而是通过 export命令
显式指定输出的代码,输入时也采用静态命令的形式:
1 | //ES6模块 |
以上这种写法与CommonJS的模块加载有什么不同?
当 require path 模块时,其实 CommonJS 会将 path 模块运行一遍,并返回一个对象,并将这个对象缓存起来,这个对象包含 path 这个模块的所有 API。以后无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
ES6 会从 path 模块只加载3个方法,其他不会加载,这就是编译时加载。ES6 可以在编译时就完成模块加载,当 ES6 遇到 import 时,不会像 CommonJS 一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。
因为 CommonJS 模块输出的是值的拷贝,所以当模块内值变化时,不会影响到输出的值。基于Node做以下尝试:
1 | //person.js |
可以看到内部 age 的变化并不会影响 person.age 的值,这是因为 person.age 的值始终是第一次运行时的结果的拷贝。
再看ES6
1 | //person.js |
总结
前端模块化规范包括 CommonJS
/ AMD
/ CMD
/ ES6
模块化,平时我们可能只知其中一种但不能全面了解他们的发展历史、用法和区别,以及当我们使用require
和 import
的时候到底发生了什么,这篇文章给大家算是比较全面的做了一次总结。