前端模块化一——规范详述

791 阅读17分钟

前言

模块化——这个东西可以说在研发圈子里,耳熟能详,家喻户晓。
都快说烂了,今天为什么又拿出来讲?
作为一个开发经验刚满一年的前端,
接触过es6 的import,nodejs 的require,webpack下的import或者require。那么他们之间到底有什么区别?
CommonJS 听说过,nodejs的模块化实现?AMD听说过,也听说过CMD,还有个ES6 模块化、webpack 模块化。
当项目里使用的时候,我们是否真正知道,你用的到底基于哪个规范的模块化?
最重要的是,当我们require( )或者import的时候,我们要知道底发生了什么?

文章很长,需要一点耐心,但是关于前端的模块化的东西从萌芽时代到现在的ES6 、webpack实战中所有模块化的东西都有详尽的讲解。

1 为何前端需要模块化?

ps:基础比较好的同学可以跳过第一章节
为了彻底弄清楚模块化这个东西,我们要从最开始模块化的起源说起。

1.1 无模块化的原始时代

最开始js只是作为一个脚本语言来使用,做一些简单的表单校验,动画实现等等。
代码都是这样的,直接把代码写进<script>标签里,代码量非常少。
<script>
 if(true) {
   ...
 } else {
   ...
 }
 for(var i=0; i< 100; i++){
   ...
 }
 document.getElementById('button').onClick = function () {
   ...
 }
</script>

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

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

1.3 早期的解决方式

(1)闭包
moduleA = function() {
   var a,b;
   return {
      add: function (c){
         return a + b + c;
      };
   }
}()
这样function内部的变量就对全局隐藏了,达到了封装的目的,但是最外层模块名还是暴露在全局,要是模快越来越多,依然会存在模块名冲突的问题。
(2)命名空间
Yahoo的YUI早起的做法
app.tools.moduleA.add = function(c){
   return app.tools.moduleA.a + c;
}
毫无疑问以上两种方法都不够优雅。

那么,模块化到底需要解决什么问题提呢?我们先设想一下可能有以下几点
  • 安全的包装一个模块的代码,避免全局污染
  • 唯一标识一个模块
  • 优雅的将模块api暴露出去
  • 方便的使用模块

2 服务端模块化

Nodejs出现开创了一个新的纪元,使得我们可以使用javascript写服务器代码,对于服务端而言必然是需要模块化的。

2.1 Nodejs和CommonJS的关系

这里要说一下Nodejs和CommonJS的关系。
  • Nodejs的模块化能一种成熟的姿态出现离不开CommonJS的规范的影响
  • 在服务器端CommonJS能以一种寻常的姿态写进各个公司的项目代码中,离不开Node的优异表现
  • Node并非完全按照规范实现,针对模块规范进行了一定的取舍,同时也增加了少许自身特性

以上三点是摘自朴灵的《深入浅出Nodejs》

2.2 CommonJS规范简介

CommonJS对模块的定义非常简单,主要分为模块引用,模块定义和模块标识3部分
(1)模块引用
var add = require('./add.js');
var config = require('config.js');
var http = require('http');
(2)模块定义
module.exports.add = function () {
  ...
}
module.exports = function () {
  return ...
}
可以在一个文件中引入模块并导出另一个模块
var add = require('./add.js');
module.exports.increment = function () {
  return add(val, 1);
}
大家可能会疑惑,并没有定义module,require 这两个属性是怎么来的呢??(后面在介绍Nodejs模块化——模块编译部分会给大家详细介绍,这里先简单说一下)。
其实,一个文件代表一个模块,一个模块除了自己的函数作用域之外,最外层还有一个模块作用域,module就是代表这个模块,exports是module的属性。require也在这个模块的上下文中,用来引入外部模块。
(3)模块标识
模块标识就是require( )函数的参数,规范是这样的:
  • 必须是字符串
  • 可以是以./ ../开头的相对路径
  • 可以是绝对路径
  • 可以省略后缀名
CommonJS的模块规范定义比较简单,意义在于将类聚的方法和变量等限定在私有的作用域中,同时支持引入和导出将上下游模块无缝衔接,每个模块具有独立的空间,它们互不干扰。

2.3 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表示文件是否已经执行完毕返回一个对象。

2.4 Node模块分类

如图所示Node模块一般分为两种核心模块和文件模块。
图1 模块分类
核心模块——就是Node内置的模块比如http, path等。在Node的源码的编译时,核心模块就一起被编译进了二进制执行文件,部分核心模块(内建模块)被直接加载进内存中。

在Node模块的引入过程中,一般要经过一下三个步骤
  • 路径分析
  • 文件定位
  • 编译执行
核心模块会省略文件定位和编译执行这两步,并且在路径分析中会优先判断,加载速度比一般模块更快。
文件模块——就是外部引入的模块如node_modules里通过npm安装的模块,或者我们项目工程里自己写的一个js文件或者json文件。
文件模块引入过程以上三个步骤都要经历。

2.5 那么NodeJS require的时候是怎么路径分析,文件定位并且编译执行的?


2.5.1 路径分析
前面已经说过,不论核心模块还是文件模块都需要经历路径分析这一步,当我们require一个模块的时候,Node是怎么区分是核心模块还是文件模块,并且进行查找定位呢?
Node支持如下几种形式的模块标识符,来引入模块:
//核心模块
require('http')

----------------------------
//文件模块

//以.开头的相对路径,(可以不带扩展名)
require('./a.js')

  
//以..开头的相对路径,(可以不带扩展名)
require('../b.js')


//以/开始的绝对路径,(可以不带扩展名)
require('/c.js')


//外部模块名称
require('express')

//外部模块某一个文件
require('codemirror/addon/merge/merge.js');
那么对于这个都是字符串的引入方式,
  • Node 会优先去内存中查找匹配核心模块,如果匹配成功便不会再继续查找
(1)比如require http 模块的时候,会优先从核心模块里去成功匹配
  • 如果核心模块没有匹配成功,便归类为文件模块
(2) 以.、..和/开头的标识符,require都会根据当前文件路径将这个相对路径或者绝对路径转化为真实路径,也就是我们平时最常见的一种路径解析
(3)非路径形式的文件模块 如上面的'express' 和'codemirror/addon/merge/merge.js',这种模块是一种特殊的文件模块,一般称为自定义模块。
自定义模块的查找最费时,因为对于自定义模块有一个模块路径,Node会根据这个模块路径依次递归查找。

模块路径——Node的模块路径是一个数组,模块路径存放在module.paths属性上。
我们可以找一个基于npm或者yarn管理项目,在根目录下创建一个test.js文件,内容为console.log(module.paths),如下:
//test.js
console.log(module.paths);
然后在根目录下用Node执行
node test.js
可以看到我们已经将模块路径打印出来。
图2 模块路径
可以看到模块路径的生成规则如下:
  • 当前路文件下的node_modules目录
  • 父目录下的node_modules目录
  • 父目录的父目录下的node_modules目录
  • 沿路径向上逐级递归,直到根目录下的node_modules目录
对于自定义文件比如express,就会根据模块路径依次递归查找。
在查找同时并进行文件定位。
2.5.2 文件定位
  • 扩展名分析
我们在使用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,若都没有定位到,将抛出查找失败的异常。
2.5.3 模块编译
  • .js文件——通过fs模块同步读取文件后编译执行
  • .node文件——用C/C++编写的扩展文件,通过dlopen( )方法加载最后编译生成的文件。
  • .json——通过fs模块同步读取文件后,用JSON.parse( ) 解析返回结果。
  • 其余扩展名文件。它们都是被当做.js文件载入。
每一个编译成功的文件都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。
这里我们只讲解一下JavaScript模块的编译过程,以解答前面所说的CommonJS模块中的require、exports、module变量的来源。
我们还知道Node的每个模块中都有__filename、__dirname 这两个变量,是怎么来的的呢?
其实JavaScript模块在编译过程中,Node对获取的JavaScript文件内容进行了头部和尾部的包装。在头部添加了(function (exports, require, module,__filename, __dirname){\n,而在尾部添加了\n}); 。
因此一个JS模块经过编译之后会被包装成下面的样子:
(function(exports, require, module, __filename, __dirname){
  var express = require('express') ;
  exports.method = function (params){
   ...
  };
});

3、前端模块化

前面我们所说的CommonJS规范,都是基于node来说的,所以前面说的CommonJS都是针对服务端的实现。

3.1 前端模块化和服务端模块化有什么区别?

  • 服务端加载一个模块,直接就从硬盘或者内存中读取了,消耗时间可以忽略不计
  • 浏览器需要从服务端下载这个文件,所以说如果用CommonJS的require方式加载模块,需要等代码模块下载完毕,并运行之后才能得到所需要的API。

3.2 为什么CommonJS不适用于前端模块?

如果我们在某个代码模块里使用CommonJS的方法require了一个模块,而这个模块需要通过http请求从服务器去取,如果网速很慢,而CommonJS又是同步的,所以将阻塞后面代码的执行,从而阻塞浏览器渲染页面,使得页面出现假死状态。

因此后面AMD规范随着RequireJS的推广被提出,异步模块加载,不阻塞后面代码执行的模块引入方式,就是解决了前端模块异步模块加载的问题。

3.3 AMD(Asynchronous Module Definition) & RequireJS

AMD——异步模块加载规范 与CommonJS的主要区别就是异步模块加载,就是模块加载过程中即使require的模块还没有获取到,也不会影响后面代码的执行。
RequireJS——AMD规范的实现。其实也可以说AMD是RequireJS在推广过程中对模块定义的规范化产出。
模块定义:
(1)独立模块的定义——不依赖其它模块的模块定义
//独立模块定义
define({
  method1: function() {}
  method2: function() {}
});  

//或者
define(function(){
  return {
    method1: function() {},
    method2: function() {},
  }
});                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
(2)非独立模块——依赖其他模块的模块定义
define(['math', 'graph'], function(math, graph){
  ...
});
模块引用:
require(['a', 'b'], function(a, b){
  a.method();
  b.method();
})

3.4 CommonJS 和AMD的对比:

  • CommonJS一般用于服务端,AMD一般用于浏览器客户端
  • CommonJS和AMD都是运行时加载

3.5 什么是运行时加载?

我觉得要从两个点上去理解:
  • CommonJS 和AMD模块都只能在运行时确定模块之间的依赖关系
  • require一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的
//CommonJS 模块
let { basename, dirname, parse } = require('path');

//等价于
let _path = require('path');
let basename = _path.basename, dirname = _path.dirname, parse = _path.parse;
上面代码实质是整体加载path模块,即加载了path所有方法,生成一个对象,然后再从这个对象上面读取3个方法。这种加载就称为"运行时加载"。
再看下面一个AMD的例子:
//a.js
define(function(){
  console.log('a.js执行');
  return {
    hello: function(){
      console.log('hello, a.js');
    }
  }
});

//b.js
require(['a'], function(a){
  console.log('b.js 执行');
  a.hello();
  $('#b').click(function(){
    b.hello();
  });
});
运行b.js时得到结果:
//a.js执行
//b.js执行
//hello, a.js
可以看到当运行b.js时,因为b.js require a.js模块的时候后a.js模块会先执行。验证了前面所说的"require一个模块的时候,模块会先被执行"。

3.6 CMD(Common Module Definition) & SeaJS

CMD——通用模块规范,由国内的玉伯提出。
SeaJS——CMD的实现,其实也可以说CMD是SeaJS在推广过程中对模块定义的规范化产出。
与AMD规范的主要区别在于定义模块和依赖引入的部分。AMD需要在声明模块的时候指定所有的依赖,通过形参传递依赖到模块内容中:
define(['dep1', 'dep2'], function(dep1, dep2){
  return function(){};
})
与AMD模块规范相比,CMD模块更接近于Node对CommonJS规范的定义:
define(factory);
在依赖示例部分,CMD支持动态引入,require、exports和module通过形参传递给模块,在需要依赖模块时,随时调用require( )引入即可,示例如下:
define(function(require, exports, module){
  //依赖模块a
  var a = require('./a');

  //调用模块a的方法
  a.method();
})
也就是说与AMD相比,CMD推崇依赖就近, AMD推崇依赖前置。

3.7 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的模块化。

3.8 ES6模块

如前面所述,CommonJS和AMD都是运行时加载。ES6在语言规格层面上实现了模块功能,是编译时加载,完全可以取代现有的CommonJS和AMD规范,可以成为浏览器和服务器通用的模块解决方案。这里关于ES6模块我们项目里使用非常多,所以详细讲解。
ES6模块使用——export
(1)导出一个变量
export var name = 'pengpeng';
(2)导出一个函数
export function foo(x, y){}
(3)常用导出方式(推荐)
// person.js
const name = 'dingman';
const age = '18';
const addr = '卡尔斯特森林';

export { firstName, lastName, year };
(4)As用法
const s = 1;
export {
  s as t,
  s as m, 
}
可以利用as将模块输出多次。
ES6模块使用——import
(1)一般用法
import { name, age } from './person.js';
(2)As用法
import { name as personName } from './person.js';
import命令具有提升效果,会提升到整个模块的头部,首先执行,如下也不会报错:
getName();

import { getName } from 'person_module';
(3)整体模块加载 *
//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 function getName() {
 ...
}
//my_module
import {getName} from './person.js';

-----------------对比---------------------

//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';

前面一直提到,CommonJS是运行时加载,ES6时编译时加载,那么两个有什么本质的区别呢?

3.9 ES6模块与CommonJS模块加载区别

ES6模块的设计思想,是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。所以说ES6是编译时加载,不同于CommonJS的运行时加载(实际加载的是一整个对象),ES6模块不是对象,而是通过export命令显式指定输出的代码,输入时也采用静态命令的形式:
//ES6模块
import { basename, dirname, parse } from 'path';

//CommonJS模块
let { basename, dirname, parse } = require('path');
以上这种写法与CommonJS的模块加载有什么不同?
  • 当require path模块时,其实 CommonJS会将path模块运行一遍,并返回一个对象,并将这个对象缓存起来,这个对象包含path这个模块的所有API。以后无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。
  • ES6会从path模块只加载3个方法,其他不会加载,这就是编译时加载。ES6可以在编译时就完成模块加载,当ES6遇到import时,不会像CommonJS一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。
因为CommonJS模块输出的是值的拷贝,所以当模块内值变化时,不会影响到输出的值。基于Node做以下尝试:
//person.js
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);

//输出结果
18
18
可以看到内部age的变化并不会影响person.age的值,这是因为person.age的值始终是第一次运行时的结果的拷贝。
再看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的时候到底发生了什么,这篇文章给大家算是比较全面的做了一次总结(我只是搬运工)。

PS: 由于文章太长为了方便阅读,前端模块化webpack实际项目讲解,放在续篇前端模块化二——webpack实际项目中的模块化

参考

2. 朴灵 (2013) 深入浅出Node.js. 人民邮电出版社, 北京。