JavaScript Modules(二)编译篇

490 阅读8分钟

目前前端开发项目的基本是使用用ESM规范进行开发,然后交由webpack之类的工具进行打包(build)成浏览器都可以执行的js代码。但是自己一直有一个疑问,我们开发的模块是ESM形式,但是从npm下载的好多模块都是CommonJS规范的,这些工具是做到兼容的呢?

webpack

测试环境: "webpack": "^5.74.0"

在开始写这段之前让我们思考一个问题。根据CJS的规范,导出由 module.exports关键字来实现。而ESM有两种导出 export export default

其实在某种意义上可以把 module.export export理解为同类型的导出。而export default 导出方式是ESM特有的导出方式。那么如何在ESM中导入CJS和ESM,是通过什么来判断这个模块是属于哪种规范呢?所以兼容各种模块化的本质就是:怎么解决ESM 中的export default和commonjs的对应关系

看下面的例子:

// commonjs/util.js
function addSuffix(str) {
  return str + " 👉CJS";
}

module.exports = {
  addSuffix,
};

// esm/util.js
const addPrefix = (str) => {
  return "ESM👈 " + str;
};

export { addPrefix };


// index.js

import { addSuffix } from "./commonjs/util";
import { addPrefix } from "./esm/util";

const source = "javaSwing";

console.log("sourceAddSuffix: ", addSuffix(source));
// sourceAddSuffix:  javaSwing 👉CJS

console.log("sourceAddPrefix: ", addPrefix(source));
// sourceAddPrefix:  ESM👈 javaSwing

分析打包代码

这里贴出打包后的部分代码,更全的代码请这里查看。

(() => {
  // webpackBootstrap
  var __webpack_modules__ = [
    (module) => {
      // module1 code
    },
    /* 2 */
    (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        addPrefix: () => /* binding */ addPrefix,
      });
      // module2 code
    },
  ];
  /************************************************************************/
  // The module cache
  var __webpack_module_cache__ = {};

  // The require function
  function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = (__webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
      exports: {},
    });

    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

    // Return the exports of the module
    return module.exports;
  }

  /************************************************************************/
  /* webpack/runtime/compat get default export */
  (() => {
    // getDefaultExport function for compatibility with non-harmony modules
    __webpack_require__.n = (module) => {
      var getter =
        module && module.__esModule
          ? /******/ () => module["default"]
          : /******/ () => module;
      __webpack_require__.d(getter, { a: getter });
      return getter;
    };
  })();

  /* webpack/runtime/define property getters */
  (() => {
    // define getter functions for harmony exports
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key],
          });
        }
      }
    };
  })();

  /* webpack/runtime/hasOwnProperty shorthand */
  (() => {
    __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
  })();

  /* webpack/runtime/make namespace object */
  (() => {
    // define __esModule on exports
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: "Module",
        });
      }
      Object.defineProperty(exports, "__esModule", { value: true });
    };
  })();

  /************************************************************************/
  var __webpack_exports__ = {};
  // This entry need to be wrapped in an IIFE because it need to be in strict mode.
  (() => {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */ var _commonjs_util__WEBPACK_IMPORTED_MODULE_0__ =
      __webpack_require__(1);
    /* harmony import */ var _commonjs_util__WEBPACK_IMPORTED_MODULE_0___default =
      /*#__PURE__*/ __webpack_require__.n(
        _commonjs_util__WEBPACK_IMPORTED_MODULE_0__
      );
    /* harmony import */ var _esm_util__WEBPACK_IMPORTED_MODULE_1__ =
      __webpack_require__(2);

    const source = "javaSwing";

    console.log(
      "sourceAddSuffix: ",
      (0, _commonjs_util__WEBPACK_IMPORTED_MODULE_0__.addSuffix)(source)
    );

    console.log(
      "sourceAddPrefix: ",
      (0, _esm_util__WEBPACK_IMPORTED_MODULE_1__.addPrefix)(source)
    );
  })();
})();

最终就是一个 IIFE

webpack 打包后的文件肯定是任何浏览器都能执行的代码,所以不能是只特定的规范。自己要实现一套所有浏览器都可执行的模块代码。 还记得浏览器最早的模块化的实现方式吗? IIFE 没错就是这个,他是任何浏览器都可以执行的模块化方式。估打包出来的主代码就包含在一个 IIFE函数内部

webpack_require

这里的__webpack_require__可以理解是webpack对于require的实现。看代码

// The module cache
var __webpack_module_cache__ = {};

function __webpack_require__(moduleId) {
    // Check if module is in cache
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    // Create a new module (and put it into the cache)
    var module = (__webpack_module_cache__[moduleId] = {
      // no module.id needed
      // no module.loaded needed
      exports: {},
    });

    // Execute the module function
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

    // Return the exports of the module
    return module.exports;
  }
  1. 首先检测 __webpack_module_cache__中有没有对应的缓存,有的话直接返回加载过的exports
  2. 创建一个module对象,并在对应到 __webpack_module_cache__ 对象上
  3. __webpack_modules__ 数组中加载对应的 module代码,并返回 module.exports

如何兼容 ESM和CJS

先看下 __webpack_modules__中打包的代码

  var __webpack_modules__ = [
    ,
    /* 0 */ /* 1 */
    (module) => {
      function addSuffix(str) {
        return str + " 👉CJS";
      }

      module.exports = {
        addSuffix,
      };
    },
    /* 2 */
    /***/ (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        addPrefix: () => /* binding */ addPrefix,
      });
      const addPrefix = (str) => {
        return "ESM👈 " + str;
      };
    },
  ];

可以看到 __webpack_modules__其实就是一个数组,且这个数组的0索引是空的。1索引是CJS代码,2索引是ESM代码。

webpack对于 CJS的代码基本保留原来的样子,对于ESM模块的打包。使用了 __webpack_require__.r__webpack_require__.d进行处理。

  1. __webpack_require__.r函数
  /* webpack/runtime/make namespace object */
  (() => {
    // define __esModule on exports
    __webpack_require__.r = (exports) => {
      if (typeof Symbol !== "undefined" && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: "Module",
        });
      }
      Object.defineProperty(exports, "__esModule", { value: true });
    };
  })();

这个函数的作用有如下两点:

    • 检测是否支持Symbol.toStringTag,如支持为exports 重写其方法。当调用对象的toString() 时返回 [Object Module]
    • exports对象添加 __esModule属性,值为 true (这里添加的属性是用于区分该模块是ESM)
  1. __webpack_require__.d
 /* webpack/runtime/define property getters */
  (() => {
    // define getter functions for harmony exports
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key],
          });
        }
      }
    };
  })();
  /* webpack/runtime/hasOwnProperty shorthand */
  (() => {
    __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
  })();

__webpack_require__.d就把传入的definintion对象的每个key都绑定到exports属性上(且不是在原型链上,通过 webpack_require.o 实现)。

webpack_require.n

在最终的打包代码可以看到如下处理

  /* webpack/runtime/compat get default export */
  (() => {
    // getDefaultExport function for compatibility with non-harmony modules
    __webpack_require__.n = (module) => {
      var getter =
        module && module.__esModule
          ? () => module["default"]
          : () => module;
      __webpack_require__.d(getter, { a: getter });
      return getter;
    };
  })();


var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be in strict mode.
(() => {
"use strict";
__webpack_require__.r(__webpack_exports__);
var _commonjs_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
var _commonjs_util__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_commonjs_util__WEBPACK_IMPORTED_MODULE_0__);
var _esm_util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);



const source = "javaSwing";

console.log("sourceAddSuffix: ", (0,_commonjs_util__WEBPACK_IMPORTED_MODULE_0__.addSuffix)(source));

console.log("sourceAddPrefix: ", (0,_esm_util__WEBPACK_IMPORTED_MODULE_1__.addPrefix)(source));
  
console.log(_commonjs_util__WEBPACK_IMPORTED_MODULE_0___default.a)
console.log(_commonjs_util__WEBPACK_IMPORTED_MODULE_0___default())

})();

从最终的调用我们可以看出,webpack对于CJS规范导出的 default(默认值)进行了兼容。通过 __webpack_require__.n函数判断 module对象有没有 __esModule, 进行取值。

  1. 为true时模块为ESM,则直接调用 module['default']
  2. 为CJS模块时把CJS里的module.exports里的导出对象,做为 default 值

注:这里的 _commonjs_util__WEBPACK_IMPORTED_MODULE_0___default.a_commonjs_util__WEBPACK_IMPORTED_MODULE_0___default()是一样的

总结

在webpack中通过module对象添加 __esModule值,进行区分CJS和ESM。在加载 default对象时通过该标识来处理CJS的对象导出,来兼容实现 export default 导出值问题。

知识: __esModule这个添加辅助标识的方法,在目前能查找到的资料中,最早是由Babel,进行提出的。后来 webpackTypeScript中对于 CJS和ESM的兼容处理,都遵循了该方法。

TypeScript

在写TypeScript这部分之前,先要说一个额外的东西。TypeScript的最初版本发布于 2012年 而ES的模块化概念是出现在 2015年,而且在TypeScript中最初的模块化叫 Internal Modules,后来由于ES模块的出现,TypeScript逐渐放弃了自己的模块化规范,转而拥抱ES的标准。可以参见下面这个PR: github.com/microsoft/T…

也可以从官网的下面这段话证明这点 :

Note: In very old versions of TypeScript namespaces were called ‘Internal Modules’, these pre-date JavaScript module systems. - 出自 《Namespaces and Modules

A note about terminology: It’s important to note that in TypeScript 1.5, the nomenclature has changed. “Internal modules” are now “namespaces”. “External modules” are now simply “modules”, as to align with ECMAScript 2015’s terminology, (namely that module X { is equivalent to the now-preferred namespace X {). - 出自《Namespaces》

思考

问题

在日常的开发中我们使用ESM语法进行开发,一般引入的第三方库多为CJS格式。在TS的世界里默认把 CJS/AMD/UMD 模块当成类似于ESM模块方式进行处理。就有了如下规则

  • import * as moment from "moment" = const moment = require("moment")
  • import moment from "moment" = const moment = require("moment").default

按照上面的规则就会出现以下两个问题:

  1. 在ES6的标准中 import * as x(Namespace import)这种导入的方式只能是一个 object(原因点这里)。如果按照上面第一条约定的做法,const x = require("xxx")并不一定是对象,也可能是函数。
  2. 规则二中,虽然符合ESM的标准,但是对于 CJS/AMD/UMD等规范并没有 default导出值。
方案

官方也发现了这个问题于是就添加了 esModuleInterop (查看这个PR)这个配置项用于处理该问题。直接上代码,也可以看这个官方的 Playground

import * as fs from "fs";
import _ from "lodash";
fs.readFileSync("file.txt", "utf8");
_.chunk(["a", "b", "c", "d"], 2);
禁用 esModuleInterop(默认值) 编译结果
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require("fs");
const lodash_1 = require("lodash");
fs.readFileSync("file.txt", "utf8");
lodash_1.default.chunk(["a", "b", "c", "d"], 2);
启用 esModuleInterop 编译结果
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
    __setModuleDefault(result, mod);
    return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
const lodash_1 = __importDefault(require("lodash"));
fs.readFileSync("file.txt", "utf8");
lodash_1.default.chunk(["a", "b", "c", "d"], 2);

在开启 esModuleInterop 之后,TS内部分别使用了 __importDefault__importStar来处理 Default ImportNamespace Import

理解了 esModuleInterop 这个选项的由来之后,回到原来的问题上TS是怎么处理 ESM引用CJS模块的问题。直接上例子:

例子

环境: TypeScript 4.7.3 版本

// cjs/util.js
function addSuffix(str) {
  return str + " 👉CJS";
}

function showName() {
  return 'cjs'
}

module.exports = addSuffix;
module.exports.showName = showName;

// util.d.ts
declare function addSuffix(str: string): string;

declare namespace addSuffix {
  export function showName(): string;
}
export = addSuffix;

// index.ts
import addSuffix from "./cjs/util";
import { showName } from "./cjs/util";

const nameStr = "ts-module";

console.log(addSuffix(nameStr));

console.log(showName());

使用以下命令进行编译: tsc index.ts --esModuleInterop --module commonjs --target ES2019得到如下文件内容:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const util_1 = __importDefault(require("./cjs/util"));
const util_2 = require("./cjs/util");
const nameStr = "ts-module";
console.log((0, util_1.default)(nameStr));
console.log((0, util_2.showName)());

在开启了 esModuleInterop选项之后,如果被导入的模块没有 __esModule属性之后,会返回一个对象 { "default": mod} 用于处理CJS模块让其,与webpack的处理结果一样兼容。想要了解为什么要开启 esModuleInterop,请参考官方文档(简单点说:最初期TS处理 default的逻辑与 webpack并不一样,后来为了兼容__esModule这一个大家都遵循的标准,就添加了该配置)。

总结

通过对于 webpack和TypeScript编译后代码的分析,明白目前的前端工具是怎么处理 ESM和CJS之前的相互兼容问题。大家都遵循了 __esModule 这个辅助标识属性的作法。问题的本质还是:怎么解决ESM 中的export default和commonjs的对应关系

参考