前言
JavaScript初期就是为了实现简单的页面交互逻辑,如今CPU、浏览器性能得到了极大的提升,很多页面逻辑迁移到了客户端,前端代码日益膨胀,此时在js方面就会考虑怎么样来管理使用模块化规范去管理。
无论是什么语言一旦发展到一定地步,其工程化能力和可维护性势必得到相应的发展。 模块化这件事,无论在哪个编程领域都是常见的事情,模块化存在的意义就是为了增加可复用性,以尽可能少的代码是实现个性化的需求。同为前端三剑客之一的 CSS 早在 2.1 的版本就提出了 @import 来实现模块化,但是 JavaScript 直到 ES6 才出现官方的模块化方案: ES Module (import、export)。尽管早期 JavaScript 语言规范上不支持模块化,但这并没有阻止 JavaScript 的发展,官方没有模块化标准开发者们就开始自己创建规范,自己实现规范。
前端模块化
JavaScript 在早期的设计中就没有模块、包甚至类的概念,虽然 ES6
中有了 class
关键字,那也只是个语法糖。随意随着项目复杂度的增加,开发者必然需要模拟类的功能,来隔离、封装、组织复杂的 JavaScript 代码,而这种封装和隔离,也被被我们称之为模块化。
模块就是一个实现特定功能的文件 or 代码块。随着前端工程体系建设的愈发成熟,或许模块化的概念已经在前端圈子里已经耳熟能详了。但是对于很多开发者而言,ES6 中的 export
、import
,nodejs
中的 require
、exports.xx
、module.exports
到底有什么区别?为什么又有 CommonJS
,又有 AMD
,CMD
,UMD
?区别是什么?甚至我们在编写 ts 文件的时候,还需要在配置文件里面说明什么模块方式,在项目中使用的时候,我们又是否真正知道,你用的到底是基于哪一种规范的模块化?
模块化的价值
- 可维护性,每一个模块都是独立的。良好的设计能够极大的降低项目的耦合度。以便于其能独立于别的功能被整改。至少维护一个独立的功能模块,比维护一坨凌乱的代码要容易很多。
- 减少全局变量污染,前端开发的初期,我们都在为全局变量而头疼,因为经常会触发一些难以排查且非技术性的 bug。当一些无关的代码一不小心重名了全局变量,我们就会遇到烦人的“命名空间污染”的问题。在模块化规范没有确定之前,其实我们都在极力的避免于此。(后文会介绍)
- 可复用性,前端模块功能的封装,极大的提高了代码的可复用性。这点应该就不用详细说明了。想想从
npm
上找package
的时候,是在干啥? - 方便管理依赖关系,在模块化规范没有完全确定的时候,模块之间相互依赖的关系非常的模糊,完全取决于 js 文件引入的顺序。粗俗!丝毫没有技术含量,不仅依赖模糊且难以维护。
模块化的进化过程
1、函数封装
回到我们刚刚说的模块的定义,模块就是一个实现特定功能的文件 or 代码块(这是我自己给定义的)。专业定义是,在程序设计中,为完成某一功能所需的一段程序或子程序;或指能由编译程序、装配程序等处理的独立程序单位;或指大型软件系统的一部分。而函数的一个功能就是实现特定逻辑的一组语句打包。并且 JavaScript 的作用域就是基于函数的,所以最原始之处,函数必然是作为模块化的第一步。
将不同的功能封装成不同的函数
- 编码: 将不同的功能封装成不同的全局函数
- 问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系,模块之间的关系模糊
//函数1
function fn1(){
//statement
}
//函数2
function fn2(){
//statement
}
2、namespace模式
也可以理解为对象封装,其实就是把相关函数、变量在外面加了一层
let module1 = {
let tag : 1,
let name:'module1',
fun1(){
console.log('this is fun1')
},
fun2(){
console.log('this is fun2')
}
}
我们在使用的时候呢,就直接
module1.fun2();
优点
- 一定程度上优化了命名冲突,降低了全局变量污染的风险
- 有一定的模块封装和隔离,并且还可以进一步语义化一些
缺点
- 并没有实质上改变命名冲突的问题
- 外部可以随意修改内部成员变量,还是容易产生意外风险
3、IIFE模式:立即执行匿名函数(闭包)
let global = 'Hello, I am a global variable :)';
(function () {
// 在函数的作用域中下面的变量是私有的
const myGrades = [93, 95, 88, 0, 55, 91];
let average = function() {
let total = myGrades.reduce(function(accumulator, item) {
return accumulator + item}, 0);
return 'Your average grade is ' + total / myGrades.length + '.';
}
let failing = function(){
let failingGrades = myGrades.filter(function(item) {
return item < 70;});
return 'You failed ' + failingGrades.length + ' times.';
}
console.log(failing());
console.log(global);
// 需要暴露的api
return {
// something
}
}());
// 控制台显示:'You failed 2 times.'
// 控制台显示:'Hello, I am a global variable :)'
这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量
类似如上的 IIFE
,还有非常多的演进写法
比如引入依赖:
把内部需要的变量传进去。
// module.js文件
(function(window, $) {
let data = 'www.baidu.com'
//操作数据的函数
function foo() {
//用于暴露有函数
console.log(`foo() ${data}`)
$('body').css('background', 'red')
}
function bar() {
//用于暴露有函数
console.log(`bar() ${data}`)
otherFun() //内部调用
}
function otherFun() {
//内部私有的函数
console.log('otherFun()')
}
//暴露行为
window.myModule = { foo, bar }
})(window, jQuery)
使用
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
myModule.foo()
</script>
优点
- 实现了基本的封装
- 只暴露对外的方法操作,利用闭包实现了类似
public
和private
的概念
缺点
- 模块依赖关系模糊
- 模块与模块之间不好管理
上述的所有解决方案,虽然每种方法都可行,但是始终没有哪一种可以很好的解决变量污染、各个模块之间依赖关系清晰、方便管理、与第三方代码方便集成。随着大前端时代的到来,在2009年提出了 CommonJS 规范,并且nodeJs 直接用该规范来管理模块化,随着时间的迁移,现在 JavaScript 模块规范也就有了:CommonJS、AMD、CMD、UMD、ES6 模块化。
4、CommonJS
CommonJS 是 JavaScript 的一个模块化规范(http://www.commonjs.org/),主要用于服务端Nodejs 中。根据规范,每一个文件既是一个模块,其内部定义的变量是属于这个模块的,不会污染全局变量。每个模块内部,module变量代表当前模块,这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
CommonJS 的核心思想是通过 require 方法来同步加载所依赖的模块,然后通过 exports 或者 module.exprots 来导出对外暴露的接口。
基本用法
- 暴露模块:module.exports = value 或 exports.xxx = value
- 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
// example.js
let x = 5;
let addX = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
let example = require('./example.js');
console.log(example.x); // 5
console.log(example.addX(1)); // 6
require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象,如果没有发现指定模块,会报错,如果存在多个 exports 只有第一个exports 有效。
require 是加载这个文件然后执行,在返回、输出 exports 这个对象
console.log('开始加载了') // 会输出 开始加载了
function run (val) {
console.log(val)
}
特点
- 以文件为一个单元模块,代码运行在模块作用域内,不会污染全局变量
- 同步加载模块,在服务端直接读取本地磁盘没问题,不太适用于浏览器
- 模块可以加载多次,但是只会在第一次加载时运行,然后在加载,就是读取的缓存文件。需清理缓存后才可再次读取文件内容
- 模块加载的顺序,按照其在代码中出现的顺序
- 导出的是值的拷贝,这一点和 ES6 有着很大的不同(后面会介绍到)
补充知识点
Node 中,一个文件是一个模块->module
源码定义如下:
function Module(id = '', parent) {
this.id = id;
this.path = path.dirname(id);
this.exports = {};
this.parent = parent;
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
//实例化一个模块
var module = new Module(filename, parent);
CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
{
id: '...',
exports: { ... },
loaded: true,
...
}
上面代码就是 Node 内部加载模块后生成的一个对象。该对象的id属性是模块名,exports属性是模块输出的各个接口,loaded属性是一个布尔值,表示该模块的脚本是否执行完毕。其他还有很多属性。以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
关于AMD、CMD
CommonJS 在 Node.js 环境下取得了很不错的成功,很多人想把commonJs 规范推向浏览器端,但是浏览器不能像服务器那样直接读取磁盘上的内容所以又有了后面的AMD、CMD规范。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代现有的 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案,因为我自己也是在近几年才做前端, AMD、CMD 并没有太多使用,所以AMD、CMD 这里只是做简单的介绍。
5、AMD
AMD 全称为 Asynchromous Module Definition(异步模块定义)
AMD 是 RequireJS 在推广过程中对模块定义的规范化产出,它是一个在浏览器端模块化开发的规范。 AMD 模式可以用于浏览器环境并且允许异步加载模块,同时又能保证正确的顺序,也可以按需动态加载模块。
特点
- 异步加载模块,不会造成因网络问题而出现的假死
- 显式地列出其依赖关系,并以函数(定义此模块的那个函数)参数的形式将这些依赖进行注入
- 在模块开始时,加载所有所需依赖
定义模块
define(id?: String, dependencies?: String[], callback: Function|Object);
- id,一个可选参数,类似给模块取个名字,但是却是模块的唯一标识。如果没有提供则取脚本的文件名
- dependence,依赖的模块数组
- callback,工厂方法,模块初始化的一些操作。如果是函数,只被执行一次。如果是对象,则为模块的输出值
使用模块
require([moduleName],callback);
//article.js文件
// 定义有依赖的模块
define(['user'], function(user) {
let name = 'THE LAST TIME'
function consoleMsg() {
console.log(`${name} by ${user.getAuthor()}`);
}
// 暴露模块
return { consoleMsg }
})
// 调用 article 模块种的 consoleMsg
require(['article'], function(alerter) { article.consoleMsg() })
6、CMD
define(factory);
define 是一个全局函数,用来定义模块,参数factory可以是对象、字符串、函数
factory 为对象、字符串时,表示模块的接口就是该对象、字符串。比如可以如下定义一个 JSON 数据模块:
define({ "foo": "bar" });
也可以通过字符串定义模板模块:
define('I am a template. My name is {{name}}.');
factory 为函数时,表示是模块的构造方法。
执行该构造方法,可以得到模块向外提供的接口。factory 方法在执行时,默认会传入三个参数:require、exports 和 module:
define(function(require, exports, module) {
// 模块代码
});
使用sea.js
/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
var $ = require('jquery.js');
var add = function(a,b){
return a+b;
}
exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
var sum = math.add(1+2);
});
关于 sea.js 的使用,仔细看文档,其实还是有很多知识点的。但是鉴于我们着实现在使用不多(我也不熟),所以这里也就参考网上优秀文章和自己实践,抛砖引玉。
7、UMD
UMD 是 AMD 和 CommonJS 的综合产物。如上所说,AMD 的用武之地是浏览器,非阻塞式加载。CommonJS 主要用于服务端 Nodejs 中使用。所以人们就想到了一个通用的模式 UMD(universal module definition)。来解决跨平台的问题。
没错!就是 ifElse 的写法。
核心思想就是:先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式。
在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
//AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
//Node, CommonJS之类的
module.exports = factory(require('jquery'));
} else {
//浏览器全局变量(root 即 window)
root.returnExports = factory(root.jQuery);
}
}(this, function ($) {
//方法
function myFunc(){};
//暴露公共方法
return myFunc;
}));
8、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)这些只能靠静态分析实现的功能。
特点
- 静态编译
- 输出的值引用,而非值拷贝
- import 只能写在顶层,因为是静态语法
9、CommonJs、ESM 区别
CommonJs | ES6 Module |
运行时加载;CommonJs模块就是对象(module.exports属性)),即在输入时是先加载整个模块、执行模块,生成一个对象,然后再从这个对象上面读取方法。 | 编译时加载;ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块。 |
输出的是值的拷贝(一旦输出一个值,模块内部的变化就影响不到这个值。) | 输出的是值的引用(JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。即原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。) |
差异
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
加载 & 编译
因为 CommonJS
加载的是一个对象(module.exports
),对象只有在有脚本运行的时候才能生成。而 ES6 模块不是一个对象,只是一个静态的定义。在代码解析阶段就会生成。
ES6 模块是编译时输出接口,因此有如下2个特点:
- import 命令会被 JS 引擎静态分析,优先于模块内的其他内容执行
- export 命令会有变量声明提升的效果,所以import 和 export 命令在模块中的位置并不影响程序的输出。
- 异步加载模块,不会造成因网络问题而出现的假死
- 显式地列出其依赖关系,并以函数(定义此模块的那个函数)参数的形式将这些依赖进行注入
- 在模块开始时,加载所有所需依赖