寒夏摸鱼站

Live your dream, and share your passion.

JS 模块系统

在对 JS 项目进行打包时,你可能会发现一个叫“format”的选项,里面可能要求你填写如:iife、amd、umd 等参数,不同的参数会生成不同格式的 JS 模块文件,那么它们之间有什么不同呢?

IIFE

IIFE 是 Immediately Invoked Function Expression(立即执行函数表达式)的缩写。

在 ES6 之前 JS 还尚未提出 块级作用域 时,只有 全局作用域函数作用域 两个作用域可供开发者使用,为了实现模块的封装功能,IIFE 利用了函数作用域,使得模块的数据和方法可以以 闭包 的形式提供给外部使用。

一般情况下,IIFE 模块会定义成一个自执行匿名函数,并通过提供一个参数作为注册的上下文,这种 IIFE 一般会用来同时适配 Node 环境和浏览器环境,因为在 Node 的全局环境变量叫 global,而浏览器全局环境是 window

(function (ctx) {
  'use strict';

  // Define members
  var foo = 'foo';
  function bar() {
    return foo;
  }

  // Inject into context
  ctx.Example = { bar };
})(window || global);

大部分情况下 IIFE 的参数也会被省略,转而直接注入到 window 对象中,这在那些仅服务于浏览器的 JS 库很常见:

(function () {
  'use strict';

  // Define members
  var foo = 'foo';
  function bar() {
    return foo;
  }

  // Inject into window
  window.Example = { bar };
})();

虽然 IIFE 几乎是最早的 JS 模块形式,但是现在 IIFE 在新标准的加持下依然强大,它可以和箭头函数以及 async 异步函数相结合,实现更加复杂的模块初始化功能:

(() => {
  'use strict';

  window.Example = { name: 'Example' };
})();

(async () => {
  'use strict';

  const data = await fetch('...');

  window.Example = { name: 'Example', data };
})();

CJS

CJS 即 CommonJS,这是 Node 环境下的一种 JS 模块系统,因此 它在浏览器中是不能使用的

在 Node 中,每个文件就是一个模块,定义在里面的所有成员都是私有的,对其他文件不可见。当然我们在 IIFE 讲到了 Node 的全局环境 global,因此你可以通过 global 来把一些成员暴露给全局,这种做法并不推荐:

global.foo = 'bar';

那么如何正确引入和导出模块呢?CJS 提供了一个函数 require 和一个对象 modulerequire 函数用于引入其他模块文件,module 对象的 exports 成员用于指定模块要导出的数据:

// Import other module
const other = require('./other.js');
other.bar();

// Export data
module.exports.foo = 'foo';
module.exports.bar = function () {
  return 'bar';
};

CJS 模块是同步加载,且全局缓存的,因此如果 require 出现在 JS 代码中间,那么就会阻塞程序运行,直到模块文件加载完成,这对于服务端程序来说并不是什么大问题,因为可以直接读取磁盘,但是如果在客户端上发生同步加载,那么要读取的就是远程服务器了,加载时间一下子就拉长了,因此 CJS 的机制并不适合浏览器。

AMD

AMD 全称 Asynchronous Module Definition(异步模块定义),它是一种用于浏览器加载模块的规范,使得能在浏览器上使用类似 CJS 的语法来异步地使用模块。

AMD 使用 define 来定义模块,对于模块的引入,则使用一个 模块名列表 来前置声明:

define(['./other.js'], (other) => {
  other.bar();

  return {
    foo: 'foo',
    bar: () => 'bar'
  };
});

可以看到,模块的代码是定义在回调函数中的,引入的模块通过函数参数注入,而模块导出的成员则通过函数返回值提供,一切都是为了异步加载服务。

AMD 内部会自动通过模块引入列表确定模块之间的依赖关系,然后按照依赖关系异步加载和执行模块,其不会出现 CJS 的阻塞情况。但是如果出现了 循环依赖 的情况,对于前置声明模块就非常麻烦了,我们在此按下不表。

需要注意的是,AMD 并不是 Web 标准,因此你需要在网页中引入基础依赖库来使用 AMD 模块,如 RequireJS

CMD

为了解决 AMD 循环依赖问题,更进一步的 CMD(Common Module Definition)被提出了。

它对模块定义使用的 define 进行了修改,不在需要前置引入模块,而是提供 requireexportsmodule 三个参数用于模块操作:

define((require, exports, module) => {
  const other = require('./other');
  other.bar();

  exports.foo = 'foo';
  module.exports.bar = () => 'bar';
});

从上面的例子就可以看出,这三个参数完全依照 CJS 的习惯来进行了实现,进一步统一了 Node 环境和浏览器环境的模块系统。

同样,CMD 并不是 Web 标准,因此你需要在网页中引入基础依赖库来使用 CMD 模块,如 SeaJS

UMD

由于 CJS 和 AMD/CMD 几乎同宗同源,但是 CJS 只能用于 Node 环境,AMD/CMD 只能用于浏览器环境,那么一个模块如果同时可以在 Node 环境和浏览器环境中使用,是否需要打包两个不同的模块呢?

这时我们需要请出 UMD(Universal Module Definition)了,它提供了一个前后端跨平台解决方案,巧妙地把 CJS 和 AMD/CMD 结合了起来,这也是其叫做 通用模块定义 的原因。

严格来说,UMD 实际上只是对 CJS、AMD 等环境进行了一个简单判断,根据不同的环境对模块代码进行不同的操作。其本质上是个 IIFE:

((ctx, factory) => {
  if (typeof exports === 'object') {
    // If CJS
    module.exports = facory();
  } else if (typeof define === 'function' && define.amd) {
    // If AMD
    define([], factory);
  } else {
    // If browser global
    ctx.umdModule = factory();
  }
})(this, () => {
  return {
    foo: 'foo',
    bar: () => 'bar'
  };
});

只要你想,你可以轻松实现你的 UMD 包装,实际上很多前后端通用模块都是如上简单的判断来支持 UMD 的。

这么看来 UMD 反而像是个和事佬。

ESM

为了解决前后端不同环境下的模块定义割裂,ECMA 标准化组织为 JS 提出了统一的 ESM(ECMA Script Module)模块规范。

ESM 使用 importexport 两个关键字来引入和导出 JS 模块,使用前置声明来引入模块:

import other from './other.js';

other.bar();

export const foo = 'foo';
export function bar() {
  return 'bar';
}

ESM 会递归读取和执行所有的模块代码,并自动处理循环依赖和语法检查。ESM 除了 import 前置声明引入模块,还支持使用 import 函数来异步加载模块:

import('./other.js').then((other) => {
  other.bar();
});

ESM 的出现解决了 JS 的模块历史问题,也使得更多的新项目和旧项目迁移至 ESM 规范,并且在 Node 和浏览器都有着广泛支持,给 JS 模块之争画上了句号。