JS 模块系统

@2024年5月8日 1.7k字 §技术 #JavaScript
目录
  • IIFE
  • CJS
  • AMD
  • CMD
  • UMD
  • ESM
  • 在对 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 模块之争画上了句号。

    正在加载索引……