前端工程化基石——模块化发展历程[IIFE、CommonJS、AMD、CMD、ESM、UMD]

352 阅读12分钟

本文主要通过了解 IIFE、CommonJS、AMD、CMD、ESM、UMD 发展过程和规范,来整体了解前端的模块化开发的规范与具体的使用。


1.模块化发展生态背景

模块化背景
前端工程化模块化推进的工具或框架的发展时间

 框架             诞生时间
 Node.js          2009NPM              2010年   
 requireJS(AMD)   2010seaJS(CMD)       2011年
 broswerify       2011年
 webpack          2012年
 grunt            2012年 
 gulp             2013年
 react            2013年 
 vue              2014年
 angular          2016年
 redux            2015年 
 vite             2020年
 snowpack         2020

2.什么是模块化开发

模块化开发:是指将代码按照功能或业务逻辑划分为独立的模块,使得代码更加可维护、可复用、可扩展的开发方式。
好处:通过模块化开发,可以将复杂的代码拆分成多个小模块,每个模块只关注自己的功能,降低了代码的耦合度,提高了代码的可读性和可维护性。同时,模块化开发也方便了代码的复用和扩展,可以更加灵活地组合和拓展功能。


3.模块化的演变过程

3.1.全局function模式

前端模块化发展中,全局 function 模式是一种早期的模块化方案,它通过在全局命名空间中定义函数来实现模块化。在这种模式下,通过将函数封装在闭包中,可以避免函数内部的变量与全局命名空间中的其他变量发生冲突。

  • 优点:

简单易用:使用全局函数模式,开发人员可以直接在代码中定义函数来进行模块化,不需要额外的构建工具或语法转换器。
全局可访问:模块中的函数可以在任何地方被调用,因为它们在全局命名空间中定义。
跨文件共享:全局函数模式允许不同的 JavaScript 文件共享全局函数,使得代码的重用更加方便。

  • 缺点:

命名空间污染:在全局函数模式中,所有函数都定义在全局命名空间下,容易导致命名冲突或变量重复定义,增加代码维护难度。
难以管理依赖:当一个模块依赖其他模块时,需要手动确保依赖模块已经加载,并且在正确的顺序中引入。这对于复杂的项目来说是一项挑战。
可维护性差:由于模块和函数之间没有明确的依赖关系,随着代码规模的增长,代码结构可能变得混乱并且难以维护。

function bar1(){
  //...
}
function bar2(){
  //...
}

3.2.命名空间模式- Namespace

Namespace 模式是一种改进的模块化方案,它使用对象作为命名空间,将模块中的函数和变量封装在该对象下,从而避免了全局命名空间的污染。

let module1 = {
  data:'this is data of module 1',
  foo(){
    console.log(`isFoo: ${this.data}`)
  },
  bar(){
    console.log(`isBar: ${this.data}`)
  }
}
  • 优点:

避免命名冲突:使用命名空间可以有效避免函数和变量之间的命名冲突
模块化的代码重用:命名空间可以被多个文件或模块共享,实现了代码的重用。

  • 缺点:

暴露所以成员模块,内部状态可能被改写,导致数据不安全等问题的发生

3.3.IIFE(立即调用函数表达式)

IIFE(Immediately Invoked Function Expression)也有人喜欢称之为匿名函数自调用(闭包)IIFE通过,封装私有变量和方法,将模块中的变量和方法封装在一个私有的作用域内,避免与全局命名空间冲突,并提供了更好的封装性。通过将模块的功能作为返回值,可以简化模块的使用并提供对外部的公共接口。

(function(moduleA, moduleB) {
  // 使用 moduleA 和 moduleB
   function bar (){
     console.log(`isbar: 使用了moduleA的方法`)
   }
  // …
  // 对外暴露
  window.foo = {
    bar
  }
})(window.moduleA, window.moduleB);

总的来说,IIFE 模块通过使用立即执行的函数表达式创造了一个私有的作用域,提供了更好的封装性和模块隔离性,避免了全局命名空间的污染和变量访问冲突。它还可以通过显式的依赖管理来更好地组织和管理模块之间的依赖关系。

虽然当模块化发展到IIFE时,解决了之前的一些问题,但也引入了新的问题。如依赖模块比较多时可能会出现以下情况:

<script type="text/javascript" src="module1.js"></script>
<script type="text/javascript" src="module2.js"></script>
<script type="text/javascript" src="module3.js"></script>
<script type="text/javascript" src="module4.js">
	foo.bar()
</script>

如果一个页面就需要引入多个JS文件,可想而知一个网站可不止一个页面。所以过多的引入 <script> 后可出现。请求过多,依赖模糊,难以维护等问题。

为了解决这些问题,才有了后续的 commonjs,AMD,UMD,COM,ES6等模块化的规范。

4.模块化的规范

4.1.CommonJS规范

CommonJS 是一种用于非浏览器环境的JavaScript模块化规范,最常见的使用场景是在NodeJS中的使用。
Node 应用由模块组成,采用 CommonJS 模块规范。每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。在服务端,模块化的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理。更多commonJS 模块在 NodeJS 中的详情

4.1.1.特点

  • 模块作用域私有,不会污染全局作用域
  • 同步加载:当前模块的代码会等待所依赖的模块加载完成后再执行。
  • 模块在运行时加载和执行,并且只在首次加载时运行一次,然后将运行结果缓存,以备后续多次加载。
  • 模块加载的顺序,按照其在代码中的出现的顺序。

4.1.2.基础语法

  • 暴露模块:module.exports = valueexports.xxx = value
  • 引入模块: require(xxx) 若是第三方模块xxx 就是模块名,如是自定义xxx 为模块文件路径
// # main.js
const bar = "Hello bar";
const val = "Hello foo";
const foo = () => val;

// case1
module.exports = {
  getFoo: foo,
  bar
};

// case2
// exports.getFoo = foo;

// # index.js 
const options = require("./main");
console.log(options.getFoo());

4.2.AMD

AMD(Asynchronous Module Definition)是一种前端模块化规范,主要用于在浏览器环境中 异步加载 模块。它允许开发者定义模块以及它们之间的依赖关系,并在需要时 按需加载 这些模块。AMD规范最著名的实现是 RequireJS

4.2.1.运用对比(特点)

加载方式:
AMD规范使用异步加载模块的方式,适合浏览器环境中多模块并行加载;
CommonJS规范加载模块是同步的,也就是说,只有所以模块加载完成,才能执行后面的操作。

依赖关系:
AMD规范在定义模块时就声明了依赖关系,通过回调函数的参数来引用依赖模块;
CommonJS规范在导入模块时直接使用require语句,通过变量来引用依赖模块。

初衷不同:
AMD规范的初衷是解决浏览器环境中模块加载的异步性和并行性问题;
CommonJS规范则更注重在服务器端环境中模块的同步加载和模块对外暴露的特性。

由于NodeJS主要用于服务器编程,模块文件一般都已经存在本地,所以加载起来比较快,不需要考虑异步加载的方式,所以commonJS规范比较适用。
但如果是浏览器环境,如果是在同步加载的情况下,浏览器需要按照模块之间的依赖关系依次加载模块,如果依赖关系较复杂,会导致页面加载时间过长。而异步加载使得浏览器可以同时发起多个模块的加载请求,并行执行加载操作,从而提高整体加载效率。

4.2.2.基本语法

定义暴露模块:

// 定义没有依赖的模块
define(function(){
  ...
  return 模块
})

// 定义有依赖的模块
define(['依赖1','依赖2'],function(m1,m2){
  ...
  // 使用依赖1 或 依赖2
  ...
  return 模块
})

引入模块使用:

require(['module1','module2'],function(m1,m2){
  ...
  // 使用 m1 
  // 使用 m2
  ...
})

4.3.CMD

CMD(Common Module Definition)模块化规范,类似于AMD规范,也用于在浏览器环境中异步加载模块。它整合了CommonJS 和AMD 规范的特点,模块的加载是异步的,模块在使用时才会加载执行。它最著名的实现是 SeaJS

4.3.1.运用对比(特点)

// AMD 
define(['module1'],function(m1){
  var result1 = module1.xxx
	return {
    res1: result1,
  }  
})


// CMD 
define(function(rquire,exports,module1){
  var result1 = module1.xxx
	return {
    res1: result1,
  }  
})

通过上述代码不难发现AMD规范和CMD规范的区别

模块依赖关系处理不同:
AMD推崇依赖前置,即模块的依赖关系在模块定义之前就已经声明好,模块加载时会立即按照依赖关系加载依赖模块。
CMD推崇依赖就近,即模块的依赖关系在模块执行时通过require方法引入并动态解析,模块加载时会先解析出所有模块的依赖关系,然后按照依赖关系的顺序加载依赖模块。

4.3.2.基本语法

定义没有依赖的模块

//定义没有依赖的模块
define(function(require,exports,module){
  ...
  exports.xxx = value
  // or
  module.exports = value
})

定义有没有依赖的模块

// 定义没有依赖的模块
define(function(require,exports,module){
  // 引入依赖(同步)
  var module2 = require('./module2')
  // 引入依赖(异步)
  require.async('./module3',function(m3){
    ...
  })

  // 暴露模块
  exports.xxx = value
  // or
  module.exports = value
})

引入模块并使用

// 引入并使用模块
define(function(require){
  var m2 = require('./module2')
  var m3 = require('./module3')
  m2.xxx
  m3.xxx
})

4.4.ES6模块化

ES6模块化是ECMAScript 6 引入的一种模块化规范,其设计思想是在编译时就能确定模块的依赖关系,以及输入和输出的变量。

4.4.1.运用对比(特点)

ES6模块化在结合了前者的优点和需要改进去缺点上,提出了一套自己的模块化规范。可用于NodeJS 服务端环境,也可用于浏览器环境中。

在语法上:
ES6模块化使用** import** 关键字来导入模块,使用 **export **关键字来导出模块。并且支持使用 **export default **语法将一个模块作为默认导出,而其他规范需要通过特定方式来实现默认导出。
commonJS使用require语句导入模块,使用 module.exports 导出模块。
AMD和CMD规范使用对应的API来实现模块的导入和导出。

在静态引用上:
ES6模块化是静态引用,模块的依赖关系在编译时就确定,可以在编译时进行静态分析和优化。这使得ES6模块化在性能方面具有优势。所以不允许在运行时动态导入模块,依赖关系必须在编译时确定。而commonJS、AMD和CMD规范允许在运行时动态加载模块。

支持命名空间导入:
ES6模块化支持命名空间导入,即可以使用 import * as 语法将一个模块的所有导出内容导入为一个对象。

4.4.2.基本语法

在Node中使用前需要在 package.json 添加 "type": "module" 或将文件名改为 .mjs

 "type": "module",

定义模块

// 定义mainA.js模块
const foo = 'hi foo'
const bar = () => 123
export {
  foo,
  bar
}

// 定义mainB.js默认导出模块
export default ()=> {
  console.log('hi bar')
}

引入并使用模块

// 引入非默认模块
import { foo } from './mainA.js'

// 引入非默认模块所以方法
import * as mA from './mainA.js'

// 引入默认模块
import Bar from './mainB.js'


// 使用
console.log(foo)
console.log(mA.bar())
console.log(Bar())

使用 export default 默认导出形式,在引入时可以随意自定义 方法名称。

那么问题来了想要使用 CJS 和 MJS 两种模块应该怎么办呢?
解决方式: 在package.json文件的 exports 字段,指明两种格式模块各自的加载入口。


"exports":{ 
    "require": "./commonJS.js""import": "./esModule.js" 
}

阮一峰更多使用详情

4.5.UMD

UMD(Universal Module Definition)是一种通用的模块定义方式,可以同时兼容CommonJS、AMD、CMD 模块化的规范。UMD的主要目的是实现在JavaScript所有运行环境不同的环境中(浏览器、NodeJS等)都能正常加载和使用模块。

4.5.1.运用对比(特点)

UMD模块定义通过适配不同的模块化规范,使得模块可以在不同的环境中使用。它可以兼容CommonJS、AMD、CMD这些主要的模块化规范,从而提供了更大的灵活性和互操作性。

4.5.2.基本语法

(function (root, factory) {
  if (typeof module === 'object' && module.exports) {
    ...
    // commonJS 模块规范,Node 环境
    module.exports = factory(require('b'));
    
  } else if(typeof define === 'function' && define.amd) {
    ...
    // AMD 模块规范
    define(['b'], factory);
    
  } else if(typeof define === 'function' && define.cmd){
    ...
    // CMD 模块规范
    define(function(rquire,exports,module1){
    	  module.exports = factory()
    })
    
  } else {
    ...
    // 没有模块环境,则直接挂载到浏览器全局变量
    root.returnExports = factory(root.b);
  }
  
}(this, function (b) {
  ...
  // UMD 模块规范
  return {}
}));

总结

随着前端工程化体系的发展,不断涌现出多种模块化规范。
1.CommonJS 规范主要用于NodeJS服务端,实现对本地模块的同步加载。但它并不适用于浏览器环境,因为同步加载意味着阻塞加载,而浏览器是异步加载的,所以才有了AMD、COM异步加载的解决方案。
2.ADM 规范主要用于在浏览器环境异步加载模块。虽然它实现了并行加载多个模块,但代码阅读和书写起来还是比较困难。
3.CMD 规范与AMD 规范很相似,都用于浏览器环境的异步加载模块,CMD依赖就近,延迟执行,可以很容易在NodeJS 中运行。
4.ESM 规范在ES6 语言规范的基础上实现了模块化,可在编译时静态分析和优化代码。完全可以取代CommonJS,AMD 规范,成为浏览器环境和服务器环境通用的模块优质方案。
5.UMD 规范可同时满足CommonJS,AMD,CMD标准的实现。