原来JavaScript是这样实现模块区分的!

505 阅读7分钟

这是我参与更文挑战的第9天,活动详情查看:更文挑战

前言

我们都知道代码模块化带来的好处有很多很多,但是在ES6以前的JavaScript中时没有代码import的概念的,那么他们又是怎么组合起来的呢?

立即执行函数

看过JQuery源码的小伙伴都知道,JQuery是用一个立即执行函数包裹住代码,对外暴露部分变量供其他模块调用的,举个例子:

    (function(window) {
        const Jq = function (target) {
            this.element = document.querySelector(target);
        }
        
        Jq.prototype.setBackground = function(color) {
            this.element.style.background = color;
            return this;
        }
        
        // 对外抛出 $ 方法
        window.$ = (target) => new Jq(target)
        
    })(window)
    
    $('body').setBackground('red');

上面代码的意思是声明了一个函数并立即调用,通过传入的window对象向外部暴露我们想要暴露的变量或方法。这就是一个简单的模块化的体现。
这样的好处就是可以将一些变量和方法私有化。但它的坏处也很明显:不提供依赖管理机制;对外暴露方法只能通过全局对象实现。
这样的简单模块化工具显然不能满足我们复杂的系统设计的需求。

显式模块声明

从上面立即执行函数对外暴露的方法可以看出,对外暴露方法只能通过传入的全局对象才能向外部暴露方法或变量。如果我们可以将想要暴露的数据集合起来统一返回,那就最好了。而且这样的实现貌似也不难,继续来写一个简单的方法:

    const module = function() {
        function sum(a, b) { return a + b };
        function multiply(a, b) { return a * b };
        
        return {
            sum,
            multiply,
        }
    }()
    
    module.sum(1, 2); // 3
    module.sum(2, 3); // 6

我们用一个变量来装函数的返回值。这样我们就可以访问这个声明的变量去调用返回的方法。达到代码复用代码封装的功能。
但是与立即执行函数一样,它也不提供模块管理机制

什么是模块管理机制

上面有两次都提到了模块管理机制,现在来简单了解一下。
在以前写JavaScript的时候,如果需要引用其他JS文件,是需要在HTML文件中添加<script src="..."></script>标签引入的,在JS文件没有找到很好的办法去引入。当我们需要用到很多的模块文件时,那么管理模块时也必然会乱。
所以需要引入一种约定,在JS文件中也能实现模块的引用,就比如说importexport提供引入和输出,requiremodule.export也是一样,这就是模块管理机制

异步模块定义(AMD)

了解完模块管理机制的概念,下面来看一种引入了模块管理机制的模块化方案。
异步模块定义(Asynchronous Module Definition),从名字可以看到,它是用异步去加载模块的。而且他是基于RequireJS的,来看下面的代码:

    // moduleA.js
    define(function() {
        return {
            TEST: 'test moduleA'
        }
    })
    
    // main.js
    require.config({
      baseUrl: 'js',
      paths: {
        "testModule": "./moduleA",
      }
    })
    require(['testModule'], function(moduleA) {
        const test = moduleA();
        console.log(test.TEST); // test moduleA
    })

上面代码声明了两个js文件,moduleA.js是定义的模块文件,使用define方法定义模块。
main.js中使用require方法引入定义的模块。require.config定义引入的配置。
require方法接收两个参数:模块数组引入成功后的回调函数,当定义的模块加载完成后,调用回调函数。
从上面例子可以看到,它提供一种模块管理机制,允许我们在js文件中引入其他的js文件,而不再是从HTML的<script>标签引入。除此之外,AMD还有如下优点:

  • 采用异步方式加载模块,模块的加载不影响它后面语句的运行。
  • 所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD虽然可以做到异步加载,但是它也是会有缺点的:

  • 不能做到按需加载,而是必须提前加载所有的依赖。

共同模块定义(CMD)

共同模块定义(Common Module Definition)也是一种异步模块定义规范。
CMD定义模块的方法是define(factory),如果 factory 是一个函数,回调函数中会指定三个参数 requireexportsmodule
require是一个函数,这个函数接收一个模块标识符(模块 id),返回的是导出模块的API。 exports提供在模块执行时添加模块 API 的对象。 module是一个对象,提供exportsdependenciesuri方法,具体了解可以点击这里
下面是使用cmd定义模块的伪代码:

    define(function(require, export, module) {
        const md = require('modulePath');
        const result = md[ moduleFunction ].get();
        module.exports = {
            result
        }
    })

CMD是SeaJS在推广过程中对模块定义的规范化产出,具体例子可以看到SeaJs的使用文档

与AMD的区别

AMD与CMD都是异步模块定义规范,但是他们也会存在一些区别:

  • 对于模块的依赖,AMD是提前执行,CMD是延时执行。
  • AMD推崇依赖前置,CMD推崇就近依赖依赖前置:在定义模块的时候要先声明其依赖的模块,就像这样:
    require(['module'], () => {...})

就近依赖:可以在在使用时引入依赖的模块

    define(function(require, export, module) {
        ...
        const md = require('modulePath');
        ...
    })

CommonJs

CommonJs也是一种模块定义规范,node的模块系统就是基于CommonJs的。

  • CommonJs中每一个文件就是一个模块,拥有自己独立的作用域,变量,以及方法等,对其他的模块都不可见。
  • CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的module.exports属性是对外的接口。
    // module.js
    module.exports = {
        ...
        ...
    }
    
    // main.js
    const md = require('./module.js');
    ...
  • 加载某个模块,其实是加载该模块的module.exports属性。require方法用于加载模块。 需要注意的是,CommonJs是同步加载,而上面提到的AMD,UMD是异步加载。

通用模块定义(UMD)

通用模块定义(Universal Module Definition)把前端和后端的模块加载融合在一起了,他提供了一个前后端统一的解决方案。支持AMDCommonJS模式。UMD的实现其实很简单,前面我们已经了解了AMD还是CommonJs,那么UMD就是提供了一个方法判断是前端加载还是后端加载,主要的判断步骤是:

  • 先判断是否支持Node.js模块格式(exports是否存在),存在则使用Node.js模块格式。
  • 再判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。
  • 前两个都不存在,则将模块公开到全局(window或global)。

ES6中的模块

ES6中使用了importexport来实现模块的引入和导出代码,模块加载分为静态加载动态加载
静态加载时,ES6规定import必须放在代码顶层,因为import命令会被 JavaScript 引擎静态分析,先于模块内的其他模块执行。

    import {} from 'modulePath';
    ...

上面的import语法显然是不能实现动态加载的动态加载,如果在某一些场景需要用到动态加载(例如动态路由),那么应该怎么做呢?
ES6提供一个import()函数,它可以在代码运行时动态引入模块,加载完成后会返回一个Promise

    import('modulePath').then(module => {
        ...
    })

小结

本文主要介绍了JavaScript是如何实现模块化的。
前端使用模块化的定义有:立即执行函数显式模块声明AMDCMDUMD
node使用CommonJs进行模块化
若文章中有不严谨或出错的地方请在评论区域指出。

参考

  1. 了不起的JavaScript工程师
  2. JavaScript高级程序设计
  3. RequireJS
  4. SeaJS