模块化

40 阅读10分钟

为何前端需要模块化

为了彻底弄清楚模块化这个东西,我们要从最开始模块化的起源说起。

无模块化的原始时代

最开始js只是作为一个脚本语言来使用,做一些简单的表单校验,动画实现等。 代码都是这样,直接把代码写在script标签里,而且代码量很少

if (true) {
  // ...
} else {
  // ...
}

for (var i=0; i< 100; i++) {
  // ...
}

document.getElementById('Btn').onClick = function() {
  // ...
}

代码量剧增带来的灾难性问题

后来随着ajax异步请求的出现,前端能做的事情越来越多,代码量飞速增长。 也暴露出一些问题。

  1. 全局变量的灾难 这个非常好理解,就是大家的代码都在一个作用域,不同的人定义的变量可能会重复从而产生覆盖。
// 试想彭彭定义了一个变量 
name = 'pengpeng';

// 后来,丁满后面又定义了
name =  'dingman';

// 再后来, 彭彭开始使用他定义的变量

if (name === 'pengpeng'){
  // ...
}

这就杯具了。

  1. 依赖关系管理的灾难
<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引入的顺序必须被依赖的放在前面,试想要是有几十个文件,我们都要弄清楚文件依赖关系然后手动,按顺序引入,无疑这是非常痛苦的事情。

早期的解决方式

  1. 闭包
var moduleA = function() {
   var a, b;
   return {
      add: function (c){
         return a + b + c;
      };
   }
}();

这样function内部的变量就对全局隐藏了,达到了封装的目的,但是最外层模块名还是暴露在全局,要是模快越来越多,依然会存在模块名冲突的问题.

  1. 命名空间 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都是运行时加载

如果一种模块加载方式满足两种条件,那就是运行时加载

  1. 运行时能确定模块之间的依赖关系(依赖前置)
  2. 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的时候到底发生了什么,这篇文章给大家算是比较全面的做了一次总结。