JS 中的模块管理 CommonJS AMD CMD ES6

1,706 阅读11分钟

以前 JS 主要用在浏览器,它是没有模块系统的。如果我们做的项目有点大,那么管理项目的依赖就非常困难,比如 A 依赖 BC,而 CB 有依赖其他的库。这时人为的用script标签的先后顺序来让项目依赖正常是非常困难的。这时就有了很多解决方案。

CommonJS

CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目,比如在服务器和桌面环境中。

比如nodejs就是用的 CommonJS 规范,但是它也没完全接受规范。

CommonJS 中,每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

module

每个模块(文件)内部都有一个 module 对象,它有以下属性。

  1. id 模块的识别符,通常是带有绝对路径的模块文件名。
  2. exports 表示模块对外输出的值。
  3. parent 一个对象,表示调用该模块的模块。(没有则是undefined
  4. filename 模块的文件名,带有绝对路径。
  5. loaded 一个布尔值,表示模块是否已经完成加载。
  6. children 一个数组,表示该模块要用到的其他模块
  7. paths 一个数组是nodejs引入库文件的绝对路径(node_modules)

一般nodejs主文件会用到parent属性。

if (module.parent == null) { // 表示不是被当为库应用,而是直接运行
    // 运行程序
}

exports属性一般用来对外暴露接口。

module.exports = function () {} // 暴露一个函数

// --------------

module.exports = {} // 暴露一个对象
// ...

除了使用module.exports对外暴露接口,还可以使用exports对外面暴露。

exports.area = function (r) { // 暴露是一个对象,它有一个 area 方法
  return Math.PI * r * r;
};

需要注意,不能将exports直接指向一个值。

exports = function(x) {console.log(x)};
// 无效

// 因为 exports 相当于
var exports = module.exports

require

它使用require函数导入模块。

// a.js

module.exports = { data: 100 }

// b.js

var a = require('./a') // 后面的 js 可以省略
console.log(a) // { data: 100 }

// 也可以用下 es6 写法

let { data } = require('./a.js')

require参数是一个路径字符串,

  1. 如果以/表示加载的是一个位于绝对路径的模块文件。
  2. 如果以./表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。
  3. 如果都不是则表示加载 nodejs 的核心模块。
  4. 如果没有找到的话,nodejs 会通过module.paths属性查找node_modules文件夹中的第三方库文件。
  5. 如果还没找到就报错。

如果没有带后缀 nodejs 会以.js、.json、.node的顺序查找模块文件。

require 上也有一些属性和方法。

  1. resolve() 得到require命令加载的确切文件名。
  2. cache nodejs 会将已经加载过的模块缓存起来,方便下次加载,已经缓存的模块就在这个对象中,可以使用delete require.cache[moduleName] 删除缓存。
  3. main 属性用来判断模块是直接执行,还是被调用执行。直接执行的时候(node module.js),require.main属性指向模块本身require.main === module // true
  4. extensions 函数数组,根据文件的后缀名,调用不同的执行函数

nodejs 中模块的代码相当于写在下面这个函数中。

(function (exports, require, module, __filename, __dirname) {
  // 代码
});

循环加载

如果发生模块的循环加载(A加载B,B又加载A),则B将加载A的不完整版本。

// a.js
exports.x = 'a1';
console.log('a.js ', require('./b.js').x);
exports.x = 'a2';

// b.js
exports.x = 'b1';
console.log('b.js ', require('./a.js').x);
exports.x = 'b2';

// main.js
console.log('main.js ', require('./a.js').x);
console.log('main.js ', require('./b.js').x);

/*
b.js  a1
a.js  b2
main.js  a2
main.js  b2
*/

AMD

首先是 Asynchronous Module Definition 规范,它在适合在浏览器环境中异步加载模块,并且可以并发的加载。

它主要只有一个接口define(id?, dependencies[]?, factory)函数。它只要有三个参数,前两个可选,它用来定义一个模块。

id 模块名,不推荐使用,一般被工具自动生成。

dependencies 是一个字符串数组,当前这个模块要依赖的模块。

factory 是函数或者对象,如果是对象,那么这个对象就是向外暴露的值,如果是一个函数那么这个函数的返回值就是对外暴露的值。如果dependencies参数为空,那么这个函数的参数默认是require, exportsmodule

define({
    color: "black",
    size: "unisize"
});

// 等同于

define(function () {
    return {
        color: "black",
        size: "unisize"
    }
});

// ---------------------

// my/shirt.js 文件
define(["./cart", "./inventory"], function(cart, inventory) {
        // cart 和 inventory 于 shirt 同一个文件

        // 返回一个对象定义 my/shirt 模块
        return {
            color: "blue",
            size: "large",
            addToCart: function() {
                inventory.decrement(this);
                cart.add(this);
            }
        }
    }
);

// -------------

define(function(require, exports, module) {
        var a = require('a'),
            b = require('b');
        // 依赖的 a 和 b 模块
        // 这其实就是 CommonJS 规范的写法
        
        return function () {};
    }
);

require.js

requirejs 是对 AMD 的具体实现。

有了它 HTML 只需要引入它一个文件。

<!DOCTYPE html>
<html>
    <head>
        <script data-main="app" src="lib/require.js"></script>
        <!-- 建议放在 head 中 -->
    </head>
    <body>
        <h1>Hello World</h1>
    </body>
</html>

script上的data-main是一个特殊属性,通过它可以找到 requirejs 的配置文件。

服务器上的目录

www
    app 项目代码
        main.js
    lib 库
        require.js
        jquery.js
    app.js 配置文件
    index.html 上面 html 文件

app.js 配置文件

requirejs.config({
    baseUrl: 'lib', // 默认情况下加载 www/lib 下的模块 id
    paths: {
        app: '../app' // 但是一旦模块以 app 开头那么,就加载 app 文件夹中的文件
    }
});

// 开始加载项目主文件
requirejs(['app/main']);

多页面时

page1.html(page2.html 和 page1.html 类似。)

<!DOCTYPE html>
<html>
    <head>
        <title>Page 1</title>
        <script src="js/lib/require.js"></script>
        <script>
            // 加载 js 下的 common 配置文件。
            requirejs(['./js/common'], function (common) {
                // 配置文件加载好调用
                // 因为配置文件中设置了路径所以可以直接用 app/main1 无需加 js
                requirejs(['app/main1']);
            });
        </script>
    </head>
    <body>
        <a href="page2.html">Go to Page 2</a>
    </body>
</html>

配置文件

requirejs.config({
    baseUrl: 'js/lib',
    paths: {
        app: '../app'
    },
    shim: {
        // 为不使用 define() 声明依赖项并设置模块值的旧的传统“浏览器全局”脚本配置依赖项
        // 导出和自定义初始化。
        backbone: {
            deps: ['jquery', 'underscore'],
            exports: 'Backbone'
        },
        underscore: {
            exports: '_'
        }
    }
});

如果两个模块发生循环依赖,a 依赖 bb 依赖 a

define(["require", "a"],
    function(require, a) {
        // 如果 a 也依赖 b ,这时参数 a 为 undefined
        // b 可以在之后使用 require 函数获取 a
        // require 函数依赖是必须的
        
        return function(title) {
            return require("a").doSomething();
        }
    }
);

// 或

define(function(require, exports, module) {
    // 在 b 返回之前不能使用 a 的属性
    // 这只在 a 和 b 返回的都是对象时有用
    var a = require("a");

    exports.foo = function () {
        return a.bar();
    };
});

UMD

对于第三方库一般会判断当前的环境,决定使用 AMD 还是 CommonJS,比如 underscore。因为 AMD 和 CommonJS 都很流行,所以我们要一个兼容两种风格的规范,于是通用模块规范 UMD 就诞生了。

(function () {
    var root = typeof self == 'object' && self.self === self && self ||
        typeof global == 'object' && global.global === global && global ||
        this || {};
    // root 等于当前环境顶层对象
    
    if (typeof exports != 'undefined' && !exports.nodeType) {
        // 如果用的 CommonJS 则用CommonJS 导出
        if (typeof module != 'undefined' && !module.nodeType && module.exports) {
            exports = module.exports = _;
        }
        exports._ = _;
    } else {
        // 否则定义在顶层对象上
        root._ = _;
    }
    
    if (typeof define == 'function' && define.amd) {
        // 如果用的 AMD 则用 define 导出
        define('underscore', [], function() {
            return _;
        });
    }
}());

CMD

CMD(CMD 模块定义规范) 和 AMD 非常类似。seajs 是对 CMD 实现。

新版的 requirejs 几乎和 seajs 写法一摸一样。

define(function(require, exports, module) {

  // 模块代码
  // requirejs 也支持这种写法,当然 seajs 也接受 define 三参数写法,

});

CMD 和 AMD 的最大区别就是,AMD 是依赖提前执行(或许你没用到这个依赖),CMD 是延迟执行。新版本的 requirejs 也改为了延迟执行。

ES6 Module

ES6 是 JS 语言层面的模块化支持,将来服务器和浏览器都会支持 ES6 模块格式。它是一个文件就是一个模块,它使用import指令导入模块,使用export导出模块。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。

import { stat, exists, readFile } from 'fs'; // es6

let { stat, exists, readFile } = require('fs'); // commonjs

上面代码中 CommonJS 实质是整体加载fs模块,而 es6 是从fs模块加载 3 个方法,其他方法不加载。

这种加载称为“编译时加载”或者静态加载,即 ES6 可以在编译时就完成模块加载,效率要比 CommonJS 模块的加载方式高。当然,这也导致了没法引用 ES6 模块本身,因为它不是对象。

class一样 ES6 的模块自动采用严格模式。

export

export 指令用来导出你想暴露的值。

export var name = 'Jackson';
export var year = 1958;

// 或

var name = 'Jackson';
var year = 1958;

export { name, year };

// -----------

export function multiply(x, y) {
  return x * y;
};

// --------

function a() {}

export {
    a as b,
    a as c
}
// 默认导出的名字和内部变量名相同。
// 可以使用 as 关键字重命名

export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

// 报错
export 1;

// 报错
var m = 1;
export m;

// 报错
function f() {}
export f;

// ------------- 正确写法

// 写法一
export var m = 1;

// 写法二
var m = 1;
export {m};

// 写法三
var n = 1;
export {n as m};

export语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。

export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

// 上面代码输出变量foo,值为bar,500 毫秒之后变成baz

这一点与 CommonJS 规范完全不同。CommonJS 模块输出的是值的缓存,不存在动态更新。

export命令不能被嵌套。

function foo() {
  export let a = 'bar' // SyntaxError
}

import

import命令用于加载模块。

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

import 'lodash'; // 执行文件但不需要其导出值
import { name as mz, year } from './profile.js';

// import 后面跟当前目录下的 profile.js 中,导出的变量, from 后面是模块路径。
// 同样可以使用 as 关键字重命名

import命令输入的变量都是只读的,因为它的本质是输入接口,修改它会报错,如果它是一个对象则可以修改它的属性。

import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}

可以使用*加载,一个模块的所有导出值。

import * as a from './a'

// 将 a.js 文件的所有导出值,都赋值到一个 a 对象变量上
// 但是不能修改它的属性

export default

export default指令用来设置默认导出值。

export default 1
// 使用 export default 就无需额外使用一个变量了
// 它其实像 CommonJS 的 module.exports

对于使用export default的值,import将它导入就无需使用大括号。

import config from './a.js'
// a.js 文件使用了 export default 导出值
// config 名字是自己随便写的

它其实相当于使用一个叫default的变量

function add(x, y) {
  return x * y;
}
export {add as default};
// 等同于
// export default add;

import { default as foo } from 'modules';
// 等同于
// import foo from 'modules';

当然一个文件中可以同时有exportexport default,使用import导入时,也可以使用逗号分隔默认值和其他导出值。

import _, { each, forEach } from 'lodash';

export ... from ... 写法

如果需要先输入后输出同一个模块,就可以使用这种写法。

export { foo, bar } from 'my_module';

foobar实际上并没有被导入当前模块,只是相当于对外转发了这两个接口,导致当前模块不能直接使用foobar

同样还可以使用as * default

// 接口改名
export { foo as myFoo } from 'my_module';

// 整体输出
export * from 'my_module';

export { default } from 'foo';

// 替换默认接口
export { es6 as default } from './someModule';

export { default as es6 } from './someModule';

export ... from ...的写法主要是用在,一个文件比如index.js,将其他的文件的导出值结合起来一次导出。

import()

import命令会被 JavaScript 引擎静态分析,所以嵌套是会报错。

if (needA) {
    import A from './a' // 报错
}

// -----------

if (needA) {
    const A = require('./a') // CommonJS 完全没问题
}

这时候就需要动态加载功能,import()就是用来解决这个问题。

import()函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行。

import()返回一个 Promise 对象,then 方法的回调参数就是模块的导出对象。

import('./myModule.js')
.then(({export1, export2}) => {
  // ...·
});

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});

async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}

import()主要用在 webpack 的代码分隔功能。比如 react 的 react-loadable 库。

import Loadable from 'react-loadable';

const LoadableOtherComponent = Loadable({
  loader: () => import('./OtherComponent'),
  loading: () => <div>Loading...</div>,
});

const MyComponent = () => (
  <LoadableOtherComponent/>
);