为何前端需要模块化
为了彻底弄清楚模块化这个东西,我们要从最开始模块化的起源说起。
无模块化的原始时代
最开始js只是作为一个脚本语言来使用,做一些简单的表单校验,动画实现等。 代码都是这样,直接把代码写在script标签里,而且代码量很少
if (true) {
// ...
} else {
// ...
}
for (var i=0; i< 100; i++) {
// ...
}
document.getElementById('Btn').onClick = function() {
// ...
}
代码量剧增带来的灾难性问题
后来随着ajax异步请求的出现,前端能做的事情越来越多,代码量飞速增长。 也暴露出一些问题。
- 全局变量的灾难 这个非常好理解,就是大家的代码都在一个作用域,不同的人定义的变量可能会重复从而产生覆盖。
// 试想彭彭定义了一个变量
name = 'pengpeng';
// 后来,丁满后面又定义了
name = 'dingman';
// 再后来, 彭彭开始使用他定义的变量
if (name === 'pengpeng'){
// ...
}
这就杯具了。
- 依赖关系管理的灾难
<script type="text/javascript" src="a.js"></script>
<script type="text/javascript" src="b.js"></script>
<script type="text/javascript" src="c.js"></script>
如果c依赖了b,b依赖了a,则script引入的顺序必须被依赖的放在前面,试想要是有几十个文件,我们都要弄清楚文件依赖关系然后手动,按顺序引入,无疑这是非常痛苦的事情。
早期的解决方式
- 闭包
var moduleA = function() {
var a, b;
return {
add: function (c){
return a + b + c;
};
}
}();
这样function内部的变量就对全局隐藏了,达到了封装的目的,但是最外层模块名还是暴露在全局,要是模快越来越多,依然会存在模块名冲突的问题.
- 命名空间 Yahoo的YUI早起的做法
app.tools.moduleA.add = function(c){
return app.tools.moduleA.a + c;
}
毫无疑问以上两种方法都不够优雅。 那么,模块化到底需要解决什么问题提呢?我们先设想一下可能有以下几点:
- 安全的包装一个模块的代码,避免全局污染
- 唯一标识一个模块
- 优雅的将模块api暴露出去
- 方便的使用模块
服务端模块化
Nodejs出现开创了一个新的纪元,使得我们可以使用javascript写服务器代码,对于服务端而言必然是需要模块化的。
Nodejs 与 CommonJs的关系
- node的模块化能以一种成熟的姿态出现离不开CommonJs规范
- 在服务器端CommonJS能以一种寻常的姿态写进各个公司的项目代码中,离不开Node的优异表现
- Node并非完全按照规范实现,针对模块规范进行了一定的取舍,同时也增加了少许自身特性
以上三点是摘自朴灵的《深入浅出Nodejs》
CommonJS规范简介
CommonJS对模块的定义非常简单,主要分为模块引用,模块定义和模块标识3部分
模块引用
var add = require('./add.js');
var config = require('config.js');
var http = require('http');
模块定义
module.exports.add = function () {
// ...
}
module.exports = function () {
return { xx: 1, fn: () => {} }
}
可以在另一个文件中引入模块并导出另一个模块
var add = require('./add.js');
module.exports.increment = function () {
return add(val, 1);
}
其实,一个文件代表一个模块,一个模块除了自己的函数作用域之外,最外层还有一个模块作用域,module就是代表这个模块,exports是module的属性。require也在这个模块的上下文中,用来引入外部模块。
模块标识
模块标识就是require( )函数的参数,规范是这样的:
- 必须是字符串
- 可以是以./ ../开头的相对路径
- 可以是绝对路径
- 可以省略后缀名
CommonJS的模块规范定义比较简单,意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出将上下游模块无缝衔接,每个模块具有独立的空间,它们互不干扰。
Nodejs的模块化实现
Node模块在实现中并非完全按照CommonJS来,进行了取舍,增加了一些自身的的特性。 Node中一个文件是一个模块——module 一个模块就是一个Module的实例 Node中Module构造函数:
function Module(id, parent){
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
// 实例化一个模块
var module = new Module(filename, parent);
其中id 是模块id,exports是这个模块要暴露出来的api,parent是父级模块,loaded表示这个模块是否加载完成,因为CommonJS是运行时加载,loaded表示文件是否已经执行完毕返回一个对象。
前端模块化
前面我们所说的CommonJS规范,都是基于node来说的,所以前面说的CommonJS都是针对服务端的实现。
前端模块化和服务端模块化有什么区别?
- 服务端加载一个模块,直接就从硬盘或者内存中读取了,消耗时间可以忽略不计
- 浏览器需要从服务端下载这个文件,所以说如果用CommonJS的require方式加载模块,需要等代码模块下载完毕,并运行之后才能得到所需要的API。
为什么CommonJS不适用于前端模块?
如果我们在某个代码模块里使用CommonJS的方法require了一个模块,而这个模块需要通过http请求从服务器去取,如果网速很慢,而CommonJS又是同步的,所以将阻塞后面代码的执行,从而阻塞浏览器渲染页面,使得页面出现假死状态。
因此后面AMD规范随着RequireJS的推广被提出,异步模块加载,不阻塞后面代码执行的模块引入方式,就是解决了前端模块异步模块加载的问题。
AMD && requieJs
AMD——异步模块加载规范 与CommonJS的主要区别就是异步模块加载,就是模块加载过程中即使require的模块还没有获取到,也不会影响后面代码的执行。
RequireJS——AMD规范的实现。其实也可以说AMD是RequireJS在推广过程中对模块定义的规范化产出。
代码示例
// 独立模块定义 ---- 不依赖其它模块的模块定义
define({
method1: function() {}
method2: function() {}
});
// 或者
define(function(){
return {
method1: function() {},
method2: function() {},
}
});
// 非独立模块——依赖其他模块的模块定义
define(['math', 'graph'], function(math, graph){
// ...
});
// 模块引用:
require(['a', 'b'], function(a, b){
a.method();
b.method();
})
对比CommonJs和AMD
- CommonJS一般用于服务端,AMD一般用于浏览器客户端
- CommonJS和AMD都是运行时加载
如果一种模块加载方式满足两种条件,那就是运行时加载
- 运行时能确定模块之间的依赖关系(依赖前置)
- require一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的
CMD && SeaJS
CMD——通用模块规范,由国内的玉伯提出。 SeaJS——CMD的实现,其实也可以说CMD是SeaJS在推广过程中对模块定义的规范化产出。 与AMD规范的主要区别在于定义模块和依赖引入的部分。AMD需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中:
define(['dep1', 'dep2'], function(dep1, dep2){
return function(){};
})
与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义,CMD规范中,一个文件就是一个模块,使用define来进行模块:
define(factory)
这里的define是一个全局函数,用来定义模块,这里的factory参数既可以是函数,又可以是字符串或对象。如果参数是字符串或对象时,表示该模块的接口就是是该对象或字符串
define({'website':'oecom'});
define('这里是OECOM');
当factory为函数时,此函数就是模块的构造方法,该函数默认为提供三个参数:require,exports,module,在需要依赖模块时,随时调用require( )引入即可,示例如下:
define(function(require, exports, module){
//依赖模块a 推崇就近原则
var a = require('./a');
//调用模块a的方法
a.method();
})
也就是说与AMD相比,CMD推崇依赖就近(延迟执行,用到再引入), AMD推崇依赖前置(提前执行)
UMD(Universal Module Definition), 通用模块规范
如下是codemirror模块lib/codemirror.js模块的定义方式:
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? module.exports = factory() // Node , CommonJS
: typeof define === 'function' && define.amd
? define(factory) //AMD CMD
: (global.CodeMirror = factory()); //模块挂载到全局
}(this, (function () {
// ...
})
可以看说所谓的兼容模式是将几种常见模块定义方式都兼容处理。 目前为止,前端常用的几种模块化规范都已经提到,还有一种我们项目里用得非常多的模块化引入和导出,就是ES6的模块化。
ES6模块化
如前面所述,CommonJS和AMD都是运行时加载。ES6在语言规格层面上实现了模块功能,是编译时加载,完全可以取代现有的CommonJS和AMD规范,可以成为浏览器和服务器通用的模块解决方案。这里关于ES6模块我们项目里使用非常多,所以详细讲解。
Es6模块导出---export
// 导出变量
export var name = 'pengpeng';
// 导出函数
export function foo(x, y){}
// 常用导出方式(推荐)
const name = 'dingman';
const age = '18';
const addr = '卡尔斯特森林';
export { firstName, lastName, year };
// as 用法
const s = 1;
export {
s as t,
s as m,
}
Es6模块导出---import
// 一般用法
import { name, age } from './person.js';
// as 用法
import { name as personName } from './person.js';
整体模块加载
// person.js
export name = 'xixi';
export age = 23;
//逐一加载
import { age, name } from './person.js';
//整体加载
import * as person from './person.js';
console.log(person.name);
console.log(person.age);
ES6模块使用——export default 其实export default,在项目里用的非常多,一般一个Vue组件或者React组件我们都是使用export default命令,需要注意的是使用export default命令时,import是不需要加{}的。而不使用export default时,import是必须加{},示例如下:
// person.js
export default function getName(){
// ...
}
// my_module
import getName from './person.js';
export default其实是导出一个叫做default的变量,所以其后面不能跟变量声明语句。
//错误
export default var a = 1;
值得注意的是我们可以同时使用export 和export default
// person.js
export name = 'dingman';
export default function getName(){
// ...
}
// my_module
import getName, { name } from './person.js';
import命令具有提升效果,会提升到整个模块的头部,首先执行,如下也不会报错
getName();
import { getName } from 'person_module';
前面一直提到,CommonJS是运行时加载,ES6时编译时加载,那么两个有什么本质的区别呢?
ES6模块与CommonJS模块加载区别
ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。所以说ES6是编译时加载,不同于CommonJS的运行时加载(实际加载的是一整个对象),ES6模块不是对象,而是通过export命令显式指定输出的代码,输入时也采用静态命令的形式:
// ES6模块
import { basename, dirname, parse } from 'path';
// CommonJS模块
let { basename, dirname, parse } = require('path');
以上这种写法es6加载与CommonJS的模块加载有什么不同?
- 当require path模块时,其实 CommonJS会将path模块运行一遍,并返回一个对象,并将这个对象缓存起来,这个对象包含path这个模块的所有API。以后无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
- ES6会从path模块只加载3个方法,其他不会加载,这就是编译时加载。ES6可以在编译时就完成模块加载,当ES6遇到import时,不会像CommonJS一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。
因为CommonJS模块输出的是值的拷贝,所以当模块内值变化时,不会影响到输出的值。基于Node做以下尝试:
// person.js Node环境
var age = 18;
module.exports ={
age: age,
addAge: function () {
age++;
}
}
// my_module
var person = require('./person.js');
console.log(person.age);
person.addAge();
console.log(person.age);
// 输出结果 可以看到内部age的变化并不会影响person.age的值,这是因为person.age的值始终是第一次运行时的结果的拷贝。s
18
18
再看ES6
// person.js
export let age = 18;
export function addAge(){
age++;
}
// my_module
import { age, addAge } from './person.js';
console.log(age);
addAge();
console.log(age);
// 输出结果
18
19
总结
前端模块化规范包括CommonJS/ AMD/CMD/ES6模块化,平时我们可能只知其中一种但不能全面了解他们的发展历史、用法和区别,以及当我们使用require 和import的时候到底发生了什么,这篇文章给大家算是比较全面的做了一次总结。