学习ES模块、CommonJS、AMD、UMD、SystemJS

5,087 阅读12分钟

首先概括下这几种模块:

  1. ES模块。ES模块是ECMAScript2015(ES6)语言规范中的一部分。在模块出现之前,引入多个JavaScript文件,JavaScript文件中定义的顶层的变量都是全局的,开发人员必须使用IIFE(Immediately Invoked Function Expression立即调用函数表达式)或者定义命名空间等方式来避免共享的全局变量可能引发的问题。而模块出现后,每一个(包含export/import的)JavaScript文件都是一个模块,在模块中定义的变量的作用域被限制在了文件中。
  2. CommonJS。在JavaScript只能编写网页应用的时候,CommonJS提供了一些API,能够让JavaScript编写不同的JavaScript解释器和主机运行环境的程序。CommonJS一般用于服务端(Node.js)。浏览器不支持CommonJS,需要进行转译。CommonJS规范中的模块使用require引入,exports导出。
  3. AMD。AMD(Asynchronous Module Definition 异步模块定义)提供的API指定了一种定义模块的机制,目的是让模块以及它的依赖能被异步加载。AMD使用define定义模块,主要用于浏览器环境。require.js使用的就是AMD。
  4. UMD。UMD(Universal Module Definition 通用模块定义)是一种模式,提供对当今最流行的脚本加载器的兼容。(虽然说是当今,但从仓库看UMD代码最新更新已经是3年前了,UMD当时主要支持的是AMD和CommonJS,可以使用UMD的思想,比如考虑兼容ES6模块)
  5. SystemJS。SystemJS是可配置的模块加载器。使用System.register方法注册模块,使用System.import方法导入模块。

我在实际开发中,使用最多的是ES原生模块,CommonJS使用比较少,AMD、UMD、SystemJS基本没有用到过。所以这篇文章主要内容是ES模块和CommonJS模块,其他模块的内容较少。整体来说是对于每一种模块的简单说明,没有深入研究。

源码地址

ES 模块

ECMAScript Module文档中引用的两个表格能概括ES模块的导入导出方式。

导入( import )

表1: 导入形式和ImportEntry Records的映射关系

Import Statement Form(导入声明的形式)[[ModuleRequest]][[ImportName]][[LocalName]]
import v from "mod";"mod""default""v"
import * as ns from "mod";"mod""*""ns"
import {x} from "mod";"mod""x""x"
import {x as v} from "mod";"mod""x""v"
import "mod";---

ImportEntry Record是一个专有名词。 ImportEntry Record包含[[ModuleRequest]]、[[ImportName]]和[[LocalName]]。

[[ModuleRequest]]指的是import声明中的模块声明符。比如import './a.js'中,字符串'./a.js'就是模块声明符。

[[ImportName]]指的是由[[ModuleRequest]]标志的模块需要绑定的名称。比如在a.js文件中导入了b.js,使用的语句是import {x as v} from "b";,那么[[ImportName]]指的就是b中的x的名称,即“x”*表明这个import请求的是目标模块的命名空间对象。Module Namespace Objects(模块命名空间对象)是一个模块命名空间外来对象,它提供对于一个模块导出的绑定的运行时基于属性的访问。简单来说命名空间对象就是用来访问模块导出的整个内容的。当使用import * as ns from "mod";这种方式导入内容的时候,就会为被导入的模块创建这样一个对象(命名空间对象)。

[[LocalName]]指的是在导入模块内部本地访问被导入模块的值的名称。比如在a.js文件中导入了b.js,使用的语句是import {x as v} from "b";,,那么在a模块中,使用名称v来访问从b模块中导入的x。

使用import "mod";时没有ImportEntry Record被创建。

导出( export )

表2: 导出形式和 ExportEntry Records的映射关系

Export Statement Form(导出声明的形式)[[ExportName]][[ModuleRequest]][[ImportName]][[LocalName]]
export var v;"v"nullnull"v"
export default function f() {}"default"nullnull"f"
export default function () {}"default"nullnull"default"
export default 42;"default"nullnull"default"
export {x};"x"nullnull"x"
export {v as x};"x"nullnull"v"
export {x} from "mod";"x""mod""x"null
export {v as x} from "mod";"x""mod""v"null
export * from "mod";null"mod""*"null
export * as ns from "mod";"ns""mod""*"null

ExportEntry Record是一个专有名词,它包含[[ExportName]]、[[ModuleRequest]]、[[ImportName]]、 [[LocalName]]。

[[ExportName]]指的是导出这个模块的绑定所用的名称。

[[ModuleRequest]]、[[ImportName]]、 [[LocalName]]和ImportEntry Record类似,这里不赘述了,想要了解的同学可以查看ExportEntry Record

循环引用

先安装下之前写的一个简易脚手架,npm i @lana-rm/create-app -g,然后创建一个练习项目create-app es-module ,在es-module文件目录的src目录下创建util文件夹,在util文件夹中创建3个文件,a.js、b.js、c.js。a引用b和c,b引用a。

a.js文件内容:

import b from './b.js';
import { printC } from './c.js';

printC();

export default {
  a: 'aaa',
  ab: b,
};

b.js文件内容:

import a from './a.js';

export default {
  b: 'bbb',
  ba: a,
};

c.js文件内容:

export const printC = function printC () {
  console.log('ccc');
};

在App.tsx中引入a:

import a from '@/util/a';

console.log(a);

执行npm start启动项目,在浏览器中观察到打印出如下内容: 首先执行a.js,a中引用了b,执行到b的时候又引用了a,所以又回到a,这时a还没有执行完成,所以b拿到的是undefined,b执行完,a拿到b,a拿到c并执行,导出对象。

import()

import和export只能在文件的最顶层使用,而import()可以在文件的任何位置使用,对模块进行异步加载。

新增一个文件d.js,文件内容如下:

function printD () {
  console.log('ddd');
}

export default printD;

在b.js中引入:

import a from './a.js';

if (!a) {
  import('./d.js').then((moduleD) => {
    moduleD.default();
  });
}
export default {
  b: 'bbb',
  ba: a,
};

打印出的内容如下:

修改导入对象的属性

将a.js的内容修改如下:

import b from './b.js';
import { printC } from './c.js';

b.ba = 'test';

if (b.ba) {
  import('./b.js').then((moduleB) => {
    console.log(moduleB.default, 'moduleB.default');
  });
}

printC();

export default {
  a: 'aaa',
  ab: b,
};

在b.js中添加内容:

...
console.log('b 执行了');

export default {
  b: 'bbb',
  ba: a,
};

控制台打印的内容如下: 只打印了一个b 执行了说明b.js只执行了一次之后就被缓存起来了,之后再有import导入的都是b执行后导出的对象。在a.js中修改了模块b导出的对象的ba属性为test,然后再使用import异步加载了模块b,异步加载的模块b的内容的ba属性已经变成了test。个人猜想,b在执行之后缓存起来,暴露了一个对b的导出对象(假设这个对象为B)的引用,当在a模块中引入b模块的时候,拿到的是对B的引用,所以在a中执行b.ba = 'test';会直接改变B,当再次使用import导入b模块的时候,拿到的就是修改过后的内容。

注意:只是练习时写循环引用和改变导入对象的属性看看效果,实际开发中最好不要使用这种方式以免给自己挖坑

CommonJS

有了CommonJS,就可以使用JavaScript来编写服务端JavaScript应用、命令行工具、桌面基于GUI的应用,混合应用。

使用Node.js来练习CommonJS。在Node.js中,每一个文件都被作为模块来处理。Node.js已经可以支持ECMAScript模块,但还是实验性的,想要了解的同学可以查看Node.js ECMAScript modules

导入(require)

require()会返回外部模块导出的API。require处理相对路径和非相对的模块名称的方式不同。

require(相对路径)

比如在 /root/src/moduleA.js文件中使用var x = require('./moduleB') ,Node.js会以如下顺序处理文件。

  1. 在同级目录下查找moduleB.js文件。(即/root/src/moduleB.js
  2. 查看上级目录中是否有moduleB文件夹( /root/src/moduleB ),文件夹下是否有package.json文件,package.json中是否有main属性,假如main属性的值如下:{ "main": "lib/mainModule.js" },那么Node.js会指向/root/src/moduleB/lib/mainModule.js.。
  3. 查看文件夹 /root/src/moduleB下是否有index.js文件,这个index.js文件被隐式地认为是文件夹的“主”模块。

require(非相对的模块名称)

/root/src/moduleA.js 文件中使用var x = require("moduleB");引入非相对路径的一个模块名称,会按照如下顺序处理模块,先在当前目录的node_modules中去查找有没有moduleB,再在上一级目录中的node_modules中去查找,最后去全局的node_modules中去查找。

  1. /root/src/node_modules/moduleB.js

  2. /root/src/node_modules/moduleB/package.json (如果声明了 "main" 属性)

  3. /root/src/node_modules/moduleB/index.js

  4. /root/node_modules/moduleB.js

  5. /root/node_modules/moduleB/package.json (如果声明了 "main" 属性)

  6. /root/node_modules/moduleB/index.js

  7. /node_modules/moduleB.js

  8. /node_modules/moduleB/package.json (如果声明了 "main" 属性)

  9. /node_modules/moduleB/index.js

导出 (exports)

使用module.exports导出模块中的对象。exports是module.exports的简写,在模块评估前将module.expots赋值给了exports。(exports = module.exports)

使用以下三种方式导出内容:

module.exports = {
  b1: '111',
  b2: '222',
  b3: '333',
};
module.exports.b1 = '111';
module.exports.b2 = '222';
module.exports.b3 = '333';
exports.b1 = '111';
exports.b2 = '222';
exports.b3 = '333';

这三种方式的效果是一样的,导出的内容都是:

{
	b1: '111',
	b2: '222',
	b3: '333',
}

但是像下面这样写并不会和上面一样导出相同的对象,下面这段代码只是将对象赋值给了exports变量。

exports = {
	b1: '111',
	b2: '222',
	b3: '333',
};

新建一个文件夹commons,在文件夹下使用npm init -y初始化项目,创建a.js文件,在a.js中引入b.js文件。在b.js中分别放入以上几段代码,在a.js中引入b.js:

const b = require('./b.js');
console.log(b);

执行node a.js查看效果。

循环引用

将上文练习的b.js文件修改成下面这样:

const a = require('./a.js');
console.log(a, 'b中拿到的a');

module.exports = {
  b1: '111',
  b2: '222',
  b3: '333',
  ba: a,
};

a.js文件内容如下:

const b = require('./b.js');
console.log(b, 'a中拿到的b');

执行node a.js,打印出

{} b中拿到的a
{ b1: '111', b2: '222', b3: '333', ba: {} } a中拿到的b

首先执行a.js,a中引用了b,所以b开始执行,b中又引用了a,此时a没有任何导出内容,所以b拿到的a是一个空对象。

修改a.js文件内容如下:

exports.a1 = '111';

const b = require('./b.js');
console.log(b, 'a中拿到的b');

exports.a2 = '222';

可以看见在导入b之前先导出了a1,执行node a.js,打印的内容是:

{ a1: '111' } b中拿到的a
{ b1: '111', b2: '222', b3: '333', ba: { a1: '111' } } a中拿到的b

说明在b中导入a的时候的拿到的是那个时刻a中已经导出的内容,如果没有导出,就会拿到一个空对象。

修改导入对象的属性

修改a.js的文件内容如下:

exports.a1 = '111';

let b = require('./b.js');
console.log(b, 'a中拿到的b');

exports.a2 = '222';

b.ab = 'test';
b = require('./b.js');
console.log(b, '在a中修改之后拿到的b');

在b.js中加入一个console.log语句观察b.js执行了几次:

...
console.log('b 执行了');

export default {
  b: 'bbb',
  ba: a,
};

执行node a.js打印出的结果如下:

{ a1: '111' } b中拿到的a
b 执行了
{ b1: '111', b2: '222', b3: '333', ba: { a1: '111' } } a中拿到的b
{
  b1: '111',
  b2: '222',
  b3: '333',
  ba: { a1: '111', a2: '222' },
  ab: 'test'
} 在a中修改之后拿到的b

说明修改导入模块的属性也会修改模块的值。CommonJS的模块执行一次就被缓存起来了,在执行require的时候拿到了b模块暴露的API的引用,所以在a中修改b的时候,模块b导出的内容也会被修改。

注意:只是练习时写循环引用和改变导入对象的属性看看效果,实际开发中最好不要使用这种方式以免给自己挖坑

AMD

AMD规范定义了一个函数:

define(id?, dependencies?, factory);

id声明这个模块的id;

dependencies是一个依赖数组,dependencies是可选的,如果不写,它默认是['require', 'exports', 'module']

factory是一个函数,用来实例化模块或对象。dependencies数组的顺序和factory参数的顺序对应。

requirejs用的是AMD规范,直接拉了一个例子来进行简单俩练习:

git clone https://github.com/volojs/create-template.git

在下载好的例子的app文件夹下创建以下几个文件:

a.js

define('a', function(require, exports) {
  exports.a = 'aaa';
});

这种方式定义了模块名称,使用exports导出内容。

b.js

define(['a'], function(a) {
  return {
    b: 'bbb',
    ba: a,
  }
});

在b模块中使用a,将a作为b的导出内容的一部分。

c.js

define({
  c: 'ccc',
});

当factory为一个对象的时候,这个对象就是导出的内容。

d.js

define(function(require, exports, module){
  const a = require('./a');
  const b = require('./b');
  const c = require('./c');

  module.exports = {
    d: 'ddd',
    da: a,
    db: b,
    dc: c,
  };
});

在main.js中导入d.js:

const d = require('./d');
console.log(d, 'd');

在浏览器中打开index.html,控制台中打印出的内容如下:

这里的da是为undefined说明以a.js中那种方式定义的模块,必需像b.js那样在dependencies数组中添加模块名来使用。

UMD

UMD模式通常致力于提供当今最流行的Script加载器的兼容。在很多情况下使用AMD作为基础,并添加了特殊情况处理来处理CommonJS的兼容性。

这里引用UMD仓库的nodeAdapter的代码为例:

(function(define) {

    define(function (require, exports, module) {
        var b = require('b');

        return function () {};
    });

}( // Help Node out by setting up define.
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(require, exports, module); } :
    define
));

上面这段代码兼容AMD模块和CommonJS模块,如果是CommonJS模块系统:module是一个对象module.exports并且define不是一个函数,就重新定义define函数,否则就使用全局define变量。

SystemJS

SystemJS是专门为了提高生产环境模块的性能的。

SystemJS也提供了一些例子,我们直接下载下来看看:

git clone https://github.com/systemjs/systemjs-examples.git

在systemjs-examples/loading-code/amd-modules可以看见通过<script type="systemjs-importmap">导入映射的方式

<script type="systemjs-importmap">
  {
    "imports": {
      "saturn": "./lib/saturn.js"
    }
  }
</script>

这就package映射一样,然后再导入模块(System.import('saturn')时会到./lib/saturn.js路径下寻找模块)。

在systemjs-features/nodejs-loader文件夹下直接在js中使用映射:

const { System, applyImportMap, setBaseUrl } = require('systemjs');

applyImportMap(System, {
  imports: {
    "antarctica": "./antarctica.js"
  }
});

使用System.import导入模块:

System.import('antarctica').then(ns => {
  console.log('antarctica module', ns);
});

使用System.register注册模块:

System.register([], (_export) => ({
  execute() {
    _export('default', "Antarctica is the southern continent");
  }
}));

参考地址

  1. ECMAScript Modlue:www.ecma-international.org/ecma-262/11…
  2. Node.js Modules:nodejs.org/dist/latest…
  3. AMD: github.com/amdjs/amdjs…
  4. UMD:github.com/umdjs/umd
  5. SystemJS:github.com/systemjs/sy…