js的模块化发展史

214 阅读9分钟

一、早期面临的问题

在早期的web开发中,javascript这门语言没有具体的模块化开发规范。为了代码的维护性,开发者将不同的功能模块分开写到不同的.js文件下,然后通过多个script标签去加载它们。

<script src="./a.js"></script> 
<script src="./b.js"></script> 
<script src="./c.js"></script>

这种写法看上去将代码模块分离,但是每个js文件下的变量定义,都会处在同一个全局作用域下,这样就必须要格外注意变量名冲突问题,还有就是由于模块之间的依赖关系,导致js文件加载顺序问题。
在es6规范之前,javascript没有语法层面的模块化实现方案。但是为了解决上述的问题,不同的javascript运行环境分别实现了自己的模块化方案。

二、CommonJs规范

Nodejs在2009年推出后,在其环境内部实现了一套CommonJs的模块化规范。
在CommonJs规范中,每个js文件就是一个模块,通过require方法 和 module.exports(或exports) 进行导入导出。

// index.js
require("./moduleA");
var m = require("./moduleB");
console.log(m);

// moduleA.js
var m
setTimeout(() => {
    m = require("./moduleB");
    console.log(m)
}, 2000);

// moduleB.js
var m = new Date().getTime();
module.exports = m;

入口文件index导入了moduleA和moduleB,然后打印从moduleB导入的时间戳m,在moduleA中又延迟2s导入moduleB并打印m。三个模块都有变量m,但是他们都在各自的模块作用域,不冲突。两次不同时间打印的时间戳m相同,说明模块具有单例(缓存)的特性。
例子非常简单,但是我们不难发现CommonJs规范的特点:

  • 有处理模块变量作⽤域的能力
  • 有导⼊导出模块的能力,同时能够处理基本的依赖关系
  • 保证了模块单例(或者说模块缓存特性)
  • 是完全同步加载模块(每当⼀个模块require⼀个⼦模块时,都会停⽌当前模块的解析直到⼦模块读取解析并加载)

三、适合Web开发的AMD模块化规范

AMD全称 Asynchronous module definition,意为异步的模块的定义。所以相对的于CommonJs的同步加载,AMD规范所以的模块都异步预先加载。为什么是异步呢?异步加载是为了满足web开发的需要,因为如果web端也使用同步加载的话,解析大量的js脚本文件文件可能会暂停页面响应。
AMD规范的经典实现就是requirejs,所以如果我们要使用AMD规范开发,一定要首先引入requirejs,然后可以通过通过data-mian属性配置入口文件,例:

<!-- 先加载 require.js  加载完成后 执行index.js 入口文件-->
<script src="./require.js" data-main="./index.js"></script>

requirejs的的写法与CommonJs有所不同,现将上面Commonjs规范打印时间戳m的示例进行修改,如下:

// index.js
require(['./moduleA', './moduleB'], function(moduleA, m) {
    console.log(m)
})

// moduleA.js
// define(['./moduleB'], function(m) {
//     setTimeout(() => {
//         console.log(m)
//     }, 2000);
// })
// 或者
define(function(require) {
    // 这里的require并没有在延迟2s后同步加载moduleB,而是在一开始执行就会预先加载,2s后直接打印m
    // 虽然写法类似同步,但是同上异步加载moduleB效果一致
    var m
    setTimeout(() => {
        m = require("./moduleB");
        console.log(m)
    }, 2000);
})

// moduleB.js
define(function() {
    var m = new Date().getTime();
    return m
})

从上面代码可以看出来,通过define函数定义一个模块,第一个参数可以是预加载的依赖模块数组,也可以是一个函数,如果是函数的话,可以接收一个require参数,用于导入其他依赖模块。函数的返回值就是模块导出的结果。入口文件比较特殊,因为开始就需要加载我们的moduleA、B,所以提供了require函数,第⼀个参数写明⼊⼝模块的依赖列表,第⼆个参数作为回调参数依次会传⼊前⾯依赖的导出值,注意这个require函数和define回调中的require参数不同。
上面代码不同时间打印的两次时间戳m也相等,说明也是单例模块。下面总结一下AMD规范的特点:

  • 有处理模块变量作⽤域的能力
  • 有导⼊导出模块的能力,同时能够处理基本的依赖关系
  • 保证了模块单例(或者说模块缓存特性)
  • 前置依赖,是完全异步加载模块

四、CMD模块化规范

借鉴了CommonJs规范和AMD规范,在两者的基础上做了改进,国内(阿里)诞生了新的CMD模块化规范。英文是Common module definition,意为通用模块化规范。 Seajs是CMD规范的实现,同时对CommonJs和AMD做了许多兼容处理。使用CMD规范对上述打印时间戳m示例进行修改,如下:

<!-- 首先先导入Seajs 然后加载入口文件index.js 注意这里和requirejs有区别-->
<script type="text/javascript" src="./sea.js"></script>
<script type="text/javascript">
    // seajs配置
    seajs.config({
        base: './', //根路径
        paths: {  // 路径
            'lib': 'static/lib'
        },
        alias: {  // 别名
            'jquery': 'lib/jquery.1.11.3.js'
        },
        // preload: ['jquery'], // 预加载项
        debug: false, // 调试模式
        charset: 'utf-8', // 文件编码
    })
    // 加载入口文件
    seajs.use('./index.js')
</script>

然后定义模块:

// index.js
define(function(require, exports, module) {
    require('./moduleA')
    var m = require('./moduleB')
    console.log(m)

    // 异步加载一个或多个模块
    // require.async(['./moduleA.js', './moduleB.js'], function (A, B) {
    //     console.log(A, B)
    //     exports.name = 'qiaoqiao'
    // })
})

// moduleA.js
define(function(require, exports, module) {
    var m
    setTimeout(() => {
        m = require('./moduleB')
        console.log(m)
    }, 2000)
})

// moduleB.js
define(function(require, exports, module) {
    var m = new Date().getTime();
    module.exports = m;
})

从上面的代码可以看出,不论是入口还其他模块,都采用了相同的define(function(require, exports, module) {})语法,大大简化了我们的语法,然后模块内部的代码和上面CommonJs实现的示例基本一致,require导入,module.exports(或exports)导出,通俗的来讲,就是CommonJs和AMD的结合产物,define语法相对于requirejs得到了优化,同时与CommonJs导入导出一致的语法,更让人直白的感受到模块的导入导出。
关于CMD是同步加载还是异步加载,我做了尝试,如果像上面这种直接require导入模块,就是同步导入;如果使用提供的require.async方法导入,就可以做到像requirejs一样的异步预先加载。CMD规范推崇的就是就近依赖,所以使用require最好啦,总结一下CMD规范的特点:

  • 有处理模块变量作⽤域的能力
  • 有导⼊导出模块的能力,同时能够处理基本的依赖关系
  • 保证了模块单例(或者说模块缓存特性)
  • 结合了CommonJs和AMD规范的特点,可以使用require进行同步加载(就近依赖),也可以使用require.async进行异步加载(前置依赖)

五、ESModule规范

上面的CommonJs是基于node环境,AMD、CMD是基于浏览器环境的模块化,这种基于环境的模块化规范是能适用于合适自己的环境,无法通用使用。Es6之后,javascript终于有了语法层面的模块化规范,那就是ESModule。可以通过import、exports、export default关键词进行模块的导入导出。使⽤ESModule关键词就需要这样定义:

// index.js
import './moduleA.js'
import m from './moduleB.js'
console.log(m)

// moduleA.js
import m from './moduleB.js'
setTimeout(() => {
    // import语法不能在这里使用  否则语法报错
    console.log(m)
}, 2000)

// moduleB.js
var m = new Date().getTime();
export default m;

ESModule和上面提到的其他模块化规范最大的却别就是,ESModule使用的是关键词,是语法层面的,由js解析器解析,其他模块化是基于环境真实的require函数实现的。
ESModule的导入导出有多种形式,如下:

// 1
export default A
import A from 'module.js'

// 2 
export { A, B }
import { A, B } from 'module.js'  // 语法类似解构赋值,但实际与解构没关系 这里的{}是语法不是对象

// 3
export const A1 = 10
export const B1 = 10
import { A1, B1 } from 'module.js'

// 4
export { A, B }
import * as AB from 'module.js' // as别名

// 5
export { A, B }
export { A, B } from 'module.js' // 导出 从index导入的AB

ESModule是属于js语法层面的规范,所以可以在node和浏览器环境直接使用,但是因为环境版本过低可能无法解析ESModule,就会出现语法错误(syntax error)。所以只需要升级⾃⼰环境中的JS Core解释引擎到⾜够的版本,引擎层⾯就能认识这种ESModule语法,就能正常运行使用。 相对于node环境我们可以根据需求来安装高版本的node,而浏览器环境是用户自己选择的,很多低版本浏览器无法使用ESModule语法,所以想要直接使⽤就会⽐较麻烦。

六、后模块化时代

在现在的主流前端框架中,如Vue、React,它们使用的都是ESModule,前边提到了直接使用会有浏览器的兼容问题,那么在框架中其实已经对ESModule进行了babel编译处理,保证了在低版本浏览器依然可以执行。这也就是前端的后模块时代,依然采用ESModule规范,然后利用babel编译工具链对js代码进行向后兼容处理。不幸的是,babel只能将我们的ESModule语法编译成CommonJs的require函数,所以在浏览器仍然不能运行,这时候就需要最重要的一步,那就是前端工程化打包,如webpack、rollup等打包工具的使用,通过对babel编译的文件进行最后的打包,完整的处理了ESModule兼容问题。

七、其他产物

在js模块化发展过程中,还有一些产物,如同构模块化UMD,通用模块加载器Systemjs等。

同构模块化UMD,英文Universal Module Definition,确切的说它不是一个模块化规范,它是一种通用的模块化解决方案,对CommonJs、AMD、CMD等模块化规范进行整合。代码如下:

(function (root, factory) {
    if (typeof module === 'object' && typeof module.exports === 'object') {
        // 当前是node环境 commonjs模块规范
        module.exports = factory()
    } else if (typeof define === 'function' && define.amd) {
        // 当前是AMD模块化规范 如require.js
        defind(factory)
    } else if (typeof define === 'function' && define.cmd) {
        // 当前是CMD模块化规范 如sea.js
        define(function (require, exports, module) {
            module.exports = factory()
        })
    } else {
        // 没有模块环境 直接挂载在全局对象上
        root.umdModule = factory()
    }
})(this, function () {
    return {
        name: '我是UMD模块'
    }
})

从上面代码可以看出,UMD的原理是判断出当前究竟在哪种模块化规范的环境下,然后把模块内容⽤对应模块化语法导出。

Systemjs,是一个通用模块加载器,它能在浏览器或者node环境中动态加载模块,支持CommonJS、AMD、全局模块对象和ESModule。例如:

System.config({
    baseURL: '/app',
    defaultJSExtensions: false,
    map: { 
        'es6module': 'esSixModule.js' 
    },
    paths: { '*': 'lib/*' }
})
System.import('es6module'); // GET /app/lib/esSixModule.js