模块的概念
(Modular design) 所谓的模块化设计,简单地说就是将产品的某些要素组合在一起,构成一个具有特定功能的子系统,将这个子系统作为通用性的模块与其他产品要素进行多种组合,构成新的系统,产生多种不同功能或相同功能、不同性能的系列产品。
这是在整个设计行业对模块化的定义。对于编程语言中的模块化设计,其基本思路就是将有相同功能的部分代码封装在一起,形成一个通用的,可复用的模块,使之在其它系统中可以重复利用,并不会对模块内部产生影响。所以设计模式中模块化设计的三大特征也要遵循:
相对独立性
互换性
通用性
换做在编程语言中,相对独立性即要使私有成员无法被外部访问并暴露给外部指定的方法。通用性在程序设计中多被称为可复用性,而模块设计的原则和目的也是可复用性。模块可以减少我们对重复代码的编写,提高开发的效率。
JavaScript对模块的需求
最初JavaScript是作为网页开发的脚本而开发,Brendan Eich 可能也不会想到当初十几天开发出的一个脚本语言如今会焕发如此的生命力,也正是因为开发周期如此之短,使之缺点在当今工程化的JavaScript中被极大的放大。其中一个就是模块的概念,JavaScript原生并没有模块的概念,就如同没有原生的类一样(尽管ES6推出了所谓的类)。为此,开发者想出了很多方法从语言层面来模拟模块化。
初期的模块化
在各类框架,插件没有流行,JavaScript仅作为一门脚本语言的时候,开发人员想出了一些方法来对项目中一些方法集合进行封装,形成类似于模块的模式。
原生写法
描述
模块就是实现特定功能的一组方法。
只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。
例子
1 | function m1(){ |
缺点
- “污染”了全局变量,无法保证不与其他模块发生变量名冲突
- 模块成员之间看不出直接关系。
对象写法
描述
为了解决上面的“全局变量污染的问题”,又利用了对象这一数据类型,使一个模块成为一个对象,模块的成员作为对象的成员变量。
例子
1 | var module1 = new Object({ |
缺点
- 私有变量被直接暴露给外部,如上面的
_count
应该是一个保留的私有变量,但是在外部我们也是可以访问到的。
立即执行函数(IIFE)写法
描述
又为了解决无法保有私有成员的问题(其本质是JavaScript没有局部作用域的问题,具体看这里),这里利用了立即执行函数形成一个闭包的同时也形成了一个局部作用域,这个作用域内的变量在外部是无法访问到的。这样就解决了上面的私有便变量的问题。
例子
1 | var module1 = (function(){ |
这里module1
形成了一个闭包,返回一个对象,我们只能访问到对象暴露的m1
和m2
方法,内部的_count
是无法被访问到了。
优点
- 提高性能:通过 IIFE 的参数传递常用全局对象 window、document,在作用域内引用这些全局对象。JavaScript 解释器首先在作用域内查找属性,然后一直沿着链向上查找,直到全局范围,因此将全局对象放在 IIFE 作用域内可以提升js解释器的查找速度和性能;
- 压缩空间:通过参数传递全局对象,压缩时可以将这些全局对象匿名为一个更加精简的变量名;
缺点
不能很好的管理依赖,缺少一个依赖管理者。比如依赖的调用顺序,在没有管理者时,我们必须自己确定调用顺序,比如:
我们要调用
module1
的mock
方法,而
module1
又依赖module2
module2
又依赖module3
module4
又依赖module3
我们则必须按这个顺序加载脚本文件
1
2
3
4
5
6
7
8<script src='./module4'></script>
<script src='./module3'></script>
<script src='./module2'></script>
<script src='./module1'></script>
<script>
module1.mock()
</script>
立即执行函数(IIFE)的衍生写法
放大模式
描述
如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用”放大模式”(augmentation)。
例子
1 | var module1 = (function (mod){ |
上面的代码为module
添加了一个m3
属性。并将新的模块实体返回。
宽放大模式
描述
对于上面的放大模式,存在一定的问题。由于在浏览器中,所有的资源都是异步加载的,所以上面的参数可能不存在,所以我们必须要考虑这种情况,增强代码的健壮性。
例子
1 | var module1 = ( function (mod){ |
上面的代码相当于是给函数参数设置了一个默认参数为{}
,当window.module1
不存在的时候,在对{}
空对象进行操作。
现代模块设计
恩格斯说:“社会一旦有技术上的需要,则这种需要会比十所大学更能把科学院推向前进。”随着进入大前端时代,网站的规模越来越大,逻辑层面越来越复杂。模块化的管理成为必然,很多模块管理框架应运而生。具有代表性的有:
- Node.js中CommonJS
- 基于 AMD 的 RequireJS
- 基于 CMD 的 SeaJS
- ECMAScript规定的ES Module
CommonJS
描述
CommonJS 是Node.js中采用的一种规范,其基本原则有:
- 由于
Node
是在服务端运行,所以CommonJS
的模块加载是同步进行的,所以其在浏览器中并不适用,因为浏览器中的文件都是通过网络加载的,并不适合同步加载。 - 每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
- 每个模块内部有一个全局变量
module
,这个变量是一个对象,它的exports
属性(即module.export
)用于导出模块。 - 每个模块内部有一个全局变量
require
,其是一个函数,用于导入模块,参数即模块的地址。 - 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
例子
导出
1 | //module1.js |
导入
1 | let module1 = require('./module1.js') |
详细介绍
module对象
Node
内部提供一个Module
构建函数。所有的模块都是Module
的实例。
查看Node
源码:
1 | function Module(id, parent){ |
可以发现其是一个构造器函数,其中设置了以下属性:
module.id
:模块的识别符,通常是带有绝对路径的模块文件名。module.filename
模块的文件名,带有绝对路径。module.loaded
返回一个布尔值,表示模块是否已经完成加载。module.parent
返回一个对象,表示调用该模块的父级块。module.children
返回一个数组,表示该模块要用到的其他子级模块。module.exports
表示模块对外输出的值。
实际上我们模块导入的过程就是为对应模块的module.exports
对象增加成员的过程。
目录加载规则
通常一个项目都有一个入口文件(或函数),比如C语言,Java中的main
函数。在Node
项目中,一般也会指定一个入口文件,让require
方法可以通过这个入口文件,加载整个项目。
一般这个入口放在packge.json
文件,并将入口文件写入main
字段。如:
1 | //packge.json |
require
发现参数字符串指向一个目录以后,会自动查看该目录的packge.json
文件,然后加载main
字段指定的入口文件。如果packge.json
文件没有main
字段,或者没有packge.json
文件,则会加载该目录下的index.js
文件或者index.node
文件。
模块缓存
Node会在第一次加载模块后,缓冲该模块(实际上是缓存该模块的module.exports
属性)。如:
1 | require('./moudle1.js') |
上面三次导入一个模块,但是我们添加的成员变量在第三次缓冲时仍然可以访问到,证明其是被缓冲在内存中的。
我们可以通过删除require.cache
的对应属性来删除模块缓冲。
删除模块缓冲
1 | //删除指定模块 |
模块的加载机制
CommonJS
模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。请看下面这个例子。
下面是一个模块文件lib.js
。
1 | // lib.js |
上面代码输出内部变量 counter
和改写这个变量的内部方法 incCounter
。
然后,加载上面的模块。
1 | // main.js |
上面代码说明,counter
输出以后,lib.js
模块内部的变化就影响不到 counter
了。
AMD
描述
AMD 全称为 Asynchromous Module Definition(异步模块定义)。 AMD 是 RequireJS 在推广过程中对模块定义的规范化产出,它是一个在浏览器端模块化开发的规范。 AMD 模式可以用于浏览器环境并且允许异步加载模块,同时又能保证正确的顺序,也可以按需动态加载模块。
特点
AMD
是依赖前置,即提前声明需要的依赖。- 对依赖的加载是提前进行的,在运行前就加载所有的依赖。
用法
模块通过 define
函数定义在闭包中,格式如下:
1 | define(id?: String, dependencies?: String[], factory: Function|Object); |
id
是模块的名字,它是可选的参数。
dependencies
指定了所要依赖的模块列表,它是一个数组,也是可选的参数,每个依赖的模块的输出将作为参数一次传入 factory
中。如果没有指定 dependencies
,那么它的默认值是 ["require", "exports", "module"]
:
1 | define(function(require, exports, module) {}) |
factory
是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值。
例子(require.js
)
定义模块
1 | define(['jquery'], function($)P{ |
引入模块
1 | // 配置文件 |
CMD
描述
CMD(Common Module Definition) 是 SeaJS 在推广过程中对模块定义的规范化产出。CMD 规范的前身是 Modules/Wrappings 规范。
特点
- CMD推崇依赖就近,即在依赖使用时才引入。
- CMD是延迟执行的,即使用的时候才延迟执行的。
用法(SeaJS)
1、**seajs.config
({…}); //用来对 Sea.js 进行配置。
2、seajs.use
([‘a’,’b’],function(a,b){…}); //用来在页面中加载一个或多个模块。
3、define
(function(require, exports, module){…}); //用来定义模块。Sea.js 推崇一个模块一个文件,遵循统一的写法:
4、require
(function(require){var a = require(“xModule”); … }); //require 用来获取指定模块的接口。
5、require.async
**, //用来在模块内部异步加载一个或多个模块。 例如:
1 | define(function(require){ |
6、**exports
**, //用来在模块内部对外提供接口。 例如:
1 | define(function(require, exports){ |
7、**module.exports
**, 与 exports 类似,用来在模块内部对外提供接口。例如:
1 | define(function(require, exports, module) { |
例子
定义模块
1 | // seajs 的简单配置 |
如果retunr
语句是模块的唯一代码,还可以简化为:
1 | define({ |
引入模块
1 |
|
注意:
- 对
module.exports
的赋值需要同步执行,不能放在回调函数里。 - 在
html
中 ,为script
标签添加data-main = true
确定其为主入口。data-main通常用在只有一个入口的情况,use可以用在多个入口的情况
UMD
描述
UMD(Universal Module Definition),AMD模块以浏览器第一的原则发展,异步加载模块。CommonJS模块以服务器第一原则发展,选择同步加载,它的模块无需包装(unwrapped modules)。这迫使人们又想出另一个更通用的模式UMD (Universal Module Definition)。希望解决跨平台的解决方案。
特点
- 兼容 AMD 和 CommonJS 规范的同时,还兼容全局引用的方式
例子
1 | (function (root, factory) { |
ES Module
描述
在 ES Module 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES Module 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES Module 的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。
CommonJS 和 AMD 模块,其本质是在运行时生成一个对象进行导出,称为“运行时加载”,没法进行“编译优化”,而 ES Module 不是对象,而是通过 export
命令显式指定输出的代码,再通过 import
命令输入。这称为“编译时加载”或者静态加载,即 ES Module 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES Module 模块本身,因为它不是对象。
由于 ES Module 是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ESz Module 还有以下好处:
- 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES Module 格式。目前,通过各种工具库,其实已经做到了这一点。
- 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
- 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。
特点
- 静态编译
- 输出的值引用,而非值拷贝
import
只能写在顶层,因为是静态语法- ES6 的模块自动采用严格模式,不管你有没有在模块头部加上
"use strict";
。(具体严格模式内容不再赘述)
例子
导出模块
1 | //module1.js |
注意:
export default
命令用于指定模块的默认输出。export default
就是输出一个叫做default
的变量或方法,然后系统允许你为它取任意名字export default
只能导出变量,不能在后面声明变量。如:1
2//错误
export default const pi = 3.1415926export
语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。如:
1
2export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);面代码输出变量
foo
,值为bar
,500 毫秒之后变成baz
。
导入模块
1 | //直接导入模块所有成员 |
注意
export
后无法直接接变量内容。如:1
2//错误
export 3
import()
import
命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行(import
命令叫做“连接” binding 其实更合适)。所以import
和export
命令只能在模块的顶层,不能在代码块之中。如,下面的代码会报错
1 | // 报错 |
但是有时候我们需要按需引入又该怎么办,ES2020提案 引入import()
函数,支持动态加载模块。
import(specifier)
特点
import
函数的参数specifier
,指定所要加载的模块的位置。import
命令能够接受什么参数,import()
函数就能接受什么参数,两者区别主要是后者为动态加载。import()
返回一个 Promise 对象。模块作为Promise
的参数返回下面是一个例子。
1 | const main = document.querySelector('main'); |
import()
函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import()
函数与所加载的模块没有静态连接关系,这点也是与import
语句不相同。import()
类似于 Node 的require
方法,区别主要是前者是异步加载,后者是同步加载。
兼容性
总结
目前JavaScript的模块规范就是上面谈到的四种,CommonJS、AMD、CMD、ES Module。 CommonJS 用在服务器端,AMD 和CMD 用在浏览器环境,ES Module 是作为终极通用解决方案,时下热议的vite即利用了ES Module。
比较
AMD 和 CMD 的区别
- 执行时机: AMD 是提前执行,CMD 是延迟执行。
- 对依赖的处理:AMD 推崇依赖前置,CMD 推崇依赖就近。
- API 设计理念:AMD 的 API 默认是一个当多个用,非常灵活,CMD 的 API 严格区分,推崇职责单一。
- 遵循的规范:RequireJS 遵循的是 Modules/AMD 规范,SeaJS 遵循的是 Mdoules/Wrappings 规范的 define 形式。
- 设计理念:SeaJS 设计理念是 focus on web, 努力成为浏览器端的模块加载器,RequireJS 想成为浏览器端的模块加载器,同时也想成为 Rhino / Node 等环境的模块加载器。
CommonJS 和 ES Module 的区别
- 加载时机:CommonJS 是运行时加载(动态加载),ES Module 是编译时加载(静态加载)
- 加载模块:CommonJS 模块就是对象,加载的是该对象,ES Module 模块不是对象,加载的不是对象,是接口
- 加载结果:CommonJS 加载的是整个模块,即将所有的接口全部加载进来,ES Module 可以单独加载其中的某个接口(方法)
- 输出:CommonJS 输出值的拷贝,ES Module 输出值的引用
- this: CommonJS 指向当前模块,ES Module 指向 undefined