JavaScript 模块加载方案(CJS,AMD,CMD,UMD,ESM)

596 阅读9分钟

1. CommonJS

CommonJS 是一个项目,其目标是为 JavaScript 在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的 JavaScript 脚本模块单元,模块在与运行JavaScript 脚本的常规网页浏览器所提供的不同的环境下可以重复使用。

几个关键点

  1. 出现的原因:JavaScript 在 ES6 之前是没有模块系统,全局变量污染和依赖管理混乱等问题突出,急需一套模块化方案。
  2. 发展历程:开始名叫 ServerJs,从名字能看出是用于服务端的,并在 Node.js 等环境下取得了很不错的实践。后来想把 ServerJS 的成功经验进一步推广到浏览器端,于是改名叫 CommonJS。

我在这里描述的不是技术问题。这是人们聚在一起并决定向前迈进并开始共同建立更大更酷的东西的问题。 -- What Server Side JavaScript needs,CommonJS 由Mozilla工程师 Kevin Dangoor于2009年1月发起,最初名为ServerJS

  1. CommonJS 本质上只是一套规范(API 定义),而 Node.js 采用并实现了部分规范。

Commonjs 模块及 Node.js 的特点

  1. 在 Node.js 中,每个文件都被视为一个单独的模块,所有代码都运行在模块作用域,不会污染全局作用域,因为模块被 Node.js 封装在函数中。
// 模块封装器
// 在执行模块代码之前,Node.js 将使用如下所示的函数封装器对其进行封装:

(function(exports, require, module, __filename, __dirname) {
	// 模块代码实际存在于此处
});

/**
通过这样做,Node.js 实现了以下几点:

1. 它将顶层变量(使用 `var`、`const` 或 `let` 定义)保持在模块而不是全局对象的范围内。
2. 它有助于提供一些实际特定于模块的全局变量,例如:
    1).`module` 和 `exports` 对象,实现者可以用来从模块中导出值。
    2).便利变量 `__filename` 和 `__dirname`,包含模块的绝对文件名和目录路径。
*/
  1. 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  2. 模块加载的顺序,按照其在代码中出现的顺序。

Node.js 使用实例

目录结构

.
└── commonjs
    ├── add.js
    ├── circle.js
    ├── foo.js
    └── main.js

一个文件就是一个模块

module.exports 表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取module.exports 变量。

为了方便,Node为每个模块提供一个exports变量,指向module.exports。

// circle.js
// __dirname 当前模块的目录名;`__filename` 当前模块的文件名
console.log('加载了 circle 模块', __dirname, __filename);
const { PI } = Math;

// 导出模块
exports.area = (r) => PI * r ** 2;
exports.circumference = (r) => 2 * PI * r;

⚠️ 注意: 不能直接将exports变量指向一个值,因为这样等于切断了exportsmodule.exports的联系。下面两种写法都是无效的。

// 1 `exports`不再指向`module.exports`
exports = function(x) {console.log(x)};

// 2 `hello`函数是无法对外输出的,`module.exports`被重新赋值
exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';

通过 require 引入模块

// foo.js
console.log('加载了 foo 模块', __dirname, __filename);
// 引入模块
const circle = require('./circle.js');
console.log(`半径为4的圆的面积是 ${circle.area(4)}`);
exports.name = "foo";
// add.js
console.log('加载了 add 模块')
const add = function(x, y) {
    return x + y;
};
module.exports.add = add;
// main.js
console.log('加载了 main 模块', __dirname, __filename);
const fooName = require("./foo");
console.log('fooName', fooName.name);
const add = require('./add');
console.log('add', add.add(1, 2));

执行 node main.js,打印的信息为

加载了 main 模块 /modules/commonjs /modules/commonjs/main.js
加载了 foo 模块 /modules/commonjs /modules/commonjs/foo.js
加载了 circle 模块 /modules/commonjs /modules/commonjs/circle.js
半径为4的圆的面积是 50.26548245743669
fooName foo
加载了 add 模块
add 3

模块加载机制

CommonJS模块的加载机制是,输入的是被输出的值的浅拷贝,看下面这个例子。

下面是一个模块文件lib.js

// lib.js
let counter = 3;
let obj = {
  counter: 1,
  child: {
    counter: 1
  }
}
function incCounter() {
  counter++;
  obj.counter ++ ;
  obj.child.counter ++ ;
}
module.exports = {
  counter: counter,
  obj: obj,
  incCounter: incCounter,
};

上面代码输出内部变量counter和改写这个变量的内部方法incCounter。然后,加载上面的模块。执行 node main.js 结果

// main.js
const {counter, obj, incCounter} = require('./lib');

console.log(counter, obj);// 3 { counter: 1, child: { counter: 1 } }
incCounter();
// obj 内部的 counter 改变了
console.log(counter, obj);// 3 { counter: 2, child: { counter: 2 } }

循环引用(node.js 文档实例)

当存在循环require()调用时,模块可能在返回时尚未完成执行。

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
// main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);

main.js加载时,a.js然后a.js依次加载b.js。此时,b.js尝试加载a.js. 为了防止无限循环,将导出对象的未完成副本a.js返回给 b.js模块,b.js 导入的实际是 module.exports变量,此时 a.js 导出的是 {done: false}。b.js然后完成加载,并将其exports对象提供给a.js模块。

执行结果:

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

推荐阅读

CommonJS规范 - 阮一峰,CommonJs 语法规范。 CommonJS-模块的循环加载,循环加载讲的很详细。

2. AMD(Asynchronous Module Definition):异步模块定义

异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖可以被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域访问等问题)。

规则定义中就说明了为什么会有AMD,commonjs 是同步加载,但是这种模块的加载方式在浏览器上并不适用。

实现AMD规范的库

requirejs

requirejs:作者就是AMD规范的创始人 James Burke,可以说 AMD 是 RequireJS 在推广过程中对模块定义的规范化的产出,

API规范

  1. 定义模块:define 方法
define(id?, dependencies?, factory);
  • id: 定义中模块的名字
  • dependencies,是个定义当前模块所依赖模块的数组。规范定义了三种特殊的依赖关键字。如果"require","exports", "module"出现在依赖列表中,参数应该按照[[#1 CommonJS]]模块规范自由变量去解析。
  • factory,为模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。
  1. define.amd 属性,为了清晰的标识全局函数(为浏览器加载script必须的)遵从AMD编程接口,任何全局函数应该有一个"amd"的属性。

使用实例(requirejs)

requirejs 中 每个文件一个模块。 考虑到模块名称到文件路径查找算法的性质,每个 JavaScript 文件只能定义一个模块。

目录结构

.
└── requirejs
    ├── add.js
    ├── circle.js
    ├── circumference.js
    ├── foo.js
    ├── index.html
    ├── lazy.js
    └── main.js

创建一个名为"foo"的模块,使用了./circle,exports模块:

// foo.js
// 通过 exports 导出模块值
define('foo', ['./circle', 'exports'], function (circleModule, exports) {
    console.log('加载了 foo 模块');
    console.log(`半径为4的圆的面积是 ${circleModule.area(4)}`);
    exports.name = "foo";
});

返回对象的匿名模块:

// circle.js
define(function () {
    console.log('加载了 circle 模块');
    const { PI } = Math;
    return {
        area: (r) => PI * r ** 2,
        circumference: (r) => 2 * PI * r
    };
});
// lazy.js
define(function () {
    console.log('加载了 lazy 模块');
    return {
      sayHi: () => console.log('Hi')
    };
});

一个没有依赖性的模块可以直接定义对象:

// add.js
define({
    add: function (x, y) {
        return x + y;
    }
});

一个使用了简单CommonJS转换的模块定义,require 虽然写法和 commonjs [[#^37d317]] 相似,但是执行顺序是不一样的。 ^94e936

// circumference.js
define(function (require, exports) {
    console.log('加载了 circumference 模块');

    const circle = require('./circle');
    console.log("circle 已经加载了");

    const lazy = require('./lazy');
    console.log("lazy sayHi", lazy.sayHi())

    exports.circumference = (r) => circle.circumference(r)
})

index.hml,data-main 属性是一个特殊的属性,require.js 将检查它以开始脚本加载。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="https://requirejs.org/docs/release/2.3.6/comments/require.js" data-main="./main.js"></script>
</head>
<body>
</body>
</html>

在浏览器中打开 index.html,打印的顺序为:

circle.js:3 加载了 circle 模块
foo.js:5 加载了 foo 模块
foo.js:6 半径为4的圆的面积是 50.26548245743669
lazy.js:2 加载了 lazy 模块
circumference.js:4 加载了 circumference 模块
circumference.js:7 circle 已经加载了
lazy.js:4 Hi
circumference.js:10 lazy sayHi undefined
main.js:2 加载了 main 模块
main.js:3 addModule.add(1, 1): 2
main.js:4 fooModule.nameL: foo
main.js:5 半径为4的圆的周长是 25.132741228718345

💡总结一下:依赖前置,依赖模块写在 定义模块,或引入模块时的 dependencies;依赖的模块会 提前执行,即使写成类似CommonJs 的写法[[#^94e936]]。

参考

Javascript模块化编程(二):AMD规范

3. CMD(Common Module Definition)

和AMD相同,都是为了JavaScript 在浏览器端 的模块化开发,而产出的规范。

实现CMD规范的库

Sea.js

CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。

Sea.js 追求简单、自然的代码书写和组织方式,具有以下核心特性:

  • 简单友好的模块定义规范:Sea.js 遵循 CMD 规范,可以像 Node.js 一般书写模块代码。
  • 自然直观的代码组织方式:依赖的自动加载、配置的简洁清晰,可以让我们更多地享受编码的乐趣。

API规范

  1. 在 CMD 规范中,一个模块就是一个文件。代码的书写格式如下:
define(factory);

factory 可以是一个函数,也可以是一个对象或字符串。

  1. define.cmd 一个空对象,可用来判定当前页面是否有 CMD 模块加载器
if (typeof define === "function" && define.cmd) {
  // 有 Sea.js 等 CMD 模块加载器存在
}
  1. require 用来获取其他模块提供的接口。
  • require.async 方法用来在模块内部异步加载模块,并在加载完成后执行指定回调。
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 使用模块系统内部的路径解析机制来解析并返回模块路径。该函数不会加载模块,只返回解析后的绝对路径。
define(function(require, exports) {
  console.log(require.resolve('./b'));
  // ==> http://example.com/path/to/b.js

});
  1. exports,exports 是一个对象,用来向外提供模块接口; exports  是  module.exports  的一个引用。
  2. module,存储了与当前模块相关联的一些属性和方法。传给 factory 构造方法的 exports 参数是 module.exports 对象的一个引用。

使用实例

目录结构

.
└── seajs
    ├── add.js
    ├── circle.js
    ├── circumference.js
    ├── foo.js
    ├── index.html
    ├── lazy.js
    ├── main.js
    └── sea.js

在 CMD 规范中,一个模块就是一个文件,factory 为对象

// add.js
define({
    add: function (x, y) {
        return x + y;
    }
});

factory 为函数时,表示是模块的构造方法。执行该构造方法,可以得到模块向外提供的接口。factory 方法在执行时,默认会传入三个参数:requireexports 和 module`

// circle.js
define(function (require, exports, module) {
    console.log('加载了 circle 模块');
    const { PI } = Math;
    module.exports = {
        area: (r) => PI * r ** 2,
        circumference: (r) => 2 * PI * r
    };
});
// circumference.js
define(function (require, exports) {
    console.log('加载了 circumference 模块');

    const circle = require('./circle');
    console.log("circle 已经加载了");

    const lazy = require('./lazy');
    console.log("lazy sayHi", lazy.sayHi())

    exports.circumference = (r) => circle.circumference(r)
})
// foo.js
define(function (require, exports) {
    console.log('加载了 foo 模块');
    const circleModule = require('./circle');
    console.log(`半径为4的圆的面积是 ${circleModule.area(4)}`);
    exports.name = "foo";
});
// lazy.js
define(function () {
    console.log('加载了 lazy 模块');
    return {
      sayHi: () => console.log('Hi')
    };
});

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./sea.js"></script>
    <script>
        // 在页面中加载主模块
        seajs.use("./main");
    </script>
</head>
<body>
</body>
</html>

在浏览器中打开 index.html,打印的顺序为:

main.js:2 加载了 main 模块
main.js:4 addModule.add(1, 1): 2
foo.js:4 加载了 foo 模块
circle.js:3 加载了 circle 模块
foo.js:6 半径为4的圆的面积是 50.26548245743669
main.js:6 fooModule.nameL: foo
circumference.js:4 加载了 circumference 模块
circumference.js:7 circle 已经加载了
lazy.js:2 加载了 lazy 模块
lazy.js:4 Hi
circumference.js:10 lazy sayHi undefined
main.js:8 半径为4的圆的周长是 25.132741228718345

💡从打印结果和代码的书写方式上,可以看到AMD 和 CMD 的几个区别:

  1. .CMD 推崇依赖就近,AMD 推崇依赖前置 AMD 也可以 就近 require.
  2. 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。AMD 是将依赖的模块先加载并执行完成完再执行本模块的代码,而 CMD 是在 require 的时候才去加载执行依赖模块文件,加载完再接着执行。

4. UMD(Universal Module Definition)

这些模块能够在任何地方工作,无论是在客户端、服务器还是其他地方。

为了让模块同时兼容AMDCommonJs规范而出现的,多被一些需要同时支持浏览器端和服务端引用的第三方库所使用。

实现原理,UMD 在其github主页上提供了更具针对性的模版,适用于不同的场景,

以 【returnExports.js 定义一个在 Node、AMD 和浏览器全局变量中工作的模块】为例展开一下判断逻辑:

  1. 通过判断 全局 deifne 以及 amd 属性来判断是否为AMD 模块,如果是 通过 define声明一个模块。
  2. 判断是否为 node 环境
  3. 两个都不存在,则将模块公开到全局(window或global)。
(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['b'], factory);
    } else if (typeof module === 'object' && module.exports) {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory(require('b'));
    } else {
        // Browser globals (root is window)
        root.returnExports = factory(root.b);
    }
}(typeof self !== 'undefined' ? self : this, function (b) {
    // Use b in some fashion.

    // Just return a value to define the module export.
    // This example returns an object, but the module
    // can return a function as the exported value.
    return {};
}));

5. ES6 Module (简称 ESM)

弄清楚 ES6(《 ECMAScript 6 入门》- ECMAScript 6 简介》)

  • ECMAScript 6.0(以下简称 ES6)是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。它的目标,是使得 JavaScript 语言可以用来编写复杂的大型应用程序,成为企业级开发语言。
  • ECMAScript 和 JavaScript 的关系,前者是后者的规格,后者是前者的一种实现(另外的 ECMAScript 方言还有 JScript 和 ActionScript)。日常场合,这两个词是可以互换的。
  • ES6 既是一个历史名词,也是一个泛指,含义是 5.1 版以后的 JavaScript 的下一代标准,涵盖了 ES2015、ES2016、ES2017 等等。
  • ES2015 是正式名称,特指该年发布的正式版本的语言标准。

ES6 Module

ECMAScript 模块是打包 JavaScript 代码以供重用的官方标准格式。模块是使用各种 import and  export语句定义的。

ES6 的模块自动采用严格模式,不管你有没有在模块头部加上"use strict";

ES6 模块是编译时加载

使用实例

目录结构

.
└── modules-test
    ├── add.js
    ├── circle.js
    ├── circumference.js
    ├── foo.js
    ├── index.html
    ├── lazy.js
    ├── main.js
    └── package.json
  1. exports 导出 存在两种 exports 导出方式:
  • 默认导出(每个模块包含一个)
// add.js
console.log('加载了 add 模块');
function add(x, y) {
    return x + y;
}
export default add;
  • 命名导出(每个模块包含任意数量),在导出多个值时,命名导出非常有用。在导入期间,必须使用相应对象的相同名称。
// circle.js
console.log('加载了 circle 模块');
const {PI} = Math;

// 导出单个特性(可以导出 var,let,const,function,class)
export const area = (r) => PI * r ** 2;
export function circumference (r) {
    return PI * r ** 2;
} 
// lazy.js
console.log('加载了 lazy 模块');
function sayHi() {
  console.log('Hi');
}
// 导出事先定义的特性
export {sayHi};

重导出

// circumference.js
import {sayHi as sayHiFn} from './lazy.js';
// 从 circle.js 导出 circumference 方法,但是 circumference 在本模块不可用
export {circumference} from './circle.js';
console.log('加载了 circumference 模块');
console.log("lazy sayHi", sayHiFn());
  1. import 导入模块

import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

import命令具有提升效果,会提升到整个模块的头部,首先执行。

导入单个接口,给定一个名为 area 的对象或值,它已经从模块 circle 导出(使用 export 语句),将 area 插入当前作用域。

// foo.js
import {area} from './circle.js';
console.log('加载了 foo 模块');
console.log(`半径为4的圆的面积是 ${area(4)}`);

export const name = 'foo';
  • 导入默认值,引入模块可能有一个 default export(无论它是对象,函数,类等)可用。然后可以使用 import 语句来导入这样的默认接口。
  • 导入带有别名的接口,你可以在导入时重命名接口。
  • 导入整个模块的内容,这将circumference插入当前作用域,其中包含来自位于./circumference.js文件中导出的所有接口。
// main.js
// 导入默认值
import addModule from './add.js';
// 导入带有别名的接口
import {name as fooName} from './foo.js';
// 导入整个模块的内容
import * as circumferenceModule from './circumference.js';
console.log('加载了 main 模块');
console.log('addModule.add(1, 1):', addModule(1, 1));
console.log('fooModule.nameL:', name);
console.log(`半径为4的圆的周长是 ${circumferenceModule.circumference(4)}`);

index.html 在浏览器中,import 语句只能在声明了 type="module" 的 script 的标签中使用。

浏览器对于带有type="module"<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>

打印结果

add.js:1 加载了 add 模块
circle.js:1 加载了 circle 模块
foo.js:2 加载了 foo 模块
foo.js:3 半径为4的圆的面积是 50.26548245743669
lazy.js:1 加载了 lazy 模块
circumference.js:3 加载了 circumference 模块
lazy.js:4 Hi
circumference.js:4 lazy sayHi undefined
main.js:4 加载了 main 模块
main.js:5 addModule.add(1, 1): 2
main.js:6 fooModule.nameL: foo
main.js:7 半径为4的圆的周长是 50.26548245743669

Node.js 对 ESM 的支持

从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。相当于Node.js 有两个模块系统:CommonJS 模块和ECMAScript 模块

.mjs文件总是以 ES6 模块加载,.cjs文件总是以 CommonJS 模块加载,.js文件的加载取决于package.json里面type字段的设置。 .mjs作者可以通过文件扩展名、package.json "type"字段或 --input-type标志告诉 Node.js 使用 ECMAScript 模块加载器。在这些情况之外,Node.js 将使用 CommonJS 模块加载器。

// package.json
{
  "name": "es6",
  "version": "1.0.0",
  "description": "es6",
  "type": "module",
  "main": "main.js",
  "scripts": {
    "test": "node ./main.js"
  },
  "author": "",
  "license": "ISC"
}

执行 npm run test 时的运行结果,和浏览器是相同的。

加载了 add 模块
加载了 circle 模块
加载了 foo 模块
半径为4的圆的面积是 50.26548245743669
加载了 lazy 模块
加载了 circumference 模块
Hi
lazy sayHi undefined
加载了 main 模块
addModule.add(1, 1): 2
fooModule.nameL: foo
半径为4的圆的周长是 50.26548245743669

模块加载机制

CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。

用 上面 CommonJS 的例子跑一下

目录结构

.
├── lib.js
├── main.js
└── package.json
// lib.js
let counter = 3;
let obj = {
    counter: 1,
    child: {
        counter: 1
    }
}
function incCounter() {
    counter++;
    obj.counter++;
    obj.child.counter++;
}

function replaceObj() {
    obj = 3;
}

export { counter, obj, incCounter, replaceObj }

执行 npm run test 时的运行结果:

// main.js
import {counter, obj, incCounter, replaceObj} from './lib.js';

console.log(counter, obj);// 3 { counter: 1, child: { counter: 1 } }
incCounter();
console.log(counter, obj);// 4 { counter: 2, child: { counter: 2 } }
replaceObj();
console.log(counter, obj);// 4 3

推荐阅读

  1. 《编程时间简史系列》JavaScript 模块化的历史进程,CommonJS 与 Node.js 的诞生,AMD,CMD 的演进,可以大概了解commonjs 与 AMD,CMD 的 关系
  2. 前端科普系列-CommonJS:不是前端却革命了前端
  3. 前端模块化:CommonJS & AMD & CMD & UMD & ES6 Module,总结了各规范的特点。
  4. AMD 和 CMD 的区别有哪些? - 玉伯于知乎的回答,SeaJs 作者的回答。
  5. 前端模块化开发那点历史,为什么会有SeaJS以及CMD规范。
  6. ES6 系列之模块加载方案,实例介绍AMD,CMD,CommonJs 和 他们之间区别。
  7. 前端模块化开发的价值
  8. ECMAScript 6 入门