JavaScript AMD CMD CommonJS ESM模块详解

704 阅读8分钟

JS 模块化深入分析

全局模块

global.js

"use strict";
var globalObj = {
    a: 1,
    output: function () {
        console.log('console from global');
        return 'console from global';
    }
};

其实不能叫模块,直接定义在全局的变量,各个文件间不通过语句引入,在HTML中通过<script>引入,全局直接使用。

问题: 带来了命名污染的问题

解决: 通过模块化方案。

AMD

定义: 异步模块加载机制,模块定义简单,完整描述了模块的定义、依赖关系、引用关系以及异步加载机制。

使用方法

requirejs并不是浏览器环境原生的模块系统,在使用前需要加载requirejs库

  • define

    使用define表明当前的js文件也是一个模块,需要有导出的结果

    在使用ts进行编程的时候,需要引入@types/requirejs模块进行类型的预定义

    // 工厂函数,返回暴露的方法
    define(dep, fun):暴露的方法
    // dep: array 描述模块依赖关系,名字决定模块调用名称
    // func: ...array 工厂函数,参数为第一个参数的返回结果,返回暴露接口
    
  • require

    用于导入模块,在回调函数里使用

    // 此处...为ES6语法,用于理解参数状态
    require([deps], func(...deps) {})
    // dep 需要的依赖
    // func: ...deps callback 执行函数
    
  • require.config

    用于配置参数路径,格式如下:

    require.config({
      // 必须为path对象
      path: {
        name: "path"
      }
    })
    

浏览器端demo

以下创建了一个demo说明AMD的使用方法

  • HTML

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>module test</title>
    </head>
    <body>
        <div id="test"></div>
        <script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script>
        <script src="./global.js"></script>
        <script src="./main.js"></script>
    </body>
    </html>
    
  • 模块1:amd.js

    define(function() {
        console.log('加载模块1');
        return {
            type: 'amd',
            output() {
                console.log('console from amd');
                return 'console from amd';
            }
        }
    })
    
  • 模块2: amd2.js

    define(['amd1'], function(amd1) {
        console.log('加载模块2');
        const name = 'amd2';
        return {
            name: name,
            output() {return 'console from amd2, ' + amd1.type + '; ' + name;}
        }
    })
    
  • 总入口模块main.js

    let testDIV = document.getElementById('test');
    if (testDIV) {
        let str = globalObj.output();
        console.log('全局模块加载完毕');
        // 注意此处的顺序
        require(['amd2', 'amd1'], function (amd1, amd2) {
            console.log('执行模块加载回调');
            console.log('dep: ' + amd1.type + ', ' + amd2.name);
            str += amd1.output();
            testDIV.innerHTML = str;
        })
        console.log('执行全局内容');
        testDIV.innerHTML = str;
    }
    
  • 输出结果

    console from global
    全局模块加载完毕
    // 加载模块部分异步执行
    执行全局内容
    // 模块全部加载,加载过程中会执行模块内部的语句
    加载模块1
    加载模块2
    执行模块加载回调
    dep: amd, amd2
    console from amd
    

node端demo

需要引入第三方包:npm install requirejs --save

const requirejs = require('requirejs');
requirejs(['./amd1'], function(amd1) {
    amd1.output();
})
// console.log(requirejs);
console.log('node 外部运行');

查看结果

node 外部运行
加载模块1
console from amd

总结

从这个浏览器端和node端可以看出,两端运行的还是JS,加载第三方库给环境增加全局的方法:在浏览器端通过<script>全局引入require方法;在node端使用原生的require方法引入包,重命名为requirejs,即可在node端使用对应的模块方法。

所以在不同端使用不同的第三方库,仅需注意提供使用库的模块化方法对应的方法可以了。

问题:在通过<script>标签引入时,具有define和require两个方法,使用node引入时,如何使用define?

源码上看:requirejs包含供浏览器端使用的requirejs.js文件,全局注册define等方法;还提供r.js文件,node package默认加载的文件,用于node环境下AMD模块的载入。

  1. 在浏览器中通过<script>标签引入的时候,define和require全部注册在全局变量;

  2. 在node环境下使用的时候,应该使用下面的方法:

    // npm install requirejs --save
    const requirejs = require('requirejs');
    // 通过打印可以看到requirejs目前的方法,其是带有require和define的方法的
    requirejs.define // define
    requirejs.require // require
    requirejs.require.config // require.config
    // 切忌使用下面的引入方法
    require('requirejs') // 会覆盖node的commonjs的载入方法
    

    通过node commonjs的引入,将对应的AMD的引入方法注册到对象返回。

CMD

定义:通用模块定义,推崇依赖就近,一个文件就是一个模块。

使用方法

define(id?, d?, factory)

Id:指模块的名称,一般不设置,和文件同名;

d: deps(上面两个参数不设置,由构建工具自动生成)

factory:工程函数: function(require, exports, module) {}

  • require

    是一个方法,接收 模块标识符id作为唯一参数,用来获取其他模块提供的接口。

    define(function(require, exports)  {
      a = require('./a');
      a.doSomething();
    })
    
  • require.async require.async(id, callback?)

    异步加载回调,相当于AMD的require

    define(function(require, exports, module) {
    
      // 异步加载一个模块,在加载完成时,执行回调
      require.async('./b', function(b) {
        b.doSomething();
      });
    
      // 异步加载多个模块,在加载完成时,执行回调
      require.async(['./c', './d'], function(c, d) {
        c.doSomething();
        d.doSomething();
      });
    
    });
    
  • Require.resolve require/resolve

    解析路径,但不加载模块

  • Exports Object

    对象,向外提供模块接口。

    // 向外提供接口由两种写法
    // exports对象增加属性
    // 返回值
    define(function(require, exports) {
    
      // 对外提供 foo 属性
      exports.foo = 'bar';
    
      // 对外提供 doSomething 方法
      exports.doSomething = function() {};
      return {
        foo: '',
        doSomething: function() {}
      }
    
    });
    
  • module Object

    • module.id: 当前模块的id
    • Module.url:当前模块的绝对路径
    • module.dependencies: 当前模块的依赖
    • Module.exports: 上面exports的引用

浏览器端demo

  • HTML

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>module test</title>
    </head>
    <body>
        <div id="test"></div>
        <script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.0.3/sea.js"></script>
        <script src="./global.js"></script>
        <!-- <script src="./main cmd.js"></script>s -->
        <script>
            seajs.use(['./main-cmd.js'], function(main) {});
        </script>
    </body>
    </html>
    
  • 模块1:cmd1.js

    define(function(require, exports, module) {
        console.log('加载cmd1');
        exports.name = 'cmd1';
    })
    
  • 模块2:cmd2.js

    define(function(require, exports, module) {
        console.log('加载cmd2');
        cmd1 = require('./cmd1');
        console.log(`cmd2 加载${cmd1.name}`);
        exports.name = 'cmd2';
    })
    
  • 总入口模块:main-cmd.js

    let testDIV = document.getElementById('test');
    if (testDIV) {
        let str = globalObj.output();
        console.log('全局模块加载完毕');
        define(function(require, exports, module) {
            console.log('顶层模块执行');
            cmd1 = require('./cmd1');
            console.log(`cmd3加载${cmd1.name}`);
            require.async('./cmd2', function(cmd2) {
                console.log(`cmd3异步加载${cmd2.name}`);
            })
            console.log('模块内部同步处理结束');
        })
        console.log('执行全局内容');
        testDIV.innerHTML = str;
    }
    
  • 输出

    console from global
    全局模块加载完毕
    执行全局内容
    顶层模块执行
    加载cmd1
    cmd3加载cmd1
    // 内部异步加载
    模块内部同步处理结束
    加载cmd2
    cmd2 加载cmd1
    cmd3异步加载cmd2
    

node端demo

CMD中的C取通用(common)之意,且其加载模块的时候,使用reuqire加载,这与node自建的require方法很相似,CMD在node中的用法见下面的demo

// 安装对应的模块 npm install seajs --save
// 全局引入seajs的require,其实是为全局引入define
require('seajs');

const cmd2 = require('./cmd2');
define('node',function(require, exports, module) {
    require.aysnc(['./cmd1'], function(cmd1) {

    })
})
console.log('node模块运行结束')

输出如下

// 开始加载模块2
加载cmd2
加载cmd1
cmd2 加载cmd1
// 可以看出,模块加载可以理解为同步的
node模块运行结束
// define定义的部分还是异步的
加载模块1

总结

CMD模块方法关键字define,内部工厂函数require具有自身的功能,所以引入seajs的define方法就就可以了。

AMD与CMD的区别

这个问题网上最常见的解答:CMD推崇依赖就近的原则。

下面从模块加载原理解释一下这句话:

首先,模块化分为模块加载(下载模块文件)和模块执行两个阶段;AMD和CMD都是异步模块加载,在语法和具体执行上有差异,表现为:

  • AMD依赖前置,在读到这一行的时候就知道需要哪些模块,然后进行加载,加载结束执行模块以后调用回调函数(异步模式)
  • CMD依赖就近,读取define方法的时候,首先会调用toString方法,然后查找里面的require语句,获取当前模块的所有的依赖,进行加载,在正式运行前,模块文件已经加载完毕,执行时,看似好像require了,但是其实文件已经存在此时执行模块文件,此时执行看似好像是同步加载,但是不是,还是异步加载模式;
  • CMD内出现async的时候,继续嵌套异步模式

从上面的分析解释中可以看出,加载全为异步;AMD用户体验更好(进入回调时,所有模块都已执行,即:所有模块都已经准备好了),CMD按需执行,性能上更好。

CommonJS

定义:每一个文件都是一个模块,模块是同步加载的,同步加载的特性不适用于浏览器端

基本用法

两个关键字module.exportsrequire

这两个关键字对应的方法由node环境提供,可以理解为文件在运行前,node给文件添加了以下内容:

var module = {
	exports: {},
}
var exports = module.exports;

两个关键字的用法如下

  • module.exports Object

    导出对象的内容

  • require

    导入其他的包,注意此处的包的寻址方式,具有单例特性

    • require的模块是第一次加载,首先会加载该模块,导出内容
    • require模块被加载过,模块代码不会执行,直接导出上次执行的结果

demo

模块1

var obj = {
    name: 'commonjs1',
    output: function() {
        console.log('console from commonjs1');
    }
}
console.log('调用commonjs1');
module.exports = obj;

模块2

const commonjs1 = require('./commonjs1');
console.log('调用js2');
console.log(`commonjs2 调用 ${commonjs1.name}`);
var obj = {
    name: 'commonjs2'
}

module.exports = obj;

顶层模块

console.log('顶层模块运行');
const commonjs2 = require('./commonjs2');
console.log(`main 调用 ${commonjs2.name}`);
const commonjs1 = require('./commonjs1');
console.log(`main 调用 ${commonjs1.name}`);
console.log('调用结束');

调用结果

顶层模块运行
调用commonjs1
调用js2
commonjs2 调用 commonjs1
main 调用 commonjs2
// 注意此处commonjs1并没有重复执行
main 调用 commonjs1
调用结束

commonjs的调用要注意两点:

  1. 文件同步加载,demo中看不出来,考虑可能node认为文件都在本地,同步加载没有问题(此处也是commonjs和ES6module的重要区别
  2. 模块文件不会重复执行,首次执行以后保存模型的实例,在其他模块中调用的时候,返回模块的实例,有点像单例模式

commonjs的特性

在上述的demo中,向模块1添加内部的变量。

模块1

var counter = 0;
var obj = {
    name: 'commonjs1',
    cnt : counter,
    output: function() {
        console.log('console from commonjs1');
    },
    getCounter() {
        return counter++;
    },
    addCnt() {
        this.cnt++;
    }
}
console.log('调用commonjs1');
module.exports = obj;

模块2

const commonjs1 = require('./commonjs1');
console.log('调用js2');
console.log(`commonjs2 调用 ${commonjs1.name}`);
var obj = {
    name: 'commonjs2'
}
console.log(`commonjs2调用commonjs的counter: ${commonjs1.getCounter()}`);
// 单独获取属性或者方法 的时候是
var cnt = require('./commonjs1').cnt;
var add = require('./commonjs1').addCnt;
console.log(`commonjs2调用commonjs的cut: ${cnt}`);
add();
console.log(`commonjs2调用commonjs的cnt: ${cnt}`);
console.log(`commonjs2调用commonjs的cnt: ${commonjs1.cnt}`);
commonjs1.addCnt();
console.log(`commonjs2调用commonjs的cnt: ${commonjs1.cnt}`);
module.exports = obj;

顶层模块

console.log('顶层模块运行');
const commonjs2 = require('./commonjs2');
console.log(`main 调用 ${commonjs2.name}`);
const commonjs1 = require('./commonjs1');
console.log(`main 调用 ${commonjs1.name}`);
console.log(`main调用commonjs的counter: ${commonjs1.getCounter()}`);
console.log(`main调用commonjs的cnt: ${commonjs1.cnt}`);
commonjs1.addCnt();
console.log(`main调用commonjs的cnt: ${commonjs1.cnt}`);

const commonjs1_2 = require('./commonjs1');
console.log(`main调用commonjs的counter: ${commonjs1_2.getCounter()}`);
console.log(`main调用commonjs的cnt: ${commonjs1_2.cnt}`);
commonjs1_2.addCnt();
console.log(`main调用commonjs的cnt: ${commonjs1_2.cnt}`);
console.log('调用结束');

输出结果

顶层模块运行
调用commonjs1
调用js2
commonjs2 调用 commonjs1
commonjs2调用commonjs的counter: 0
commonjs2调用commonjs的cut: 0
commonjs2调用commonjs的cnt: 0
commonjs2调用commonjs的cnt: 0
commonjs2调用commonjs的cnt: 1
main 调用 commonjs2
main 调用 commonjs1
main调用commonjs的counter: 1
main调用commonjs的cnt: 1
main调用commonjs的cnt: 2
main调用commonjs的counter: 2
main调用commonjs的cnt: 2
main调用commonjs的cnt: 3
调用结束

上面这个例子比较复杂,但需要注意两点:

  1. commonjs调用是和js的赋值一样,对象传递引用,基本变量传递值拷贝;
  2. commonjs在加载后会在内存里保存当前的加载内容,并根据require的类型进行返回。

ES6module

定义:一个js代表一个模块,每一个模块只加载一次。

包含import和export两个关键字。

  • export

    可以在程序中多次使用,表示导出多个变量;

    仅能使用一次:export default

  • Import

    必须使用结构语法

    1. import {func1, func2} from 'mudule':针对使用export {}导出的对象

    2. import [name] from 'module':针对使用export default {}导出的对象

demo

node为了兼容es6模块,需要使用.mjs作为文件后缀

模块1: esm1.mjs

var counter = 0;
var obj = {
    name: 'esm1'
}
function getName() {
    console.log('from esm1');
}
function add() {
    counter++;
}
console.log('运行模块1');
export {obj};
export {counter, add};
export default getName;

模块2: esm2.mjs

var obj = {
    name: 'esm2'
}
function getName() {
    console.log('from esm2');
}
console.log('运行模块2');
// 调用模块1
import {obj as obj1, counter, add} from './esm1.mjs'
console.log(`esm2调用${obj1.name}`);
console.log(counter);
add();
console.log(counter);
export {obj};
export default getName;

顶层模块: main.mjs

import {obj as obj1} from './esm2.mjs'
import esm2Default from './esm1.mjs'
import {obj as obj2} from './esm1.mjs'
import esm1Default from './esm1.mjs'


console.log(`main调用${obj1.name}`);
esm1Default();

console.log(`main调用${obj2.name}`);
esm2Default();

import {counter, add} from './esm1.mjs'
// counter++; TypeError: Assignment to constant variable. 只读
console.log(counter);
add();
console.log(counter);

// export ;

执行结果

// 模块运行一次
运行模块1
运行模块2
esm2调用esm1
// 注意此处的导出值
0
1
main调用esm2
from esm1
main调用esm1
from esm2
// 注意此处的导出值
1
2

上面结果有两个需要注意的点:

  1. 模块自身代码只运行一次
  2. esm是一种映射关系,第一次加载模块到内存,后续加载模块的时候,将内存中全有的部分进行映射,有点类似于『单例模式』